Fun picker improvements

This commit is contained in:
Jamie Kyle
2025-03-26 12:35:32 -07:00
committed by GitHub
parent 427f91f903
commit b0653d06fe
142 changed files with 3581 additions and 1280 deletions

View File

@@ -1,22 +1,50 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { RefObject } from 'react';
import React, { useRef, useEffect, useState } from 'react';
import type { ForwardedRef, RefObject } from 'react';
import React, { useRef, useEffect, useState, forwardRef } from 'react';
import classNames from 'classnames';
import { isFocusable } from '@react-aria/focus';
import { strictAssert } from '../../../util/assert';
import { useReducedMotion } from '../../../hooks/useReducedMotion';
import type { FunImageAriaProps } from '../types';
export type FunAnimatedImageProps = Readonly<{
role: 'image' | 'presentation';
className?: string;
src: string;
width: number;
height: number;
alt: string;
}>;
export type FunImageProps = FunImageAriaProps &
Readonly<{
className?: string;
src: string;
width: number;
height: number;
ignoreReducedMotion?: boolean;
}>;
export function FunImage(props: FunAnimatedImageProps): JSX.Element {
export function FunImage(props: FunImageProps): JSX.Element {
if (props.ignoreReducedMotion) {
return <FunImageBase {...props} />;
}
return <FunImageReducedMotion {...props} />;
}
/** @internal */
const FunImageBase = forwardRef(function FunImageBase(
props: FunImageProps,
ref: ForwardedRef<HTMLImageElement>
) {
return (
<img
ref={ref}
role={props.role}
aria-label={props['aria-label']}
className={props.className}
src={props.src}
width={props.width}
height={props.height}
draggable={false}
/>
);
});
/** @internal */
function FunImageReducedMotion(props: FunImageProps) {
const imageRef = useRef<HTMLImageElement>(null);
const intent = useIntent(imageRef);
const [staticSource, setStaticSource] = useState<string | null>(null);
@@ -69,16 +97,7 @@ export function FunImage(props: FunAnimatedImageProps): JSX.Element {
{staticSource != null && reducedMotion && !intent && (
<source className="FunImage--StaticSource" srcSet={staticSource} />
)}
{/* Using <img> to benefit from browser */}
<img
ref={imageRef}
role={props.role}
className={props.className}
src={props.src}
width={props.width}
height={props.height}
alt={props.alt}
/>
<FunImageBase {...props} ref={imageRef} />
</picture>
);
}
@@ -108,7 +127,7 @@ function closestElement(
* - However, this will break if elements become focusable/unfocusable during
* their lifetime (this is generally a sign something is being done wrong).
*/
function useIntent(ref: RefObject<HTMLElement>): boolean {
export function useIntent(ref: RefObject<HTMLElement>): boolean {
const [intent, setIntent] = useState(false);
useEffect(() => {

View File

@@ -1,8 +1,7 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { MouseEvent, ReactNode } from 'react';
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
import React from 'react';
import { FunImage } from './FunImage';
/**
* Button
@@ -10,9 +9,8 @@ import { FunImage } from './FunImage';
export type FunItemButtonProps = Readonly<{
'aria-label': string;
'aria-describedby'?: string;
tabIndex: number;
onClick: (event: MouseEvent) => void;
onClick: (event: ReactMouseEvent) => void;
children: ReactNode;
}>;
@@ -22,7 +20,6 @@ export function FunItemButton(props: FunItemButtonProps): JSX.Element {
type="button"
className="FunItem__Button"
aria-label={props['aria-label']}
aria-describedby={props['aria-describedby']}
onClick={props.onClick}
tabIndex={props.tabIndex}
>
@@ -30,48 +27,3 @@ export function FunItemButton(props: FunItemButtonProps): JSX.Element {
</button>
);
}
/**
* Sticker
*/
export type FunItemStickerProps = Readonly<{
src: string;
}>;
export function FunItemSticker(props: FunItemStickerProps): JSX.Element {
return (
<FunImage
role="presentation"
className="FunItem__Sticker"
src={props.src}
width={68}
height={68}
alt=""
/>
);
}
/**
* Gif
*/
export type FunItemGifProps = Readonly<{
src: string;
width: number;
height: number;
}>;
export function FunItemGif(props: FunItemGifProps): JSX.Element {
return (
<FunImage
role="presentation"
className="FunItem__Gif"
src={props.src}
width={props.width}
height={props.height}
// For presentation only
alt=""
/>
);
}

View File

@@ -0,0 +1,158 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode, RefObject } from 'react';
import React, { createContext, useContext, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { strictAssert } from '../../../util/assert';
/**
* Tracks the current `data-key` that has a long-press/long-focus
*/
const FunLightboxKeyContext = createContext<string | null>(null);
export function useFunLightboxKey(): string | null {
return useContext(FunLightboxKeyContext);
}
/**
* Provider
*/
export type FunLightboxProviderProps = Readonly<{
containerRef: RefObject<HTMLDivElement>;
children: ReactNode;
}>;
export function FunLightboxProvider(
props: FunLightboxProviderProps
): JSX.Element {
const [lightboxKey, setLightboxKey] = useState<string | null>(null);
useEffect(() => {
strictAssert(props.containerRef.current, 'Missing container ref');
const container = props.containerRef.current;
let isLongPressed = false;
let lastLongPress: number | null = null;
let currentKey: string | null;
let timer: NodeJS.Timeout | undefined;
function lookupKey(event: Event): string | null {
if (!(event.target instanceof HTMLElement)) {
return null;
}
const closest = event.target.closest('[data-key]');
if (!(closest instanceof HTMLElement)) {
return null;
}
const { key } = closest.dataset;
strictAssert(key, 'Must have key');
return key;
}
function update() {
if (isLongPressed && currentKey != null) {
setLightboxKey(currentKey);
} else {
setLightboxKey(null);
}
}
function onMouseDown(event: MouseEvent) {
currentKey = lookupKey(event);
timer = setTimeout(() => {
isLongPressed = true;
update();
}, 500);
}
function onMouseUp(event: MouseEvent) {
clearTimeout(timer);
if (isLongPressed) {
lastLongPress = event.timeStamp;
isLongPressed = false;
currentKey = null;
update();
}
}
function onMouseMove(event: MouseEvent) {
const foundKey = lookupKey(event);
if (foundKey != null) {
currentKey = lookupKey(event);
update();
}
}
function onClick(event: MouseEvent) {
if (event.timeStamp === lastLongPress) {
event.stopImmediatePropagation();
}
}
container.addEventListener('mousedown', onMouseDown);
container.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
window.addEventListener('click', onClick, { capture: true });
return () => {
container.removeEventListener('mousedown', onMouseDown);
container.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
window.addEventListener('click', onClick, { capture: true });
};
}, [props.containerRef]);
return (
<FunLightboxKeyContext.Provider value={lightboxKey}>
{props.children}
</FunLightboxKeyContext.Provider>
);
}
/**
* Portal
*/
export type FunLightboxPortalProps = Readonly<{
children: ReactNode;
}>;
export function FunLightboxPortal(props: FunLightboxPortalProps): JSX.Element {
return createPortal(props.children, document.body);
}
/**
* Backdrop
*/
export type FunLightboxBackdropProps = Readonly<{
children: ReactNode;
}>;
export function FunLightboxBackdrop(
props: FunLightboxBackdropProps
): JSX.Element {
return <div className="FunLightbox__Backdrop">{props.children}</div>;
}
/**
* Dialog
*/
export type FunLightboxDialogProps = Readonly<{
'aria-label': string;
children: ReactNode;
}>;
export function FunLightboxDialog(props: FunLightboxDialogProps): JSX.Element {
return (
<div
role="dialog"
className="FunLightbox__Dialog"
aria-label={props['aria-label']}
>
{props.children}
</div>
);
}

View File

@@ -1,8 +1,11 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react';
import { VisuallyHidden } from 'react-aria';
import type { LocalizerType } from '../../../types/I18N';
export type FunSearchProps = Readonly<{
i18n: LocalizerType;
'aria-label': string;
placeholder: string;
searchInput: string;
@@ -10,7 +13,8 @@ export type FunSearchProps = Readonly<{
}>;
export function FunSearch(props: FunSearchProps): JSX.Element {
const { onSearchInputChange } = props;
const { i18n, onSearchInputChange } = props;
const handleChange = useCallback(
event => {
onSearchInputChange(event.target.value);
@@ -18,6 +22,10 @@ export function FunSearch(props: FunSearchProps): JSX.Element {
[onSearchInputChange]
);
const handleClear = useCallback(() => {
onSearchInputChange('');
}, [onSearchInputChange]);
return (
<div className="FunSearch__Container">
<div className="FunSearch__Icon" />
@@ -29,6 +37,19 @@ export function FunSearch(props: FunSearchProps): JSX.Element {
onChange={handleChange}
placeholder={props.placeholder}
/>
{props.searchInput !== '' && (
<button
type="button"
className="FunSearch__Clear"
onClick={handleClear}
>
<span className="FunSearch__ClearButton">
<VisuallyHidden>
{i18n('icu:FunSearch__ClearButtonLabel')}
</VisuallyHidden>
</span>
</button>
)}
</div>
);
}

View File

@@ -1,81 +0,0 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useMemo } from 'react';
import type { Selection } from 'react-aria-components';
import { ListBox, ListBoxItem } from 'react-aria-components';
import type { EmojiParentKey } from '../data/emojis';
import {
EmojiSkinTone,
getEmojiVariantByParentKeyAndSkinTone,
} from '../data/emojis';
import { strictAssert } from '../../../util/assert';
import { FunEmoji } from '../FunEmoji';
export type SkinTonesListBoxProps = Readonly<{
emoji: EmojiParentKey;
skinTone: EmojiSkinTone;
onSelectSkinTone: (skinTone: EmojiSkinTone) => void;
}>;
export function SkinTonesListBox(props: SkinTonesListBoxProps): JSX.Element {
const { onSelectSkinTone } = props;
const handleSelectionChange = useCallback(
(keys: Selection) => {
strictAssert(keys !== 'all', 'Expected single selection');
strictAssert(keys.size === 1, 'Expected single selection');
const [first] = keys.values();
onSelectSkinTone(first as EmojiSkinTone);
},
[onSelectSkinTone]
);
return (
<ListBox
className="FunSkinTones__ListBox"
orientation="horizontal"
selectedKeys={[props.skinTone]}
selectionMode="single"
onSelectionChange={handleSelectionChange}
>
<SkinTonesListBoxItem emoji={props.emoji} skinTone={EmojiSkinTone.None} />
<SkinTonesListBoxItem
emoji={props.emoji}
skinTone={EmojiSkinTone.Type1}
/>
<SkinTonesListBoxItem
emoji={props.emoji}
skinTone={EmojiSkinTone.Type2}
/>
<SkinTonesListBoxItem
emoji={props.emoji}
skinTone={EmojiSkinTone.Type3}
/>
<SkinTonesListBoxItem
emoji={props.emoji}
skinTone={EmojiSkinTone.Type4}
/>
<SkinTonesListBoxItem
emoji={props.emoji}
skinTone={EmojiSkinTone.Type5}
/>
</ListBox>
);
}
type SkinTonesListBoxItemProps = Readonly<{
emoji: EmojiParentKey;
skinTone: EmojiSkinTone;
}>;
function SkinTonesListBoxItem(props: SkinTonesListBoxItemProps) {
const variant = useMemo(() => {
return getEmojiVariantByParentKeyAndSkinTone(props.emoji, props.skinTone);
}, [props.emoji, props.skinTone]);
return (
<ListBoxItem id={props.skinTone} className="FunSkinTones__ListBoxItem">
<FunEmoji role="presentation" aria-label="" size={32} emoji={variant} />
</ListBoxItem>
);
}

View File

@@ -279,8 +279,6 @@ export function FunSubNavImage(props: FunSubNavImageProps): JSX.Element {
src={props.src}
width={26}
height={26}
// presentational
alt=""
/>
);
}

View File

@@ -7,7 +7,7 @@ import React, { useCallback } from 'react';
import type { Key } from 'react-aria';
import { useId } from 'react-aria';
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components';
import type { FunPickerTabKey } from '../FunConstants';
import type { FunPickerTabKey } from '../constants';
export type FunTabsProps = Readonly<{
value: FunPickerTabKey;