Skip to content

Commit 3fb96e4

Browse files
authored
feat: aria live region for announcements (#9653)
* feat: aria live region for announcements * fix: code review and add tests * fix: better suite name * chore: remove unused function * fix: code review changes * chore: add back ability to remove role
1 parent 5d304df commit 3fb96e4

5 files changed

Lines changed: 341 additions & 5 deletions

File tree

packages/blockly/core/css.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,4 +529,11 @@ input[type=number] {
529529
) {
530530
outline: none;
531531
}
532+
.hiddenForAria {
533+
position: absolute;
534+
left: -9999px;
535+
width: 1px;
536+
height: 1px;
537+
overflow: hidden;
538+
}
532539
`;

packages/blockly/core/inject.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {Options} from './options.js';
1717
import {ScrollbarPair} from './scrollbar_pair.js';
1818
import * as Tooltip from './tooltip.js';
1919
import * as Touch from './touch.js';
20+
import * as aria from './utils/aria.js';
2021
import * as dom from './utils/dom.js';
2122
import {Svg} from './utils/svg.js';
2223
import * as WidgetDiv from './widgetdiv.js';
@@ -78,6 +79,8 @@ export function inject(
7879
common.globalShortcutHandler,
7980
);
8081

82+
aria.initializeGlobalAriaLiveRegion(subContainer);
83+
8184
return workspace;
8285
}
8386

packages/blockly/core/utils/aria.ts

Lines changed: 143 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,50 @@
66

77
// Former goog.module ID: Blockly.utils.aria
88

9+
import * as dom from './dom.js';
10+
911
/** ARIA states/properties prefix. */
1012
const ARIA_PREFIX = 'aria-';
1113

1214
/** ARIA role attribute. */
1315
const ROLE_ATTRIBUTE = 'role';
1416

17+
/**
18+
* ARIA state values for LivePriority.
19+
* Copied from Closure's goog.a11y.aria.LivePriority
20+
*/
21+
export enum LiveRegionAssertiveness {
22+
// This information has the highest priority and assistive technologies
23+
// SHOULD notify the user immediately. Because an interruption may disorient
24+
// users or cause them to not complete their current task, authors SHOULD NOT
25+
// use the assertive value unless the interruption is imperative.
26+
ASSERTIVE = 'assertive',
27+
// Updates to the region will not be presented to the user unless the
28+
// assistive technology is currently focused on that region.
29+
OFF = 'off',
30+
// (Background change) Assistive technologies SHOULD announce the updates at
31+
// the next graceful opportunity, such as at the end of speaking the current
32+
// sentence or when the users pauses typing.
33+
POLITE = 'polite',
34+
}
35+
36+
/**
37+
* Customization options that can be passed when using `announceDynamicAriaState`.
38+
*/
39+
export interface DynamicAnnouncementOptions {
40+
/** The custom ARIA `Role` that should be used for the announcement container. */
41+
role?: Role;
42+
43+
/**
44+
* How assertive the announcement should be.
45+
*
46+
* Important*: It was found through testing that `ASSERTIVE` announcements are
47+
* often outright ignored by some screen readers, so it's generally recommended
48+
* to always use `POLITE` unless specifically tested across supported readers.
49+
*/
50+
assertiveness?: LiveRegionAssertiveness;
51+
}
52+
1553
/**
1654
* ARIA role values.
1755
* Copied from Closure's goog.a11y.aria.Role
@@ -56,6 +94,8 @@ export enum Role {
5694
STATUS = 'status',
5795
}
5896

97+
const DEFAULT_LIVE_REGION_ROLE = Role.STATUS;
98+
5999
/**
60100
* ARIA states and properties.
61101
* Copied from Closure's goog.a11y.aria.State
@@ -64,6 +104,9 @@ export enum State {
64104
// ARIA property for setting the currently active descendant of an element,
65105
// for example the selected item in a list box. Value: ID of an element.
66106
ACTIVEDESCENDANT = 'activedescendant',
107+
// ARIA property that, if true, indicates that all of a changed region should
108+
// be presented, instead of only parts. Value: one of {true, false}.
109+
ATOMIC = 'atomic',
67110
// ARIA property defines the total number of columns in a table, grid, or
68111
// treegrid.
69112
// Value: integer.
@@ -124,15 +167,32 @@ export enum State {
124167
}
125168

126169
/**
127-
* Sets the role of an element.
170+
* Removes the ARIA role from an element.
128171
*
129-
* Similar to Closure's goog.a11y.aria
172+
* Similar to Closure's goog.a11y.aria.removeRole
173+
*
174+
* @param element DOM element to remove the role from.
175+
*/
176+
export function removeRole(element: Element) {
177+
element.removeAttribute(ROLE_ATTRIBUTE);
178+
}
179+
180+
/**
181+
* Sets the ARIA role of an element. If `roleName` is null,
182+
* the role is removed.
183+
*
184+
* Similar to Closure's goog.a11y.aria.setRole
130185
*
131186
* @param element DOM node to set role of.
132-
* @param roleName Role name.
187+
* @param roleName Role name, or null to remove the role.
133188
*/
134-
export function setRole(element: Element, roleName: Role) {
135-
element.setAttribute(ROLE_ATTRIBUTE, roleName);
189+
export function setRole(element: Element, roleName: Role | null) {
190+
if (!roleName) {
191+
console.log('Removing role from element', element, roleName);
192+
removeRole(element);
193+
} else {
194+
element.setAttribute(ROLE_ATTRIBUTE, roleName);
195+
}
136196
}
137197

138198
/**
@@ -156,3 +216,81 @@ export function setState(
156216
const attrStateName = ARIA_PREFIX + stateName;
157217
element.setAttribute(attrStateName, `${value}`);
158218
}
219+
220+
let liveRegionElement: HTMLElement | null = null;
221+
222+
/**
223+
* Creates an ARIA live region under the specified parent Element to be used
224+
* for all dynamic announcements via `announceDynamicAriaState`. This must be
225+
* called only once and before any dynamic announcements can be made.
226+
*
227+
* @param parent The container element to which the live region will be appended.
228+
*/
229+
export function initializeGlobalAriaLiveRegion(parent: HTMLDivElement) {
230+
if (liveRegionElement && document.contains(liveRegionElement)) {
231+
return;
232+
}
233+
const ariaAnnouncementDiv = document.createElement('div');
234+
ariaAnnouncementDiv.textContent = '';
235+
ariaAnnouncementDiv.id = 'blocklyAriaAnnounce';
236+
dom.addClass(ariaAnnouncementDiv, 'hiddenForAria');
237+
setState(ariaAnnouncementDiv, State.LIVE, LiveRegionAssertiveness.POLITE);
238+
setRole(ariaAnnouncementDiv, DEFAULT_LIVE_REGION_ROLE);
239+
setState(ariaAnnouncementDiv, State.ATOMIC, true);
240+
parent.appendChild(ariaAnnouncementDiv);
241+
liveRegionElement = ariaAnnouncementDiv;
242+
}
243+
244+
let ariaAnnounceTimeout: ReturnType<typeof setTimeout>;
245+
let addBreakingSpace = false;
246+
247+
/**
248+
* Requests that the specified text be read to the user if a screen reader is
249+
* currently active.
250+
*
251+
* This relies on a centrally managed ARIA live region that is hidden from the
252+
* visual DOM. This live region is designed to try and ensure the text is read,
253+
* including if the same text is issued multiple times consecutively. Note that
254+
* `initializeGlobalAriaLiveRegion` must be called before this can be used.
255+
*
256+
* Callers should use this judiciously. It's often considered bad practice to
257+
* over-announce information that can be inferred from other sources on the page,
258+
* so this ought to be used only when certain context cannot be easily determined
259+
* (such as dynamic states that may not have perfect ARIA representations or
260+
* indications).
261+
*
262+
* @param text The text to read to the user.
263+
* @param options Custom options to configure the announcement. This defaults to
264+
* the status role and polite assertiveness.
265+
*/
266+
export function announceDynamicAriaState(
267+
text: string,
268+
options?: DynamicAnnouncementOptions,
269+
) {
270+
if (!liveRegionElement) {
271+
throw new Error('ARIA live region not initialized.');
272+
}
273+
const ariaAnnouncementContainer = liveRegionElement;
274+
const {
275+
assertiveness = LiveRegionAssertiveness.POLITE,
276+
role = DEFAULT_LIVE_REGION_ROLE,
277+
} = options || {};
278+
279+
// We use a short delay so rapid successive calls collapse into a single
280+
// announcement, and to ensure assistive technologies reliably detect the
281+
// DOM change.
282+
clearTimeout(ariaAnnounceTimeout);
283+
ariaAnnounceTimeout = setTimeout(() => {
284+
// Clear previous content.
285+
ariaAnnouncementContainer.replaceChildren();
286+
setState(ariaAnnouncementContainer, State.LIVE, assertiveness);
287+
setRole(ariaAnnouncementContainer, role);
288+
289+
const span = document.createElement('span');
290+
// The non-breaking space toggle ensures otherwise identical consecutive
291+
// messages are still announced.
292+
span.textContent = text + (addBreakingSpace ? '\u00A0' : '');
293+
addBreakingSpace = !addBreakingSpace;
294+
ariaAnnouncementContainer.appendChild(span);
295+
}, 10);
296+
}

0 commit comments

Comments
 (0)