import type { ReactNode } from 'react' import { createContext, useCallback, useContext, useRef, useState } from 'react' type Politeness = 'polite' | 'assertive' interface AnnouncerContextValue { announce: (message: string, politeness?: Politeness) => void } const AnnouncerContext = createContext(null) /** * useAnnounce — 向屏幕阅读器播报消息 * * @example * const announce = useAnnounce() * announce('保存成功') // polite(默认) * announce('操作失败,请重试', 'assertive') // assertive(立即打断) */ export function useAnnounce(): (message: string, politeness?: Politeness) => void { const ctx = useContext(AnnouncerContext) if (!ctx) { // 未在 AnnouncerProvider 内时静默降级,不抛错 return () => {} } return ctx.announce } interface AnnouncerState { polite: string assertive: string } /** * AnnouncerProvider — 在应用根部挂载两个 aria-live 区域 * * 将此组件包裹在应用根节点,所有子组件即可通过 useAnnounce() 播报消息。 * aria-live 区域视觉上隐藏(sr-only),不影响布局。 */ export function AnnouncerProvider({ children }: { children: ReactNode }) { const [messages, setMessages] = useState({ polite: '', assertive: '' }) // 用于清空 -> 重新设置,触发屏幕阅读器重新朗读相同消息 const politeTimerRef = useRef | null>(null) const assertiveTimerRef = useRef | null>(null) const announce = useCallback((message: string, politeness: Politeness = 'polite') => { if (politeness === 'assertive') { // 先清空,再填入,确保屏幕阅读器重新朗读 setMessages((prev: AnnouncerState) => ({ ...prev, assertive: '' })) if (assertiveTimerRef.current) clearTimeout(assertiveTimerRef.current) assertiveTimerRef.current = setTimeout(() => { setMessages((prev: AnnouncerState) => ({ ...prev, assertive: message })) }, 50) } else { setMessages((prev: AnnouncerState) => ({ ...prev, polite: '' })) if (politeTimerRef.current) clearTimeout(politeTimerRef.current) politeTimerRef.current = setTimeout(() => { setMessages((prev: AnnouncerState) => ({ ...prev, polite: message })) }, 50) } }, []) return ( {children} {/* aria-live 区域:视觉隐藏,屏幕阅读器可读 */}
{messages.polite}
{messages.assertive}
) }