2 * Copyright (c) 2017 The xterm.js authors. All rights reserved.
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';
16 const MAX_ROWS_TO_READ = 20;
18 const enum BoundaryPosition {
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;
30 private _renderRowsDebouncer: RenderDebouncer;
31 private _screenDprMonitor: ScreenDprMonitor;
33 private _topBoundaryFocusListener: (e: FocusEvent) => void;
34 private _bottomBoundaryFocusListener: (e: FocusEvent) => void;
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.).
45 private _charsToConsume: string[] = [];
47 private _charsToAnnounce: string = '';
50 private readonly _terminal: ITerminal,
51 private readonly _renderService: IRenderService
54 this._accessibilityTreeRoot = document.createElement('div');
55 this._accessibilityTreeRoot.classList.add('xterm-accessibility');
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]);
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);
70 this._refreshRowsDimensions();
71 this._accessibilityTreeRoot.appendChild(this._rowContainer);
73 this._renderRowsDebouncer = new RenderDebouncer(this._renderRows.bind(this));
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);
81 this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityTreeRoot);
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()));
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()));
103 public dispose(): void {
105 this._terminal.element.removeChild(this._accessibilityTreeRoot);
106 this._rowElements.length = 0;
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];
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) {
120 // Don't scroll when the last focused item was not the second row (focus is going the other
122 if (e.relatedTarget !== beforeBoundaryElement) {
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);
134 topBoundaryElement = this._rowElements.shift()!;
135 bottomBoundaryElement = boundaryElement;
136 this._rowContainer.removeChild(topBoundaryElement);
139 // Remove listeners from old boundary elements
140 topBoundaryElement.removeEventListener('focus', this._topBoundaryFocusListener);
141 bottomBoundaryElement.removeEventListener('focus', this._bottomBoundaryFocusListener);
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);
149 const newElement = this._createAccessibilityTreeNode();
150 this._rowElements.push(newElement);
151 this._rowContainer.appendChild(newElement);
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);
159 this._terminal.scrollLines(position === BoundaryPosition.TOP ? -1 : 1);
161 // Focus new boundary before element
162 this._rowElements[position === BoundaryPosition.TOP ? 1 : this._rowElements.length - 2].focus();
164 // Prevent the standard behavior
166 e.stopImmediatePropagation();
169 private _onResize(rows: number): void {
170 // Remove bottom boundary listener
171 this._rowElements[this._rowElements.length - 1].removeEventListener('focus', this._bottomBoundaryFocusListener);
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]);
178 // Shrink rows as required
179 while (this._rowElements.length > rows) {
180 this._rowContainer.removeChild(this._rowElements.pop()!);
183 // Add bottom boundary listener
184 this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener);
186 this._refreshRowsDimensions();
189 private _createAccessibilityTreeNode(): HTMLElement {
190 const element = document.createElement('div');
191 element.setAttribute('role', 'listitem');
192 element.tabIndex = -1;
193 this._refreshRowDimensions(element);
197 private _onTab(spaceCount: number): void {
198 for (let i = 0; i < spaceCount; i++) {
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;
212 this._charsToAnnounce += char;
216 this._liveRegionLineCount++;
217 if (this._liveRegionLineCount === MAX_ROWS_TO_READ + 1) {
218 this._liveRegion.textContent += Strings.tooMuchOutput;
222 // Only detach/attach on mac as otherwise messages can go unaccounced
224 if (this._liveRegion.textContent && this._liveRegion.textContent.length > 0 && !this._liveRegion.parentNode) {
226 this._accessibilityTreeRoot.appendChild(this._liveRegion);
233 private _clearLiveRegion(): void {
234 this._liveRegion.textContent = '';
235 this._liveRegionLineCount = 0;
237 // Only detach/attach on mac as otherwise messages can go unaccounced
239 if (this._liveRegion.parentNode) {
240 this._accessibilityTreeRoot.removeChild(this._liveRegion);
245 private _onKey(keyChar: string): void {
246 this._clearLiveRegion();
247 this._charsToConsume.push(keyChar);
250 private _refreshRows(start?: number, end?: number): void {
251 this._renderRowsDebouncer.refresh(start, end, this._terminal.rows);
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];
262 if (lineData.length === 0) {
263 element.innerHTML = ' ';
265 element.textContent = lineData;
267 element.setAttribute('aria-posinset', posInSet);
268 element.setAttribute('aria-setsize', setSize);
271 this._announceCharacters();
274 private _refreshRowsDimensions(): void {
275 if (!this._renderService.dimensions.actualCellHeight) {
278 if (this._rowElements.length !== this._terminal.rows) {
279 this._onResize(this._terminal.rows);
281 for (let i = 0; i < this._terminal.rows; i++) {
282 this._refreshRowDimensions(this._rowElements[i]);
286 private _refreshRowDimensions(element: HTMLElement): void {
287 element.style.height = `${this._renderService.dimensions.actualCellHeight}px`;
290 private _announceCharacters(): void {
291 if (this._charsToAnnounce.length === 0) {
294 this._liveRegion.textContent += this._charsToAnnounce;
295 this._charsToAnnounce = '';