hover: on the <span> only fired on the 20×20px visual circle, not the full 44×44px touch target. Add `group` + `focus-visible:ring-*` to the outer button; switch to `group-hover:` on the inner span so the visual response covers the entire interactive area. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
102 lines
2.7 KiB
Svelte
102 lines
2.7 KiB
Svelte
<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">
|
||
import type { Snippet } from 'svelte';
|
||
|
||
type Placement = 'bottom' | 'top' | 'left' | 'right';
|
||
|
||
type Props = {
|
||
label: string;
|
||
placement?: Placement;
|
||
children?: Snippet;
|
||
};
|
||
|
||
let { label, placement = 'bottom', children }: Props = $props();
|
||
|
||
const popoverId = `help-popover-${_counter++}`;
|
||
|
||
let open = $state(false);
|
||
let triggerEl: HTMLButtonElement | null = $state(null);
|
||
|
||
function toggle() {
|
||
open = !open;
|
||
}
|
||
|
||
function close() {
|
||
open = false;
|
||
triggerEl?.focus();
|
||
}
|
||
|
||
$effect(() => {
|
||
if (!open) return;
|
||
|
||
function onKeyDown(e: KeyboardEvent) {
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
close();
|
||
}
|
||
}
|
||
|
||
function onPointerDown(e: PointerEvent) {
|
||
if (triggerEl && (e.target === triggerEl || triggerEl.contains(e.target as Node))) return;
|
||
const popoverEl = document.getElementById(popoverId);
|
||
if (popoverEl && popoverEl.contains(e.target as Node)) return;
|
||
open = false;
|
||
}
|
||
|
||
document.addEventListener('keydown', onKeyDown);
|
||
document.addEventListener('pointerdown', onPointerDown);
|
||
return () => {
|
||
document.removeEventListener('keydown', onKeyDown);
|
||
document.removeEventListener('pointerdown', onPointerDown);
|
||
};
|
||
});
|
||
|
||
const placementClass: Record<Placement, string> = {
|
||
bottom: 'top-full mt-1.5 left-1/2 -translate-x-1/2',
|
||
top: 'bottom-full mb-1.5 left-1/2 -translate-x-1/2',
|
||
left: 'right-full mr-1.5 top-1/2 -translate-y-1/2',
|
||
right: 'left-full ml-1.5 top-1/2 -translate-y-1/2'
|
||
};
|
||
</script>
|
||
|
||
<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
|
||
bind:this={triggerEl}
|
||
type="button"
|
||
aria-label={label}
|
||
aria-expanded={open}
|
||
aria-controls={popoverId}
|
||
onclick={toggle}
|
||
class="group flex h-[44px] w-[44px] items-center justify-center rounded-full focus-visible:ring-2 focus-visible:ring-brand-navy"
|
||
>
|
||
<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 group-hover:border-brand-navy group-hover:text-brand-navy"
|
||
>
|
||
?
|
||
</span>
|
||
</button>
|
||
|
||
{#if open}
|
||
<div
|
||
id={popoverId}
|
||
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]}"
|
||
>
|
||
{#if children}
|
||
{@render children()}
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|