Skip to content

Commit

Permalink
Merge pull request #1182 from Tyriar/731_screen_reader
Browse files Browse the repository at this point in the history
Screen reader support
  • Loading branch information
Tyriar committed Feb 15, 2018
2 parents 712b91a + f089b99 commit 93e5996
Show file tree
Hide file tree
Showing 16 changed files with 551 additions and 67 deletions.
5 changes: 5 additions & 0 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ <h3>Size</h3>
</div>
</div>
</div>
<div>
<h3>Accessibility</h3>
<p>
<label><input type="checkbox" id="option-screen-reader-mode"> screenReaderMode</label>
</p>
</div>
<p><strong>Attention:</strong> The demo is a barebones implementation and is designed for the development and evaluation of xterm.js only. Exposing the demo to the public as is would introduce security risks for the host.</p>
<script src="dist/bundle.js" defer ></script>
Expand Down
9 changes: 7 additions & 2 deletions demo/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ var terminalContainer = document.getElementById('terminal-container'),
macOptionIsMeta: document.querySelector('#option-mac-option-is-meta'),
scrollback: document.querySelector('#option-scrollback'),
tabstopwidth: document.querySelector('#option-tabstopwidth'),
bellStyle: document.querySelector('#option-bell-style')
bellStyle: document.querySelector('#option-bell-style'),
screenReaderMode: document.querySelector('#option-screen-reader-mode')
},
colsElement = document.getElementById('cols'),
rowsElement = document.getElementById('rows'),
Expand Down Expand Up @@ -86,6 +87,9 @@ optionElements.scrollback.addEventListener('change', function () {
optionElements.tabstopwidth.addEventListener('change', function () {
term.setOption('tabStopWidth', parseInt(optionElements.tabstopwidth.value, 10));
});
optionElements.screenReaderMode.addEventListener('change', function () {
term.setOption('screenReaderMode', optionElements.screenReaderMode.checked);
});

createTerminal();

Expand All @@ -98,7 +102,8 @@ function createTerminal() {
macOptionIsMeta: optionElements.macOptionIsMeta.enabled,
cursorBlink: optionElements.cursorBlink.checked,
scrollback: parseInt(optionElements.scrollback.value, 10),
tabStopWidth: parseInt(optionElements.tabstopwidth.value, 10)
tabStopWidth: parseInt(optionElements.tabstopwidth.value, 10),
screenReaderMode: optionElements.screenReaderMode.checked
});
window.term = term; // Expose `term` to window for debugging purposes
term.on('resize', function (size) {
Expand Down
287 changes: 287 additions & 0 deletions src/AccessibilityManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/

import * as Strings from './Strings';
import { ITerminal, IBuffer } from './Types';
import { isMac } from './shared/utils/Browser';
import { RenderDebouncer } from './utils/RenderDebouncer';
import { addDisposableListener } from './utils/Dom';
import { IDisposable } from 'xterm';

const MAX_ROWS_TO_READ = 20;
const ACTIVE_ITEM_ID_PREFIX = 'xterm-active-item-';

enum BoundaryPosition {
Top,
Bottom
}

export class AccessibilityManager implements IDisposable {
private _accessibilityTreeRoot: HTMLElement;
private _rowContainer: HTMLElement;
private _rowElements: HTMLElement[] = [];
private _liveRegion: HTMLElement;
private _liveRegionLineCount: number = 0;

private _renderRowsDebouncer: RenderDebouncer;

private _topBoundaryFocusListener: (e: FocusEvent) => void;
private _bottomBoundaryFocusListener: (e: FocusEvent) => void;

private _disposables: IDisposable[] = [];

/**
* This queue has a character pushed to it for keys that are pressed, if the
* next character added to the terminal is equal to the key char then it is
* not announced (added to live region) because it has already been announced
* by the textarea event (which cannot be canceled). There are some race
* condition cases if there is typing while data is streaming, but this covers
* the main case of typing into the prompt and inputting the answer to a
* question (Y/N, etc.).
*/
private _charsToConsume: string[] = [];

constructor(private _terminal: ITerminal) {
this._accessibilityTreeRoot = document.createElement('div');
this._accessibilityTreeRoot.classList.add('xterm-accessibility');

this._rowContainer = document.createElement('div');
this._rowContainer.classList.add('xterm-accessibility-tree');
for (let i = 0; i < this._terminal.rows; i++) {
this._rowElements[i] = this._createAccessibilityTreeNode();
this._rowContainer.appendChild(this._rowElements[i]);
}

this._topBoundaryFocusListener = e => this._onBoundaryFocus(e, BoundaryPosition.Top);
this._bottomBoundaryFocusListener = e => this._onBoundaryFocus(e, BoundaryPosition.Bottom);
this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener);
this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener);

this._refreshRowsDimensions();
this._accessibilityTreeRoot.appendChild(this._rowContainer);

this._renderRowsDebouncer = new RenderDebouncer(this._terminal, this._renderRows.bind(this));
this._refreshRows();

this._liveRegion = document.createElement('div');
this._liveRegion.classList.add('live-region');
this._liveRegion.setAttribute('aria-live', 'assertive');
this._accessibilityTreeRoot.appendChild(this._liveRegion);

this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityTreeRoot);

this._disposables.push(this._renderRowsDebouncer);
this._disposables.push(this._terminal.addDisposableListener('resize', data => this._onResize(data.cols, data.rows)));
this._disposables.push(this._terminal.addDisposableListener('refresh', data => this._refreshRows(data.start, data.end)));
this._disposables.push(this._terminal.addDisposableListener('scroll', data => this._refreshRows()));
// Line feed is an issue as the prompt won't be read out after a command is run
this._disposables.push(this._terminal.addDisposableListener('a11y.char', (char) => this._onChar(char)));
this._disposables.push(this._terminal.addDisposableListener('linefeed', () => this._onChar('\n')));
this._disposables.push(this._terminal.addDisposableListener('a11y.tab', spaceCount => this._onTab(spaceCount)));
this._disposables.push(this._terminal.addDisposableListener('key', keyChar => this._onKey(keyChar)));
this._disposables.push(this._terminal.addDisposableListener('blur', () => this._clearLiveRegion()));
// TODO: Maybe renderer should fire an event on terminal when the characters change and that
// should be listened to instead? That would mean that the order of events are always
// guarenteed
this._disposables.push(this._terminal.addDisposableListener('dprchange', () => this._refreshRowsDimensions()));
this._disposables.push(this._terminal.renderer.addDisposableListener('resize', () => this._refreshRowsDimensions()));
// This shouldn't be needed on modern browsers but is present in case the
// media query that drives the dprchange event isn't supported
this._disposables.push(addDisposableListener(window, 'resize', () => this._refreshRowsDimensions()));
}

public dispose(): void {
this._terminal.element.removeChild(this._accessibilityTreeRoot);
this._disposables.forEach(d => d.dispose());
this._disposables = null;
this._accessibilityTreeRoot = null;
this._rowContainer = null;
this._liveRegion = null;
this._rowContainer = null;
this._rowElements = null;
}

private _onBoundaryFocus(e: FocusEvent, position: BoundaryPosition): void {
const boundaryElement = <HTMLElement>e.target;
const beforeBoundaryElement = this._rowElements[position === BoundaryPosition.Top ? 1 : this._rowElements.length - 2];

// Don't scroll if the buffer top has reached the end in that direction
const posInSet = boundaryElement.getAttribute('aria-posinset');
const lastRowPos = position === BoundaryPosition.Top ? '1' : `${this._terminal.buffer.lines.length}`;
if (posInSet === lastRowPos) {
return;
}

// Don't scroll when the last focused item was not the second row (focus is going the other
// direction)
if (e.relatedTarget !== beforeBoundaryElement) {
return;
}

// Remove old boundary element from array
let topBoundaryElement: HTMLElement;
let bottomBoundaryElement: HTMLElement;
if (position === BoundaryPosition.Top) {
topBoundaryElement = boundaryElement;
bottomBoundaryElement = this._rowElements.pop();
this._rowContainer.removeChild(bottomBoundaryElement);
} else {
topBoundaryElement = this._rowElements.shift();
bottomBoundaryElement = boundaryElement;
this._rowContainer.removeChild(topBoundaryElement);
}

// Remove listeners from old boundary elements
topBoundaryElement.removeEventListener('focus', this._topBoundaryFocusListener);
bottomBoundaryElement.removeEventListener('focus', this._bottomBoundaryFocusListener);

// Add new element to array/DOM
if (position === BoundaryPosition.Top) {
const newElement = this._createAccessibilityTreeNode();
this._rowElements.unshift(newElement);
this._rowContainer.insertAdjacentElement('afterbegin', newElement);
} else {
const newElement = this._createAccessibilityTreeNode();
this._rowElements.push(newElement);
this._rowContainer.appendChild(newElement);
}

// Add listeners to new boundary elements
this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener);
this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener);

// Scroll up
this._terminal.scrollLines(position === BoundaryPosition.Top ? -1 : 1);

// Focus new boundary before element
this._rowElements[position === BoundaryPosition.Top ? 1 : this._rowElements.length - 2].focus();

// Prevent the standard behavior
e.preventDefault();
e.stopImmediatePropagation();
}

private _onResize(cols: number, rows: number): void {
// Remove bottom boundary listener
this._rowElements[this._rowElements.length - 1].removeEventListener('focus', this._bottomBoundaryFocusListener);

// Grow rows as required
for (let i = this._rowContainer.children.length; i < this._terminal.rows; i++) {
this._rowElements[i] = this._createAccessibilityTreeNode();
this._rowContainer.appendChild(this._rowElements[i]);
}
// Shrink rows as required
while (this._rowElements.length > rows) {
this._rowContainer.removeChild(this._rowElements.pop());
}

// Add bottom boundary listener
this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener);

this._refreshRowsDimensions();
}

public _createAccessibilityTreeNode(): HTMLElement {
const element = document.createElement('div');
element.setAttribute('role', 'listitem');
element.tabIndex = -1;
this._refreshRowDimensions(element);
return element;
}

private _onTab(spaceCount: number): void {
for (let i = 0; i < spaceCount; i++) {
this._onChar(' ');
}
}

private _onChar(char: string): void {
if (this._liveRegionLineCount < MAX_ROWS_TO_READ + 1) {
if (this._charsToConsume.length > 0) {
// Have the screen reader ignore the char if it was just input
const shiftedChar = this._charsToConsume.shift();
if (shiftedChar !== char) {
this._announceCharacter(char);
}
} else {
this._announceCharacter(char);
}

if (char === '\n') {
this._liveRegionLineCount++;
if (this._liveRegionLineCount === MAX_ROWS_TO_READ + 1) {
this._liveRegion.textContent += Strings.tooMuchOutput;
}
}

// Only detach/attach on mac as otherwise messages can go unaccounced
if (isMac) {
if (this._liveRegion.textContent.length > 0 && !this._liveRegion.parentNode) {
setTimeout(() => {
this._accessibilityTreeRoot.appendChild(this._liveRegion);
}, 0);
}
}
}
}

private _clearLiveRegion(): void {
this._liveRegion.textContent = '';
this._liveRegionLineCount = 0;

// Only detach/attach on mac as otherwise messages can go unaccounced
if (isMac) {
if (this._liveRegion.parentNode) {
this._accessibilityTreeRoot.removeChild(this._liveRegion);
}
}
}

private _onKey(keyChar: string): void {
this._clearLiveRegion();
this._charsToConsume.push(keyChar);
}

private _refreshRows(start?: number, end?: number): void {
this._renderRowsDebouncer.refresh(start, end);
}

private _renderRows(start: number, end: number): void {
const buffer: IBuffer = this._terminal.buffer;
const setSize = buffer.lines.length.toString();
for (let i = start; i <= end; i++) {
const lineData = buffer.translateBufferLineToString(buffer.ydisp + i, true);
const posInSet = (buffer.ydisp + i + 1).toString();
const element = this._rowElements[i];
element.textContent = lineData.length === 0 ? Strings.blankLine : lineData;
element.setAttribute('aria-posinset', posInSet);
element.setAttribute('aria-setsize', setSize);
}
}

private _refreshRowsDimensions(): void {
if (!this._terminal.renderer.dimensions.actualCellHeight) {
return;
}
const buffer: IBuffer = this._terminal.buffer;
for (let i = 0; i < this._terminal.rows; i++) {
this._refreshRowDimensions(this._rowElements[i]);
}
}

private _refreshRowDimensions(element: HTMLElement): void {
element.style.height = `${this._terminal.renderer.dimensions.actualCellHeight}px`;
}

private _announceCharacter(char: string): void {
if (char === ' ') {
// Always use nbsp for spaces in order to preserve the space between characters in
// voiceover's caption window
this._liveRegion.innerHTML += '&nbsp;';
} else {
this._liveRegion.textContent += char;
}
}
}
30 changes: 25 additions & 5 deletions src/EventEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,43 @@
* @license MIT
*/

import { IEventEmitter } from 'xterm';
import { XtermListener } from './Types';
import { IEventEmitter, IDisposable } from 'xterm';

export class EventEmitter implements IEventEmitter {
private _events: {[type: string]: ((...args: any[]) => void)[]};
private _events: {[type: string]: XtermListener[]};

constructor() {
// Restore the previous events if available, this will happen if the
// constructor is called multiple times on the same object (terminal reset).
this._events = this._events || {};
}

public on(type: string, listener: ((...args: any[]) => void)): void {
public on(type: string, listener: XtermListener): void {
this._events[type] = this._events[type] || [];
this._events[type].push(listener);
}

public off(type: string, listener: ((...args: any[]) => void)): void {
/**
* Adds a disposabe listener to the EventEmitter, returning the disposable.
* @param type The event type.
* @param handler The handler for the listener.
*/
public addDisposableListener(type: string, handler: XtermListener): IDisposable {
this.on(type, handler);
return {
dispose: () => {
if (!handler) {
// Already disposed
return;
}
this.off(type, handler);
handler = null;
}
};
}

public off(type: string, listener: XtermListener): void {
if (!this._events[type]) {
return;
}
Expand Down Expand Up @@ -51,7 +71,7 @@ export class EventEmitter implements IEventEmitter {
}
}

public listeners(type: string): ((...args: any[]) => void)[] {
public listeners(type: string): XtermListener[] {
return this._events[type] || [];
}

Expand Down
Loading

0 comments on commit 93e5996

Please sign in to comment.