Files
collective-lists/apps/web/src/lib/components/EmojiPicker.svelte
Oier Bravo Urtasun a075a792d6 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>
2026-05-19 03:13:24 +02:00

134 lines
4.4 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
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>