diff --git a/frontend/src/stimulus/controllers/flash.controller.spec.ts b/frontend/src/stimulus/controllers/flash.controller.spec.ts index 59b6bc41b4f..6bbbb869207 100644 --- a/frontend/src/stimulus/controllers/flash.controller.spec.ts +++ b/frontend/src/stimulus/controllers/flash.controller.spec.ts @@ -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(); }); diff --git a/frontend/src/stimulus/controllers/flash.controller.ts b/frontend/src/stimulus/controllers/flash.controller.ts index 37ffd70f1ab..4e3a5f88ef9 100644 --- a/frontend/src/stimulus/controllers/flash.controller.ts +++ b/frontend/src/stimulus/controllers/flash.controller.ts @@ -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); } /**