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
This commit is contained in:
Michael Harvey
2026-04-01 10:23:45 -04:00
committed by GitHub
parent 5d304df504
commit 3fb96e4ecd
5 changed files with 341 additions and 5 deletions
+7
View File
@@ -529,4 +529,11 @@ input[type=number] {
) {
outline: none;
}
.hiddenForAria {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
`;
+3
View File
@@ -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;
}
+143 -5
View File
@@ -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<typeof setTimeout>;
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);
}
+187
View File
@@ -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');
});
});
+1
View File
@@ -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';