Skip to content

Commit 5912c64

Browse files
committed
feat(emojis): add StreamEmojiPicker compound components + useEmojiPickerContext
Expose the built-in emoji picker as compound components so integrators can rearrange or replace parts, while `StreamEmojiPicker` (called directly) stays the unchanged zero-config preset built on top of them. - Add `StreamEmojiPicker.Root` (provider + dialog container owning dataset load, filtering, search, and container-level a11y — dialog role, Escape-to-close, theme class) and the slots `StreamEmojiPicker.Nav` / `.Search` / `.Grid` / `.Preview` / `.SkinTone`. - Expose `useEmojiPickerContext()` with the public contract: read-only data (`categories`, `searchResults`, `status`, `skinTones`, `resolveNative`) and report-back setters (`selectEmoji`, `setQuery`, `setSkinTone`, `setActiveCategory`, `requestScrollToCategory`). Hover-preview state lives in an internal, non-exported context so it doesn't re-render the grid and a custom grid doesn't implicitly drive the default preview. - Convert each slot from prop-driven to context-driven; `EmojiPickerPanel` becomes the default composition. Behavior, a11y, keyboard nav, virtualization, and bundle optionality are unchanged — all pre-existing tests stay green. - `autoFocus` moves to the panel's own props (it's a search behavior), so `Root` no longer advertises a prop it ignores.
1 parent d162162 commit 5912c64

24 files changed

Lines changed: 894 additions & 543 deletions

src/plugins/Emojis/StreamEmojiPicker.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ import {
99
} from '../../components';
1010
import { usePopoverPosition } from '../../components/Dialog/hooks/usePopoverPosition';
1111
import { useIsCooldownActive } from '../../components/MessageComposer/hooks/useIsCooldownActive';
12-
import { EmojiPickerPanel } from './components';
12+
import { CategoryNav } from './components/CategoryNav';
13+
import { EmojiGrid } from './components/EmojiGrid';
14+
import { EmojiPickerPanel } from './components/EmojiPickerPanel';
15+
import { EmojiPickerRoot } from './components/EmojiPickerRoot';
16+
import { PreviewPane } from './components/PreviewPane';
17+
import { SearchInput } from './components/SearchInput';
18+
import { SkinToneSelector } from './components/SkinToneSelector';
1319
import { useFrequentlyUsedEmoji } from './hooks/useFrequentlyUsedEmoji';
1420
import { useSkinTone } from './hooks/useSkinTone';
1521

@@ -61,7 +67,7 @@ const classNames: Pick<
6167
wrapperClassName: 'str-chat__message-textarea-emoji-picker',
6268
};
6369

64-
export const StreamEmojiPicker = (props: StreamEmojiPickerProps) => {
70+
const StreamEmojiPickerComponent = (props: StreamEmojiPickerProps) => {
6571
const { t } = useTranslationContext('EmojiPicker');
6672
const { textareaRef } = useMessageComposerContext('EmojiPicker');
6773
const { textComposer } = useMessageComposerController();
@@ -179,3 +185,18 @@ export const StreamEmojiPicker = (props: StreamEmojiPickerProps) => {
179185
</div>
180186
);
181187
};
188+
189+
/**
190+
* The built-in emoji picker. Rendered directly, it is the batteries-included preset
191+
* (toggle button + popover + the standard panel). For a custom layout, compose the parts
192+
* — `StreamEmojiPicker.Root` wrapping any arrangement of `.Nav` / `.Search` / `.Grid` /
193+
* `.Preview` / `.SkinTone` (and your own slots via `useEmojiPickerContext`).
194+
*/
195+
export const StreamEmojiPicker = Object.assign(StreamEmojiPickerComponent, {
196+
Grid: EmojiGrid,
197+
Nav: CategoryNav,
198+
Preview: PreviewPane,
199+
Root: EmojiPickerRoot,
200+
Search: SearchInput,
201+
SkinTone: SkinToneSelector,
202+
});

src/plugins/Emojis/__tests__/StreamEmojiPicker.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
55
// stub. This lets us assert that the owner (EmojiPicker) preserves skin tone and
66
// frequently-used across open/close — without depending on the real panel's
77
// virtualized grid + async data load, which don't render reliably in jsdom.
8-
vi.mock('../components', () => ({
8+
vi.mock('../components/EmojiPickerPanel', () => ({
99
EmojiPickerPanel: ({
1010
frequentlyUsedIds = [],
1111
onEmojiSelect,
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
11
import * as emojis from '../index';
2+
import { StreamEmojiPicker } from '../StreamEmojiPicker';
23

34
describe('emojis entry exports', () => {
45
it('exports both the deprecated EmojiPicker and the StreamEmojiPicker successor', () => {
56
expect(typeof emojis.EmojiPicker).toBe('function');
67
expect(typeof emojis.StreamEmojiPicker).toBe('function');
78
});
9+
10+
it('exposes the compound API + context hook', () => {
11+
expect(typeof emojis.useEmojiPickerContext).toBe('function');
12+
for (const part of [
13+
'Grid',
14+
'Nav',
15+
'Preview',
16+
'Root',
17+
'Search',
18+
'SkinTone',
19+
] as const) {
20+
expect(typeof StreamEmojiPicker[part]).toBe('function');
21+
}
22+
});
823
});

src/plugins/Emojis/components/CategoryNav.tsx

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,23 @@
11
import { type KeyboardEvent, useRef } from 'react';
22
import clsx from 'clsx';
33
import { EMOJI_CATEGORY_META } from './categories';
4-
import type { EmojiPickerCategory } from './EmojiGrid';
5-
6-
export type CategoryNavProps = {
7-
categories: EmojiPickerCategory[];
8-
onNavigate: (categoryId: string) => void;
9-
activeCategoryId?: string;
10-
};
4+
import { useEmojiPickerContext } from '../context/EmojiPickerContext';
115

126
const NAV_KEYS = ['ArrowRight', 'ArrowLeft', 'Home', 'End'];
137

148
/**
159
* Top navigation bar with one tab per category (role="tablist"). Clicking a tab
1610
* scrolls its section into view; Left/Right/Home/End move focus between tabs with a
17-
* roving tabindex; the active tab reflects the currently visible section.
11+
* roving tabindex; the active tab reflects the currently visible section. Reads the
12+
* category model + active category from context and reports clicks via
13+
* `requestScrollToCategory`. The active tab is suppressed during search.
1814
*/
19-
export const CategoryNav = ({
20-
activeCategoryId,
21-
categories,
22-
onNavigate,
23-
}: CategoryNavProps) => {
15+
export const CategoryNav = () => {
16+
const { activeCategoryId, categories, isSearching, requestScrollToCategory } =
17+
useEmojiPickerContext('CategoryNav');
2418
const navRef = useRef<HTMLDivElement>(null);
25-
const rovingId = activeCategoryId ?? categories[0]?.id;
19+
const active = isSearching ? undefined : activeCategoryId;
20+
const rovingId = active ?? categories[0]?.id;
2621

2722
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
2823
if (!NAV_KEYS.includes(event.key)) return;
@@ -51,13 +46,12 @@ export const CategoryNav = ({
5146
{categories.map(({ id, label }) => (
5247
<button
5348
aria-label={label}
54-
aria-selected={activeCategoryId === id}
49+
aria-selected={active === id}
5550
className={clsx('str-chat__emoji-picker__category-nav-button', {
56-
'str-chat__emoji-picker__category-nav-button--active':
57-
activeCategoryId === id,
51+
'str-chat__emoji-picker__category-nav-button--active': active === id,
5852
})}
5953
key={id}
60-
onClick={() => onNavigate(id)}
54+
onClick={() => requestScrollToCategory(id)}
6155
role='tab'
6256
tabIndex={id === rovingId ? 0 : -1}
6357
type='button'
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { memo } from 'react';
22
import { useEmojiPickerContext } from '../context/EmojiPickerContext';
3+
import { useEmojiPickerPreviewContext } from '../context/EmojiPickerPreviewContext';
34
import type { EmojiDataEmoji } from '../data';
45

56
export type EmojiButtonProps = {
@@ -11,16 +12,15 @@ export type EmojiButtonProps = {
1112
* skin tone. Memoized because the grid can render ~1800 of these.
1213
*/
1314
export const EmojiButton = memo(function EmojiButton({ emoji }: EmojiButtonProps) {
14-
const { onSelectEmoji, setPreviewedEmoji, skinToneIndex } =
15-
useEmojiPickerContext('EmojiButton');
16-
const native = emoji.skins[skinToneIndex]?.native ?? emoji.skins[0]?.native ?? '';
15+
const { resolveNative, selectEmoji } = useEmojiPickerContext('EmojiButton');
16+
const { setPreviewedEmoji } = useEmojiPickerPreviewContext();
1717

1818
return (
1919
<button
2020
aria-label={emoji.name}
2121
className='str-chat__emoji-picker__emoji'
2222
data-emoji-id={emoji.id}
23-
onClick={() => onSelectEmoji(emoji)}
23+
onClick={() => selectEmoji(emoji)}
2424
onFocus={() => setPreviewedEmoji(emoji)}
2525
onMouseEnter={() => setPreviewedEmoji(emoji)}
2626
// Native <button> semantics (not role='gridcell'): the category view is
@@ -29,7 +29,7 @@ export const EmojiButton = memo(function EmojiButton({ emoji }: EmojiButtonProps
2929
tabIndex={-1}
3030
type='button'
3131
>
32-
{native}
32+
{resolveNative(emoji)}
3333
</button>
3434
);
3535
});
Lines changed: 71 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import { forwardRef, useImperativeHandle, useRef } from 'react';
1+
import { useCallback, useEffect, useRef } from 'react';
22
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso';
33
import { EmojiButton } from './EmojiButton';
4+
import { EmptyResults } from './EmptyResults';
5+
import { useEmojiPickerContext } from '../context/EmojiPickerContext';
6+
import { useGridKeyboardNav } from '../hooks/useGridKeyboardNav';
47
import type { EmojiDataEmoji } from '../data';
58

69
export type EmojiPickerCategory = {
@@ -9,15 +12,6 @@ export type EmojiPickerCategory = {
912
label: string;
1013
};
1114

12-
export type EmojiGridHandle = {
13-
scrollToCategory: (categoryId: string) => void;
14-
};
15-
16-
export type EmojiGridProps = {
17-
categories: EmojiPickerCategory[];
18-
onActiveCategoryChange?: (categoryId: string) => void;
19-
};
20-
2115
const CategorySection = ({ category }: { category: EmojiPickerCategory }) => (
2216
<section
2317
aria-label={category.label}
@@ -36,51 +30,80 @@ const CategorySection = ({ category }: { category: EmojiPickerCategory }) => (
3630
);
3731

3832
/**
39-
* The category-grouped emoji grid. Virtualized at the category level with
40-
* react-virtuoso: only the categories in (and near) the viewport are mounted, which
41-
* keeps opening the picker fast without giving up section headers, scroll-spy, or
42-
* per-category scrolling. `scrollToCategory` is exposed imperatively for the nav.
33+
* The context-driven body of the picker: while browsing, a category-grouped grid,
34+
* virtualized at the category level with react-virtuoso (only categories in/near the
35+
* viewport mount); while searching, a flat result list or the empty state. Scrolls to
36+
* the category named by `scrollTarget`, reports the visible category via
37+
* `setActiveCategory` (scroll-spy), and owns 2D roving keyboard navigation over its
38+
* cells. A custom Grid slot forfeits these mechanics but keeps reading the same context.
4339
*/
44-
export const EmojiGrid = forwardRef<EmojiGridHandle, EmojiGridProps>(function EmojiGrid(
45-
{ categories, onActiveCategoryChange },
46-
ref,
47-
) {
40+
export const EmojiGrid = () => {
41+
const { categories, isSearching, scrollTarget, searchResults, setActiveCategory } =
42+
useEmojiPickerContext('EmojiGrid');
4843
const virtuosoRef = useRef<VirtuosoHandle>(null);
4944
const atBottomRef = useRef(false);
45+
const bodyRef = useRef<HTMLDivElement>(null);
5046

51-
useImperativeHandle(
52-
ref,
53-
() => ({
54-
scrollToCategory: (categoryId: string) => {
55-
const index = categories.findIndex((category) => category.id === categoryId);
56-
if (index >= 0) virtuosoRef.current?.scrollToIndex({ align: 'start', index });
57-
},
58-
}),
47+
const scrollToCategory = useCallback(
48+
(categoryId: string) => {
49+
const index = categories.findIndex((category) => category.id === categoryId);
50+
if (index >= 0) virtuosoRef.current?.scrollToIndex({ align: 'start', index });
51+
},
5952
[categories],
6053
);
6154

55+
// Nav clicks (and any consumer) request a scroll via `scrollTarget`; the nonce makes a
56+
// repeat request to the same category re-fire.
57+
useEffect(() => {
58+
if (scrollTarget) scrollToCategory(scrollTarget.categoryId);
59+
}, [scrollTarget, scrollToCategory]);
60+
61+
// Keyboard nav can target a category that virtualization has unmounted; give it the
62+
// category order + a way to scroll one into view so focus can traverse the whole set.
63+
const { onKeyDown } = useGridKeyboardNav(bodyRef, { categories, scrollToCategory });
64+
65+
if (isSearching) {
66+
return (
67+
<div className='str-chat__emoji-picker__body' onKeyDown={onKeyDown} ref={bodyRef}>
68+
{searchResults && searchResults.length ? (
69+
<div className='str-chat__emoji-picker__grid-container'>
70+
<div className='str-chat__emoji-picker__grid'>
71+
<div className='str-chat__emoji-picker__category-emojis'>
72+
{searchResults.map((emoji) => (
73+
<EmojiButton emoji={emoji} key={emoji.id} />
74+
))}
75+
</div>
76+
</div>
77+
</div>
78+
) : (
79+
<EmptyResults />
80+
)}
81+
</div>
82+
);
83+
}
84+
6285
return (
63-
<Virtuoso
64-
atBottomStateChange={(atBottom) => {
65-
atBottomRef.current = atBottom;
66-
// The last category is often short and never reaches the top of the viewport,
67-
// so scroll-spy alone would never mark it active — pin it while at the bottom.
68-
if (atBottom) {
86+
<div className='str-chat__emoji-picker__body' onKeyDown={onKeyDown} ref={bodyRef}>
87+
<Virtuoso
88+
atBottomStateChange={(atBottom) => {
89+
atBottomRef.current = atBottom;
90+
// The last category is often short and never reaches the top of the viewport,
91+
// so scroll-spy alone would never mark it active — pin it while at the bottom.
6992
const last = categories[categories.length - 1];
70-
if (last) onActiveCategoryChange?.(last.id);
71-
}
72-
}}
73-
className='str-chat__emoji-picker__grid'
74-
data={categories}
75-
itemContent={(_index, category) => <CategorySection category={category} />}
76-
rangeChanged={({ startIndex }) => {
77-
// While pinned at the bottom, atBottomStateChange owns the active category.
78-
if (atBottomRef.current) return;
79-
const category = categories[startIndex];
80-
if (category) onActiveCategoryChange?.(category.id);
81-
}}
82-
ref={virtuosoRef}
83-
style={{ height: '100%' }}
84-
/>
93+
if (atBottom && last) setActiveCategory(last.id);
94+
}}
95+
className='str-chat__emoji-picker__grid'
96+
data={categories}
97+
itemContent={(_index, category) => <CategorySection category={category} />}
98+
rangeChanged={({ startIndex }) => {
99+
// While pinned at the bottom, atBottomStateChange owns the active category.
100+
if (atBottomRef.current) return;
101+
const category = categories[startIndex];
102+
if (category) setActiveCategory(category.id);
103+
}}
104+
ref={virtuosoRef}
105+
style={{ height: '100%' }}
106+
/>
107+
</div>
85108
);
86-
});
109+
};

0 commit comments

Comments
 (0)