Add tooltips to fun picker

This commit is contained in:
Jamie Kyle
2025-04-24 15:17:35 -07:00
committed by GitHub
parent 16e877ece4
commit d6efe16566
10 changed files with 156 additions and 48 deletions

View File

@@ -21,4 +21,5 @@
@use './FunSticker.scss';
@use './FunSubNav.scss';
@use './FunTabs.scss';
@use './FunTooltip.scss';
@use './FunWaterfall.scss';

View File

@@ -0,0 +1,27 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
@use '../../mixins';
@use '../../variables';
@use './FunConstants.scss';
.FunTooltip {
max-width: calc(32ch + (8px * 2));
padding-block: 4px;
padding-inline: 8px;
background: FunConstants.$Fun__BgColor;
color: light-dark(
variables.$color-black-alpha-80,
variables.$color-white-alpha-80
);
border-radius: 6px;
box-shadow:
0px 4px 12px variables.$color-black-alpha-30,
0px 0px 4px variables.$color-black-alpha-05;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
user-select: none;
@include mixins.font-body-2();
}

View File

@@ -1,14 +1,13 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ForwardedRef, MouseEvent, ReactNode } from 'react';
import React, { forwardRef } from 'react';
import {
mergeProps,
type PressEvent,
useLongPress,
usePress,
} from 'react-aria';
import type { ForwardedRef, ReactNode } from 'react';
import React, { forwardRef, useEffect, useRef } from 'react';
import { type PressEvent, useLongPress } from 'react-aria';
import type { LongPressEvent } from '@react-types/shared';
import { Button } from 'react-aria-components';
import { mergeRefs } from '@react-aria/utils';
import { PressResponder } from '@react-aria/interactions';
import { strictAssert } from '../../../util/assert';
/**
* Button
@@ -28,7 +27,7 @@ export type FunItemButtonLongPressProps = Readonly<
export type FunItemButtonProps = Readonly<
{
'aria-label': string;
tabIndex: number;
excludeFromTabOrder: boolean;
onPress: (event: PressEvent) => void;
onContextMenu?: (event: MouseEvent) => void;
children: ReactNode;
@@ -37,11 +36,10 @@ export type FunItemButtonProps = Readonly<
export const FunItemButton = forwardRef(function FunItemButton(
props: FunItemButtonProps,
ref: ForwardedRef<HTMLButtonElement>
outerRef: ForwardedRef<HTMLButtonElement>
): JSX.Element {
const { pressProps } = usePress({
onPress: props.onPress,
});
const { onContextMenu } = props;
const innerRef = useRef<HTMLButtonElement>(null);
const { longPressProps } = useLongPress({
isDisabled: props.onLongPress == null,
@@ -49,17 +47,30 @@ export const FunItemButton = forwardRef(function FunItemButton(
onLongPress: props.onLongPress,
});
useEffect(() => {
strictAssert(innerRef.current, 'Missing ref element');
const element = innerRef.current;
if (onContextMenu == null) {
return () => null;
}
element.addEventListener('contextmenu', onContextMenu);
return () => {
element.removeEventListener('contextmenu', onContextMenu);
};
}, [onContextMenu]);
return (
<button
ref={ref}
type="button"
className="FunItem__Button"
aria-label={props['aria-label']}
tabIndex={props.tabIndex}
{...mergeProps(pressProps, longPressProps)}
onContextMenu={props.onContextMenu}
>
{props.children}
</button>
<PressResponder {...longPressProps}>
<Button
ref={mergeRefs(innerRef, outerRef)}
type="button"
className="FunItem__Button"
aria-label={props['aria-label']}
excludeFromTabOrder={props.excludeFromTabOrder}
onPress={props.onPress}
>
{props.children}
</Button>
</PressResponder>
);
});

View File

@@ -64,6 +64,25 @@ export function FunSubNavScroller(props: FunSubNavScrollerProps): JSX.Element {
);
});
useEffect(() => {
strictAssert(outerRef.current, 'Must have scroller ref');
const scroller = outerRef.current;
function onWheel(event: WheelEvent) {
event.preventDefault();
scroller.scrollBy({
left: event.deltaX + event.deltaY,
behavior: 'instant',
});
}
scroller.addEventListener('wheel', onWheel, { passive: false });
return () => {
scroller.addEventListener('wheel', onWheel, { passive: false });
};
}, []);
return (
<div className="FunSubNav__Scroller">
<div
@@ -203,12 +222,21 @@ function FunSubNavListBoxItemButton(props: {
useEffect(() => {
strictAssert(ref.current, 'Expected ref to be defined');
const element = ref.current;
let timer: ReturnType<typeof setTimeout>;
if (props.isSelected) {
ref.current.scrollIntoView({
behavior: 'smooth',
inline: 'nearest',
});
// Needs setTimeout() for arrow key navigation to work.
// Might be something to do with native arrow key scroll handling.
timer = setTimeout(() => {
element.scrollIntoView({
behavior: 'smooth',
inline: 'nearest',
});
}, 1);
}
return () => {
clearTimeout(timer);
};
}, [props.isSelected]);
return (
@@ -230,7 +258,7 @@ export function FunSubNavListBoxItem(
aria-label={props.label}
textValue={props.label}
>
{({ isSelected }) => {
{({ isSelected, isFocusVisible }) => {
return (
<FunSubNavListBoxItemButton isSelected={isSelected}>
<span className="FunSubNav__ListBoxItem__ButtonIcon">
@@ -244,6 +272,9 @@ export function FunSubNavListBoxItem(
transition={FunSubNavListBoxItemTransition}
/>
)}
{!isSelected && isFocusVisible && (
<div className="FunSubNav__ListBoxItem__ButtonIndicator" />
)}
</FunSubNavListBoxItemButton>
);
}}

View File

@@ -0,0 +1,19 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { type ReactNode } from 'react';
import type { Placement } from 'react-aria';
import { Tooltip } from 'react-aria-components';
export type FunTooltipProps = Readonly<{
placement?: Placement;
children: ReactNode;
}>;
export function FunTooltip(props: FunTooltipProps): JSX.Element {
return (
<Tooltip className="FunTooltip" placement={props.placement}>
{props.children}
</Tooltip>
);
}

View File

@@ -560,7 +560,7 @@ export function emojiVariantConstant(input: string): EmojiVariantData {
export function normalizeShortNameCompletionDisplay(shortName: string): string {
return removeDiacritics(shortName)
.normalize('NFD')
.replaceAll(' ', '_')
.replaceAll(/[\s,]+/gi, '_')
.toLowerCase();
}
@@ -568,7 +568,7 @@ export function normalizeShortNameCompletionDisplay(shortName: string): string {
export function normalizeShortNameCompletionQuery(query: string): string {
return removeDiacritics(query)
.normalize('NFD')
.replaceAll(/[\s_-]+/gi, ' ')
.replaceAll(/[\s,_-]+/gi, ' ')
.toLowerCase();
}

View File

@@ -1,6 +1,5 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { MouseEvent } from 'react';
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import {
Dialog,
@@ -8,6 +7,7 @@ import {
Heading,
OverlayArrow,
Popover,
TooltipTrigger,
} from 'react-aria-components';
import type { PressEvent } from 'react-aria';
import { VisuallyHidden } from 'react-aria';
@@ -54,6 +54,7 @@ import {
getEmojiPickerCategoryParentKeys,
getEmojiVariantByParentKeyAndSkinTone,
isEmojiParentKey,
normalizeShortNameCompletionDisplay,
} from '../data/emojis';
import { useFunEmojiSearch } from '../useFunEmojiSearch';
import { FunKeyboard } from '../keyboard/FunKeyboard';
@@ -70,6 +71,7 @@ import { FunStaticEmoji } from '../FunEmoji';
import { useFunContext } from '../FunProvider';
import { FunResults, FunResultsHeader } from '../base/FunResults';
import { useFunEmojiLocalizer } from '../useFunEmojiLocalizer';
import { FunTooltip } from '../base/FunTooltip';
function getTitleForSection(
i18n: LocalizerType,
@@ -638,7 +640,13 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element {
]
);
const emojiName = emojiLocalizer(emojiVariant.key);
const emojiName = useMemo(() => {
return emojiLocalizer(emojiVariant.key);
}, [emojiVariant.key, emojiLocalizer]);
const emojiShortNameDisplay = useMemo(() => {
return normalizeShortNameCompletionDisplay(emojiName);
}, [emojiName]);
return (
<FunGridCell
@@ -646,19 +654,23 @@ const Cell = memo(function Cell(props: CellProps): JSX.Element {
colIndex={props.colIndex}
rowIndex={props.rowIndex}
>
<FunItemButton
ref={popoverTriggerRef}
tabIndex={props.isTabbable ? 0 : -1}
aria-label={emojiName}
onPress={handlePress}
onLongPress={handleLongPress}
onContextMenu={handleContextMenu}
longPressAccessibilityDescription={i18n(
'icu:FunPanelEmojis__SkinTonePicker__LongPressAccessibilityDescription'
)}
>
<FunStaticEmoji role="presentation" size={32} emoji={emojiVariant} />
</FunItemButton>
<TooltipTrigger>
<FunItemButton
ref={popoverTriggerRef}
excludeFromTabOrder={!props.isTabbable}
aria-label={emojiName}
onPress={handlePress}
onLongPress={handleLongPress}
onContextMenu={handleContextMenu}
longPressAccessibilityDescription={i18n(
'icu:FunPanelEmojis__SkinTonePicker__LongPressAccessibilityDescription'
)}
>
<FunStaticEmoji role="presentation" size={32} emoji={emojiVariant} />
</FunItemButton>
<FunTooltip placement="top">{`:${emojiShortNameDisplay}:`}</FunTooltip>
</TooltipTrigger>
d
{emojiHasSkinToneVariants && (
<Popover
data-fun-overlay

View File

@@ -606,7 +606,7 @@ const Item = memo(function Item(props: {
<FunItemButton
aria-label={props.gif.title}
onPress={handlePress}
tabIndex={props.isTabbable ? 0 : -1}
excludeFromTabOrder={!props.isTabbable}
>
{src != null && (
<FunGif

View File

@@ -584,7 +584,7 @@ const Cell = memo(function Cell(props: {
rowIndex={props.rowIndex}
>
<FunItemButton
tabIndex={props.isTabbable ? 0 : -1}
excludeFromTabOrder={!props.isTabbable}
aria-label={
stickerLookupItem.kind === 'sticker'
? (stickerLookupItem.sticker.emoji ?? '')

View File

@@ -2022,6 +2022,13 @@
"reasonCategory": "usageTrusted",
"updated": "2025-02-19T20:14:46.879Z"
},
{
"rule": "React-useRef",
"path": "ts/components/fun/base/FunItem.tsx",
"line": " const innerRef = useRef<HTMLButtonElement>(null);",
"reasonCategory": "usageTrusted",
"updated": "2025-04-23T23:43:10.675Z"
},
{
"rule": "React-useRef",
"path": "ts/components/fun/base/FunScroller.tsx",