Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/components/GlobalLiveRegion.tsx
Original file line number Diff line number Diff line change
@@ -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 <LiveRegion message={current?.message ?? ""} />
}
122 changes: 122 additions & 0 deletions app/components/__tests__/GlobalLiveRegion.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<GlobalLiveRegion />)
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(<GlobalLiveRegion />)
const region = screen.getByRole("status")
expect(region).toHaveTextContent("")
})

it("announces a message when announce() is called", () => {
render(<GlobalLiveRegion />)
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(<GlobalLiveRegion />)
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(<GlobalLiveRegion />)
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(<GlobalLiveRegion />)
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(<GlobalLiveRegion />)
announce({ message: "" })
act(() => jest.advanceTimersByTime(100))
expect(screen.getByRole("status")).toHaveTextContent("")
})

it("defaults to polite priority", () => {
render(<GlobalLiveRegion />)
announce({ message: "Default priority" })
act(() => jest.advanceTimersByTime(100))
expect(screen.getByRole("status")).toHaveTextContent("Default priority")
})

it("can be called outside of React components", () => {
render(<GlobalLiveRegion />)
// 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)
})
})
2 changes: 2 additions & 0 deletions components/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -37,6 +38,7 @@ export function Providers({ children }: ProvidersProps) {
</ClaimShareProvider>
</WalletProvider>
</PrivacyProvider>
<GlobalLiveRegion />
<Toaster />
</ThemeProvider>
</ErrorBoundary>
Expand Down
86 changes: 86 additions & 0 deletions hooks/use-global-live-region.ts
Original file line number Diff line number Diff line change
@@ -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<string, ReturnType<typeof setTimeout>>()

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<State>(memoryState)

React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) listeners.splice(index, 1)
}
}, [])

return { ...state, announce }
}