forked from FlowwStar/FlowStar
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathaccessible-countdown-timer.tsx
More file actions
104 lines (94 loc) · 3.16 KB
/
Copy pathaccessible-countdown-timer.tsx
File metadata and controls
104 lines (94 loc) · 3.16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
'use client'
import { useState, useRef, useEffect } from 'react'
import { Volume2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useNow } from '@/hooks/use-now'
import { formatTimeRemaining } from '@/lib/stream-utils'
import { Button } from '@/components/ui/button'
interface AccessibleCountdownTimerProps {
/** Target UNIX timestamp (seconds). */
target: bigint
className?: string
endedLabel?: string
/** Whether to hide the read button (for embedded usage). */
hideButton?: boolean
/** Callback when a significant state change occurs (e.g., timer expires). */
onStateChange?: (state: 'expired' | 'active') => void
}
/**
* Accessible countdown timer that minimizes screen reader announcements.
*
* - Display uses `aria-live="off"` to prevent rapid updates from being announced
* - Provides a "Read current time" button for on-demand announcements
* - Only announces significant state changes (expired, cliff reached, etc.)
* - Uses `aria-atomic="true"` on announcements for clarity
*/
export function AccessibleCountdownTimer({
target,
className,
endedLabel = 'Ended',
hideButton = false,
onStateChange,
}: AccessibleCountdownTimerProps) {
const now = useNow(1000)
const ended = Number(target) <= now
const displayText = formatTimeRemaining(target, now)
const [lastState, setLastState] = useState<'expired' | 'active'>(ended ? 'expired' : 'active')
const [announcementText, setAnnouncementText] = useState('')
const announceRef = useRef<HTMLDivElement>(null)
// Announce state changes
useEffect(() => {
const newState = ended ? 'expired' : 'active'
if (newState !== lastState) {
setLastState(newState)
onStateChange?.(newState)
if (newState === 'expired') {
setAnnouncementText(`Countdown finished. ${endedLabel}.`)
} else {
setAnnouncementText('Countdown resumed.')
}
}
}, [ended, lastState, endedLabel, onStateChange])
function announceCurrentTime() {
const timeText = ended ? endedLabel : displayText
setAnnouncementText(`Time remaining: ${timeText}`)
// Focus the announcement for immediate reading
announceRef.current?.focus()
}
return (
<div className="flex items-center gap-2">
{/* Display with aria-live="off" to prevent announcement spam */}
<span
className={cn('font-mono tabular-nums', className)}
aria-live="off"
aria-label={`Time remaining: ${ended ? endedLabel : displayText}`}
>
{ended ? endedLabel : displayText}
</span>
{/* On-demand read button */}
{!hideButton && (
<Button
variant="ghost"
size="sm"
onClick={announceCurrentTime}
className="h-6 w-6 p-0"
aria-label="Announce current time remaining"
title="Click to announce the current time to screen readers"
>
<Volume2 className="h-3.5 w-3.5" />
</Button>
)}
{/* Live region for announcements */}
<div
ref={announceRef}
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
tabIndex={-1}
>
{announcementText}
</div>
</div>
)
}