Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/blockly/core/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,4 +529,11 @@ input[type=number] {
) {
outline: none;
}
.hiddenForAria {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
`;
3 changes: 3 additions & 0 deletions packages/blockly/core/inject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -78,6 +79,8 @@ export function inject(
common.globalShortcutHandler,
);

aria.initializeGlobalAriaLiveRegion(subContainer);

return workspace;
}

Expand Down
118 changes: 116 additions & 2 deletions packages/blockly/core/utils/aria.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -131,8 +169,12 @@
* @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);
}
}

/**
Expand All @@ -156,3 +198,75 @@
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<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 no
* custom `Role` and polite assertiveness.
*/

export function announceDynamicAriaState(

Check warning on line 249 in packages/blockly/core/utils/aria.ts

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc comment
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this & the other changes in this file: is it possible perhaps to adapt the API the one proposed in the WIP screen reader design document? Specifically this section:

https://docs.google.com/document/d/1-q0paWfV9qYWoZKWb9BpcfYOy3xYZtvcLyuuD61qHXw/edit?tab=t.0#bookmark=id.a3l9k4e1tah4

That was written after analyzing the approach taken in the experimental branch, considering micro:bit's changes, and other longer-term considerations. If there's an aspect of the design that doesn't work then it'd be great to discuss (e.g. via chat and/or comments directly on the doc) so that the doc and code end up being close to the same.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Ben. Thanks for the link and comments. I've pushed an update that attempts to better align to the document. PTAL and let me know what you think.

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);
}
141 changes: 141 additions & 0 deletions packages/blockly/tests/mocha/aria_live_region_test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
1 change: 1 addition & 0 deletions packages/blockly/tests/mocha/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading