fix(HelpPopover): role=region, 44px touch target, counter-based ID

- role="tooltip" → role="region" + aria-label={label}: tooltip semantics
  are wrong for a click-triggered panel (Nora/Sara)
- expand button to 44×44px with inner visual <span>: WCAG 2.5.8 touch
  target for 60+ transcriber audience (Sara/Leonie)
- replace Math.random() with module-level counter: SSR/hydration mismatch
  when server and client generate different IDs (Felix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-25 01:06:52 +02:00
parent 71892e7293
commit 82af3f85a2
2 changed files with 42 additions and 12 deletions

View File

@@ -1,3 +1,10 @@
<script module>
// Module-level counter produces stable, predictable IDs across SSR + hydration.
// Math.random() would generate different values server-side vs client-side,
// causing a hydration mismatch on first render.
let _counter = 0;
</script>
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
@@ -11,8 +18,9 @@ type Props = {
let { label, placement = 'bottom', children }: Props = $props(); let { label, placement = 'bottom', children }: Props = $props();
const popoverId = `help-popover-${_counter++}`;
let open = $state(false); let open = $state(false);
const popoverId = `help-popover-${Math.random().toString(36).slice(2)}`;
let triggerEl: HTMLButtonElement | null = $state(null); let triggerEl: HTMLButtonElement | null = $state(null);
function toggle() { function toggle() {
@@ -58,6 +66,10 @@ const placementClass: Record<Placement, string> = {
</script> </script>
<div class="relative inline-block"> <div class="relative inline-block">
<!--
Outer button is 44×44px for WCAG 2.5.8 touch-target compliance (our transcriber
audience is 60+). The inner <span> carries the visual 20×20px circle.
-->
<button <button
bind:this={triggerEl} bind:this={triggerEl}
type="button" type="button"
@@ -65,15 +77,20 @@ const placementClass: Record<Placement, string> = {
aria-expanded={open} aria-expanded={open}
aria-controls={popoverId} aria-controls={popoverId}
onclick={toggle} onclick={toggle}
class="flex h-5 w-5 items-center justify-center rounded-full border border-line bg-muted font-sans text-[10px] font-bold text-ink-3 transition-colors hover:border-brand-navy hover:text-brand-navy" class="flex h-[44px] w-[44px] items-center justify-center"
> >
? <span
class="flex h-5 w-5 items-center justify-center rounded-full border border-line bg-muted font-sans text-[10px] font-bold text-ink-3 transition-colors hover:border-brand-navy hover:text-brand-navy"
>
?
</span>
</button> </button>
{#if open} {#if open}
<div <div
id={popoverId} id={popoverId}
role="tooltip" role="region"
aria-label={label}
class="absolute z-50 w-64 rounded-sm border border-line bg-white p-3 font-sans text-sm text-ink shadow-md {placementClass[placement]}" class="absolute z-50 w-64 rounded-sm border border-line bg-white p-3 font-sans text-sm text-ink shadow-md {placementClass[placement]}"
> >
{#if children} {#if children}

View File

@@ -20,7 +20,7 @@ describe('HelpPopover — initial state', () => {
renderPopover(); renderPopover();
const btn = page.getByRole('button', { name: /Help/ }); const btn = page.getByRole('button', { name: /Help/ });
await expect.element(btn).toHaveAttribute('aria-expanded', 'false'); await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
expect(document.querySelector('[role="tooltip"]')).toBeNull(); expect(document.querySelector('[role="region"]')).toBeNull();
}); });
}); });
@@ -30,37 +30,37 @@ describe('HelpPopover — open / close interactions', () => {
await page.getByRole('button', { name: /Help/ }).click(); await page.getByRole('button', { name: /Help/ }).click();
const btn = page.getByRole('button', { name: /Help/ }); const btn = page.getByRole('button', { name: /Help/ });
await expect.element(btn).toHaveAttribute('aria-expanded', 'true'); await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); expect(document.querySelector('[role="region"]')).not.toBeNull();
}); });
it('closes on Esc key', async () => { it('closes on Esc key', async () => {
renderPopover(); renderPopover();
await page.getByRole('button', { name: /Help/ }).click(); await page.getByRole('button', { name: /Help/ }).click();
expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); expect(document.querySelector('[role="region"]')).not.toBeNull();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull()); await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
}); });
it('closes on outside click', async () => { it('closes on outside click', async () => {
renderPopover(); renderPopover();
await page.getByRole('button', { name: /Help/ }).click(); await page.getByRole('button', { name: /Help/ }).click();
expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); expect(document.querySelector('[role="region"]')).not.toBeNull();
document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })); document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
await vi.waitFor(() => expect(document.querySelector('[role="tooltip"]')).toBeNull()); await vi.waitFor(() => expect(document.querySelector('[role="region"]')).toBeNull());
}); });
it('opens on Enter key (button is keyboard-reachable, Enter fires click)', async () => { it('opens on Enter key (button is keyboard-reachable, Enter fires click)', async () => {
renderPopover(); renderPopover();
await page.getByRole('button', { name: /Help/ }).click(); await page.getByRole('button', { name: /Help/ }).click();
expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); expect(document.querySelector('[role="region"]')).not.toBeNull();
}); });
it('opens on Space key (button is keyboard-reachable, Space fires click)', async () => { it('opens on Space key (button is keyboard-reachable, Space fires click)', async () => {
renderPopover(); renderPopover();
await page.getByRole('button', { name: /Help/ }).click(); await page.getByRole('button', { name: /Help/ }).click();
expect(document.querySelector('[role="tooltip"]')).not.toBeNull(); expect(document.querySelector('[role="region"]')).not.toBeNull();
}); });
}); });
@@ -74,4 +74,17 @@ describe('HelpPopover — aria wiring', () => {
const popover = document.getElementById(controls!); const popover = document.getElementById(controls!);
expect(popover).not.toBeNull(); expect(popover).not.toBeNull();
}); });
it('two renders produce different, predictable IDs (no Math.random — SSR safe)', async () => {
const { container: c1 } = render(HelpPopover, { props: { label: 'A' } });
const { container: c2 } = render(HelpPopover, { props: { label: 'B' } });
const id1 = c1.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
const id2 = c2.querySelector('button[aria-controls]')?.getAttribute('aria-controls');
expect(id1).toBeTruthy();
expect(id2).toBeTruthy();
expect(id1).not.toBe(id2);
// IDs must be deterministic (counter-based), not random hex
expect(id1).toMatch(/^help-popover-\d+$/);
expect(id2).toMatch(/^help-popover-\d+$/);
});
}); });