Delay flash announcements briefly to avoid missing the first screen

reader announcement
This commit is contained in:
Behrokh Satarnejad
2026-06-11 14:26:35 +02:00
parent 13bb93b95a
commit b256e51d64
2 changed files with 12 additions and 13 deletions
@@ -29,7 +29,7 @@
*/
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import FlashController, { SUCCESS_AUTOHIDE_TIMEOUT } from './flash.controller';
import FlashController, { LIVE_REGION_ANNOUNCEMENT_DELAY, SUCCESS_AUTOHIDE_TIMEOUT } from './flash.controller';
import { setupStimulusTest, type StimulusTestContext } from 'core-stimulus/test-helpers';
interface LiveRegionTestElement extends HTMLElement {
@@ -80,8 +80,8 @@ describe('FlashController', () => {
await ctx.nextFrame();
const item = ctx.screen.getByText('Saved');
// Keep the deferred live-region update deterministic.
vi.runOnlyPendingTimers();
// Keep the delayed live-region update deterministic.
vi.advanceTimersByTime(LIVE_REGION_ANNOUNCEMENT_DELAY);
expect(announceSpy).toHaveBeenCalledWith('Saved', { politeness: 'polite', from: item });
});
@@ -94,7 +94,7 @@ describe('FlashController', () => {
await ctx.nextFrame();
const item = ctx.screen.getByText('Invalid input');
vi.runOnlyPendingTimers();
vi.advanceTimersByTime(LIVE_REGION_ANNOUNCEMENT_DELAY);
expect(announceSpy).toHaveBeenCalledWith('Invalid input', { politeness: 'assertive', from: item });
});
@@ -106,7 +106,7 @@ describe('FlashController', () => {
const announceSpy:LiveRegionTestElement['announce'] = vi.fn((_message:string, _options:unknown) => undefined);
stubLiveRegionAnnouncement(announceSpy);
await ctx.nextFrame();
vi.runOnlyPendingTimers();
vi.advanceTimersByTime(LIVE_REGION_ANNOUNCEMENT_DELAY);
expect(announceSpy).not.toHaveBeenCalled();
});
@@ -119,7 +119,7 @@ describe('FlashController', () => {
await ctx.nextFrame();
ctx.screen.getByText('Saved').remove();
vi.runOnlyPendingTimers();
vi.advanceTimersByTime(LIVE_REGION_ANNOUNCEMENT_DELAY);
expect(announceSpy).not.toHaveBeenCalled();
});
@@ -2,6 +2,9 @@ import { ApplicationController } from 'stimulus-use';
import { announce } from '@primer/live-region-element';
export const SUCCESS_AUTOHIDE_TIMEOUT = 5000;
// Match Primer's live-region registration delay. Manual screen reader
// testing showed the first announcement can be missed without this pause.
export const LIVE_REGION_ANNOUNCEMENT_DELAY = 150;
export default class FlashController extends ApplicationController {
static values = {
@@ -70,14 +73,14 @@ export default class FlashController extends ApplicationController {
// Determine politeness level: 'assertive' for errors/alerts, 'polite' for other messages
const politeness = element.dataset.politeness === 'assertive' ? 'assertive' : 'polite';
// Defer announcement until after the flash has been connected to the DOM.
// Defer announcement so screen readers can observe the live region before it changes.
window.setTimeout(() => {
if (!element.isConnected) {
return;
}
void announce(message, { politeness, from: element });
});
}, LIVE_REGION_ANNOUNCEMENT_DELAY);
}
/**
@@ -85,11 +88,7 @@ export default class FlashController extends ApplicationController {
* Clears any existing timer without restarting it.
*/
private pauseAutohideTimer(element:HTMLElement) {
const timeoutId = this.autohideTimers.get(element);
if (timeoutId) {
window.clearTimeout(timeoutId);
this.autohideTimers.delete(element);
}
this.clearAutohideTimer(element);
}
/**