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 }
+}