Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Screen reader support #1182

Merged
merged 56 commits into from
Feb 15, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
3997796
Initial prototype
Tyriar Dec 19, 2017
e5d57b0
Merge remote-tracking branch 'ups/v3' into 731_screen_reader
Tyriar Dec 19, 2017
d03f5b0
Range of improvements for macOS VoiceOver
Tyriar Dec 19, 2017
ae957ec
Get output reading working on Windows, refresh on window resize
Tyriar Dec 19, 2017
0d65cb3
Add max rows to read to handle spam case
Tyriar Dec 20, 2017
5dc57a1
Add detach logic back for mac only
Tyriar Dec 20, 2017
8cb7a9d
Merge remote-tracking branch 'origin/v3' into 731_screen_reader
Tyriar Jan 3, 2018
17964a5
Add screenReaderMode to typings
Tyriar Jan 3, 2018
5ca15cf
Fix linefeed event
Tyriar Jan 3, 2018
6d1064f
Set height of a11y rows on creation
Tyriar Jan 3, 2018
bb830eb
Rate limit row refresh, tweak live region
Tyriar Jan 5, 2018
c332aeb
Remove obsolete code and resolved TODOs
Tyriar Jan 5, 2018
a846e8c
Pull render animation frame logic into a helper class
Tyriar Jan 5, 2018
e20d2ca
Use consistent refresh/render method names
Tyriar Jan 5, 2018
c46304c
Merge remote-tracking branch 'origin/master' into 731_screen_reader
Tyriar Jan 5, 2018
a19b05f
Fix bad type check that caused rows to not refresh
Tyriar Jan 8, 2018
bbc9a01
Listen to dpr change in a11y manager
Tyriar Jan 11, 2018
2311d7d
Add basic navigation mode support for current viewport
Tyriar Jan 12, 2018
2a187a2
Update a11y rows on scroll
Tyriar Jan 12, 2018
1e41b11
Remove logs
Tyriar Jan 13, 2018
70489e5
Move navigation mode stuff into its own class
Tyriar Jan 14, 2018
85c02f1
Improve dispoable listener functions
Tyriar Jan 14, 2018
23bd60a
Properly dispose of disposables
Tyriar Jan 14, 2018
81b4d8f
Dispose of NavigationMode when AccessibilityManager is
Tyriar Jan 14, 2018
1e9fb86
Merge remote-tracking branch 'origin/master' into 731_screen_reader
Tyriar Jan 16, 2018
e9b5593
Fix test mocks
Tyriar Jan 16, 2018
34eb089
Fix RenderDebouncer not refreshing whole viewport when asked
Tyriar Jan 16, 2018
222c42f
Expose screen reader and navigation mode on demo/API
Tyriar Jan 16, 2018
3fa4b49
Support nav mode pgup/pgdown/end/home, check boundaries
Tyriar Jan 16, 2018
f48da12
Prevent event propagation when handled by nav mode
Tyriar Jan 16, 2018
78d7341
Support aria posinset and setsize
Tyriar Jan 17, 2018
abfa58f
Keep track of nav mode focused element
Tyriar Jan 17, 2018
0f04f9f
Rotate rows to ensure an item will be read in nav mode when scrolling
Tyriar Jan 17, 2018
39fcbe6
Improve announcement on focus
Tyriar Jan 19, 2018
b97cedc
Merge remote-tracking branch 'origin/master' into 731_screen_reader
Tyriar Jan 19, 2018
421a885
Merge remote-tracking branch 'origin/master' into 731_screen_reader
Tyriar Jan 22, 2018
c2d78e5
Fix screenReadeMode disable in demo
Tyriar Jan 23, 2018
bce0cce
Ensure a11y tree is located above the prompt
Tyriar Jan 23, 2018
fba4f5f
Ensure active item is removed before scroll
Tyriar Jan 23, 2018
45c6008
Rotate terminal rows after removing focus
Tyriar Jan 23, 2018
c41b35a
Add message about entering navigation mode
Tyriar Jan 24, 2018
3e734fa
Support i18n
Tyriar Jan 24, 2018
1cceda6
Change navigation mode to work using focus instead of activedescendant
Tyriar Jan 25, 2018
b9976c3
Merge remote-tracking branch 'origin/master' into 731_screen_reader
Tyriar Jan 26, 2018
5c06119
Get navigation right/down working with nav mode
Tyriar Jan 27, 2018
963db4d
Remove navigation mode/more rows element
Tyriar Jan 27, 2018
65850c1
Reduce duplication
Tyriar Jan 27, 2018
6439aca
Refresh dimensions of only a single row
Tyriar Jan 27, 2018
5506af5
Clean up a11y manager
Tyriar Jan 28, 2018
0bdf1e8
Remove unused function
Tyriar Jan 28, 2018
b138264
Hook up scroll APIs to scroll and focus when navigating
Tyriar Jan 28, 2018
7c8b64a
Revert "Hook up scroll APIs to scroll and focus when navigating"
Tyriar Jan 28, 2018
a3958a1
Merge branch 'master' into 731_screen_reader
Tyriar Jan 28, 2018
ec156f6
Only update a11y manager dimensions after a renderer resize
Tyriar Jan 28, 2018
a0f390e
Expose strings through the API
Tyriar Jan 28, 2018
f089b99
Merge branch 'master' into 731_screen_reader
Tyriar Feb 14, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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