Skip to content

Commit 439e1d5

Browse files
committed
feat(a11y): add component NotificationAnnouncer
1 parent 112718b commit 439e1d5

6 files changed

Lines changed: 446 additions & 1 deletion

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import React, { useCallback, useEffect, useRef, useState } from 'react';
2+
import type { Notification } from 'stream-chat';
3+
4+
import { useNotifications } from '../Notifications';
5+
import { VisuallyHidden } from '../VisuallyHidden';
6+
import { useTranslationContext } from '../../context';
7+
8+
type LivePriority = 'assertive' | 'polite';
9+
10+
type QueuedAnnouncement = {
11+
id: string;
12+
message: string;
13+
priority: LivePriority;
14+
};
15+
16+
export type NotificationAnnouncementBuilderParams = {
17+
defaultMessage: string;
18+
notification: Notification;
19+
translatedMessage: string;
20+
};
21+
22+
export type NotificationAnnouncementBuilder = (
23+
params: NotificationAnnouncementBuilderParams,
24+
) => string;
25+
export type NotificationAnnouncementFilter = (notification: Notification) => boolean;
26+
27+
const ANNOUNCEMENT_CLEAR_DELAY_MS = 50;
28+
const ANNOUNCEMENT_QUEUE_GAP_MS = 120;
29+
30+
const getAnnouncementPriority = (notification: Notification): LivePriority =>
31+
notification.severity === 'error' ? 'assertive' : 'polite';
32+
33+
const getSeverityLabel = (notification: Notification) => {
34+
if (!notification.severity) return null;
35+
return `${notification.severity[0].toUpperCase()}${notification.severity.slice(1)}`;
36+
};
37+
38+
const getDefaultAnnouncementMessage = (notification: Notification, message: string) => {
39+
const severityLabel = getSeverityLabel(notification);
40+
41+
if (severityLabel) {
42+
return `${severityLabel} notification: ${message}`;
43+
}
44+
45+
return `Notification: ${message}`;
46+
};
47+
48+
export type NotificationAnnouncerProps = {
49+
buildNotificationAnnouncement?: NotificationAnnouncementBuilder;
50+
notificationFilter?: NotificationAnnouncementFilter;
51+
};
52+
53+
const defaultBuildNotificationAnnouncement: NotificationAnnouncementBuilder = ({
54+
defaultMessage,
55+
}) => defaultMessage;
56+
const defaultNotificationFilter: NotificationAnnouncementFilter = () => true;
57+
58+
export const NotificationAnnouncer = ({
59+
buildNotificationAnnouncement = defaultBuildNotificationAnnouncement,
60+
notificationFilter = defaultNotificationFilter,
61+
}: NotificationAnnouncerProps) => {
62+
const { t } = useTranslationContext();
63+
const notifications = useNotifications();
64+
const [announcementQueue, setAnnouncementQueue] = useState<QueuedAnnouncement[]>([]);
65+
const [isAnnouncing, setIsAnnouncing] = useState(false);
66+
const [politeAnnouncement, setPoliteAnnouncement] = useState('');
67+
const [assertiveAnnouncement, setAssertiveAnnouncement] = useState('');
68+
const initializedRef = useRef(false);
69+
const seenNotificationIdsRef = useRef(new Set<string>());
70+
const announceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
71+
const dequeueTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
72+
73+
const clearTimeouts = useCallback(() => {
74+
if (announceTimeoutRef.current) {
75+
clearTimeout(announceTimeoutRef.current);
76+
announceTimeoutRef.current = null;
77+
}
78+
79+
if (dequeueTimeoutRef.current) {
80+
clearTimeout(dequeueTimeoutRef.current);
81+
dequeueTimeoutRef.current = null;
82+
}
83+
}, []);
84+
85+
useEffect(
86+
() => () => {
87+
clearTimeouts();
88+
},
89+
[clearTimeouts],
90+
);
91+
92+
useEffect(() => {
93+
const visibleNotificationIds = new Set(notifications.map(({ id }) => id));
94+
95+
seenNotificationIdsRef.current.forEach((id) => {
96+
if (!visibleNotificationIds.has(id)) {
97+
seenNotificationIdsRef.current.delete(id);
98+
}
99+
});
100+
101+
if (!initializedRef.current) {
102+
notifications.forEach(({ id }) => {
103+
seenNotificationIdsRef.current.add(id);
104+
});
105+
initializedRef.current = true;
106+
return;
107+
}
108+
109+
const nextAnnouncements: QueuedAnnouncement[] = [];
110+
111+
notifications.forEach((notification) => {
112+
if (seenNotificationIdsRef.current.has(notification.id)) return;
113+
114+
seenNotificationIdsRef.current.add(notification.id);
115+
if (!notificationFilter(notification)) return;
116+
117+
const message = t('translationBuilderTopic/notification', {
118+
notification,
119+
value: notification.message,
120+
});
121+
122+
if (!message) return;
123+
124+
const defaultMessage = getDefaultAnnouncementMessage(notification, message);
125+
const announcementMessage = buildNotificationAnnouncement({
126+
defaultMessage,
127+
notification,
128+
translatedMessage: message,
129+
});
130+
131+
if (!announcementMessage) return;
132+
133+
nextAnnouncements.push({
134+
id: notification.id,
135+
message: announcementMessage,
136+
priority: getAnnouncementPriority(notification),
137+
});
138+
});
139+
140+
if (!nextAnnouncements.length) return;
141+
142+
setAnnouncementQueue((currentQueue) => [...currentQueue, ...nextAnnouncements]);
143+
}, [buildNotificationAnnouncement, notificationFilter, notifications, t]);
144+
145+
useEffect(() => {
146+
if (isAnnouncing) return;
147+
148+
const nextAnnouncement = announcementQueue[0];
149+
if (!nextAnnouncement) return;
150+
151+
setIsAnnouncing(true);
152+
clearTimeouts();
153+
setPoliteAnnouncement('');
154+
setAssertiveAnnouncement('');
155+
156+
announceTimeoutRef.current = setTimeout(() => {
157+
if (nextAnnouncement.priority === 'assertive') {
158+
setAssertiveAnnouncement(nextAnnouncement.message);
159+
} else {
160+
setPoliteAnnouncement(nextAnnouncement.message);
161+
}
162+
163+
dequeueTimeoutRef.current = setTimeout(() => {
164+
setAnnouncementQueue((currentQueue) =>
165+
currentQueue.filter(({ id }) => id !== nextAnnouncement.id),
166+
);
167+
setIsAnnouncing(false);
168+
dequeueTimeoutRef.current = null;
169+
}, ANNOUNCEMENT_QUEUE_GAP_MS);
170+
171+
announceTimeoutRef.current = null;
172+
}, ANNOUNCEMENT_CLEAR_DELAY_MS);
173+
}, [announcementQueue, clearTimeouts, isAnnouncing]);
174+
175+
return (
176+
<VisuallyHidden data-testid='notification-announcer'>
177+
<div aria-atomic='true' aria-live='polite' role='status'>
178+
{politeAnnouncement}
179+
</div>
180+
<div aria-atomic='true' aria-live='assertive' role='alert'>
181+
{assertiveAnnouncement}
182+
</div>
183+
</VisuallyHidden>
184+
);
185+
};

0 commit comments

Comments
 (0)