diff --git a/stylesheets/components/fun/Fun.scss b/stylesheets/components/fun/Fun.scss index 75e73b8109..203e222f8a 100644 --- a/stylesheets/components/fun/Fun.scss +++ b/stylesheets/components/fun/Fun.scss @@ -21,4 +21,5 @@ @use './FunSticker.scss'; @use './FunSubNav.scss'; @use './FunTabs.scss'; +@use './FunTooltip.scss'; @use './FunWaterfall.scss'; diff --git a/stylesheets/components/fun/FunTooltip.scss b/stylesheets/components/fun/FunTooltip.scss new file mode 100644 index 0000000000..ba0d59f7fc --- /dev/null +++ b/stylesheets/components/fun/FunTooltip.scss @@ -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(); +} diff --git a/ts/components/fun/base/FunItem.tsx b/ts/components/fun/base/FunItem.tsx index 71fc746486..08fabcc9c4 100644 --- a/ts/components/fun/base/FunItem.tsx +++ b/ts/components/fun/base/FunItem.tsx @@ -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 + outerRef: ForwardedRef ): JSX.Element { - const { pressProps } = usePress({ - onPress: props.onPress, - }); + const { onContextMenu } = props; + const innerRef = useRef(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 ( - + + + ); }); diff --git a/ts/components/fun/base/FunSubNav.tsx b/ts/components/fun/base/FunSubNav.tsx index 97b19b45a1..f5434dc11a 100644 --- a/ts/components/fun/base/FunSubNav.tsx +++ b/ts/components/fun/base/FunSubNav.tsx @@ -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 (
{ strictAssert(ref.current, 'Expected ref to be defined'); + const element = ref.current; + let timer: ReturnType; 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 ( @@ -244,6 +272,9 @@ export function FunSubNavListBoxItem( transition={FunSubNavListBoxItemTransition} /> )} + {!isSelected && isFocusVisible && ( +
+ )} ); }} diff --git a/ts/components/fun/base/FunTooltip.tsx b/ts/components/fun/base/FunTooltip.tsx new file mode 100644 index 0000000000..3630af9556 --- /dev/null +++ b/ts/components/fun/base/FunTooltip.tsx @@ -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 ( + + {props.children} + + ); +} diff --git a/ts/components/fun/data/emojis.ts b/ts/components/fun/data/emojis.ts index 3fc1c10f41..85350feedf 100644 --- a/ts/components/fun/data/emojis.ts +++ b/ts/components/fun/data/emojis.ts @@ -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(); } diff --git a/ts/components/fun/panels/FunPanelEmojis.tsx b/ts/components/fun/panels/FunPanelEmojis.tsx index 440c1dda7c..4e8479f08b 100644 --- a/ts/components/fun/panels/FunPanelEmojis.tsx +++ b/ts/components/fun/panels/FunPanelEmojis.tsx @@ -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 ( - - - + + + + + {`:${emojiShortNameDisplay}:`} + + d {emojiHasSkinToneVariants && ( {src != null && ( (null);", + "reasonCategory": "usageTrusted", + "updated": "2025-04-23T23:43:10.675Z" + }, { "rule": "React-useRef", "path": "ts/components/fun/base/FunScroller.tsx",