/** * useLiveRegion composable * * Manages an ARIA live region for screen reader announcements. * Creates/reuses a single visually-hidden
element * in the DOM, and announces content when messages arrive. * * This matches the React reference implementation's approach to accessibility * announcements for message components. */ import { onMounted, onUnmounted } from 'vue' const LIVE_REGION_ID = 'webchat-live-region' /** * Get or create the shared live region element in the DOM */ function getOrCreateLiveRegion(): HTMLDivElement { let region = document.getElementById(LIVE_REGION_ID) as HTMLDivElement | null if (!region) { region = document.createElement('div') region.id = LIVE_REGION_ID region.setAttribute('role', 'status') region.setAttribute('aria-live', 'polite') region.setAttribute('aria-atomic', 'true') // Visually hidden but accessible to screen readers Object.assign(region.style, { position: 'absolute', width: '1px', height: '1px', padding: '0', margin: '-1px', overflow: 'hidden', clip: 'rect(0, 0, 0, 0)', whiteSpace: 'nowrap', borderWidth: '0', }) document.body.appendChild(region) } return region } /** * Track reference count so we only clean up when no components are using the region */ let refCount = 0 interface UseLiveRegionOptions { /** Text to announce to screen readers */ announcement: string /** Whether the announcement should be made (defaults to true) */ enabled?: boolean } /** * Composable that announces content to screen readers via an ARIA live region. * * @example * ```typescript * // In a message component: * useLiveRegion({ * announcement: `New message: ${messageText}`, * enabled: !props.ignoreLiveRegion, * }) * ``` */ export function useLiveRegion(options: UseLiveRegionOptions): void { onMounted(() => { refCount++ if (options.enabled === false) return if (!options.announcement) return const region = getOrCreateLiveRegion() // Clear then set to trigger screen reader re-announcement region.textContent = '' // Use requestAnimationFrame to ensure the empty text is processed first requestAnimationFrame(() => { region.textContent = options.announcement }) }) onUnmounted(() => { refCount-- // Clean up the live region when no components are using it if (refCount <= 0) { refCount = 0 const region = document.getElementById(LIVE_REGION_ID) if (region) { region.textContent = '' } } }) }