diff --git a/apps/web/src/lib/components/EmojiPicker.svelte b/apps/web/src/lib/components/EmojiPicker.svelte new file mode 100644 index 0000000..37abf4b --- /dev/null +++ b/apps/web/src/lib/components/EmojiPicker.svelte @@ -0,0 +1,133 @@ + + + +
+ +
+ {#each CATEGORIES as cat (cat)} + + {/each} +
+ + +
+ {#each activeList as e, i (e)} + + {/each} +
+
diff --git a/apps/web/src/lib/components/EmojiPicker.test.ts b/apps/web/src/lib/components/EmojiPicker.test.ts new file mode 100644 index 0000000..6aeedc8 --- /dev/null +++ b/apps/web/src/lib/components/EmojiPicker.test.ts @@ -0,0 +1,115 @@ +/** + * Fase 19.4.1 — EmojiPicker component unit tests. + * + * Mirrors the Spinner.test.ts / ChangelogModal.test.ts pattern (Svelte 5 + * `mount`/`unmount`, no @testing-library dep). + * + * Specs: + * EP-U-01 — 4 category tabs are rendered with role="tab". + * EP-U-02 — default selected tab is "faces" (aria-selected on the faces tab). + * EP-U-03 — clicking a gridcell fires `onSelect` with the underlying emoji. + * EP-U-04 — clicking a different tab re-renders the grid with that + * category's first emoji (lazy render: only the active grid + * is mounted, so the previously-active grid is gone from the DOM). + * EP-U-05 — ArrowRight moves focus from cell N to cell N+1. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { flushSync, mount, unmount } from 'svelte'; +import EmojiPicker from './EmojiPicker.svelte'; +import { EMOJI_CATALOG } from '$lib/data/emoji-catalog'; + +let host: HTMLElement; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let instance: any; + +function render(props: Record) { + host = document.createElement('div'); + document.body.appendChild(host); + instance = mount(EmojiPicker, { target: host, props }); + flushSync(); + return host; +} + +beforeEach(() => { + host = document.createElement('div'); +}); + +afterEach(() => { + if (instance) unmount(instance); + if (host?.parentNode) host.parentNode.removeChild(host); + instance = null; +}); + +describe('EmojiPicker component', () => { + it('EP-U-01: renders 4 category tabs with role="tab"', () => { + const root = render({ selected: null, onSelect: () => {} }); + const tabs = root.querySelectorAll('[role="tab"]'); + expect(tabs.length).toBe(4); + }); + + it('EP-U-02: default category is "faces" (aria-selected)', () => { + const root = render({ selected: null, onSelect: () => {} }); + const facesTab = root.querySelector('[data-testid="emoji-picker-tab-faces"]'); + expect(facesTab).not.toBeNull(); + expect(facesTab?.getAttribute('aria-selected')).toBe('true'); + + // And the grid's first cell should be the first faces emoji. + const firstCell = root.querySelector('[role="gridcell"]'); + expect(firstCell?.textContent?.trim()).toBe(EMOJI_CATALOG.faces[0]); + }); + + it('EP-U-03: clicking a gridcell fires onSelect with the emoji', () => { + const onSelect = vi.fn(); + const root = render({ selected: null, onSelect }); + const cells = root.querySelectorAll('[role="gridcell"]'); + expect(cells.length).toBeGreaterThan(0); + const target = cells[2] as HTMLElement; + const expected = EMOJI_CATALOG.faces[2]; + target.click(); + expect(onSelect).toHaveBeenCalledWith(expected); + }); + + it('EP-U-04: switching tabs lazy-renders the new category grid', () => { + const root = render({ selected: null, onSelect: () => {} }); + + const animalsTab = root.querySelector( + '[data-testid="emoji-picker-tab-animals"]' + ) as HTMLElement; + expect(animalsTab).not.toBeNull(); + animalsTab.click(); + flushSync(); + + // Tab state moved. + expect(animalsTab.getAttribute('aria-selected')).toBe('true'); + expect( + root + .querySelector('[data-testid="emoji-picker-tab-faces"]') + ?.getAttribute('aria-selected') + ).toBe('false'); + + // Grid's first cell is now the first animals emoji. + const firstCell = root.querySelector('[role="gridcell"]'); + expect(firstCell?.textContent?.trim()).toBe(EMOJI_CATALOG.animals[0]); + + // Lazy-render invariant: only the active category's grid lives in the + // DOM. We assert the total cell count equals the active category size. + const cells = root.querySelectorAll('[role="gridcell"]'); + expect(cells.length).toBe(EMOJI_CATALOG.animals.length); + }); + + it('EP-U-05: ArrowRight moves focus from one cell to the next', () => { + const root = render({ selected: null, onSelect: () => {} }); + const cells = Array.from(root.querySelectorAll('[role="gridcell"]')) as HTMLElement[]; + expect(cells.length).toBeGreaterThanOrEqual(2); + + cells[0].focus(); + expect(document.activeElement).toBe(cells[0]); + + cells[0].dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }) + ); + flushSync(); + + expect(document.activeElement).toBe(cells[1]); + }); +});