From f1bad32c978f7aaf902919c30e0f0055f21b23f7 Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:35:03 -0400 Subject: [PATCH 1/4] feat: aria live region for announcements --- packages/blockly/core/css.ts | 8 ++ packages/blockly/core/inject.ts | 6 ++ packages/blockly/core/utils/aria.ts | 111 +++++++++++++++++++++++++++- 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/packages/blockly/core/css.ts b/packages/blockly/core/css.ts index 1e796b354ff..0bac3a9fc32 100644 --- a/packages/blockly/core/css.ts +++ b/packages/blockly/core/css.ts @@ -529,4 +529,12 @@ input[type=number] { ) { outline: none; } +.hiddenForAria { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; + white-space: nowrap; +} `; diff --git a/packages/blockly/core/inject.ts b/packages/blockly/core/inject.ts index ca62eb47f29..2a4be0ee949 100644 --- a/packages/blockly/core/inject.ts +++ b/packages/blockly/core/inject.ts @@ -17,6 +17,7 @@ import {Options} from './options.js'; import {ScrollbarPair} from './scrollbar_pair.js'; import * as Tooltip from './tooltip.js'; import * as Touch from './touch.js'; +import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; import {Svg} from './utils/svg.js'; import * as WidgetDiv from './widgetdiv.js'; @@ -54,6 +55,9 @@ export function inject( dom.addClass(subContainer, 'blocklyRTL'); } + // Ignore the subcontainer in aria since it is not focusable. + aria.setRole(subContainer, aria.Role.PRESENTATION); + containerElement!.appendChild(subContainer); const svg = createDom(subContainer, options); @@ -78,6 +82,8 @@ export function inject( common.globalShortcutHandler, ); + aria.createLiveRegion(subContainer); + return workspace; } diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index d997b8d0af0..9ccab9f09d1 100644 --- a/packages/blockly/core/utils/aria.ts +++ b/packages/blockly/core/utils/aria.ts @@ -6,12 +6,33 @@ // Former goog.module ID: Blockly.utils.aria +import * as dom from './dom.js'; + /** ARIA states/properties prefix. */ const ARIA_PREFIX = 'aria-'; /** ARIA role attribute. */ const ROLE_ATTRIBUTE = 'role'; +/** + * ARIA state values for LivePriority. + * Copied from Closure's goog.a11y.aria.LivePriority + */ +export enum LivePriority { + // This information has the highest priority and assistive technologies + // SHOULD notify the user immediately. Because an interruption may disorient + // users or cause them to not complete their current task, authors SHOULD NOT + // use the assertive value unless the interruption is imperative. + ASSERTIVE = 'assertive', + // Updates to the region will not be presented to the user unless the + // assistive teechnology is currently focused on that region. + OFF = 'off', + // (Background change) Assistive technologies SHOULD announce the updates at + // the next graceful opportunity, such as at the end of speaking the current + // sentence or when the users pauses typing. + POLITE = 'polite', +} + /** * ARIA role values. * Copied from Closure's goog.a11y.aria.Role @@ -131,8 +152,12 @@ export enum State { * @param element DOM node to set role of. * @param roleName Role name. */ -export function setRole(element: Element, roleName: Role) { - element.setAttribute(ROLE_ATTRIBUTE, roleName); +export function setRole(element: Element, roleName: Role | null) { + if (roleName) { + element.setAttribute(ROLE_ATTRIBUTE, roleName); + } else { + element.removeAttribute(ROLE_ATTRIBUTE); + } } /** @@ -156,3 +181,85 @@ export function setState( const attrStateName = ARIA_PREFIX + stateName; element.setAttribute(attrStateName, `${value}`); } + +/** + * Creates the centralized ARIA live region used to announce dynamic state + * changes to screen readers. This live region is visually hidden but exposed to + * assistive technologies. + * + * Only one live region should exist per Blockly injection. This function should + * be called during workspace/injection setup to create the region inside the + * Blockly container. + * + * See: https://stackoverflow.com/a/48590836 for a reference. + * + * @param container The container element to which the live region will be + * appended. + */ +export function createLiveRegion(container: HTMLDivElement) { + const ariaAnnouncementDiv = document.createElement('div'); + ariaAnnouncementDiv.textContent = ''; + ariaAnnouncementDiv.id = 'blocklyAriaAnnounce'; + dom.addClass(ariaAnnouncementDiv, 'hiddenForAria'); + setState(ariaAnnouncementDiv, State.LIVE, LivePriority.POLITE); + container.appendChild(ariaAnnouncementDiv); +} + +let ariaAnnounceTimeout: ReturnType; +let addBreakingSpace = false; + +/** + * Requests that the specified text be announced to the user via a centrally + * managed ARIA live region, if a screen reader is active. + * + * Announcements are scheduled asynchronously. If this function is called again + * before a pending announcement is inserted into the live region, the pending + * announcement is canceled and replaced with the new one. + * + * The live region element must have id `blocklyAriaAnnounce`. Its `aria-live` + * politeness setting and optional `role` are updated before the message is + * inserted so screen readers announce the content correctly. + * + * A non-breaking space is alternated at the end of the message to ensure that + * repeated messages are still announced by screen readers. + * + * Callers should use this judiciously. Over-announcing can reduce usability, + * so this should primarily be used for dynamic states or information that + * cannot be conveyed through standard ARIA semantics. + * + * @param text The text to announce to the user. + * @param options Configuration options for the announcement. + * @param options.assertiveness The ARIA live region priority + * @param options.role Optional ARIA role to apply to the live region before + */ +export function announceDynamicAriaState( + text: string, + options: { + assertiveness: LivePriority; + role: Role | null; + } = { + assertiveness: LivePriority.POLITE, + role: null, + }, +) { + const ariaAnnouncementContainer = document.getElementById( + 'blocklyAriaAnnounce', + ); + if (!ariaAnnouncementContainer) { + throw new Error('Expected element with id blocklyAriaAnnounce to exist.'); + } + const {assertiveness, role} = options; + + // Clear previous content. + ariaAnnouncementContainer.replaceChildren(); + setState(ariaAnnouncementContainer, State.LIVE, assertiveness); + + clearTimeout(ariaAnnounceTimeout); + ariaAnnounceTimeout = setTimeout(() => { + setRole(ariaAnnouncementContainer, role); + const p = document.createElement('p'); + p.textContent = text + (addBreakingSpace ? '\u00A0' : ''); + addBreakingSpace = !addBreakingSpace; + ariaAnnouncementContainer.appendChild(p); + }, 10); +} From e6bb136e6e489ced0228e092a153c66609fd9b7d Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Fri, 27 Mar 2026 12:37:40 -0400 Subject: [PATCH 2/4] fix: code review and add tests --- packages/blockly/core/css.ts | 1 - packages/blockly/core/inject.ts | 5 +- packages/blockly/core/utils/aria.ts | 115 +++++++------- .../tests/mocha/aria_live_region_test.js | 145 ++++++++++++++++++ packages/blockly/tests/mocha/index.html | 1 + 5 files changed, 208 insertions(+), 59 deletions(-) create mode 100644 packages/blockly/tests/mocha/aria_live_region_test.js diff --git a/packages/blockly/core/css.ts b/packages/blockly/core/css.ts index 0bac3a9fc32..fdfa9c041c0 100644 --- a/packages/blockly/core/css.ts +++ b/packages/blockly/core/css.ts @@ -535,6 +535,5 @@ input[type=number] { width: 1px; height: 1px; overflow: hidden; - white-space: nowrap; } `; diff --git a/packages/blockly/core/inject.ts b/packages/blockly/core/inject.ts index 2a4be0ee949..8cbae9b6102 100644 --- a/packages/blockly/core/inject.ts +++ b/packages/blockly/core/inject.ts @@ -55,9 +55,6 @@ export function inject( dom.addClass(subContainer, 'blocklyRTL'); } - // Ignore the subcontainer in aria since it is not focusable. - aria.setRole(subContainer, aria.Role.PRESENTATION); - containerElement!.appendChild(subContainer); const svg = createDom(subContainer, options); @@ -82,7 +79,7 @@ export function inject( common.globalShortcutHandler, ); - aria.createLiveRegion(subContainer); + aria.initializeGlobalAriaLiveRegion(subContainer); return workspace; } diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index 9ccab9f09d1..da41dfc306e 100644 --- a/packages/blockly/core/utils/aria.ts +++ b/packages/blockly/core/utils/aria.ts @@ -18,7 +18,7 @@ const ROLE_ATTRIBUTE = 'role'; * ARIA state values for LivePriority. * Copied from Closure's goog.a11y.aria.LivePriority */ -export enum LivePriority { +export enum LiveRegionAssertiveness { // This information has the highest priority and assistive technologies // SHOULD notify the user immediately. Because an interruption may disorient // users or cause them to not complete their current task, authors SHOULD NOT @@ -33,6 +33,23 @@ export enum LivePriority { POLITE = 'polite', } +/** + * Customization options that can be passed when using `announceDynamicAriaState`. + */ +export interface DynamicAnnouncementOptions { + /** The custom ARIA `Role` that should be used for the announcement container. */ + role?: Role; + + /** + * How assertive the announcement should be. + * + * Important*: It was found through testing that `ASSERTIVE` announcements are + * often outright ignored by some screen readers, so it's generally recommended + * to always use `POLITE` unless specifically tested across supported readers. + */ + assertiveness?: LiveRegionAssertiveness; +} + /** * ARIA role values. * Copied from Closure's goog.a11y.aria.Role @@ -182,84 +199,74 @@ export function setState( element.setAttribute(attrStateName, `${value}`); } +let liveRegionElement: HTMLElement | null = null; + /** - * Creates the centralized ARIA live region used to announce dynamic state - * changes to screen readers. This live region is visually hidden but exposed to - * assistive technologies. - * - * Only one live region should exist per Blockly injection. This function should - * be called during workspace/injection setup to create the region inside the - * Blockly container. - * - * See: https://stackoverflow.com/a/48590836 for a reference. + * Creates an ARIA live region under the specified parent Element to be used + * for all dynamic announcements via `announceDynamicAriaState`. This must be + * called only once and before any dynamic announcements can be made. * - * @param container The container element to which the live region will be - * appended. + * @param parent The container element to which the live region will be appended. */ -export function createLiveRegion(container: HTMLDivElement) { +export function initializeGlobalAriaLiveRegion(parent: HTMLDivElement) { + if (liveRegionElement && document.contains(liveRegionElement)) { + return; + } const ariaAnnouncementDiv = document.createElement('div'); ariaAnnouncementDiv.textContent = ''; ariaAnnouncementDiv.id = 'blocklyAriaAnnounce'; dom.addClass(ariaAnnouncementDiv, 'hiddenForAria'); - setState(ariaAnnouncementDiv, State.LIVE, LivePriority.POLITE); - container.appendChild(ariaAnnouncementDiv); + setState(ariaAnnouncementDiv, State.LIVE, LiveRegionAssertiveness.POLITE); + setRole(ariaAnnouncementDiv, Role.STATUS); + ariaAnnouncementDiv.setAttribute('aria-atomic', 'true'); + parent.appendChild(ariaAnnouncementDiv); + liveRegionElement = ariaAnnouncementDiv; } let ariaAnnounceTimeout: ReturnType; let addBreakingSpace = false; /** - * Requests that the specified text be announced to the user via a centrally - * managed ARIA live region, if a screen reader is active. - * - * Announcements are scheduled asynchronously. If this function is called again - * before a pending announcement is inserted into the live region, the pending - * announcement is canceled and replaced with the new one. - * - * The live region element must have id `blocklyAriaAnnounce`. Its `aria-live` - * politeness setting and optional `role` are updated before the message is - * inserted so screen readers announce the content correctly. + * Requests that the specified text be read to the user if a screen reader is + * currently active. * - * A non-breaking space is alternated at the end of the message to ensure that - * repeated messages are still announced by screen readers. + * This relies on a centrally managed ARIA live region that is hidden from the + * visual DOM. This live region is designed to try and ensure the text is read, + * including if the same text is issued multiple times consecutively. Note that + * `initializeGlobalAriaLiveRegion` must be called before this can be used. * - * Callers should use this judiciously. Over-announcing can reduce usability, - * so this should primarily be used for dynamic states or information that - * cannot be conveyed through standard ARIA semantics. + * Callers should use this judiciously. It's often considered bad practice to + * over-announce information that can be inferred from other sources on the page, + * so this ought to be used only when certain context cannot be easily determined + * (such as dynamic states that may not have perfect ARIA representations or + * indications). * - * @param text The text to announce to the user. - * @param options Configuration options for the announcement. - * @param options.assertiveness The ARIA live region priority - * @param options.role Optional ARIA role to apply to the live region before + * @param text The text to read to the user. + * @param options Custom options to configure the announcement. This defaults to no + * custom `Role` and polite assertiveness. */ + export function announceDynamicAriaState( text: string, - options: { - assertiveness: LivePriority; - role: Role | null; - } = { - assertiveness: LivePriority.POLITE, - role: null, - }, + options?: DynamicAnnouncementOptions, ) { - const ariaAnnouncementContainer = document.getElementById( - 'blocklyAriaAnnounce', - ); - if (!ariaAnnouncementContainer) { - throw new Error('Expected element with id blocklyAriaAnnounce to exist.'); + if (!liveRegionElement) { + throw new Error('ARIA live region not initialized.'); } - const {assertiveness, role} = options; - - // Clear previous content. - ariaAnnouncementContainer.replaceChildren(); - setState(ariaAnnouncementContainer, State.LIVE, assertiveness); + const ariaAnnouncementContainer = liveRegionElement; + const {assertiveness = LiveRegionAssertiveness.POLITE, role = null} = + options || {}; clearTimeout(ariaAnnounceTimeout); ariaAnnounceTimeout = setTimeout(() => { + // Clear previous content. + ariaAnnouncementContainer.replaceChildren(); + setState(ariaAnnouncementContainer, State.LIVE, assertiveness); setRole(ariaAnnouncementContainer, role); - const p = document.createElement('p'); - p.textContent = text + (addBreakingSpace ? '\u00A0' : ''); + + const span = document.createElement('span'); + span.textContent = text + (addBreakingSpace ? '\u00A0' : ''); addBreakingSpace = !addBreakingSpace; - ariaAnnouncementContainer.appendChild(p); + ariaAnnouncementContainer.appendChild(span); }, 10); } diff --git a/packages/blockly/tests/mocha/aria_live_region_test.js b/packages/blockly/tests/mocha/aria_live_region_test.js new file mode 100644 index 00000000000..57561b414b9 --- /dev/null +++ b/packages/blockly/tests/mocha/aria_live_region_test.js @@ -0,0 +1,145 @@ +/** + * @license + * Copyright 2026 Raspberry Pi Foundation + * SPDX-License-Identifier: Apache-2.0 + */ + +import {assert} from '../../node_modules/chai/index.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; + +suite('Aria', function () { + setup(function () { + sharedTestSetup.call(this); + this.workspace = Blockly.inject('blocklyDiv', {}); + this.liveRegion = document.getElementById('blocklyAriaAnnounce'); + this.messageInLiveRegion = (message) => { + const liveRegion = this.liveRegion; + return !!(liveRegion && liveRegion.textContent === message); + }; + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + test('live region is created', function () { + assert.isNotNull(this.liveRegion); + }); + + test('live region has polite aria-live', function () { + assert.equal(this.liveRegion.getAttribute('aria-live'), 'polite'); + }); + + test('live region has atomic true', function () { + assert.equal(this.liveRegion.getAttribute('aria-atomic'), 'true'); + }); + + test('live region has status role by default', function () { + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + }); + + test('live region is visually hidden but not display none', function () { + const style = window.getComputedStyle(this.liveRegion); + assert.notEqual(style.display, 'none'); + }); + + test('createLiveRegion only creates one region (singleton)', function () { + // Calling again should not create a duplicate. + Blockly.utils.aria.initializeGlobalAriaLiveRegion( + this.workspace.getInjectionDiv(), + ); + + const regions = this.workspace + .getInjectionDiv() + .querySelectorAll('#blocklyAriaAnnounce'); + + assert.equal(regions.length, 1); + }); + + test('announcement is delayed', function () { + Blockly.utils.aria.announceDynamicAriaState('Hello world'); + + assert.equal(this.liveRegion.textContent, ''); + + // Advance past the delay in announceDynamicAriaState. + this.clock.tick(11); + assert.include(this.liveRegion.textContent, 'Hello world'); + }); + + test('repeated announcements are unique', function () { + Blockly.utils.aria.announceDynamicAriaState('Block moved'); + this.clock.tick(11); + + const first = this.liveRegion.textContent; + + Blockly.utils.aria.announceDynamicAriaState('Block moved'); + this.clock.tick(11); + + const second = this.liveRegion.textContent; + + assert.notEqual(first, second); + }); + + test('last write wins when called rapidly', function () { + Blockly.utils.aria.announceDynamicAriaState('First message'); + Blockly.utils.aria.announceDynamicAriaState('Second message'); + Blockly.utils.aria.announceDynamicAriaState('Final message'); + + this.clock.tick(11); + + assert.include(this.liveRegion.textContent, 'Final message'); + }); + + test('assertive option sets aria-live assertive', function () { + Blockly.utils.aria.announceDynamicAriaState('Warning', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.ASSERTIVE, + role: null, + }); + + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('aria-live'), 'assertive'); + }); + + test('role option updates role attribute', function () { + Blockly.utils.aria.announceDynamicAriaState('Alert message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: Blockly.utils.aria.Role.GROUP, + }); + + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('role'), 'group'); + }); + + test('role and text update after delay', function () { + // Initial announcement to establish baseline role + text. + Blockly.utils.aria.announceDynamicAriaState('Initial message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: Blockly.utils.aria.Role.STATUS, + }); + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + const initialText = this.liveRegion.textContent; + + // Now announce with different role. + Blockly.utils.aria.announceDynamicAriaState('New message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: null, + }); + + // Before delay: role and text should not have changed yet. + this.clock.tick(5); + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + assert.equal(this.liveRegion.textContent, initialText); + + // After delay: both should update. + this.clock.tick(6); + assert.isNull(this.liveRegion.getAttribute('role')); + assert.include(this.liveRegion.textContent, 'New message'); + }); +}); diff --git a/packages/blockly/tests/mocha/index.html b/packages/blockly/tests/mocha/index.html index 012bfe201ca..2aaa5fb7ef7 100644 --- a/packages/blockly/tests/mocha/index.html +++ b/packages/blockly/tests/mocha/index.html @@ -159,6 +159,7 @@ import {javascriptGenerator} from '../../build/javascript.loader.mjs'; // Import tests. + import './aria_live_region_test.js'; import './block_json_test.js'; import './block_test.js'; import './clipboard_test.js'; From 6fa71b09d623a392055eb466951b29308c2eddce Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:09:27 -0400 Subject: [PATCH 3/4] fix: better suite name --- packages/blockly/tests/mocha/aria_live_region_test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/blockly/tests/mocha/aria_live_region_test.js b/packages/blockly/tests/mocha/aria_live_region_test.js index 57561b414b9..842586982dc 100644 --- a/packages/blockly/tests/mocha/aria_live_region_test.js +++ b/packages/blockly/tests/mocha/aria_live_region_test.js @@ -10,7 +10,7 @@ import { sharedTestTeardown, } from './test_helpers/setup_teardown.js'; -suite('Aria', function () { +suite('Aria Live Region', function () { setup(function () { sharedTestSetup.call(this); this.workspace = Blockly.inject('blocklyDiv', {}); From 55f5a4b4bb4838633f492598e9a4fa029ec161c4 Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:14:15 -0400 Subject: [PATCH 4/4] chore: remove unused function --- packages/blockly/tests/mocha/aria_live_region_test.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/blockly/tests/mocha/aria_live_region_test.js b/packages/blockly/tests/mocha/aria_live_region_test.js index 842586982dc..67a4131f26c 100644 --- a/packages/blockly/tests/mocha/aria_live_region_test.js +++ b/packages/blockly/tests/mocha/aria_live_region_test.js @@ -15,10 +15,6 @@ suite('Aria Live Region', function () { sharedTestSetup.call(this); this.workspace = Blockly.inject('blocklyDiv', {}); this.liveRegion = document.getElementById('blocklyAriaAnnounce'); - this.messageInLiveRegion = (message) => { - const liveRegion = this.liveRegion; - return !!(liveRegion && liveRegion.textContent === message); - }; }); teardown(function () {