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:
@@ -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-[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"
|
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}
|
||||||
|
|||||||
@@ -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+$/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user