Skip to content

Commit 0ff06db

Browse files
authored
bring back accessibility tree (#4536)
1 parent 83a77b6 commit 0ff06db

File tree

1 file changed

+153
-2
lines changed

1 file changed

+153
-2
lines changed

src/browser/AccessibilityManager.ts

Lines changed: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,32 @@ import { ITerminal, IRenderDebouncer } from 'browser/Types';
88
import { isMac } from 'common/Platform';
99
import { TimeBasedDebouncer } from 'browser/TimeBasedDebouncer';
1010
import { Disposable, toDisposable } from 'common/Lifecycle';
11+
import { ScreenDprMonitor } from 'browser/ScreenDprMonitor';
12+
import { IRenderService } from 'browser/services/Services';
13+
import { addDisposableDomListener } from 'browser/Lifecycle';
1114

1215
const MAX_ROWS_TO_READ = 20;
1316

17+
const enum BoundaryPosition {
18+
TOP,
19+
BOTTOM
20+
}
21+
1422
export class AccessibilityManager extends Disposable {
1523
private _accessibilityContainer: HTMLElement;
1624

25+
private _rowContainer: HTMLElement;
26+
private _rowElements: HTMLElement[];
27+
1728
private _liveRegion: HTMLElement;
1829
private _liveRegionLineCount: number = 0;
1930
private _liveRegionDebouncer: IRenderDebouncer;
2031

32+
private _screenDprMonitor: ScreenDprMonitor;
33+
34+
private _topBoundaryFocusListener: (e: FocusEvent) => void;
35+
private _bottomBoundaryFocusListener: (e: FocusEvent) => void;
36+
2137
/**
2238
* This queue has a character pushed to it for keys that are pressed, if the
2339
* next character added to the terminal is equal to the key char then it is
@@ -32,12 +48,30 @@ export class AccessibilityManager extends Disposable {
3248
private _charsToAnnounce: string = '';
3349

3450
constructor(
35-
private readonly _terminal: ITerminal
51+
private readonly _terminal: ITerminal,
52+
@IRenderService private readonly _renderService: IRenderService
3653
) {
3754
super();
3855
this._accessibilityContainer = document.createElement('div');
3956
this._accessibilityContainer.classList.add('xterm-accessibility');
4057

58+
this._rowContainer = document.createElement('div');
59+
this._rowContainer.setAttribute('role', 'list');
60+
this._rowContainer.classList.add('xterm-accessibility-tree');
61+
this._rowElements = [];
62+
for (let i = 0; i < this._terminal.rows; i++) {
63+
this._rowElements[i] = this._createAccessibilityTreeNode();
64+
this._rowContainer.appendChild(this._rowElements[i]);
65+
}
66+
67+
this._topBoundaryFocusListener = e => this._handleBoundaryFocus(e, BoundaryPosition.TOP);
68+
this._bottomBoundaryFocusListener = e => this._handleBoundaryFocus(e, BoundaryPosition.BOTTOM);
69+
this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener);
70+
this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener);
71+
72+
this._refreshRowsDimensions();
73+
this._accessibilityContainer.appendChild(this._rowContainer);
74+
4175
this._liveRegion = document.createElement('div');
4276
this._liveRegion.classList.add('live-region');
4377
this._liveRegion.setAttribute('aria-live', 'assertive');
@@ -50,6 +84,7 @@ export class AccessibilityManager extends Disposable {
5084
this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityContainer);
5185

5286
this.register(this._liveRegionDebouncer);
87+
this.register(this._terminal.onResize(e => this._handleResize(e.rows)));
5388
this.register(this._terminal.onRender(e => this._refreshRows(e.start, e.end)));
5489
this.register(this._terminal.onScroll(() => this._refreshRows()));
5590
// Line feed is an issue as the prompt won't be read out after a command is run
@@ -58,7 +93,20 @@ export class AccessibilityManager extends Disposable {
5893
this.register(this._terminal.onA11yTab(spaceCount => this._handleTab(spaceCount)));
5994
this.register(this._terminal.onKey(e => this._handleKey(e.key)));
6095
this.register(this._terminal.onBlur(() => this._clearLiveRegion()));
61-
this.register(toDisposable(() => this._accessibilityContainer.remove()));
96+
this.register(this._renderService.onDimensionsChange(() => this._refreshRowsDimensions()));
97+
98+
this._screenDprMonitor = new ScreenDprMonitor(window);
99+
this.register(this._screenDprMonitor);
100+
this._screenDprMonitor.setListener(() => this._refreshRowsDimensions());
101+
// This shouldn't be needed on modern browsers but is present in case the
102+
// media query that drives the ScreenDprMonitor isn't supported
103+
this.register(addDisposableDomListener(window, 'resize', () => this._refreshRowsDimensions()));
104+
105+
this._refreshRows();
106+
this.register(toDisposable(() => {
107+
this._accessibilityContainer.remove();
108+
this._rowElements.length = 0;
109+
}));
62110
}
63111

64112
private _handleTab(spaceCount: number): void {
@@ -126,4 +174,107 @@ export class AccessibilityManager extends Disposable {
126174
this._liveRegion.textContent += this._charsToAnnounce;
127175
this._charsToAnnounce = '';
128176
}
177+
178+
private _handleBoundaryFocus(e: FocusEvent, position: BoundaryPosition): void {
179+
const boundaryElement = e.target as HTMLElement;
180+
const beforeBoundaryElement = this._rowElements[position === BoundaryPosition.TOP ? 1 : this._rowElements.length - 2];
181+
182+
// Don't scroll if the buffer top has reached the end in that direction
183+
const posInSet = boundaryElement.getAttribute('aria-posinset');
184+
const lastRowPos = position === BoundaryPosition.TOP ? '1' : `${this._terminal.buffer.lines.length}`;
185+
if (posInSet === lastRowPos) {
186+
return;
187+
}
188+
189+
// Don't scroll when the last focused item was not the second row (focus is going the other
190+
// direction)
191+
if (e.relatedTarget !== beforeBoundaryElement) {
192+
return;
193+
}
194+
195+
// Remove old boundary element from array
196+
let topBoundaryElement: HTMLElement;
197+
let bottomBoundaryElement: HTMLElement;
198+
if (position === BoundaryPosition.TOP) {
199+
topBoundaryElement = boundaryElement;
200+
bottomBoundaryElement = this._rowElements.pop()!;
201+
this._rowContainer.removeChild(bottomBoundaryElement);
202+
} else {
203+
topBoundaryElement = this._rowElements.shift()!;
204+
bottomBoundaryElement = boundaryElement;
205+
this._rowContainer.removeChild(topBoundaryElement);
206+
}
207+
208+
// Remove listeners from old boundary elements
209+
topBoundaryElement.removeEventListener('focus', this._topBoundaryFocusListener);
210+
bottomBoundaryElement.removeEventListener('focus', this._bottomBoundaryFocusListener);
211+
212+
// Add new element to array/DOM
213+
if (position === BoundaryPosition.TOP) {
214+
const newElement = this._createAccessibilityTreeNode();
215+
this._rowElements.unshift(newElement);
216+
this._rowContainer.insertAdjacentElement('afterbegin', newElement);
217+
} else {
218+
const newElement = this._createAccessibilityTreeNode();
219+
this._rowElements.push(newElement);
220+
this._rowContainer.appendChild(newElement);
221+
}
222+
223+
// Add listeners to new boundary elements
224+
this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener);
225+
this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener);
226+
227+
// Scroll up
228+
this._terminal.scrollLines(position === BoundaryPosition.TOP ? -1 : 1);
229+
230+
// Focus new boundary before element
231+
this._rowElements[position === BoundaryPosition.TOP ? 1 : this._rowElements.length - 2].focus();
232+
233+
// Prevent the standard behavior
234+
e.preventDefault();
235+
e.stopImmediatePropagation();
236+
}
237+
238+
private _handleResize(rows: number): void {
239+
// Remove bottom boundary listener
240+
this._rowElements[this._rowElements.length - 1].removeEventListener('focus', this._bottomBoundaryFocusListener);
241+
242+
// Grow rows as required
243+
for (let i = this._rowContainer.children.length; i < this._terminal.rows; i++) {
244+
this._rowElements[i] = this._createAccessibilityTreeNode();
245+
this._rowContainer.appendChild(this._rowElements[i]);
246+
}
247+
// Shrink rows as required
248+
while (this._rowElements.length > rows) {
249+
this._rowContainer.removeChild(this._rowElements.pop()!);
250+
}
251+
252+
// Add bottom boundary listener
253+
this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener);
254+
255+
this._refreshRowsDimensions();
256+
}
257+
258+
private _createAccessibilityTreeNode(): HTMLElement {
259+
const element = document.createElement('div');
260+
element.setAttribute('role', 'listitem');
261+
element.tabIndex = -1;
262+
this._refreshRowDimensions(element);
263+
return element;
264+
}
265+
private _refreshRowsDimensions(): void {
266+
if (!this._renderService.dimensions.css.cell.height) {
267+
return;
268+
}
269+
this._accessibilityContainer.style.width = `${this._renderService.dimensions.css.canvas.width}px`;
270+
if (this._rowElements.length !== this._terminal.rows) {
271+
this._handleResize(this._terminal.rows);
272+
}
273+
for (let i = 0; i < this._terminal.rows; i++) {
274+
this._refreshRowDimensions(this._rowElements[i]);
275+
}
276+
}
277+
private _refreshRowDimensions(element: HTMLElement): void {
278+
element.style.height = `${this._renderService.dimensions.css.cell.height}px`;
279+
}
129280
}

0 commit comments

Comments
 (0)