import { useEffect, useState } from 'react'; import type { HTMLAttributes, ReactNode } from 'react'; import { WDS_LIVE_REGION_DELAY_MS } from '../constants'; export type AriaLive = 'off' | 'polite' | 'assertive'; type LivePoliteness = Exclude; const LIVE_REGION_ROLE_BY_POLITENESS: Record = { assertive: 'alert', polite: 'status', }; let nextPoliteAnnouncementAt = 0; let nextAssertiveAnnouncementAt = 0; const getNextAnnouncementAt = (politeness: LivePoliteness): number => { if (politeness === 'polite') { return nextPoliteAnnouncementAt; } return nextAssertiveAnnouncementAt; }; const setNextAnnouncementAt = (politeness: LivePoliteness, value: number): void => { if (politeness === 'polite') { nextPoliteAnnouncementAt = value; return; } nextAssertiveAnnouncementAt = value; }; export const resetLiveRegionAnnouncementQueue = (): void => { nextPoliteAnnouncementAt = 0; nextAssertiveAnnouncementAt = 0; }; const calcAnnouncementDelayMs = (politeness: LivePoliteness, now: number): number => { return Math.max(now + WDS_LIVE_REGION_DELAY_MS, getNextAnnouncementAt(politeness)) - now; }; const scheduleAnnouncement = (politeness: LivePoliteness): number => { const now = Date.now(); const delayMs = calcAnnouncementDelayMs(politeness, now); setNextAnnouncementAt(politeness, now + delayMs + WDS_LIVE_REGION_DELAY_MS); return delayMs; }; export interface LiveRegionProps extends Omit< HTMLAttributes, 'role' | 'aria-live' | 'aria-atomic' > { /** * Determines urgency: 'assertive' interrupts, 'polite' waits for idle, 'off' disables live region. */ 'aria-live': AriaLive; /** Optional stable key that triggers a new announcement when it changes. */ announceOnChange?: string | number; /** Test ID for testing tools */ 'data-testid'?: string; children?: ReactNode; } /** * Renders an ARIA live region with the correct implicit role. * * - `aria-live="polite"` → `role="status"` * - `aria-live="assertive"` → `role="alert"` * - `aria-live="off"` → no live region (renders children unwrapped) * * The `role` prop is intentionally excluded from the public API * to prevent mismatches between `aria-live` and `role`. */ export const LiveRegion = ({ 'aria-live': ariaLive, announceOnChange, children, className, ...props }: LiveRegionProps) => { const [shouldAnnounce, setShouldAnnounce] = useState(false); const announcementTrigger = announceOnChange ?? (typeof children === 'string' || typeof children === 'number' ? children : undefined); useEffect(() => { setShouldAnnounce(false); if (ariaLive === 'off') { return; } const timeoutId = window.setTimeout( () => setShouldAnnounce(true), scheduleAnnouncement(ariaLive), ); return () => window.clearTimeout(timeoutId); }, [ariaLive, announcementTrigger]); if (ariaLive === 'off') { return <>{children}; } return (
{children}
); };