Skip to content

Commit e539909

Browse files
committed
fix(emojis): mark the last category active when scrolled to the bottom
The grid's scroll-spy only used Virtuoso's startIndex, so a short final category that never reaches the top of the viewport never lit its nav tab. Pin the last category active while at the bottom (and keep range changes from stealing it back). The Virtuoso mock previously rendered all items and never fired the scroll callbacks, so this was untested — the mock now captures them. Also harden two search tests that passed vacuously: the localeCompare tie-break fixture now actually ties (equal-length names put the shared keyword at the same offset), and the AND-semantics assertion checks comma-anchored token matches rather than a loose substring.
1 parent 97b285a commit e539909

3 files changed

Lines changed: 97 additions & 14 deletions

File tree

src/plugins/Emojis/components/EmojiGrid.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export const EmojiGrid = forwardRef<EmojiGridHandle, EmojiGridProps>(function Em
4646
ref,
4747
) {
4848
const virtuosoRef = useRef<VirtuosoHandle>(null);
49+
const atBottomRef = useRef(false);
4950

5051
useImperativeHandle(
5152
ref,
@@ -60,10 +61,21 @@ export const EmojiGrid = forwardRef<EmojiGridHandle, EmojiGridProps>(function Em
6061

6162
return (
6263
<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) {
69+
const last = categories[categories.length - 1];
70+
if (last) onActiveCategoryChange?.(last.id);
71+
}
72+
}}
6373
className='str-chat__emoji-picker__grid'
6474
data={categories}
6575
itemContent={(_index, category) => <CategorySection category={category} />}
6676
rangeChanged={({ startIndex }) => {
77+
// While pinned at the bottom, atBottomStateChange owns the active category.
78+
if (atBottomRef.current) return;
6779
const category = categories[startIndex];
6880
if (category) onActiveCategoryChange?.(category.id);
6981
}}

src/plugins/Emojis/components/__tests__/EmojiGrid.test.tsx

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,47 @@ import { render, screen } from '@testing-library/react';
33
import { EmojiGrid, type EmojiPickerCategory } from '../EmojiGrid';
44
import { EmojiPickerProvider } from '../../context/EmojiPickerContext';
55

6-
// Mirror the repo's react-virtuoso mock: render every item synchronously so the
7-
// non-search view's accessibility tree is assertable in jsdom.
6+
// Capture the Virtuoso scroll callbacks so the scroll-spy behavior is testable, and
7+
// render every item synchronously so the non-search view's a11y tree is assertable.
8+
const { virtuoso } = vi.hoisted(() => ({
9+
virtuoso: {} as {
10+
atBottomStateChange?: (atBottom: boolean) => void;
11+
rangeChanged?: (range: { endIndex: number; startIndex: number }) => void;
12+
},
13+
}));
14+
815
vi.mock('react-virtuoso', () => ({
916
Virtuoso: ({
17+
atBottomStateChange,
1018
data = [],
1119
itemContent,
20+
rangeChanged,
1221
}: {
22+
atBottomStateChange?: (atBottom: boolean) => void;
1323
data?: EmojiPickerCategory[];
1424
itemContent?: (index: number, item: EmojiPickerCategory) => React.ReactNode;
15-
}) => (
16-
<div>
17-
{data.map((item, index) => (
18-
<React.Fragment key={index}>{itemContent?.(index, item)}</React.Fragment>
19-
))}
20-
</div>
21-
),
25+
rangeChanged?: (range: { endIndex: number; startIndex: number }) => void;
26+
}) => {
27+
virtuoso.atBottomStateChange = atBottomStateChange;
28+
virtuoso.rangeChanged = rangeChanged;
29+
return (
30+
<div>
31+
{data.map((item, index) => (
32+
<React.Fragment key={index}>{itemContent?.(index, item)}</React.Fragment>
33+
))}
34+
</div>
35+
);
36+
},
2237
}));
2338

39+
const emoji = (id: string, native: string) => ({
40+
id,
41+
keywords: [],
42+
name: id,
43+
skins: [{ native, unified: '' }],
44+
version: 1,
45+
});
46+
2447
const categories: EmojiPickerCategory[] = [
2548
{
2649
emojis: [
@@ -65,3 +88,46 @@ describe('EmojiGrid accessibility (non-search view)', () => {
6588
expect(screen.getByRole('region', { name: 'Smileys & People' })).toBeInTheDocument();
6689
});
6790
});
91+
92+
describe('EmojiGrid scroll-spy (active category tracking)', () => {
93+
const twoCategories: EmojiPickerCategory[] = [
94+
{ emojis: [emoji('grinning', '😀')], id: 'people', label: 'People' },
95+
{ emojis: [emoji('checkered_flag', '🏁')], id: 'flags', label: 'Flags' },
96+
];
97+
98+
const renderSpy = (onActiveCategoryChange: (id: string) => void) =>
99+
render(
100+
<EmojiPickerProvider
101+
value={{ onSelectEmoji: vi.fn(), setPreviewedEmoji: vi.fn(), skinToneIndex: 0 }}
102+
>
103+
<EmojiGrid
104+
categories={twoCategories}
105+
onActiveCategoryChange={onActiveCategoryChange}
106+
/>
107+
</EmojiPickerProvider>,
108+
);
109+
110+
it('marks the category at the top of the viewport active as the list scrolls', () => {
111+
const onActive = vi.fn();
112+
renderSpy(onActive);
113+
114+
virtuoso.rangeChanged?.({ endIndex: 1, startIndex: 1 });
115+
116+
expect(onActive).toHaveBeenLastCalledWith('flags');
117+
});
118+
119+
it('marks the last category active at the bottom, even when a short final category never reaches the top', () => {
120+
const onActive = vi.fn();
121+
renderSpy(onActive);
122+
123+
// Top of the viewport is still the first category…
124+
virtuoso.rangeChanged?.({ endIndex: 1, startIndex: 0 });
125+
// …but the list is scrolled to the very bottom, so the last category is active.
126+
virtuoso.atBottomStateChange?.(true);
127+
expect(onActive).toHaveBeenLastCalledWith('flags');
128+
129+
// Range changes while pinned at the bottom must not steal the active category back.
130+
virtuoso.rangeChanged?.({ endIndex: 1, startIndex: 0 });
131+
expect(onActive).toHaveBeenLastCalledWith('flags');
132+
});
133+
});

src/plugins/Emojis/search/__tests__/search.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,12 @@ describe('runSearch (against the vendored dataset)', () => {
3333
it('applies AND semantics across multiple words', () => {
3434
const results = runSearch(index, 'red heart') ?? [];
3535
expect(results.map((emoji) => emoji.id)).toContain('heart'); // "Red Heart"
36-
// every returned emoji must have matched both words
36+
// Every result matched BOTH words as comma-anchored tokens — mirroring runSearch's
37+
// own `indexOf(",word")`, not a loose substring that a match like "tired" would pass.
3738
expect(
38-
results.every((emoji) => /red/.test(emoji.search) && /heart/.test(emoji.search)),
39+
results.every(
40+
(emoji) => emoji.search.includes(',red') && emoji.search.includes(',heart'),
41+
),
3942
).toBe(true);
4043
});
4144

@@ -70,18 +73,20 @@ describe('runSearch (ranking details, synthetic data)', () => {
7073
skins: [{ native: '②', unified: '' }],
7174
version: 1,
7275
},
73-
// both share the "tie" keyword at the same offset (equal-length ids)
76+
// Equal-length names (and ids) so the shared "tie" keyword lands at the same
77+
// haystack offset in both — giving an actual score tie that the id.localeCompare
78+
// tie-break must resolve (otherwise score alone would decide the order).
7479
ccc: {
7580
id: 'ccc',
7681
keywords: ['tie'],
77-
name: 'Third',
82+
name: 'Cat',
7883
skins: [{ native: '③', unified: '' }],
7984
version: 1,
8085
},
8186
ddd: {
8287
id: 'ddd',
8388
keywords: ['tie'],
84-
name: 'Fourth',
89+
name: 'Dog',
8590
skins: [{ native: '④', unified: '' }],
8691
version: 1,
8792
},

0 commit comments

Comments
 (0)