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]);
+ });
+});