diff --git a/packages/blockly/core/css.ts b/packages/blockly/core/css.ts index 1e796b354ff..fdfa9c041c0 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 ca62eb47f29..8cbae9b6102 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 d997b8d0af0..da41dfc306e 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 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', +} + +/** + * 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 @@ -131,8 +169,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 +198,75 @@ 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, Role.STATUS); + ariaAnnouncementDiv.setAttribute('aria-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 no + * custom `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 = null} = + options || {}; + + clearTimeout(ariaAnnounceTimeout); + ariaAnnounceTimeout = setTimeout(() => { + // Clear previous content. + ariaAnnouncementContainer.replaceChildren(); + setState(ariaAnnouncementContainer, State.LIVE, assertiveness); + setRole(ariaAnnouncementContainer, role); + + const span = document.createElement('span'); + span.textContent = text + (addBreakingSpace ? '\u00A0' : ''); + addBreakingSpace = !addBreakingSpace; + 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..67a4131f26c --- /dev/null +++ b/packages/blockly/tests/mocha/aria_live_region_test.js @@ -0,0 +1,141 @@ +/** + * @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 Live Region', 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 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';