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:
2026-05-19 03:13:24 +02:00
parent ae6ceef34f
commit a075a792d6
2 changed files with 248 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
<!--
Fase 19.2 — Emoji picker.
Tabbed grid of ~280360 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>