Skip to content

Commit e6bb136

Browse files
committed
fix: code review and add tests
1 parent f1bad32 commit e6bb136

5 files changed

Lines changed: 208 additions & 59 deletions

File tree

packages/blockly/core/css.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,5 @@ input[type=number] {
535535
width: 1px;
536536
height: 1px;
537537
overflow: hidden;
538-
white-space: nowrap;
539538
}
540539
`;

packages/blockly/core/inject.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,6 @@ export function inject(
5555
dom.addClass(subContainer, 'blocklyRTL');
5656
}
5757

58-
// Ignore the subcontainer in aria since it is not focusable.
59-
aria.setRole(subContainer, aria.Role.PRESENTATION);
60-
6158
containerElement!.appendChild(subContainer);
6259
const svg = createDom(subContainer, options);
6360

@@ -82,7 +79,7 @@ export function inject(
8279
common.globalShortcutHandler,
8380
);
8481

85-
aria.createLiveRegion(subContainer);
82+
aria.initializeGlobalAriaLiveRegion(subContainer);
8683

8784
return workspace;
8885
}

packages/blockly/core/utils/aria.ts

Lines changed: 61 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const ROLE_ATTRIBUTE = 'role';
1818
* ARIA state values for LivePriority.
1919
* Copied from Closure's goog.a11y.aria.LivePriority
2020
*/
21-
export enum LivePriority {
21+
export enum LiveRegionAssertiveness {
2222
// This information has the highest priority and assistive technologies
2323
// SHOULD notify the user immediately. Because an interruption may disorient
2424
// users or cause them to not complete their current task, authors SHOULD NOT
@@ -33,6 +33,23 @@ export enum LivePriority {
3333
POLITE = 'polite',
3434
}
3535

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+
3653
/**
3754
* ARIA role values.
3855
* Copied from Closure's goog.a11y.aria.Role
@@ -182,84 +199,74 @@ export function setState(
182199
element.setAttribute(attrStateName, `${value}`);
183200
}
184201

202+
let liveRegionElement: HTMLElement | null = null;
203+
185204
/**
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.
205+
* Creates an ARIA live region under the specified parent Element to be used
206+
* for all dynamic announcements via `announceDynamicAriaState`. This must be
207+
* called only once and before any dynamic announcements can be made.
195208
*
196-
* @param container The container element to which the live region will be
197-
* appended.
209+
* @param parent The container element to which the live region will be appended.
198210
*/
199-
export function createLiveRegion(container: HTMLDivElement) {
211+
export function initializeGlobalAriaLiveRegion(parent: HTMLDivElement) {
212+
if (liveRegionElement && document.contains(liveRegionElement)) {
213+
return;
214+
}
200215
const ariaAnnouncementDiv = document.createElement('div');
201216
ariaAnnouncementDiv.textContent = '';
202217
ariaAnnouncementDiv.id = 'blocklyAriaAnnounce';
203218
dom.addClass(ariaAnnouncementDiv, 'hiddenForAria');
204-
setState(ariaAnnouncementDiv, State.LIVE, LivePriority.POLITE);
205-
container.appendChild(ariaAnnouncementDiv);
219+
setState(ariaAnnouncementDiv, State.LIVE, LiveRegionAssertiveness.POLITE);
220+
setRole(ariaAnnouncementDiv, Role.STATUS);
221+
ariaAnnouncementDiv.setAttribute('aria-atomic', 'true');
222+
parent.appendChild(ariaAnnouncementDiv);
223+
liveRegionElement = ariaAnnouncementDiv;
206224
}
207225

208226
let ariaAnnounceTimeout: ReturnType<typeof setTimeout>;
209227
let addBreakingSpace = false;
210228

211229
/**
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.
230+
* Requests that the specified text be read to the user if a screen reader is
231+
* currently active.
222232
*
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.
233+
* This relies on a centrally managed ARIA live region that is hidden from the
234+
* visual DOM. This live region is designed to try and ensure the text is read,
235+
* including if the same text is issued multiple times consecutively. Note that
236+
* `initializeGlobalAriaLiveRegion` must be called before this can be used.
225237
*
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.
238+
* Callers should use this judiciously. It's often considered bad practice to
239+
* over-announce information that can be inferred from other sources on the page,
240+
* so this ought to be used only when certain context cannot be easily determined
241+
* (such as dynamic states that may not have perfect ARIA representations or
242+
* indications).
229243
*
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
244+
* @param text The text to read to the user.
245+
* @param options Custom options to configure the announcement. This defaults to no
246+
* custom `Role` and polite assertiveness.
234247
*/
248+
235249
export function announceDynamicAriaState(
236250
text: string,
237-
options: {
238-
assertiveness: LivePriority;
239-
role: Role | null;
240-
} = {
241-
assertiveness: LivePriority.POLITE,
242-
role: null,
243-
},
251+
options?: DynamicAnnouncementOptions,
244252
) {
245-
const ariaAnnouncementContainer = document.getElementById(
246-
'blocklyAriaAnnounce',
247-
);
248-
if (!ariaAnnouncementContainer) {
249-
throw new Error('Expected element with id blocklyAriaAnnounce to exist.');
253+
if (!liveRegionElement) {
254+
throw new Error('ARIA live region not initialized.');
250255
}
251-
const {assertiveness, role} = options;
252-
253-
// Clear previous content.
254-
ariaAnnouncementContainer.replaceChildren();
255-
setState(ariaAnnouncementContainer, State.LIVE, assertiveness);
256+
const ariaAnnouncementContainer = liveRegionElement;
257+
const {assertiveness = LiveRegionAssertiveness.POLITE, role = null} =
258+
options || {};
256259

257260
clearTimeout(ariaAnnounceTimeout);
258261
ariaAnnounceTimeout = setTimeout(() => {
262+
// Clear previous content.
263+
ariaAnnouncementContainer.replaceChildren();
264+
setState(ariaAnnouncementContainer, State.LIVE, assertiveness);
259265
setRole(ariaAnnouncementContainer, role);
260-
const p = document.createElement('p');
261-
p.textContent = text + (addBreakingSpace ? '\u00A0' : '');
266+
267+
const span = document.createElement('span');
268+
span.textContent = text + (addBreakingSpace ? '\u00A0' : '');
262269
addBreakingSpace = !addBreakingSpace;
263-
ariaAnnouncementContainer.appendChild(p);
270+
ariaAnnouncementContainer.appendChild(span);
264271
}, 10);
265272
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Raspberry Pi Foundation
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {assert} from '../../node_modules/chai/index.js';
8+
import {
9+
sharedTestSetup,
10+
sharedTestTeardown,
11+
} from './test_helpers/setup_teardown.js';
12+
13+
suite('Aria', function () {
14+
setup(function () {
15+
sharedTestSetup.call(this);
16+
this.workspace = Blockly.inject('blocklyDiv', {});
17+
this.liveRegion = document.getElementById('blocklyAriaAnnounce');
18+
this.messageInLiveRegion = (message) => {
19+
const liveRegion = this.liveRegion;
20+
return !!(liveRegion && liveRegion.textContent === message);
21+
};
22+
});
23+
24+
teardown(function () {
25+
sharedTestTeardown.call(this);
26+
});
27+
28+
test('live region is created', function () {
29+
assert.isNotNull(this.liveRegion);
30+
});
31+
32+
test('live region has polite aria-live', function () {
33+
assert.equal(this.liveRegion.getAttribute('aria-live'), 'polite');
34+
});
35+
36+
test('live region has atomic true', function () {
37+
assert.equal(this.liveRegion.getAttribute('aria-atomic'), 'true');
38+
});
39+
40+
test('live region has status role by default', function () {
41+
assert.equal(this.liveRegion.getAttribute('role'), 'status');
42+
});
43+
44+
test('live region is visually hidden but not display none', function () {
45+
const style = window.getComputedStyle(this.liveRegion);
46+
assert.notEqual(style.display, 'none');
47+
});
48+
49+
test('createLiveRegion only creates one region (singleton)', function () {
50+
// Calling again should not create a duplicate.
51+
Blockly.utils.aria.initializeGlobalAriaLiveRegion(
52+
this.workspace.getInjectionDiv(),
53+
);
54+
55+
const regions = this.workspace
56+
.getInjectionDiv()
57+
.querySelectorAll('#blocklyAriaAnnounce');
58+
59+
assert.equal(regions.length, 1);
60+
});
61+
62+
test('announcement is delayed', function () {
63+
Blockly.utils.aria.announceDynamicAriaState('Hello world');
64+
65+
assert.equal(this.liveRegion.textContent, '');
66+
67+
// Advance past the delay in announceDynamicAriaState.
68+
this.clock.tick(11);
69+
assert.include(this.liveRegion.textContent, 'Hello world');
70+
});
71+
72+
test('repeated announcements are unique', function () {
73+
Blockly.utils.aria.announceDynamicAriaState('Block moved');
74+
this.clock.tick(11);
75+
76+
const first = this.liveRegion.textContent;
77+
78+
Blockly.utils.aria.announceDynamicAriaState('Block moved');
79+
this.clock.tick(11);
80+
81+
const second = this.liveRegion.textContent;
82+
83+
assert.notEqual(first, second);
84+
});
85+
86+
test('last write wins when called rapidly', function () {
87+
Blockly.utils.aria.announceDynamicAriaState('First message');
88+
Blockly.utils.aria.announceDynamicAriaState('Second message');
89+
Blockly.utils.aria.announceDynamicAriaState('Final message');
90+
91+
this.clock.tick(11);
92+
93+
assert.include(this.liveRegion.textContent, 'Final message');
94+
});
95+
96+
test('assertive option sets aria-live assertive', function () {
97+
Blockly.utils.aria.announceDynamicAriaState('Warning', {
98+
assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.ASSERTIVE,
99+
role: null,
100+
});
101+
102+
this.clock.tick(11);
103+
104+
assert.equal(this.liveRegion.getAttribute('aria-live'), 'assertive');
105+
});
106+
107+
test('role option updates role attribute', function () {
108+
Blockly.utils.aria.announceDynamicAriaState('Alert message', {
109+
assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE,
110+
role: Blockly.utils.aria.Role.GROUP,
111+
});
112+
113+
this.clock.tick(11);
114+
115+
assert.equal(this.liveRegion.getAttribute('role'), 'group');
116+
});
117+
118+
test('role and text update after delay', function () {
119+
// Initial announcement to establish baseline role + text.
120+
Blockly.utils.aria.announceDynamicAriaState('Initial message', {
121+
assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE,
122+
role: Blockly.utils.aria.Role.STATUS,
123+
});
124+
this.clock.tick(11);
125+
126+
assert.equal(this.liveRegion.getAttribute('role'), 'status');
127+
const initialText = this.liveRegion.textContent;
128+
129+
// Now announce with different role.
130+
Blockly.utils.aria.announceDynamicAriaState('New message', {
131+
assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE,
132+
role: null,
133+
});
134+
135+
// Before delay: role and text should not have changed yet.
136+
this.clock.tick(5);
137+
assert.equal(this.liveRegion.getAttribute('role'), 'status');
138+
assert.equal(this.liveRegion.textContent, initialText);
139+
140+
// After delay: both should update.
141+
this.clock.tick(6);
142+
assert.isNull(this.liveRegion.getAttribute('role'));
143+
assert.include(this.liveRegion.textContent, 'New message');
144+
});
145+
});

packages/blockly/tests/mocha/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@
159159
import {javascriptGenerator} from '../../build/javascript.loader.mjs';
160160

161161
// Import tests.
162+
import './aria_live_region_test.js';
162163
import './block_json_test.js';
163164
import './block_test.js';
164165
import './clipboard_test.js';

0 commit comments

Comments
 (0)