From 98ce7d3c484fe2a168cd0218aaa277217daeaf2f Mon Sep 17 00:00:00 2001 From: Wuraola Olaniyan <122721324+OG-wura@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:48:22 +0000 Subject: [PATCH] feat: global live region --- app/components/GlobalLiveRegion.tsx | 12 ++ .../__tests__/GlobalLiveRegion.test.tsx | 122 ++++++++++++++++++ components/providers.tsx | 2 + hooks/use-global-live-region.ts | 86 ++++++++++++ 4 files changed, 222 insertions(+) create mode 100644 app/components/GlobalLiveRegion.tsx create mode 100644 app/components/__tests__/GlobalLiveRegion.test.tsx create mode 100644 hooks/use-global-live-region.ts diff --git a/app/components/GlobalLiveRegion.tsx b/app/components/GlobalLiveRegion.tsx new file mode 100644 index 00000000..c0b4b0f2 --- /dev/null +++ b/app/components/GlobalLiveRegion.tsx @@ -0,0 +1,12 @@ +"use client" + +import * as React from "react" +import { useGlobalLiveRegion } from "@/hooks/use-global-live-region" +import { LiveRegion } from "@/components/ui/live-region" + +export function GlobalLiveRegion() { + const { announcements } = useGlobalLiveRegion() + const current = announcements[0] + + return +} diff --git a/app/components/__tests__/GlobalLiveRegion.test.tsx b/app/components/__tests__/GlobalLiveRegion.test.tsx new file mode 100644 index 00000000..281c4f73 --- /dev/null +++ b/app/components/__tests__/GlobalLiveRegion.test.tsx @@ -0,0 +1,122 @@ +import { render, screen, act } from "@testing-library/react" +import { GlobalLiveRegion } from "../GlobalLiveRegion" +import { announce, reducer } from "@/hooks/use-global-live-region" + +beforeEach(() => { + jest.useFakeTimers() +}) + +afterEach(() => { + jest.useRealTimers() + // Reset the global state by dispatching a remove for any announcements + // This is a workaround since we can't easily reset the module state + const state = reducer({ announcements: [] }, { type: "REMOVE_ANNOUNCEMENT", announcementId: "__reset__" }) + // Re-run dispatch indirectly by announcing an empty message (no-op) +}) + +describe("GlobalLiveRegion Component", () => { + it("renders a live region with role='status'", () => { + render() + const region = screen.getByRole("status") + expect(region).toBeInTheDocument() + expect(region).toHaveAttribute("aria-live", "polite") + expect(region).toHaveAttribute("aria-atomic", "true") + }) + + it("shows an empty live region when no announcements exist", () => { + render() + const region = screen.getByRole("status") + expect(region).toHaveTextContent("") + }) + + it("announces a message when announce() is called", () => { + render() + announce({ message: "Bet placed successfully" }) + act(() => jest.advanceTimersByTime(100)) + expect(screen.getByRole("status")).toHaveTextContent("Bet placed successfully") + }) + + it("updates to the latest announcement when multiple are made", () => { + render() + announce({ message: "First announcement" }) + act(() => jest.advanceTimersByTime(100)) + announce({ message: "Second announcement" }) + act(() => jest.advanceTimersByTime(100)) + const region = screen.getByRole("status") + expect(region).toHaveTextContent("Second announcement") + expect(region).not.toHaveTextContent("First announcement") + }) + + it("supports assertive priority announcements", () => { + render() + announce({ message: "Urgent notice", priority: "assertive" }) + act(() => jest.advanceTimersByTime(100)) + expect(screen.getByRole("status")).toHaveTextContent("Urgent notice") + }) + + it("clears the live region after the removal delay", () => { + render() + announce({ message: "Temporary message" }) + act(() => jest.advanceTimersByTime(100)) + expect(screen.getByRole("status")).toHaveTextContent("Temporary message") + act(() => jest.advanceTimersByTime(8000)) + expect(screen.getByRole("status")).toHaveTextContent("") + }) +}) + +describe("announce() function", () => { + it("does not announce an empty message", () => { + render() + announce({ message: "" }) + act(() => jest.advanceTimersByTime(100)) + expect(screen.getByRole("status")).toHaveTextContent("") + }) + + it("defaults to polite priority", () => { + render() + announce({ message: "Default priority" }) + act(() => jest.advanceTimersByTime(100)) + expect(screen.getByRole("status")).toHaveTextContent("Default priority") + }) + + it("can be called outside of React components", () => { + render() + // Simulate calling announce from a non-React context (e.g., API callback) + const externalAnnounce = announce + externalAnnounce({ message: "External call" }) + act(() => jest.advanceTimersByTime(100)) + expect(screen.getByRole("status")).toHaveTextContent("External call") + }) +}) + +describe("reducer", () => { + it("replaces previous announcements on ADD_ANNOUNCEMENT (keeps only latest)", () => { + const state1 = reducer( + { announcements: [] }, + { type: "ADD_ANNOUNCEMENT", announcement: { id: "1", message: "First", priority: "polite" } }, + ) + expect(state1.announcements).toHaveLength(1) + expect(state1.announcements[0].message).toBe("First") + + const state2 = reducer(state1, { + type: "ADD_ANNOUNCEMENT", + announcement: { id: "2", message: "Second", priority: "polite" }, + }) + expect(state2.announcements).toHaveLength(1) + expect(state2.announcements[0].message).toBe("Second") + }) + + it("removes an announcement by id on REMOVE_ANNOUNCEMENT", () => { + const state = reducer( + { announcements: [{ id: "1", message: "Test", priority: "polite" }] }, + { type: "REMOVE_ANNOUNCEMENT", announcementId: "1" }, + ) + expect(state.announcements).toHaveLength(0) + }) + + it("returns state unchanged on unknown action", () => { + const state = { announcements: [{ id: "1", message: "Test", priority: "polite" }] } + const result = reducer(state, { type: "UNKNOWN" } as any) + expect(result).toEqual(state) + }) +}) diff --git a/components/providers.tsx b/components/providers.tsx index 2148e6ad..d2b9a261 100644 --- a/components/providers.tsx +++ b/components/providers.tsx @@ -8,6 +8,7 @@ import { ErrorBoundary } from "@/components/error-boundary"; import { ReactNode } from "react"; import { useHideBalancesShortcut } from "@/hooks/useHideBalancesShortcut"; import { ClaimShareProvider } from "@/context/ClaimShareContext"; +import { GlobalLiveRegion } from "@/app/components/GlobalLiveRegion"; import { RouteDocumentTitle } from "@/app/hooks/useDocumentTitle"; interface ProvidersProps { @@ -37,6 +38,7 @@ export function Providers({ children }: ProvidersProps) { + diff --git a/hooks/use-global-live-region.ts b/hooks/use-global-live-region.ts new file mode 100644 index 00000000..5f9b11d1 --- /dev/null +++ b/hooks/use-global-live-region.ts @@ -0,0 +1,86 @@ +"use client" + +import * as React from "react" + +const ANNOUNCEMENT_REMOVE_DELAY = 8000 + +interface Announcement { + id: string + message: string + priority: "polite" | "assertive" +} + +interface State { + announcements: Announcement[] +} + +type Action = + | { type: "ADD_ANNOUNCEMENT"; announcement: Announcement } + | { type: "REMOVE_ANNOUNCEMENT"; announcementId: string } + +let count = 0 +function genId(): string { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +const announcementTimeouts = new Map>() + +function addToRemoveQueue(announcementId: string) { + if (announcementTimeouts.has(announcementId)) return + const timeout = setTimeout(() => { + announcementTimeouts.delete(announcementId) + dispatch({ type: "REMOVE_ANNOUNCEMENT", announcementId }) + }, ANNOUNCEMENT_REMOVE_DELAY) + announcementTimeouts.set(announcementId, timeout) +} + +export function reducer(state: State, action: Action): State { + switch (action.type) { + case "ADD_ANNOUNCEMENT": + return { ...state, announcements: [action.announcement] } + case "REMOVE_ANNOUNCEMENT": + return { + ...state, + announcements: state.announcements.filter( + (a) => a.id !== action.announcementId, + ), + } + default: + return state + } +} + +const listeners: Array<(state: State) => void> = [] +let memoryState: State = { announcements: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => listener(memoryState)) +} + +export interface AnnounceOptions { + message: string + priority?: "polite" | "assertive" +} + +export function announce({ message, priority = "polite" }: AnnounceOptions): void { + if (!message) return + const id = genId() + dispatch({ type: "ADD_ANNOUNCEMENT", announcement: { id, message, priority } }) + addToRemoveQueue(id) +} + +export function useGlobalLiveRegion(): State & { announce: typeof announce } { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) listeners.splice(index, 1) + } + }, []) + + return { ...state, announce } +}