feat(fase-19): EmojiPicker component with tabbed lazy-rendered grids
4-tab tablist + WAI-ARIA grid for the active category only. Switching tabs unmounts the previous category's cells so the DOM never holds more than ~140 buttons at once (animals is the worst case). Cells use roving tabindex (first cell tabindex=0, rest -1) per the grid pattern; arrow keys / Home / End walk focus cell-by-cell, the tablist receives a single Tab stop. Covers EP-U-01..05 (tab count + role, default selected, click→onSelect, tab switch re-renders grid, ArrowRight roving focus). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
133
apps/web/src/lib/components/EmojiPicker.svelte
Normal file
133
apps/web/src/lib/components/EmojiPicker.svelte
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<!--
|
||||||
|
Fase 19.2 — Emoji picker.
|
||||||
|
|
||||||
|
Tabbed grid of ~280–360 emoji codepoints (see $lib/data/emoji-catalog).
|
||||||
|
Header is a tablist with 4 category buttons; body is a single grid for
|
||||||
|
the currently-selected tab. Switching tabs unmounts the previous grid
|
||||||
|
and mounts the new one — lazy-render — so the DOM never holds more
|
||||||
|
than `EMOJI_CATALOG[activeCategory].length` cells at once (the
|
||||||
|
"objects" tab is the smallest; "animals" is the worst case at ~140).
|
||||||
|
|
||||||
|
Props:
|
||||||
|
- `selected`: the currently-active emoji string (highlights the
|
||||||
|
matching cell, if any).
|
||||||
|
- `onSelect(emoji)`: fired on cell click. Caller owns the persistence
|
||||||
|
side-effect (update DB / Supabase row).
|
||||||
|
|
||||||
|
Keyboard nav: arrow keys move focus between cells in the active grid.
|
||||||
|
Cells are `tabindex={index === 0 ? 0 : -1}` (roving focus) per WAI-ARIA
|
||||||
|
grid pattern — the parent tab order has a single entry into the grid,
|
||||||
|
and arrow keys then walk it cell-by-cell. Tab order is consistent with
|
||||||
|
existing modals on the host pages (settings, onboarding, manage,
|
||||||
|
CreateCollectiveModal).
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
EMOJI_CATALOG,
|
||||||
|
CATEGORY_LABELS,
|
||||||
|
type EmojiCategory
|
||||||
|
} from '$lib/data/emoji-catalog';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
selected: string | null;
|
||||||
|
onSelect: (emoji: string) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { selected, onSelect }: Props = $props();
|
||||||
|
|
||||||
|
const CATEGORIES: EmojiCategory[] = ['faces', 'food', 'animals', 'objects'];
|
||||||
|
|
||||||
|
let activeCategory = $state<EmojiCategory>('faces');
|
||||||
|
const activeList = $derived(EMOJI_CATALOG[activeCategory]);
|
||||||
|
|
||||||
|
let gridEl: HTMLDivElement | null = $state(null);
|
||||||
|
|
||||||
|
function selectTab(cat: EmojiCategory) {
|
||||||
|
activeCategory = cat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(emoji: string) {
|
||||||
|
onSelect(emoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roving-focus keyboard nav. We resolve cells from the live DOM each
|
||||||
|
// keystroke (instead of caching the array) so the grid swap on tab
|
||||||
|
// change doesn't strand stale references.
|
||||||
|
function onGridKeydown(e: KeyboardEvent) {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (!target || !gridEl) return;
|
||||||
|
const cells = Array.from(
|
||||||
|
gridEl.querySelectorAll<HTMLElement>('[role="gridcell"]')
|
||||||
|
);
|
||||||
|
const idx = cells.indexOf(target);
|
||||||
|
if (idx < 0) return;
|
||||||
|
|
||||||
|
let next = -1;
|
||||||
|
const cols = 8;
|
||||||
|
if (e.key === 'ArrowRight') next = Math.min(idx + 1, cells.length - 1);
|
||||||
|
else if (e.key === 'ArrowLeft') next = Math.max(idx - 1, 0);
|
||||||
|
else if (e.key === 'ArrowDown') next = Math.min(idx + cols, cells.length - 1);
|
||||||
|
else if (e.key === 'ArrowUp') next = Math.max(idx - cols, 0);
|
||||||
|
else if (e.key === 'Home') next = 0;
|
||||||
|
else if (e.key === 'End') next = cells.length - 1;
|
||||||
|
else return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
cells[next]?.focus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="emoji-picker" data-testid="emoji-picker">
|
||||||
|
<!-- Tablist header. Each button toggles `activeCategory` and Svelte
|
||||||
|
re-renders the grid below from `EMOJI_CATALOG[activeCategory]`. -->
|
||||||
|
<div role="tablist" class="mb-2 flex gap-1">
|
||||||
|
{#each CATEGORIES as cat (cat)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeCategory === cat}
|
||||||
|
aria-controls="emoji-picker-grid"
|
||||||
|
data-testid={`emoji-picker-tab-${cat}`}
|
||||||
|
onclick={() => selectTab(cat)}
|
||||||
|
class="flex-1 rounded-md px-2 py-1 text-xs font-medium transition-colors
|
||||||
|
{activeCategory === cat
|
||||||
|
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
|
||||||
|
: 'border border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-400 dark:hover:bg-slate-700'}"
|
||||||
|
>
|
||||||
|
{CATEGORY_LABELS[cat]}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active grid. Only one category mounted at a time. Cells have
|
||||||
|
fixed 32×32 footprint (h-9/w-9 with text-xl) — same size as the
|
||||||
|
legacy single-row picker so caller layouts don't reflow. -->
|
||||||
|
<div
|
||||||
|
id="emoji-picker-grid"
|
||||||
|
bind:this={gridEl}
|
||||||
|
role="grid"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label={CATEGORY_LABELS[activeCategory]}
|
||||||
|
data-testid="emoji-picker-grid"
|
||||||
|
data-category={activeCategory}
|
||||||
|
onkeydown={onGridKeydown}
|
||||||
|
class="grid max-h-64 grid-cols-8 gap-1 overflow-y-auto sm:grid-cols-10"
|
||||||
|
>
|
||||||
|
{#each activeList as e, i (e)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="gridcell"
|
||||||
|
tabindex={i === 0 ? 0 : -1}
|
||||||
|
data-testid={`emoji-picker-cell-${e}`}
|
||||||
|
aria-label={e}
|
||||||
|
aria-selected={selected === e}
|
||||||
|
onclick={() => pick(e)}
|
||||||
|
class="flex h-9 w-9 items-center justify-center rounded-md text-xl transition-colors
|
||||||
|
{selected === e
|
||||||
|
? 'bg-slate-900 dark:bg-slate-100'
|
||||||
|
: 'hover:bg-slate-100 dark:hover:bg-slate-700'}"
|
||||||
|
>{e}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
115
apps/web/src/lib/components/EmojiPicker.test.ts
Normal file
115
apps/web/src/lib/components/EmojiPicker.test.ts
Normal file
@@ -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<string, unknown>) {
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user