Skip to content

Commit 0e1041b

Browse files
committed
feat(frontend): enhance history logging by splitting multiline output into separate entries (screenreader)
1 parent d8d4b6e commit 0e1041b

3 files changed

Lines changed: 117 additions & 5 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
min-height: 0;
1111
overflow: hidden;
1212
z-index: 1;
13+
padding-left: 8px;
1314
}
1415

1516
.sr-announcer {

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

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,104 @@ describe('MudScreenReaderAnnouncer', () => {
7070
expect(liveRegion.textContent).toBe('');
7171
});
7272
});
73+
74+
describe('MudScreenReaderAnnouncer - appendToHistory', () => {
75+
let historyRegion: HTMLElement;
76+
let announcer: MudScreenReaderAnnouncer;
77+
78+
beforeEach(() => {
79+
historyRegion = document.createElement('div');
80+
announcer = new MudScreenReaderAnnouncer(
81+
document.createElement('div'),
82+
historyRegion,
83+
);
84+
});
85+
86+
afterEach(() => {
87+
announcer.dispose();
88+
});
89+
90+
it('splits multiline output into separate log items', () => {
91+
announcer.appendToHistory('Line 1\nLine 2\nLine 3');
92+
93+
const items = historyRegion.querySelectorAll('p.sr-log-item');
94+
expect(items.length).toBe(3);
95+
expect(items[0].textContent).toBe('Line 1');
96+
expect(items[1].textContent).toBe('Line 2');
97+
expect(items[2].textContent).toBe('Line 3');
98+
});
99+
100+
it('skips empty lines', () => {
101+
announcer.appendToHistory('Line 1\n\nLine 3\n');
102+
103+
const items = historyRegion.querySelectorAll('p.sr-log-item');
104+
expect(items.length).toBe(2);
105+
expect(items[0].textContent).toBe('Line 1');
106+
expect(items[1].textContent).toBe('Line 3');
107+
});
108+
109+
it('skips lines with only whitespace', () => {
110+
announcer.appendToHistory('Line 1\n \n\t\nLine 4');
111+
112+
const items = historyRegion.querySelectorAll('p.sr-log-item');
113+
expect(items.length).toBe(2);
114+
expect(items[0].textContent).toBe('Line 1');
115+
expect(items[1].textContent).toBe('Line 4');
116+
});
117+
118+
it('handles CRLF line endings correctly', () => {
119+
announcer.appendToHistory('Line 1\r\nLine 2\r\nLine 3');
120+
121+
const items = historyRegion.querySelectorAll('p.sr-log-item');
122+
expect(items.length).toBe(3);
123+
expect(items[0].textContent).toBe('Line 1');
124+
expect(items[1].textContent).toBe('Line 2');
125+
expect(items[2].textContent).toBe('Line 3');
126+
});
127+
128+
it('strips ANSI escape sequences from each line', () => {
129+
announcer.appendToHistory(
130+
'Line 1 \x1b[31mRed\x1b[0m\nLine 2 \x1b[32mGreen\x1b[0m',
131+
);
132+
133+
const items = historyRegion.querySelectorAll('p.sr-log-item');
134+
expect(items.length).toBe(2);
135+
expect(items[0].textContent).toBe('Line 1 Red');
136+
expect(items[1].textContent).toBe('Line 2 Green');
137+
});
138+
139+
it('sets role="text" on each log item', () => {
140+
announcer.appendToHistory('Line 1\nLine 2');
141+
142+
const items = historyRegion.querySelectorAll('p.sr-log-item');
143+
expect(items[0].getAttribute('role')).toBe('text');
144+
expect(items[1].getAttribute('role')).toBe('text');
145+
});
146+
147+
it('ignores empty normalized output', () => {
148+
announcer.appendToHistory('\x1b[31m\x1b[0m\r\n\x1b[32m\x1b[0m');
149+
150+
const items = historyRegion.querySelectorAll('p.sr-log-item');
151+
expect(items.length).toBe(0);
152+
});
153+
154+
it('does nothing when history region is not provided', () => {
155+
const announcer2 = new MudScreenReaderAnnouncer(
156+
document.createElement('div'),
157+
undefined, // no history region
158+
);
159+
160+
// Should not throw
161+
announcer2.appendToHistory('Line 1\nLine 2');
162+
});
163+
164+
it('collapses excessive blank lines before splitting', () => {
165+
announcer.appendToHistory('Line 1\n\n\n\nLine 5');
166+
167+
const items = historyRegion.querySelectorAll('p.sr-log-item');
168+
// Should be 3 items: 'Line 1', '', 'Line 5' -> after filtering empty: 2 items
169+
expect(items.length).toBe(2);
170+
expect(items[0].textContent).toBe('Line 1');
171+
expect(items[1].textContent).toBe('Line 5');
172+
});
173+
});

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ export class MudScreenReaderAnnouncer {
9090

9191
/**
9292
* Appends sanitized text to the history region so users can navigate it later.
93+
* Splits text by newlines to create separate entries for each line, allowing screen readers
94+
* to announce each line individually rather than reading the entire block as one.
9395
*/
9496
public appendToHistory(raw: string): void {
9597
if (!this.historyRegion) {
@@ -102,13 +104,21 @@ export class MudScreenReaderAnnouncer {
102104
}
103105

104106
const doc = this.historyRegion.ownerDocument;
107+
const lines = normalized.split('\n');
105108

106-
const item = doc.createElement('p');
107-
item.className = 'sr-log-item';
108-
item.textContent = normalized;
109-
item.role = 'text';
109+
for (const line of lines) {
110+
// Skip empty lines to avoid cluttering the history
111+
if (!line.trim()) {
112+
continue;
113+
}
114+
115+
const item = doc.createElement('p');
116+
item.className = 'sr-log-item';
117+
item.textContent = line;
118+
item.setAttribute('role', 'text');
110119

111-
this.historyRegion.appendChild(item);
120+
this.historyRegion.appendChild(item);
121+
}
112122
}
113123

114124
/**

0 commit comments

Comments
 (0)