diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index ebcb5ebf8..7a9a3d8d4 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`. */ @@ -386,6 +389,12 @@ export function announceDynamicAriaState( role = DEFAULT_LIVE_REGION_ROLE, } = options || {}; + 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 // DOM change. @@ -393,14 +402,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 2f10259b5..87f9e77e8 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.include( + 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 11b7e929b..21d1cc55e 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'],