Skip to content

Commit f1bad32

Browse files
committed
feat: aria live region for announcements
1 parent 6059d1f commit f1bad32

3 files changed

Lines changed: 123 additions & 2 deletions

File tree

packages/blockly/core/css.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,4 +529,12 @@ 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+
white-space: nowrap;
539+
}
532540
`;

packages/blockly/core/inject.ts

Lines changed: 6 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';
@@ -54,6 +55,9 @@ export function inject(
5455
dom.addClass(subContainer, 'blocklyRTL');
5556
}
5657

58+
// Ignore the subcontainer in aria since it is not focusable.
59+
aria.setRole(subContainer, aria.Role.PRESENTATION);
60+
5761
containerElement!.appendChild(subContainer);
5862
const svg = createDom(subContainer, options);
5963

@@ -78,6 +82,8 @@ export function inject(
7882
common.globalShortcutHandler,
7983
);
8084

85+
aria.createLiveRegion(subContainer);
86+
8187
return workspace;
8288
}
8389

packages/blockly/core/utils/aria.ts

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,33 @@
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 LivePriority {
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 teechnology 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+
1536
/**
1637
* ARIA role values.
1738
* Copied from Closure's goog.a11y.aria.Role
@@ -131,8 +152,12 @@ export enum State {
131152
* @param element DOM node to set role of.
132153
* @param roleName Role name.
133154
*/
134-
export function setRole(element: Element, roleName: Role) {
135-
element.setAttribute(ROLE_ATTRIBUTE, roleName);
155+
export function setRole(element: Element, roleName: Role | null) {
156+
if (roleName) {
157+
element.setAttribute(ROLE_ATTRIBUTE, roleName);
158+
} else {
159+
element.removeAttribute(ROLE_ATTRIBUTE);
160+
}
136161
}
137162

138163
/**
@@ -156,3 +181,85 @@ export function setState(
156181
const attrStateName = ARIA_PREFIX + stateName;
157182
element.setAttribute(attrStateName, `${value}`);
158183
}
184+
185+
/**
186+
* Creates the centralized ARIA live region used to announce dynamic state
187+
* changes to screen readers. This live region is visually hidden but exposed to
188+
* assistive technologies.
189+
*
190+
* Only one live region should exist per Blockly injection. This function should
191+
* be called during workspace/injection setup to create the region inside the
192+
* Blockly container.
193+
*
194+
* See: https://stackoverflow.com/a/48590836 for a reference.
195+
*
196+
* @param container The container element to which the live region will be
197+
* appended.
198+
*/
199+
export function createLiveRegion(container: HTMLDivElement) {
200+
const ariaAnnouncementDiv = document.createElement('div');
201+
ariaAnnouncementDiv.textContent = '';
202+
ariaAnnouncementDiv.id = 'blocklyAriaAnnounce';
203+
dom.addClass(ariaAnnouncementDiv, 'hiddenForAria');
204+
setState(ariaAnnouncementDiv, State.LIVE, LivePriority.POLITE);
205+
container.appendChild(ariaAnnouncementDiv);
206+
}
207+
208+
let ariaAnnounceTimeout: ReturnType<typeof setTimeout>;
209+
let addBreakingSpace = false;
210+
211+
/**
212+
* Requests that the specified text be announced to the user via a centrally
213+
* managed ARIA live region, if a screen reader is active.
214+
*
215+
* Announcements are scheduled asynchronously. If this function is called again
216+
* before a pending announcement is inserted into the live region, the pending
217+
* announcement is canceled and replaced with the new one.
218+
*
219+
* The live region element must have id `blocklyAriaAnnounce`. Its `aria-live`
220+
* politeness setting and optional `role` are updated before the message is
221+
* inserted so screen readers announce the content correctly.
222+
*
223+
* A non-breaking space is alternated at the end of the message to ensure that
224+
* repeated messages are still announced by screen readers.
225+
*
226+
* Callers should use this judiciously. Over-announcing can reduce usability,
227+
* so this should primarily be used for dynamic states or information that
228+
* cannot be conveyed through standard ARIA semantics.
229+
*
230+
* @param text The text to announce to the user.
231+
* @param options Configuration options for the announcement.
232+
* @param options.assertiveness The ARIA live region priority
233+
* @param options.role Optional ARIA role to apply to the live region before
234+
*/
235+
export function announceDynamicAriaState(
236+
text: string,
237+
options: {
238+
assertiveness: LivePriority;
239+
role: Role | null;
240+
} = {
241+
assertiveness: LivePriority.POLITE,
242+
role: null,
243+
},
244+
) {
245+
const ariaAnnouncementContainer = document.getElementById(
246+
'blocklyAriaAnnounce',
247+
);
248+
if (!ariaAnnouncementContainer) {
249+
throw new Error('Expected element with id blocklyAriaAnnounce to exist.');
250+
}
251+
const {assertiveness, role} = options;
252+
253+
// Clear previous content.
254+
ariaAnnouncementContainer.replaceChildren();
255+
setState(ariaAnnouncementContainer, State.LIVE, assertiveness);
256+
257+
clearTimeout(ariaAnnounceTimeout);
258+
ariaAnnounceTimeout = setTimeout(() => {
259+
setRole(ariaAnnouncementContainer, role);
260+
const p = document.createElement('p');
261+
p.textContent = text + (addBreakingSpace ? '\u00A0' : '');
262+
addBreakingSpace = !addBreakingSpace;
263+
ariaAnnouncementContainer.appendChild(p);
264+
}, 10);
265+
}

0 commit comments

Comments
 (0)