|
1 | 1 | import React from 'react'; |
2 | 2 | import { render, screen } from '@testing-library/react'; |
3 | 3 |
|
4 | | -// Capture the Virtuoso scroll callbacks so scroll-spy is testable, and render items |
5 | | -// synchronously so the browse view's a11y tree is assertable (jsdom has no layout). |
| 4 | +// Capture the Virtuoso scroll callbacks + imperative scroll so scroll-spy and |
| 5 | +// scroll-to-category are testable, and render items synchronously so the browse view's |
| 6 | +// a11y tree is assertable (jsdom has no layout). |
6 | 7 | const { virtuoso } = vi.hoisted(() => ({ |
7 | | - virtuoso: {} as { |
| 8 | + virtuoso: { scrollToIndex: vi.fn() } as { |
8 | 9 | atBottomStateChange?: (atBottom: boolean) => void; |
9 | 10 | rangeChanged?: (range: { endIndex: number; startIndex: number }) => void; |
| 11 | + scrollToIndex: ReturnType<typeof vi.fn>; |
10 | 12 | }, |
11 | 13 | })); |
12 | 14 |
|
13 | | -vi.mock('react-virtuoso', () => ({ |
14 | | - Virtuoso: ({ |
15 | | - atBottomStateChange, |
16 | | - data = [], |
17 | | - itemContent, |
18 | | - rangeChanged, |
19 | | - }: { |
20 | | - atBottomStateChange?: (atBottom: boolean) => void; |
21 | | - data?: unknown[]; |
22 | | - itemContent?: (index: number, item: unknown) => React.ReactNode; |
23 | | - rangeChanged?: (range: { endIndex: number; startIndex: number }) => void; |
24 | | - }) => { |
25 | | - virtuoso.atBottomStateChange = atBottomStateChange; |
26 | | - virtuoso.rangeChanged = rangeChanged; |
27 | | - return ( |
28 | | - <div> |
29 | | - {data.map((item, index) => ( |
30 | | - <React.Fragment key={index}>{itemContent?.(index, item)}</React.Fragment> |
31 | | - ))} |
32 | | - </div> |
33 | | - ); |
34 | | - }, |
35 | | -})); |
| 15 | +vi.mock('react-virtuoso', async () => { |
| 16 | + const { forwardRef, useImperativeHandle } = await import('react'); |
| 17 | + return { |
| 18 | + Virtuoso: forwardRef(function Virtuoso( |
| 19 | + { |
| 20 | + atBottomStateChange, |
| 21 | + data = [], |
| 22 | + itemContent, |
| 23 | + rangeChanged, |
| 24 | + }: { |
| 25 | + atBottomStateChange?: (atBottom: boolean) => void; |
| 26 | + data?: unknown[]; |
| 27 | + itemContent?: (index: number, item: unknown) => React.ReactNode; |
| 28 | + rangeChanged?: (range: { endIndex: number; startIndex: number }) => void; |
| 29 | + }, |
| 30 | + ref, |
| 31 | + ) { |
| 32 | + virtuoso.atBottomStateChange = atBottomStateChange; |
| 33 | + virtuoso.rangeChanged = rangeChanged; |
| 34 | + useImperativeHandle(ref, () => ({ scrollToIndex: virtuoso.scrollToIndex })); |
| 35 | + return ( |
| 36 | + <div> |
| 37 | + {data.map((item, index) => ( |
| 38 | + <React.Fragment key={index}>{itemContent?.(index, item)}</React.Fragment> |
| 39 | + ))} |
| 40 | + </div> |
| 41 | + ); |
| 42 | + }), |
| 43 | + }; |
| 44 | +}); |
36 | 45 |
|
37 | 46 | const { ctx, preview } = vi.hoisted(() => ({ |
38 | 47 | ctx: {} as Record<string, unknown>, |
@@ -62,7 +71,7 @@ const base = () => ({ |
62 | 71 | categories: [] as EmojiPickerCategory[], |
63 | 72 | isSearching: false, |
64 | 73 | resolveNative: (e: { skins: { native: string }[] }) => e.skins[0]?.native ?? '', |
65 | | - scrollTarget: null, |
| 74 | + scrollTarget: null as { categoryId: string; nonce: number } | null, |
66 | 75 | searchResults: null, |
67 | 76 | selectEmoji: vi.fn(), |
68 | 77 | setActiveCategory: vi.fn(), |
@@ -116,3 +125,43 @@ describe('EmojiGrid scroll-spy', () => { |
116 | 125 | expect(ctx.setActiveCategory).toHaveBeenLastCalledWith('flags'); |
117 | 126 | }); |
118 | 127 | }); |
| 128 | + |
| 129 | +describe('EmojiGrid scroll-to-category', () => { |
| 130 | + beforeEach(() => { |
| 131 | + // Run the scroll's requestAnimationFrame defer synchronously so it's assertable. |
| 132 | + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { |
| 133 | + cb(0); |
| 134 | + return 0; |
| 135 | + }); |
| 136 | + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined); |
| 137 | + virtuoso.scrollToIndex.mockClear(); |
| 138 | + }); |
| 139 | + afterEach(() => { |
| 140 | + vi.restoreAllMocks(); |
| 141 | + }); |
| 142 | + |
| 143 | + it('scrolls to a requested category once, and not when categories change', () => { |
| 144 | + ctx.categories = [ |
| 145 | + { emojis: [emoji('grinning', 'π')], id: 'people', label: 'People' }, |
| 146 | + { emojis: [emoji('checkered_flag', 'π')], id: 'flags', label: 'Flags' }, |
| 147 | + ]; |
| 148 | + ctx.scrollTarget = { categoryId: 'flags', nonce: 1 }; |
| 149 | + const { rerender } = render(<EmojiGrid />); |
| 150 | + expect(virtuoso.scrollToIndex).toHaveBeenCalledTimes(1); |
| 151 | + expect(virtuoso.scrollToIndex).toHaveBeenLastCalledWith({ align: 'start', index: 1 }); |
| 152 | + |
| 153 | + // A categories change (e.g. frequently-used updating after a selection) must NOT |
| 154 | + // re-fire the scroll β otherwise the grid jumps back to the last-requested category. |
| 155 | + ctx.categories = [ |
| 156 | + { emojis: [emoji('clock', 'π')], id: 'frequent', label: 'Frequently used' }, |
| 157 | + ...(ctx.categories as EmojiPickerCategory[]), |
| 158 | + ]; |
| 159 | + rerender(<EmojiGrid />); |
| 160 | + expect(virtuoso.scrollToIndex).toHaveBeenCalledTimes(1); |
| 161 | + |
| 162 | + // A fresh scroll request (new nonce) does scroll again. |
| 163 | + ctx.scrollTarget = { categoryId: 'flags', nonce: 2 }; |
| 164 | + rerender(<EmojiGrid />); |
| 165 | + expect(virtuoso.scrollToIndex).toHaveBeenCalledTimes(2); |
| 166 | + }); |
| 167 | +}); |
0 commit comments