Skip to content

Commit f29cd1a

Browse files
committed
feat(screenreader): implement announcement queue and stop functionality
1 parent 6dcb2ca commit f29cd1a

3 files changed

Lines changed: 89 additions & 20 deletions

File tree

frontend/src/app/core/mud/components/mud-client/mud-client.component.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,9 @@ export class MudClientComponent implements AfterViewInit, OnDestroy {
326326
: { value: message };
327327

328328
if (typeof payload === 'string') {
329+
if (!payload.length) {
330+
this.screenReader?.stopAnnouncements();
331+
}
329332
this.screenReader?.appendToHistory(payload);
330333
// Announce the complete input so user can verify what they typed
331334
this.screenReader?.announceInputCommitted(payload);

frontend/src/app/features/terminal/mud-screenreader.spec.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ describe('MudScreenReaderAnnouncer', () => {
1212
liveRegion,
1313
undefined,
1414
undefined,
15-
100,
15+
50,
16+
1000,
1617
);
1718
});
1819

@@ -26,7 +27,7 @@ describe('MudScreenReaderAnnouncer', () => {
2627

2728
expect(liveRegion.textContent).toBe('Hello World');
2829

29-
jest.advanceTimersByTime(99);
30+
jest.advanceTimersByTime(49);
3031
expect(liveRegion.textContent).toBe('Hello World');
3132

3233
jest.advanceTimersByTime(1);
@@ -43,16 +44,19 @@ describe('MudScreenReaderAnnouncer', () => {
4344
expect(liveRegion.textContent).toBe('');
4445
});
4546

46-
it('resets the clear timer for rapid consecutive announcements', () => {
47+
it('queues rapid consecutive announcements', () => {
4748
announcer.announce('First');
48-
jest.advanceTimersByTime(50);
49-
5049
announcer.announce('Second');
51-
jest.advanceTimersByTime(99);
5250

53-
expect(liveRegion.textContent).toBe('Second');
51+
expect(liveRegion.textContent).toBe('First');
52+
53+
jest.advanceTimersByTime(49);
54+
expect(liveRegion.textContent).toBe('First');
5455

5556
jest.advanceTimersByTime(1);
57+
expect(liveRegion.textContent).toBe('Second');
58+
59+
jest.advanceTimersByTime(50);
5660
expect(liveRegion.textContent).toBe('');
5761
});
5862

@@ -64,6 +68,19 @@ describe('MudScreenReaderAnnouncer', () => {
6468
expect(liveRegion.textContent).toBe('');
6569
});
6670

71+
it('stopAnnouncements() clears live region and backlog', () => {
72+
announcer.announce('First');
73+
announcer.announce('Second');
74+
75+
expect(liveRegion.textContent).toBe('First');
76+
77+
announcer.stopAnnouncements();
78+
expect(liveRegion.textContent).toBe('');
79+
80+
jest.advanceTimersByTime(200);
81+
expect(liveRegion.textContent).toBe('');
82+
});
83+
6784
it('ignores empty output after normalization', () => {
6885
announcer.announce('\x1b[31m\x1b[0m');
6986

frontend/src/app/features/terminal/mud-screenreader.ts

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
const DEFAULT_CLEAR_DELAY_MS = 300;
1+
const DEFAULT_MIN_ANNOUNCE_MS = 700;
2+
const DEFAULT_SPEECH_CHARS_PER_SEC = 12;
23
const INPUT_CLEAR_DELAY_MS = 700;
34
const ANSI_ESCAPE_PATTERN = /\x1B\[[0-9;?]*[ -\/]*[@-~]/g;
45
const CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B-\x1F\x7F]/g;
@@ -16,12 +17,16 @@ export class MudScreenReaderAnnouncer {
1617
private inputClearTimer: number | undefined;
1718
private sessionStartedAt: number;
1819
private lastAnnouncedBuffer = '';
20+
private announceQueue: string[] = [];
21+
private isAnnouncing = false;
22+
private abortToken = 0;
1923

2024
constructor(
2125
private readonly liveRegion: HTMLElement,
2226
private readonly historyRegion?: HTMLElement,
2327
private readonly inputRegion?: HTMLElement,
24-
private readonly clearDelayMs: number = DEFAULT_CLEAR_DELAY_MS,
28+
private readonly minAnnouncementMs: number = DEFAULT_MIN_ANNOUNCE_MS,
29+
private readonly speechCharsPerSecond: number = DEFAULT_SPEECH_CHARS_PER_SEC,
2530
) {
2631
this.sessionStartedAt = Date.now();
2732
}
@@ -31,7 +36,7 @@ export class MudScreenReaderAnnouncer {
3136
*/
3237
public markSessionStart(timestamp: number = Date.now()): void {
3338
this.sessionStartedAt = timestamp;
34-
this.clear();
39+
this.stopAnnouncements();
3540
this.clearHistory();
3641
this.lastAnnouncedBuffer = '';
3742
}
@@ -64,12 +69,7 @@ export class MudScreenReaderAnnouncer {
6469
return;
6570
}
6671

67-
this.liveRegion.textContent = normalized;
68-
console.debug(
69-
'[ScreenReader] Live region updated:',
70-
this.liveRegion.textContent,
71-
);
72-
this.scheduleClear();
72+
this.enqueueAnnouncement(normalized);
7373
}
7474

7575
/**
@@ -80,11 +80,21 @@ export class MudScreenReaderAnnouncer {
8080
this.cancelClearTimer();
8181
}
8282

83+
/**
84+
* Stops any in-flight announcements and drops the queued backlog.
85+
*/
86+
public stopAnnouncements(): void {
87+
this.abortToken += 1;
88+
this.announceQueue = [];
89+
this.isAnnouncing = false;
90+
this.clear();
91+
}
92+
8393
/**
8494
* Disposes internal timers.
8595
*/
8696
public dispose(): void {
87-
this.clear();
97+
this.stopAnnouncements();
8898
this.cancelInputClearTimer();
8999
}
90100

@@ -272,12 +282,44 @@ export class MudScreenReaderAnnouncer {
272282
this.lastAnnouncedBuffer = '';
273283
}
274284

275-
private scheduleClear(): void {
285+
private enqueueAnnouncement(normalized: string): void {
286+
this.announceQueue.push(normalized);
287+
this.processQueue();
288+
}
289+
290+
private processQueue(): void {
291+
if (this.isAnnouncing) {
292+
return;
293+
}
294+
295+
const next = this.announceQueue.shift();
296+
if (!next) {
297+
return;
298+
}
299+
300+
this.isAnnouncing = true;
301+
const token = this.abortToken;
302+
303+
this.liveRegion.textContent = next;
304+
console.debug('[ScreenReader] Live region updated:', next);
305+
306+
const duration = this.getAnnouncementDuration(next);
307+
this.scheduleClear(duration, token);
308+
}
309+
310+
private scheduleClear(durationMs: number, token: number): void {
276311
this.cancelClearTimer();
277312

278313
this.clearTimer = window.setTimeout(() => {
279-
this.clear();
280-
}, this.clearDelayMs);
314+
if (token !== this.abortToken) {
315+
return;
316+
}
317+
318+
this.liveRegion.textContent = '';
319+
this.isAnnouncing = false;
320+
this.clearTimer = undefined;
321+
this.processQueue();
322+
}, durationMs);
281323
}
282324

283325
private cancelClearTimer(): void {
@@ -287,6 +329,13 @@ export class MudScreenReaderAnnouncer {
287329
}
288330
}
289331

332+
private getAnnouncementDuration(text: string): number {
333+
const chars = Math.max(text.length, 1);
334+
const estimatedMs = Math.ceil((chars / this.speechCharsPerSecond) * 1000);
335+
336+
return Math.max(estimatedMs, this.minAnnouncementMs);
337+
}
338+
290339
// Input clear helpers are retained for potential future use (currently unused)
291340
private scheduleInputClear(): void {
292341
this.cancelInputClearTimer();

0 commit comments

Comments
 (0)