Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
121 changes: 121 additions & 0 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 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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -156,3 +199,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(
Comment thread
BenHenning marked this conversation as resolved.
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(() => {
Comment thread
mikeharv marked this conversation as resolved.
// Clear previous content.
ariaAnnouncementContainer.replaceChildren();
setState(ariaAnnouncementContainer, State.LIVE, assertiveness);
setRole(ariaAnnouncementContainer, role);
Comment thread
BenHenning marked this conversation as resolved.

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 changes: 187 additions & 0 deletions packages/blockly/tests/mocha/aria_test.js
Original file line number Diff line number Diff line change
@@ -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');
Comment thread
BenHenning marked this conversation as resolved.
});

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 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_test.js';
import './block_json_test.js';
import './block_test.js';
import './clipboard_test.js';
Expand Down
Loading