From 3fb96e4ecda94686c09bd79159e095e56a32c8be Mon Sep 17 00:00:00 2001 From: Michael Harvey <43474485+mikeharv@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:23:45 -0400 Subject: [PATCH] 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 --- packages/blockly/core/css.ts | 7 + packages/blockly/core/inject.ts | 3 + packages/blockly/core/utils/aria.ts | 148 ++++++++++++++++- packages/blockly/tests/mocha/aria_test.js | 187 ++++++++++++++++++++++ packages/blockly/tests/mocha/index.html | 1 + 5 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 packages/blockly/tests/mocha/aria_test.js diff --git a/packages/blockly/core/css.ts b/packages/blockly/core/css.ts index 1e796b354..fdfa9c041 100644 --- a/packages/blockly/core/css.ts +++ b/packages/blockly/core/css.ts @@ -529,4 +529,11 @@ input[type=number] { ) { outline: none; } +.hiddenForAria { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +} `; diff --git a/packages/blockly/core/inject.ts b/packages/blockly/core/inject.ts index ca62eb47f..8cbae9b61 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'; @@ -78,6 +79,8 @@ export function inject( common.globalShortcutHandler, ); + aria.initializeGlobalAriaLiveRegion(subContainer); + return workspace; } diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index d997b8d0a..b324f8c60 100644 --- a/packages/blockly/core/utils/aria.ts +++ b/packages/blockly/core/utils/aria.ts @@ -6,12 +6,50 @@ // 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 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 + // 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 technology 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', +} + +/** + * 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 @@ -56,6 +94,8 @@ export enum Role { STATUS = 'status', } +const DEFAULT_LIVE_REGION_ROLE = Role.STATUS; + /** * ARIA states and properties. * Copied from Closure's goog.a11y.aria.State @@ -64,6 +104,9 @@ export enum State { // ARIA property for setting the currently active descendant of an element, // for example the selected item in a list box. Value: ID of an element. ACTIVEDESCENDANT = 'activedescendant', + // ARIA property that, if true, indicates that all of a changed region should + // be presented, instead of only parts. Value: one of {true, false}. + ATOMIC = 'atomic', // ARIA property defines the total number of columns in a table, grid, or // treegrid. // Value: integer. @@ -124,15 +167,32 @@ export enum State { } /** - * Sets the role of an element. + * Removes the ARIA role from an element. * - * Similar to Closure's goog.a11y.aria + * Similar to Closure's goog.a11y.aria.removeRole + * + * @param element DOM element to remove the role from. + */ +export function removeRole(element: Element) { + element.removeAttribute(ROLE_ATTRIBUTE); +} + +/** + * Sets the ARIA role of an element. If `roleName` is null, + * the role is removed. + * + * Similar to Closure's goog.a11y.aria.setRole * * @param element DOM node to set role of. - * @param roleName Role name. + * @param roleName Role name, or null to remove the role. */ -export function setRole(element: Element, roleName: Role) { - element.setAttribute(ROLE_ATTRIBUTE, roleName); +export function setRole(element: Element, roleName: Role | null) { + if (!roleName) { + console.log('Removing role from element', element, roleName); + removeRole(element); + } else { + element.setAttribute(ROLE_ATTRIBUTE, roleName); + } } /** @@ -156,3 +216,81 @@ export function setState( const attrStateName = ARIA_PREFIX + stateName; element.setAttribute(attrStateName, `${value}`); } + +let liveRegionElement: HTMLElement | null = null; + +/** + * 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 parent The container element to which the live region will be appended. + */ +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, LiveRegionAssertiveness.POLITE); + setRole(ariaAnnouncementDiv, DEFAULT_LIVE_REGION_ROLE); + setState(ariaAnnouncementDiv, State.ATOMIC, true); + parent.appendChild(ariaAnnouncementDiv); + liveRegionElement = ariaAnnouncementDiv; +} + +let ariaAnnounceTimeout: ReturnType; +let addBreakingSpace = false; + +/** + * Requests that the specified text be read to the user if a screen reader is + * currently active. + * + * 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. 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 read to the user. + * @param options Custom options to configure the announcement. This defaults to + * the status role and polite assertiveness. + */ +export function announceDynamicAriaState( + text: string, + options?: DynamicAnnouncementOptions, +) { + if (!liveRegionElement) { + throw new Error('ARIA live region not initialized.'); + } + const ariaAnnouncementContainer = liveRegionElement; + const { + assertiveness = LiveRegionAssertiveness.POLITE, + role = DEFAULT_LIVE_REGION_ROLE, + } = options || {}; + + // We use a short delay so rapid successive calls collapse into a single + // announcement, and to ensure assistive technologies reliably detect the + // DOM change. + clearTimeout(ariaAnnounceTimeout); + ariaAnnounceTimeout = setTimeout(() => { + // Clear previous content. + ariaAnnouncementContainer.replaceChildren(); + setState(ariaAnnouncementContainer, State.LIVE, assertiveness); + setRole(ariaAnnouncementContainer, role); + + const span = document.createElement('span'); + // The non-breaking space toggle ensures otherwise identical consecutive + // messages are still announced. + span.textContent = text + (addBreakingSpace ? '\u00A0' : ''); + addBreakingSpace = !addBreakingSpace; + ariaAnnouncementContainer.appendChild(span); + }, 10); +} diff --git a/packages/blockly/tests/mocha/aria_test.js b/packages/blockly/tests/mocha/aria_test.js new file mode 100644 index 000000000..960f8e69b --- /dev/null +++ b/packages/blockly/tests/mocha/aria_test.js @@ -0,0 +1,187 @@ +/** + * @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'); + }); + + 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 rendered for screen readers but visually hidden', function () { + const style = window.getComputedStyle(this.liveRegion); + + // Still rendered for screen readers + assert.notEqual(style.display, 'none'); + + // Visually hidden via hiddenForAria class + assert.equal(style.position, 'absolute'); + assert.equal(style.left, '-9999px'); + assert.equal(style.width, '1px'); + assert.equal(style.height, '1px'); + assert.equal(style.overflow, 'hidden'); + }); + + 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('Group message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: Blockly.utils.aria.Role.GROUP, + }); + + // 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.equal(this.liveRegion.getAttribute('role'), 'group'); + assert.include(this.liveRegion.textContent, 'Group message'); + }); + test('missing role does not clear default status role', function () { + Blockly.utils.aria.announceDynamicAriaState('Hello world'); + + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + }); + test('custom role overrides default status role', function () { + Blockly.utils.aria.announceDynamicAriaState('Group 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 reverts to status after custom role when role not provided', function () { + // First: default + Blockly.utils.aria.announceDynamicAriaState('Normal message'); + this.clock.tick(11); + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + + // Second: custom role + Blockly.utils.aria.announceDynamicAriaState('Group message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: Blockly.utils.aria.Role.GROUP, + }); + this.clock.tick(11); + assert.equal(this.liveRegion.getAttribute('role'), 'group'); + + // Third: no role provided should revert to default status. + Blockly.utils.aria.announceDynamicAriaState('Back to normal'); + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + }); +}); diff --git a/packages/blockly/tests/mocha/index.html b/packages/blockly/tests/mocha/index.html index 012bfe201..e75b145d5 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_test.js'; import './block_json_test.js'; import './block_test.js'; import './clipboard_test.js';