From 609f0bb37c0ea0d2879bac89fc5130243860861b Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 29 Apr 2026 14:33:20 -0700 Subject: [PATCH 1/3] fix: Make live region coalesce messages --- packages/blockly/core/utils/aria.ts | 42 ++++++++++++++++++- packages/blockly/tests/mocha/aria_test.js | 23 +++++++++- .../tests/mocha/keyboard_movement_test.js | 8 ++-- 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index 69e86549f4b..292cfd496eb 100644 --- a/packages/blockly/core/utils/aria.ts +++ b/packages/blockly/core/utils/aria.ts @@ -33,6 +33,9 @@ export enum LiveRegionAssertiveness { POLITE = 'polite', } +let nextAnnouncementAssertiveness = LiveRegionAssertiveness.OFF; +const queuedAnnouncements: string[] = []; + /** * Customization options that can be passed when using `announceDynamicAriaState`. */ @@ -378,6 +381,17 @@ export function announceDynamicAriaState( assertiveness = LiveRegionAssertiveness.POLITE, role = DEFAULT_LIVE_REGION_ROLE, } = options || {}; + if ( + assertiveness === LiveRegionAssertiveness.ASSERTIVE || + nextAnnouncementAssertiveness !== LiveRegionAssertiveness.ASSERTIVE + ) { + } + + queuedAnnouncements.push(text); + nextAnnouncementAssertiveness = mostAssertive( + assertiveness, + nextAnnouncementAssertiveness, + ); // We use a short delay so rapid successive calls collapse into a single // announcement, and to ensure assistive technologies reliably detect the @@ -386,14 +400,38 @@ export function announceDynamicAriaState( ariaAnnounceTimeout = setTimeout(() => { // Clear previous content. ariaAnnouncementContainer.replaceChildren(); - setState(ariaAnnouncementContainer, State.LIVE, assertiveness); + setState( + ariaAnnouncementContainer, + State.LIVE, + nextAnnouncementAssertiveness, + ); 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' : ''); + span.textContent = + queuedAnnouncements.join('\n') + (addBreakingSpace ? '\u00A0' : ''); addBreakingSpace = !addBreakingSpace; ariaAnnouncementContainer.appendChild(span); + queuedAnnouncements.length = 0; + nextAnnouncementAssertiveness = LiveRegionAssertiveness.OFF; }, 10); } + +/** Returns the maximally assertive of the given assertiveness levels. */ +function mostAssertive(a: LiveRegionAssertiveness, b: LiveRegionAssertiveness) { + if ( + a === LiveRegionAssertiveness.ASSERTIVE || + b === LiveRegionAssertiveness.ASSERTIVE + ) { + return LiveRegionAssertiveness.ASSERTIVE; + } else if ( + a === LiveRegionAssertiveness.POLITE || + b === LiveRegionAssertiveness.POLITE + ) { + return LiveRegionAssertiveness.POLITE; + } + + return LiveRegionAssertiveness.OFF; +} diff --git a/packages/blockly/tests/mocha/aria_test.js b/packages/blockly/tests/mocha/aria_test.js index 91227ff4cbb..2772374d2a1 100644 --- a/packages/blockly/tests/mocha/aria_test.js +++ b/packages/blockly/tests/mocha/aria_test.js @@ -103,14 +103,33 @@ suite('ARIA', function () { assert.notEqual(first, second); }); - test('last write wins when called rapidly', function () { + test('Coalesces messages 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'); + assert.equal( + this.liveRegion.textContent, + 'First message\nSecond message\nFinal message', + ); + }); + + test('Uses maximal assertiveness when coalescing', function () { + Blockly.utils.aria.announceDynamicAriaState('First message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.OFF, + }); + Blockly.utils.aria.announceDynamicAriaState('Second message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.ASSERTIVE, + }); + Blockly.utils.aria.announceDynamicAriaState('Final message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + }); + + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('aria-live'), 'assertive'); }); test('assertive option sets aria-live assertive', function () { diff --git a/packages/blockly/tests/mocha/keyboard_movement_test.js b/packages/blockly/tests/mocha/keyboard_movement_test.js index 11b7e929bcb..21d1cc55e3d 100644 --- a/packages/blockly/tests/mocha/keyboard_movement_test.js +++ b/packages/blockly/tests/mocha/keyboard_movement_test.js @@ -1040,7 +1040,7 @@ suite('Keyboard-driven movement', function () { Blockly.getFocusManager().focusNode(valueBlock); startMove(this.workspace); - + this.clock.tick(10); this.moveAndAssert( moveRight, ['moving', 'inside', this.getBlockLabel(parent)], @@ -1057,6 +1057,7 @@ suite('Keyboard-driven movement', function () { Blockly.getFocusManager().focusNode(loop); startMove(this.workspace); moveRight(this.workspace); + this.clock.tick(10); this.moveAndAssert( moveRight, @@ -1081,6 +1082,7 @@ suite('Keyboard-driven movement', function () { Blockly.getFocusManager().focusNode(ifBlock); startMove(this.workspace); // on workspace moveRight(this.workspace); // before block1 + this.clock.tick(10); this.moveAndAssert( moveRight, [ @@ -1114,7 +1116,7 @@ suite('Keyboard-driven movement', function () { Blockly.getFocusManager().focusNode(boolean); startMove(this.workspace); - + this.clock.tick(10); this.moveAndAssert( moveRight, [ @@ -1151,7 +1153,7 @@ suite('Keyboard-driven movement', function () { Blockly.getFocusManager().focusNode(text); startMove(this.workspace); moveRight(this.workspace); // First labeled input - + this.clock.tick(10); this.moveAndAssert( moveRight, ['moving', 'inside', this.getBlockLabel(textJoin), 'input 2'], From 20be2162362d21d67698f3409f84909706116be5 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 29 Apr 2026 14:43:58 -0700 Subject: [PATCH 2/3] chore: Remove dead code --- packages/blockly/core/utils/aria.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index 292cfd496eb..aca661b2bd6 100644 --- a/packages/blockly/core/utils/aria.ts +++ b/packages/blockly/core/utils/aria.ts @@ -381,11 +381,6 @@ export function announceDynamicAriaState( assertiveness = LiveRegionAssertiveness.POLITE, role = DEFAULT_LIVE_REGION_ROLE, } = options || {}; - if ( - assertiveness === LiveRegionAssertiveness.ASSERTIVE || - nextAnnouncementAssertiveness !== LiveRegionAssertiveness.ASSERTIVE - ) { - } queuedAnnouncements.push(text); nextAnnouncementAssertiveness = mostAssertive( From 47df882d371cfa6a2f5d4463291ce8229987471e Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 29 Apr 2026 15:21:22 -0700 Subject: [PATCH 3/3] fix: Fix test --- packages/blockly/tests/mocha/aria_test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/blockly/tests/mocha/aria_test.js b/packages/blockly/tests/mocha/aria_test.js index 2772374d2a1..a8e0e9acc16 100644 --- a/packages/blockly/tests/mocha/aria_test.js +++ b/packages/blockly/tests/mocha/aria_test.js @@ -110,7 +110,7 @@ suite('ARIA', function () { this.clock.tick(11); - assert.equal( + assert.include( this.liveRegion.textContent, 'First message\nSecond message\nFinal message', );