Skip to content

Commit 97b285a

Browse files
committed
perf(emojis): drive picker keyboard navigation from the category model
Replace the DOM-geometry grid navigation (getBoundingClientRect + row-epsilon tolerance + center-x scoring) with a pure navigateGrid() over the known category model; only the current column count is read from layout (via offsetTop, once per keypress). This makes Up/Down navigation unit-testable (previously impossible in jsdom, so it was uncovered) and removes the duplicated row-extraction helpers. Also track the roving cell in a ref so moving focus flips two tabIndex values instead of sweeping every mounted cell, and reconcile the roving cell in the render effect only when it has actually detached.
1 parent c375e7d commit 97b285a

4 files changed

Lines changed: 299 additions & 197 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { navigateGrid } from '../gridNavigation';
2+
3+
// A "grid" is a vertical stack of sections (categories); each section is laid out as a
4+
// `columns`-wide grid filled in reading order. navigateGrid is pure: given the section
5+
// lengths, the active (section, index), a key, and the column count, it returns the
6+
// target position (or null to stay put at a grid edge).
7+
8+
describe('navigateGrid — single section (search view)', () => {
9+
const single = [7]; // rows (cols=3): [0,1,2] [3,4,5] [6]
10+
11+
it('moves right/left within a row', () => {
12+
expect(navigateGrid(single, { index: 0, section: 0 }, 'ArrowRight', 3)).toEqual({
13+
index: 1,
14+
section: 0,
15+
});
16+
expect(navigateGrid(single, { index: 1, section: 0 }, 'ArrowLeft', 3)).toEqual({
17+
index: 0,
18+
section: 0,
19+
});
20+
});
21+
22+
it('clamps (null) at the last cell going right and the first going left', () => {
23+
expect(navigateGrid(single, { index: 6, section: 0 }, 'ArrowRight', 3)).toBeNull();
24+
expect(navigateGrid(single, { index: 0, section: 0 }, 'ArrowLeft', 3)).toBeNull();
25+
});
26+
27+
it('moves down/up by one row, same column', () => {
28+
expect(navigateGrid(single, { index: 1, section: 0 }, 'ArrowDown', 3)).toEqual({
29+
index: 4,
30+
section: 0,
31+
});
32+
expect(navigateGrid(single, { index: 4, section: 0 }, 'ArrowUp', 3)).toEqual({
33+
index: 1,
34+
section: 0,
35+
});
36+
});
37+
38+
it('down into a partial last row clamps to that row’s last cell', () => {
39+
// index 5 (row 1, col 2) down: row 2 has only index 6 → clamp to 6.
40+
expect(navigateGrid(single, { index: 5, section: 0 }, 'ArrowDown', 3)).toEqual({
41+
index: 6,
42+
section: 0,
43+
});
44+
});
45+
46+
it('clamps (null) going down from the last row and up from the first', () => {
47+
expect(navigateGrid(single, { index: 6, section: 0 }, 'ArrowDown', 3)).toBeNull();
48+
expect(navigateGrid(single, { index: 2, section: 0 }, 'ArrowUp', 3)).toBeNull();
49+
});
50+
51+
it('Home/End jump to the first/last cell of the whole grid', () => {
52+
expect(navigateGrid(single, { index: 5, section: 0 }, 'Home', 3)).toEqual({
53+
index: 0,
54+
section: 0,
55+
});
56+
expect(navigateGrid(single, { index: 0, section: 0 }, 'End', 3)).toEqual({
57+
index: 6,
58+
section: 0,
59+
});
60+
});
61+
});
62+
63+
describe('navigateGrid — multiple sections (category view)', () => {
64+
const multi = [5, 6]; // section 0 rows (cols=3): [0,1,2] [3,4]; section 1: [0,1,2] [3,4,5]
65+
66+
it('crosses to the next section going right off a section’s last cell', () => {
67+
expect(navigateGrid(multi, { index: 4, section: 0 }, 'ArrowRight', 3)).toEqual({
68+
index: 0,
69+
section: 1,
70+
});
71+
});
72+
73+
it('crosses to the previous section going left off a section’s first cell', () => {
74+
expect(navigateGrid(multi, { index: 0, section: 1 }, 'ArrowLeft', 3)).toEqual({
75+
index: 4,
76+
section: 0,
77+
});
78+
});
79+
80+
it('crosses down into the next section preserving the column', () => {
81+
expect(navigateGrid(multi, { index: 3, section: 0 }, 'ArrowDown', 3)).toEqual({
82+
index: 0,
83+
section: 1,
84+
});
85+
expect(navigateGrid(multi, { index: 4, section: 0 }, 'ArrowDown', 3)).toEqual({
86+
index: 1,
87+
section: 1,
88+
});
89+
});
90+
91+
it('crosses up into the previous section’s last row, clamping the column', () => {
92+
expect(navigateGrid(multi, { index: 1, section: 1 }, 'ArrowUp', 3)).toEqual({
93+
index: 4,
94+
section: 0,
95+
});
96+
// Column 2 has no cell in section 0's last row ([3,4]) → clamp to its last cell.
97+
expect(navigateGrid(multi, { index: 2, section: 1 }, 'ArrowUp', 3)).toEqual({
98+
index: 4,
99+
section: 0,
100+
});
101+
});
102+
103+
it('clamps (null) going right off the very last cell of the last section', () => {
104+
expect(navigateGrid(multi, { index: 5, section: 1 }, 'ArrowRight', 3)).toBeNull();
105+
});
106+
107+
it('End jumps to the last cell of the last section', () => {
108+
expect(navigateGrid(multi, { index: 0, section: 0 }, 'End', 3)).toEqual({
109+
index: 5,
110+
section: 1,
111+
});
112+
});
113+
});

src/plugins/Emojis/hooks/__tests__/useGridKeyboardNav.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ beforeAll(() => {
77
Element.prototype.scrollIntoView = () => undefined;
88
});
99

10-
type Category = { emojis: string[]; id: string };
10+
type Category = { emojis: { id: string }[]; id: string };
1111

1212
/**
1313
* Mimics the virtualized grid: only `initialMounted` categories are in the DOM, and
@@ -33,7 +33,7 @@ const Harness = ({
3333
.filter((category) => mounted.includes(category.id))
3434
.map((category) => (
3535
<section data-category-id={category.id} key={category.id}>
36-
{category.emojis.map((id) => (
36+
{category.emojis.map(({ id }) => (
3737
<button
3838
className='str-chat__emoji-picker__emoji'
3939
data-emoji-id={id}
@@ -51,8 +51,8 @@ const Harness = ({
5151
};
5252

5353
const categories: Category[] = [
54-
{ emojis: ['a1', 'a2', 'a3'], id: 'a' },
55-
{ emojis: ['b1', 'b2', 'b3'], id: 'b' },
54+
{ emojis: [{ id: 'a1' }, { id: 'a2' }, { id: 'a3' }], id: 'a' },
55+
{ emojis: [{ id: 'b1' }, { id: 'b2' }, { id: 'b3' }], id: 'b' },
5656
];
5757

5858
describe('useGridKeyboardNav across virtualization boundaries', () => {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
export type GridPosition = { index: number; section: number };
2+
3+
/**
4+
* Pure keyboard navigation over a sectioned grid: the emoji picker is a vertical stack
5+
* of sections (categories), each laid out as a `columns`-wide grid filled in reading
6+
* order. Given the section lengths, the active `(section, index)`, the pressed key, and
7+
* the current column count, it returns the target position — or `null` to stay put at a
8+
* grid edge.
9+
*
10+
* Kept free of the DOM so the (otherwise layout-dependent, untestable) navigation logic
11+
* can be unit-tested exhaustively. The hook maps the DOM position in/out and measures
12+
* `columns` from layout.
13+
*/
14+
export const navigateGrid = (
15+
sectionLengths: number[],
16+
pos: GridPosition,
17+
key: string,
18+
columns: number,
19+
): GridPosition | null => {
20+
const cols = Math.max(1, columns);
21+
const len = sectionLengths[pos.section] ?? 0;
22+
if (len === 0) return null;
23+
24+
const col = pos.index % cols;
25+
const row = Math.floor(pos.index / cols);
26+
const lastRow = Math.floor((len - 1) / cols);
27+
28+
switch (key) {
29+
case 'Home':
30+
return { index: 0, section: 0 };
31+
32+
case 'End': {
33+
const last = sectionLengths.length - 1;
34+
return { index: Math.max(0, (sectionLengths[last] ?? 0) - 1), section: last };
35+
}
36+
37+
case 'ArrowRight':
38+
if (pos.index < len - 1) return { index: pos.index + 1, section: pos.section };
39+
if (pos.section < sectionLengths.length - 1) {
40+
return { index: 0, section: pos.section + 1 };
41+
}
42+
return null;
43+
44+
case 'ArrowLeft':
45+
if (pos.index > 0) return { index: pos.index - 1, section: pos.section };
46+
if (pos.section > 0) {
47+
return {
48+
index: Math.max(0, (sectionLengths[pos.section - 1] ?? 0) - 1),
49+
section: pos.section - 1,
50+
};
51+
}
52+
return null;
53+
54+
case 'ArrowDown':
55+
if (row < lastRow) {
56+
return { index: Math.min(pos.index + cols, len - 1), section: pos.section };
57+
}
58+
if (pos.section < sectionLengths.length - 1) {
59+
const nextLen = sectionLengths[pos.section + 1] ?? 0;
60+
if (nextLen === 0) return null;
61+
return { index: Math.min(col, nextLen - 1), section: pos.section + 1 };
62+
}
63+
return null;
64+
65+
case 'ArrowUp':
66+
if (row > 0) return { index: pos.index - cols, section: pos.section };
67+
if (pos.section > 0) {
68+
const prevLen = sectionLengths[pos.section - 1] ?? 0;
69+
if (prevLen === 0) return null;
70+
const prevLastRow = Math.floor((prevLen - 1) / cols);
71+
return {
72+
index: Math.min(prevLastRow * cols + col, prevLen - 1),
73+
section: pos.section - 1,
74+
};
75+
}
76+
return null;
77+
78+
default:
79+
return null;
80+
}
81+
};

0 commit comments

Comments
 (0)