Skip to content

Commit 41dc93a

Browse files
authored
Prototype improved a11y buffer view (#4340)
1 parent ba9f2e0 commit 41dc93a

File tree

8 files changed

+114
-7
lines changed

8 files changed

+114
-7
lines changed

css/xterm.css

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,25 @@
150150
color: transparent;
151151
}
152152

153+
.xterm .xterm-accessibility-buffer {
154+
position: absolute;
155+
left: 0;
156+
top: 0;
157+
bottom: 0;
158+
right: 0;
159+
padding: .5em;
160+
background: #000;
161+
color: #fff;
162+
opacity: 0;
163+
overflow: scroll;
164+
overflow-x: hidden;
165+
}
166+
167+
.xterm .xterm-accessibility-buffer:focus {
168+
opacity: 1;
169+
z-index: 20;
170+
}
171+
153172
.xterm .live-region {
154173
position: absolute;
155174
left: -9999px;

src/browser/AccessibilityManager.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@
44
*/
55

66
import * as Strings from 'browser/LocalizableStrings';
7-
import { ITerminal, IRenderDebouncer } from 'browser/Types';
7+
import { ITerminal, IRenderDebouncer, ReadonlyColorSet } from 'browser/Types';
88
import { IBuffer } from 'common/buffer/Types';
99
import { isMac } from 'common/Platform';
1010
import { TimeBasedDebouncer } from 'browser/TimeBasedDebouncer';
1111
import { addDisposableDomListener } from 'browser/Lifecycle';
1212
import { Disposable, toDisposable } from 'common/Lifecycle';
1313
import { ScreenDprMonitor } from 'browser/ScreenDprMonitor';
14-
import { IRenderService } from 'browser/services/Services';
14+
import { IRenderService, IThemeService } from 'browser/services/Services';
15+
import { IOptionsService } from 'common/services/Services';
16+
import { ITerminalOptions } from 'xterm';
1517

1618
const MAX_ROWS_TO_READ = 20;
1719

@@ -26,6 +28,7 @@ export class AccessibilityManager extends Disposable {
2628
private _rowElements: HTMLElement[];
2729
private _liveRegion: HTMLElement;
2830
private _liveRegionLineCount: number = 0;
31+
private _accessiblityBuffer: HTMLElement;
2932

3033
private _renderRowsDebouncer: IRenderDebouncer;
3134
private _screenDprMonitor: ScreenDprMonitor;
@@ -48,7 +51,9 @@ export class AccessibilityManager extends Disposable {
4851

4952
constructor(
5053
private readonly _terminal: ITerminal,
51-
private readonly _renderService: IRenderService
54+
@IOptionsService optionsService: IOptionsService,
55+
@IRenderService private readonly _renderService: IRenderService,
56+
@IThemeService themeService: IThemeService
5257
) {
5358
super();
5459
this._accessibilityTreeRoot = document.createElement('div');
@@ -83,7 +88,16 @@ export class AccessibilityManager extends Disposable {
8388
if (!this._terminal.element) {
8489
throw new Error('Cannot enable accessibility before Terminal.open');
8590
}
86-
this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityTreeRoot);
91+
92+
this._accessiblityBuffer = document.createElement('div');
93+
this._accessiblityBuffer.ariaLabel = Strings.accessibilityBuffer;
94+
this._accessiblityBuffer.classList.add('xterm-accessibility-buffer');
95+
96+
// TODO: this is needed when content editable is false
97+
this._refreshAccessibilityBuffer();
98+
this._accessiblityBuffer.addEventListener('focus', () => this._refreshAccessibilityBuffer());
99+
this._terminal.element.insertAdjacentElement('afterbegin', this._accessiblityBuffer);
100+
87101

88102
this.register(this._renderRowsDebouncer);
89103
this.register(this._terminal.onResize(e => this._handleResize(e.rows)));
@@ -97,6 +111,11 @@ export class AccessibilityManager extends Disposable {
97111
this.register(this._terminal.onBlur(() => this._clearLiveRegion()));
98112
this.register(this._renderService.onDimensionsChange(() => this._refreshRowsDimensions()));
99113

114+
this._handleColorChange(themeService.colors);
115+
this.register(themeService.onChangeColors(e => this._handleColorChange(e)));
116+
this._handleFontOptionChange(optionsService.options);
117+
this.register(optionsService.onMultipleOptionChange(['fontSize', 'fontFamily'], () => this._handleFontOptionChange(optionsService.options)));
118+
100119
this._screenDprMonitor = new ScreenDprMonitor(window);
101120
this.register(this._screenDprMonitor);
102121
this._screenDprMonitor.setListener(() => this._refreshRowsDimensions());
@@ -299,4 +318,33 @@ export class AccessibilityManager extends Disposable {
299318
this._liveRegion.textContent += this._charsToAnnounce;
300319
this._charsToAnnounce = '';
301320
}
321+
322+
323+
private _refreshAccessibilityBuffer(): void {
324+
if (!this._terminal.viewport) {
325+
return;
326+
}
327+
328+
const { bufferElements, cursorElement } = this._terminal.viewport.getBufferElements(0);
329+
for (const element of bufferElements) {
330+
if (element.textContent) {
331+
element.textContent = element.textContent.replace(new RegExp(' ', 'g'), '\xA0');
332+
}
333+
}
334+
this._accessiblityBuffer.tabIndex = 0;
335+
this._accessiblityBuffer.ariaRoleDescription = 'document';
336+
this._accessiblityBuffer.replaceChildren(...bufferElements);
337+
this._accessiblityBuffer.scrollTop = this._accessiblityBuffer.scrollHeight;
338+
this._accessiblityBuffer.focus();
339+
}
340+
341+
private _handleColorChange(colorSet: ReadonlyColorSet): void {
342+
this._accessiblityBuffer.style.backgroundColor = colorSet.background.css;
343+
this._accessiblityBuffer.style.color = colorSet.foreground.css;
344+
}
345+
346+
private _handleFontOptionChange(options: Required<ITerminalOptions>): void {
347+
this._accessiblityBuffer.style.fontFamily = options.fontFamily;
348+
this._accessiblityBuffer.style.fontSize = `${options.fontSize}px`;
349+
}
302350
}

src/browser/LocalizableStrings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,5 @@ export let promptLabel = 'Terminal input';
1010

1111
// eslint-disable-next-line prefer-const
1212
export let tooMuchOutput = 'Too much output to announce, navigate to rows manually to read';
13+
14+
export const accessibilityBuffer = 'Accessibility buffer';

src/browser/Terminal.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ export class Terminal extends CoreTerminal implements ITerminal {
267267
private _handleScreenReaderModeOptionChange(value: boolean): void {
268268
if (value) {
269269
if (!this._accessibilityManager && this._renderService) {
270-
this._accessibilityManager = new AccessibilityManager(this, this._renderService);
270+
this._accessibilityManager = this._instantiationService.createInstance(AccessibilityManager, this);
271271
}
272272
} else {
273273
this._accessibilityManager?.dispose();
@@ -419,7 +419,6 @@ export class Terminal extends CoreTerminal implements ITerminal {
419419
this.element.dir = 'ltr'; // xterm.css assumes LTR
420420
this.element.classList.add('terminal');
421421
this.element.classList.add('xterm');
422-
this.element.setAttribute('tabindex', '0');
423422
parent.appendChild(this.element);
424423

425424
// Performance: Use a document fragment to build the terminal
@@ -553,7 +552,7 @@ export class Terminal extends CoreTerminal implements ITerminal {
553552
if (this.options.screenReaderMode) {
554553
// Note that this must be done *after* the renderer is created in order to
555554
// ensure the correct order of the dprchange event
556-
this._accessibilityManager = new AccessibilityManager(this, this._renderService);
555+
this._accessibilityManager = this._instantiationService.createInstance(AccessibilityManager, this);
557556
}
558557
this.register(this.optionsService.onSpecificOptionChange('screenReaderMode', e => this._handleScreenReaderModeOptionChange(e)));
559558

src/browser/TestUtils.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ export class MockTerminal implements ITerminal {
140140
public write(data: string): void {
141141
throw new Error('Method not implemented.');
142142
}
143+
public getBufferElements(startLine: number, endLine?: number | undefined): { bufferElements: HTMLElement[], cursorElement?: HTMLElement | undefined } {
144+
throw new Error('Method not implemented.');
145+
}
143146
public bracketedPasteMode!: boolean;
144147
public renderer!: IRenderer;
145148
public linkifier2!: ILinkifier2;
@@ -310,6 +313,9 @@ export class MockViewport implements IViewport {
310313
public getLinesScrolled(ev: WheelEvent): number {
311314
throw new Error('Method not implemented.');
312315
}
316+
public getBufferElements(startLine: number, endLine?: number | undefined): { bufferElements: HTMLElement[], cursorElement?: HTMLElement | undefined } {
317+
throw new Error('Method not implemented.');
318+
}
313319
}
314320

315321
export class MockCompositionHelper implements ICompositionHelper {

src/browser/Types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ export interface IViewport extends IDisposable {
144144
scrollBarWidth: number;
145145
syncScrollArea(immediate?: boolean): void;
146146
getLinesScrolled(ev: WheelEvent): number;
147+
getBufferElements(startLine: number, endLine?: number): { bufferElements: HTMLElement[], cursorElement?: HTMLElement };
147148
handleWheel(ev: WheelEvent): boolean;
148149
handleTouchStart(ev: TouchEvent): void;
149150
handleTouchMove(ev: TouchEvent): boolean;

src/browser/Viewport.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,33 @@ export class Viewport extends Disposable implements IViewport {
279279
return amount;
280280
}
281281

282+
283+
public getBufferElements(startLine: number, endLine?: number): { bufferElements: HTMLElement[], cursorElement?: HTMLElement } {
284+
let currentLine: string = '';
285+
let cursorElement: HTMLElement | undefined;
286+
const bufferElements: HTMLElement[] = [];
287+
const end = endLine ?? this._bufferService.buffer.lines.length;
288+
const lines = this._bufferService.buffer.lines;
289+
for (let i = startLine; i < end; i++) {
290+
const line = lines.get(i);
291+
if (!line) {
292+
continue;
293+
}
294+
const isWrapped = lines.get(i + 1)?.isWrapped;
295+
currentLine += line.translateToString(!isWrapped);
296+
if (!isWrapped || i === lines.length - 1) {
297+
const div = document.createElement('div');
298+
div.textContent = currentLine;
299+
bufferElements.push(div);
300+
if (currentLine.length > 0) {
301+
cursorElement = div;
302+
}
303+
currentLine = '';
304+
}
305+
}
306+
return { bufferElements, cursorElement };
307+
}
308+
282309
/**
283310
* Gets the number of pixels scrolled by the mouse event taking into account what type of delta
284311
* is being used.

typings/xterm.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,11 @@ declare module 'xterm' {
498498
* being printed to the terminal when `screenReaderMode` is enabled.
499499
*/
500500
tooMuchOutput: string;
501+
502+
/**
503+
* The aria label for the accessibility buffer
504+
*/
505+
accessibilityBuffer: string;
501506
}
502507

503508
/**

0 commit comments

Comments
 (0)