mirror of
https://github.com/google/blockly.git
synced 2026-04-26 23:20:22 +02:00
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:
@@ -529,4 +529,11 @@ input[type=number] {
|
||||
) {
|
||||
outline: none;
|
||||
}
|
||||
.hiddenForAria {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user