/**
* @fileoverview Announce hook for screen reader announcements
*
* This hook provides a way to announce messages to screen readers
* using ARIA live regions. It supports both polite and assertive
* politeness levels and handles rapid announcement queuing.
*
* ## Features:
* - Polite and assertive announcement modes
* - Message queuing for rapid announcements
* - Automatic cleanup of old announcements
* - Integration with global live region
*
* @module @writenex/astro/client/hooks/useAnnounce
*/
import { useCallback, useEffect, useRef, useState } from "react";
/**
* Politeness level for announcements
* - polite: Waits for user to finish current task before announcing
* - assertive: Interrupts current task to announce immediately
*/
export type AnnouncePoliteness = "polite" | "assertive";
/**
* Announcement message structure
*/
export interface Announcement {
/** Unique identifier for the announcement */
id: string;
/** Message to announce */
message: string;
/** Politeness level */
politeness: AnnouncePoliteness;
/** Timestamp when announcement was created */
timestamp: number;
}
/**
* Return value from useAnnounce hook
*/
export interface UseAnnounceReturn {
/** Announce a message to screen readers */
announce: (message: string, politeness?: AnnouncePoliteness) => void;
/** Current announcement message (for live region) */
currentMessage: string;
/** Current politeness level */
currentPoliteness: AnnouncePoliteness;
/** Clear the current announcement */
clear: () => void;
}
/** Maximum age for announcements in milliseconds (5 seconds) */
const MAX_ANNOUNCEMENT_AGE = 5000;
/** Delay between processing queued announcements */
const ANNOUNCEMENT_DELAY = 100;
/** Counter for generating unique announcement IDs */
let announcementIdCounter = 0;
/**
* Generate a unique announcement ID
*/
function generateAnnouncementId(): string {
return `announcement-${++announcementIdCounter}-${Date.now()}`;
}
/**
* Hook for announcing messages to screen readers via live regions
*
* This hook manages a queue of announcements and processes them
* sequentially to ensure screen readers can properly announce each
* message. It automatically discards stale announcements.
*
* @returns Object containing announce function and current message state
*
* @example
* ```tsx
* function SaveButton() {
* const { announce } = useAnnounce();
*
* const handleSave = async () => {
* try {
* await saveContent();
* announce("Content saved", "polite");
* } catch (error) {
* announce("Save failed", "assertive");
* }
* };
*
* return ;
* }
* ```
*/
export function useAnnounce(): UseAnnounceReturn {
const [currentMessage, setCurrentMessage] = useState("");
const [currentPoliteness, setCurrentPoliteness] =
useState("polite");
const queueRef = useRef([]);
const isProcessingRef = useRef(false);
const timeoutRef = useRef | null>(null);
/**
* Process the next announcement in the queue
*/
const processQueue = useCallback(() => {
if (isProcessingRef.current || queueRef.current.length === 0) {
return;
}
isProcessingRef.current = true;
// Get the next announcement
const announcement = queueRef.current.shift();
if (!announcement) {
isProcessingRef.current = false;
return;
}
// Check if announcement is stale
const age = Date.now() - announcement.timestamp;
if (age > MAX_ANNOUNCEMENT_AGE) {
// Skip stale announcement and process next
isProcessingRef.current = false;
processQueue();
return;
}
// Clear current message first to ensure screen reader announces new message
setCurrentMessage("");
// Set the new message after a brief delay
timeoutRef.current = setTimeout(() => {
setCurrentMessage(announcement.message);
setCurrentPoliteness(announcement.politeness);
// Clear after announcement and process next
timeoutRef.current = setTimeout(() => {
setCurrentMessage("");
isProcessingRef.current = false;
processQueue();
}, ANNOUNCEMENT_DELAY);
}, ANNOUNCEMENT_DELAY);
}, []);
/**
* Announce a message to screen readers
*/
const announce = useCallback(
(message: string, politeness: AnnouncePoliteness = "polite") => {
if (!message.trim()) return;
const announcement: Announcement = {
id: generateAnnouncementId(),
message: message.trim(),
politeness,
timestamp: Date.now(),
};
// Add to queue
queueRef.current.push(announcement);
// Start processing if not already
processQueue();
},
[processQueue]
);
/**
* Clear the current announcement
*/
const clear = useCallback(() => {
setCurrentMessage("");
queueRef.current = [];
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
isProcessingRef.current = false;
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return {
announce,
currentMessage,
currentPoliteness,
clear,
};
}