Skip to content

Commit 79b2a23

Browse files
committed
fix(emojis): let keyboard navigation cross virtualized category boundaries
The emoji grid virtualizes at the category level, but useGridKeyboardNav only considered the cells currently mounted. At the edge of the mounted window ArrowRight clamped to the current cell and ArrowDown found nothing, with no way to scroll an unmounted category in — so keyboard users could not reach the full emoji set. Give the hook the ordered categories and a scrollToCategory callback. Within the mounted window it behaves as before; when a move leaves that window it scrolls the neighbouring category into view, waits (MutationObserver, with a timeout fallback) for its cells to mount, then focuses the target — first/last cell for Left/Right, same-column first/last row for Up/Down. Home/End now reach the absolute first/last emoji. Crossing is suppressed in the search view, whose flat results have no category and are all mounted.
1 parent 044e97a commit 79b2a23

3 files changed

Lines changed: 344 additions & 35 deletions

File tree

src/plugins/Emojis/components/EmojiPickerPanel.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,6 @@ export const EmojiPickerPanel = ({
8888
const [query, setQuery] = useState('');
8989
const emojiGridRef = useRef<EmojiGridHandle>(null);
9090
const bodyRef = useRef<HTMLDivElement>(null);
91-
const { focusFirst, onKeyDown: onGridKeyDown } = useGridKeyboardNav(bodyRef);
9291

9392
const baseCategories = useMemo<EmojiPickerCategory[]>(() => {
9493
if (!data) return [];
@@ -111,6 +110,17 @@ export const EmojiPickerPanel = ({
111110
return frequent.emojis.length ? [frequent, ...baseCategories] : baseCategories;
112111
}, [baseCategories, data, frequentlyUsedIds, t]);
113112

113+
const scrollToCategory = useCallback((categoryId: string) => {
114+
emojiGridRef.current?.scrollToCategory(categoryId);
115+
}, []);
116+
117+
// Keyboard nav can target a category that virtualization has unmounted; give it the
118+
// category order + a way to scroll one into view so focus can traverse the whole set.
119+
const { focusFirst, onKeyDown: onGridKeyDown } = useGridKeyboardNav(bodyRef, {
120+
categories,
121+
scrollToCategory,
122+
});
123+
114124
const searchIndex = useMemo(() => (data ? buildEmojiSearchData(data) : []), [data]);
115125

116126
// `null` when not searching; otherwise the (possibly empty) list of matches.
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { useRef, useState } from 'react';
2+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3+
import { useGridKeyboardNav } from '../useGridKeyboardNav';
4+
5+
// jsdom doesn't implement scrollIntoView; the hook calls it after focusing a cell.
6+
beforeAll(() => {
7+
Element.prototype.scrollIntoView = () => undefined;
8+
});
9+
10+
type Category = { emojis: string[]; id: string };
11+
12+
/**
13+
* Mimics the virtualized grid: only `initialMounted` categories are in the DOM, and
14+
* asking to scroll to a category "mounts" it (as Virtuoso would). This lets us prove
15+
* navigation can cross into a category that virtualization has not yet rendered.
16+
*/
17+
const Harness = ({
18+
categories,
19+
initialMounted,
20+
}: {
21+
categories: Category[];
22+
initialMounted: string[];
23+
}) => {
24+
const ref = useRef<HTMLDivElement>(null);
25+
const [mounted, setMounted] = useState<string[]>(initialMounted);
26+
const scrollToCategory = (id: string) =>
27+
setMounted((current) => (current.includes(id) ? current : [...current, id]));
28+
const { onKeyDown } = useGridKeyboardNav(ref, { categories, scrollToCategory });
29+
30+
return (
31+
<div data-testid='grid' onKeyDown={onKeyDown} ref={ref}>
32+
{categories
33+
.filter((category) => mounted.includes(category.id))
34+
.map((category) => (
35+
<section data-category-id={category.id} key={category.id}>
36+
{category.emojis.map((id) => (
37+
<button
38+
className='str-chat__emoji-picker__emoji'
39+
data-emoji-id={id}
40+
key={id}
41+
tabIndex={-1}
42+
type='button'
43+
>
44+
{id}
45+
</button>
46+
))}
47+
</section>
48+
))}
49+
</div>
50+
);
51+
};
52+
53+
const categories: Category[] = [
54+
{ emojis: ['a1', 'a2', 'a3'], id: 'a' },
55+
{ emojis: ['b1', 'b2', 'b3'], id: 'b' },
56+
];
57+
58+
describe('useGridKeyboardNav across virtualization boundaries', () => {
59+
it('ArrowRight past the last mounted cell mounts the next category and focuses its first cell', async () => {
60+
render(<Harness categories={categories} initialMounted={['a']} />);
61+
// The next category isn't mounted yet (virtualization).
62+
expect(screen.queryByText('b1')).not.toBeInTheDocument();
63+
64+
screen.getByText('a3').focus();
65+
fireEvent.keyDown(screen.getByText('a3'), { key: 'ArrowRight' });
66+
67+
await waitFor(() => expect(screen.getByText('b1')).toHaveFocus());
68+
});
69+
70+
it('ArrowLeft before the first mounted cell mounts the previous category and focuses its last cell', async () => {
71+
render(<Harness categories={categories} initialMounted={['b']} />);
72+
expect(screen.queryByText('a3')).not.toBeInTheDocument();
73+
74+
screen.getByText('b1').focus();
75+
fireEvent.keyDown(screen.getByText('b1'), { key: 'ArrowLeft' });
76+
77+
await waitFor(() => expect(screen.getByText('a3')).toHaveFocus());
78+
});
79+
80+
it('ArrowRight still moves within the mounted set without scrolling', () => {
81+
render(<Harness categories={categories} initialMounted={['a']} />);
82+
83+
screen.getByText('a1').focus();
84+
fireEvent.keyDown(screen.getByText('a1'), { key: 'ArrowRight' });
85+
86+
expect(screen.getByText('a2')).toHaveFocus();
87+
});
88+
89+
it('does not try to cross categories in the search view (flat results, no category sections)', () => {
90+
const scrollToCategory = vi.fn();
91+
const SearchHarness = () => {
92+
const ref = useRef<HTMLDivElement>(null);
93+
const { onKeyDown } = useGridKeyboardNav(ref, { categories, scrollToCategory });
94+
// Search results render as a flat grid with no [data-category-id] ancestor.
95+
return (
96+
<div data-testid='grid' onKeyDown={onKeyDown} ref={ref}>
97+
{['r1', 'r2'].map((id) => (
98+
<button
99+
className='str-chat__emoji-picker__emoji'
100+
data-emoji-id={id}
101+
key={id}
102+
tabIndex={-1}
103+
type='button'
104+
>
105+
{id}
106+
</button>
107+
))}
108+
</div>
109+
);
110+
};
111+
render(<SearchHarness />);
112+
113+
// ArrowRight at the last result clamps (no category to scroll to)…
114+
screen.getByText('r2').focus();
115+
fireEvent.keyDown(screen.getByText('r2'), { key: 'ArrowRight' });
116+
expect(screen.getByText('r2')).toHaveFocus();
117+
118+
// …and Home goes to the first result rather than scrolling to a category.
119+
fireEvent.keyDown(screen.getByText('r2'), { key: 'Home' });
120+
expect(screen.getByText('r1')).toHaveFocus();
121+
122+
expect(scrollToCategory).not.toHaveBeenCalled();
123+
});
124+
});

0 commit comments

Comments
 (0)