Skip to content
File

Blob: src/client/hooks/use-route-announcer.ts

typescript52 lines
1import { useCallback, useEffect, useRef } from "react";
2import { useLocation } from "react-router-dom";
3 
4/**
5 * Manages SPA route-change accessibility: updates document.title from the
6 * page's <h1>, moves focus to it, and announces the navigation via an
7 * aria-live region.
8 *
9 * Returns a ref to attach to a visually-hidden announcer element:
10 * `<div ref={announcerRef} className="sr-only" aria-live="polite" />`
11 */
12export const useRouteAnnouncer = () => {
13 const { pathname } = useLocation();
14 const announcerRef = useRef<HTMLDivElement>(null);
15 const isFirstRender = useRef(true);
16 
17 const announce = useCallback((text: string) => {
18 if (announcerRef.current) {
19 announcerRef.current.textContent = text;
20 }
21 }, []);
22 
23 useEffect(() => {
24 // Skip the initial mount — only announce subsequent navigations.
25 if (isFirstRender.current) {
26 isFirstRender.current = false;
27 return;
28 }
29 
30 // Wait a tick for the new page component to render its <h1>.
31 const id = requestAnimationFrame(() => {
32 const h1 = document.querySelector("h1");
33 const title = h1?.textContent?.trim() ?? "anvil";
34 
35 document.title = title === "anvil" ? "anvil" : `${title} — anvil`;
36 
37 if (h1) {
38 if (!h1.hasAttribute("tabindex")) {
39 h1.setAttribute("tabindex", "-1");
40 }
41 h1.focus({ preventScroll: true });
42 }
43 
44 announce(`Navigated to ${title}`);
45 });
46 
47 return () => cancelAnimationFrame(id);
48 }, [pathname, announce]);
49 
50 return announcerRef;
51};