xterm
[VSoRC/.git] / node_modules / xterm / src / AccessibilityManager.ts
1 /**
2  * Copyright (c) 2017 The xterm.js authors. All rights reserved.
3  * @license MIT
4  */
5
6 import * as Strings from './browser/LocalizableStrings';
7 import { ITerminal } from './Types';
8 import { IBuffer } from 'common/buffer/Types';
9 import { isMac } from 'common/Platform';
10 import { RenderDebouncer } from 'browser/RenderDebouncer';
11 import { addDisposableDomListener } from 'browser/Lifecycle';
12 import { Disposable } from 'common/Lifecycle';
13 import { ScreenDprMonitor } from 'browser/ScreenDprMonitor';
14 import { IRenderService } from 'browser/services/Services';
15
16 const MAX_ROWS_TO_READ = 20;
17
18 const enum BoundaryPosition {
19   TOP,
20   BOTTOM
21 }
22
23 export class AccessibilityManager extends Disposable {
24   private _accessibilityTreeRoot: HTMLElement;
25   private _rowContainer: HTMLElement;
26   private _rowElements: HTMLElement[];
27   private _liveRegion: HTMLElement;
28   private _liveRegionLineCount: number = 0;
29
30   private _renderRowsDebouncer: RenderDebouncer;
31   private _screenDprMonitor: ScreenDprMonitor;
32
33   private _topBoundaryFocusListener: (e: FocusEvent) => void;
34   private _bottomBoundaryFocusListener: (e: FocusEvent) => void;
35
36   /**
37    * This queue has a character pushed to it for keys that are pressed, if the
38    * next character added to the terminal is equal to the key char then it is
39    * not announced (added to live region) because it has already been announced
40    * by the textarea event (which cannot be canceled). There are some race
41    * condition cases if there is typing while data is streaming, but this covers
42    * the main case of typing into the prompt and inputting the answer to a
43    * question (Y/N, etc.).
44    */
45   private _charsToConsume: string[] = [];
46
47   private _charsToAnnounce: string = '';
48
49   constructor(
50     private readonly _terminal: ITerminal,
51     private readonly _renderService: IRenderService
52   ) {
53     super();
54     this._accessibilityTreeRoot = document.createElement('div');
55     this._accessibilityTreeRoot.classList.add('xterm-accessibility');
56
57     this._rowContainer = document.createElement('div');
58     this._rowContainer.classList.add('xterm-accessibility-tree');
59     this._rowElements = [];
60     for (let i = 0; i < this._terminal.rows; i++) {
61       this._rowElements[i] = this._createAccessibilityTreeNode();
62       this._rowContainer.appendChild(this._rowElements[i]);
63     }
64
65     this._topBoundaryFocusListener = e => this._onBoundaryFocus(e, BoundaryPosition.TOP);
66     this._bottomBoundaryFocusListener = e => this._onBoundaryFocus(e, BoundaryPosition.BOTTOM);
67     this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener);
68     this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener);
69
70     this._refreshRowsDimensions();
71     this._accessibilityTreeRoot.appendChild(this._rowContainer);
72
73     this._renderRowsDebouncer = new RenderDebouncer(this._renderRows.bind(this));
74     this._refreshRows();
75
76     this._liveRegion = document.createElement('div');
77     this._liveRegion.classList.add('live-region');
78     this._liveRegion.setAttribute('aria-live', 'assertive');
79     this._accessibilityTreeRoot.appendChild(this._liveRegion);
80
81     this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityTreeRoot);
82
83     this.register(this._renderRowsDebouncer);
84     this.register(this._terminal.onResize(e => this._onResize(e.rows)));
85     this.register(this._terminal.onRender(e => this._refreshRows(e.start, e.end)));
86     this.register(this._terminal.onScroll(() => this._refreshRows()));
87     // Line feed is an issue as the prompt won't be read out after a command is run
88     this.register(this._terminal.onA11yChar(char => this._onChar(char)));
89     this.register(this._terminal.onLineFeed(() => this._onChar('\n')));
90     this.register(this._terminal.onA11yTab(spaceCount => this._onTab(spaceCount)));
91     this.register(this._terminal.onKey(e => this._onKey(e.key)));
92     this.register(this._terminal.onBlur(() => this._clearLiveRegion()));
93     this.register(this._renderService.onDimensionsChange(() => this._refreshRowsDimensions()));
94
95     this._screenDprMonitor = new ScreenDprMonitor();
96     this.register(this._screenDprMonitor);
97     this._screenDprMonitor.setListener(() => this._refreshRowsDimensions());
98     // This shouldn't be needed on modern browsers but is present in case the
99     // media query that drives the ScreenDprMonitor isn't supported
100     this.register(addDisposableDomListener(window, 'resize', () => this._refreshRowsDimensions()));
101   }
102
103   public dispose(): void {
104     super.dispose();
105     this._terminal.element.removeChild(this._accessibilityTreeRoot);
106     this._rowElements.length = 0;
107   }
108
109   private _onBoundaryFocus(e: FocusEvent, position: BoundaryPosition): void {
110     const boundaryElement = <HTMLElement>e.target;
111     const beforeBoundaryElement = this._rowElements[position === BoundaryPosition.TOP ? 1 : this._rowElements.length - 2];
112
113     // Don't scroll if the buffer top has reached the end in that direction
114     const posInSet = boundaryElement.getAttribute('aria-posinset');
115     const lastRowPos = position === BoundaryPosition.TOP ? '1' : `${this._terminal.buffer.lines.length}`;
116     if (posInSet === lastRowPos) {
117       return;
118     }
119
120     // Don't scroll when the last focused item was not the second row (focus is going the other
121     // direction)
122     if (e.relatedTarget !== beforeBoundaryElement) {
123       return;
124     }
125
126     // Remove old boundary element from array
127     let topBoundaryElement: HTMLElement;
128     let bottomBoundaryElement: HTMLElement;
129     if (position === BoundaryPosition.TOP) {
130       topBoundaryElement = boundaryElement;
131       bottomBoundaryElement = this._rowElements.pop()!;
132       this._rowContainer.removeChild(bottomBoundaryElement);
133     } else {
134       topBoundaryElement = this._rowElements.shift()!;
135       bottomBoundaryElement = boundaryElement;
136       this._rowContainer.removeChild(topBoundaryElement);
137     }
138
139     // Remove listeners from old boundary elements
140     topBoundaryElement.removeEventListener('focus', this._topBoundaryFocusListener);
141     bottomBoundaryElement.removeEventListener('focus', this._bottomBoundaryFocusListener);
142
143     // Add new element to array/DOM
144     if (position === BoundaryPosition.TOP) {
145       const newElement = this._createAccessibilityTreeNode();
146       this._rowElements.unshift(newElement);
147       this._rowContainer.insertAdjacentElement('afterbegin', newElement);
148     } else {
149       const newElement = this._createAccessibilityTreeNode();
150       this._rowElements.push(newElement);
151       this._rowContainer.appendChild(newElement);
152     }
153
154     // Add listeners to new boundary elements
155     this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener);
156     this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener);
157
158     // Scroll up
159     this._terminal.scrollLines(position === BoundaryPosition.TOP ? -1 : 1);
160
161     // Focus new boundary before element
162     this._rowElements[position === BoundaryPosition.TOP ? 1 : this._rowElements.length - 2].focus();
163
164     // Prevent the standard behavior
165     e.preventDefault();
166     e.stopImmediatePropagation();
167   }
168
169   private _onResize(rows: number): void {
170     // Remove bottom boundary listener
171     this._rowElements[this._rowElements.length - 1].removeEventListener('focus', this._bottomBoundaryFocusListener);
172
173     // Grow rows as required
174     for (let i = this._rowContainer.children.length; i < this._terminal.rows; i++) {
175       this._rowElements[i] = this._createAccessibilityTreeNode();
176       this._rowContainer.appendChild(this._rowElements[i]);
177     }
178     // Shrink rows as required
179     while (this._rowElements.length > rows) {
180       this._rowContainer.removeChild(this._rowElements.pop()!);
181     }
182
183     // Add bottom boundary listener
184     this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener);
185
186     this._refreshRowsDimensions();
187   }
188
189   private _createAccessibilityTreeNode(): HTMLElement {
190     const element = document.createElement('div');
191     element.setAttribute('role', 'listitem');
192     element.tabIndex = -1;
193     this._refreshRowDimensions(element);
194     return element;
195   }
196
197   private _onTab(spaceCount: number): void {
198     for (let i = 0; i < spaceCount; i++) {
199       this._onChar(' ');
200     }
201   }
202
203   private _onChar(char: string): void {
204     if (this._liveRegionLineCount < MAX_ROWS_TO_READ + 1) {
205       if (this._charsToConsume.length > 0) {
206         // Have the screen reader ignore the char if it was just input
207         const shiftedChar = this._charsToConsume.shift();
208         if (shiftedChar !== char) {
209           this._charsToAnnounce += char;
210         }
211       } else {
212         this._charsToAnnounce += char;
213       }
214
215       if (char === '\n') {
216         this._liveRegionLineCount++;
217         if (this._liveRegionLineCount === MAX_ROWS_TO_READ + 1) {
218           this._liveRegion.textContent += Strings.tooMuchOutput;
219         }
220       }
221
222       // Only detach/attach on mac as otherwise messages can go unaccounced
223       if (isMac) {
224         if (this._liveRegion.textContent && this._liveRegion.textContent.length > 0 && !this._liveRegion.parentNode) {
225           setTimeout(() => {
226             this._accessibilityTreeRoot.appendChild(this._liveRegion);
227           }, 0);
228         }
229       }
230     }
231   }
232
233   private _clearLiveRegion(): void {
234     this._liveRegion.textContent = '';
235     this._liveRegionLineCount = 0;
236
237     // Only detach/attach on mac as otherwise messages can go unaccounced
238     if (isMac) {
239       if (this._liveRegion.parentNode) {
240         this._accessibilityTreeRoot.removeChild(this._liveRegion);
241       }
242     }
243   }
244
245   private _onKey(keyChar: string): void {
246     this._clearLiveRegion();
247     this._charsToConsume.push(keyChar);
248   }
249
250   private _refreshRows(start?: number, end?: number): void {
251     this._renderRowsDebouncer.refresh(start, end, this._terminal.rows);
252   }
253
254   private _renderRows(start: number, end: number): void {
255     const buffer: IBuffer = this._terminal.buffer;
256     const setSize = buffer.lines.length.toString();
257     for (let i = start; i <= end; i++) {
258       const lineData = buffer.translateBufferLineToString(buffer.ydisp + i, true);
259       const posInSet = (buffer.ydisp + i + 1).toString();
260       const element = this._rowElements[i];
261       if (element) {
262         if (lineData.length === 0) {
263           element.innerHTML = '&nbsp;';
264         } else {
265           element.textContent = lineData;
266         }
267         element.setAttribute('aria-posinset', posInSet);
268         element.setAttribute('aria-setsize', setSize);
269       }
270     }
271     this._announceCharacters();
272   }
273
274   private _refreshRowsDimensions(): void {
275     if (!this._renderService.dimensions.actualCellHeight) {
276       return;
277     }
278     if (this._rowElements.length !== this._terminal.rows) {
279       this._onResize(this._terminal.rows);
280     }
281     for (let i = 0; i < this._terminal.rows; i++) {
282       this._refreshRowDimensions(this._rowElements[i]);
283     }
284   }
285
286   private _refreshRowDimensions(element: HTMLElement): void {
287     element.style.height = `${this._renderService.dimensions.actualCellHeight}px`;
288   }
289
290   private _announceCharacters(): void {
291     if (this._charsToAnnounce.length === 0) {
292       return;
293     }
294     this._liveRegion.textContent += this._charsToAnnounce;
295     this._charsToAnnounce = '';
296   }
297 }