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

Prototype improved a11y buffer view #4340

Merged
merged 22 commits into from
Jan 18, 2023
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
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
18 changes: 18 additions & 0 deletions css/xterm.css
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,24 @@
color: transparent;
}

.xterm .xterm-accessibility-full-output {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
padding: .5em;
background: #000;
color: #fff;
opacity: 0;
overflow: scroll;
overflow-x: hidden;
}
.xterm .xterm-accessibility-full-output:focus {
opacity: 1;
z-index: 20;
}

.xterm .live-region {
position: absolute;
left: -9999px;
Expand Down
68 changes: 63 additions & 5 deletions src/browser/AccessibilityManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
*/

import * as Strings from 'browser/LocalizableStrings';
import { ITerminal, IRenderDebouncer } from 'browser/Types';
import { ITerminal, IRenderDebouncer, ReadonlyColorSet } from 'browser/Types';
import { IBuffer } from 'common/buffer/Types';
import { isMac } from 'common/Platform';
import { TimeBasedDebouncer } from 'browser/TimeBasedDebouncer';
import { addDisposableDomListener } from 'browser/Lifecycle';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { ScreenDprMonitor } from 'browser/ScreenDprMonitor';
import { IRenderService } from 'browser/services/Services';
import { IRenderService, IThemeService } from 'browser/services/Services';
import { IOptionsService } from 'common/services/Services';
import { ITerminalOptions } from 'xterm';

const MAX_ROWS_TO_READ = 20;

Expand All @@ -26,6 +28,7 @@ export class AccessibilityManager extends Disposable {
private _rowElements: HTMLElement[];
private _liveRegion: HTMLElement;
private _liveRegionLineCount: number = 0;
private _fullOutputElement: HTMLElement;
meganrogge marked this conversation as resolved.
Show resolved Hide resolved

private _renderRowsDebouncer: IRenderDebouncer;
private _screenDprMonitor: ScreenDprMonitor;
Expand All @@ -48,12 +51,14 @@ export class AccessibilityManager extends Disposable {

constructor(
private readonly _terminal: ITerminal,
private readonly _renderService: IRenderService
@IOptionsService optionsService: IOptionsService,
@IRenderService private readonly _renderService: IRenderService,
@IThemeService themeService: IThemeService
) {
super();
this._accessibilityTreeRoot = document.createElement('div');
this._accessibilityTreeRoot.classList.add('xterm-accessibility');
this._accessibilityTreeRoot.tabIndex = 0;
// this._accessibilityTreeRoot.tabIndex = 0;
meganrogge marked this conversation as resolved.
Show resolved Hide resolved

this._rowContainer = document.createElement('div');
this._rowContainer.setAttribute('role', 'list');
Expand Down Expand Up @@ -83,7 +88,18 @@ export class AccessibilityManager extends Disposable {
if (!this._terminal.element) {
throw new Error('Cannot enable accessibility before Terminal.open');
}
this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityTreeRoot);

const contentEditable = optionsService.options.accessibilityElementContentEditable;
meganrogge marked this conversation as resolved.
Show resolved Hide resolved
this._fullOutputElement = document.createElement(contentEditable ? 'section' : 'div');
this._fullOutputElement.contentEditable = contentEditable ? 'true' : 'false';

// TODO: Add to strings
meganrogge marked this conversation as resolved.
Show resolved Hide resolved
this._fullOutputElement.ariaLabel = 'Terminal buffer';
this._fullOutputElement.contentEditable = 'true';
this._fullOutputElement.spellcheck = false;
this._fullOutputElement.classList.add('xterm-accessibility-full-output');
this._fullOutputElement.addEventListener('focus', () => this._refreshFullOutput());
this._terminal.element.insertAdjacentElement('afterbegin', this._fullOutputElement);

this.register(this._renderRowsDebouncer);
this.register(this._terminal.onResize(e => this._handleResize(e.rows)));
Expand All @@ -97,6 +113,11 @@ export class AccessibilityManager extends Disposable {
this.register(this._terminal.onBlur(() => this._clearLiveRegion()));
this.register(this._renderService.onDimensionsChange(() => this._refreshRowsDimensions()));

this._handleColorChange(themeService.colors);
this.register(themeService.onChangeColors(e => this._handleColorChange(e)));
this._handleFontOptionChange(optionsService.options);
this.register(optionsService.onMultipleOptionChange(['fontSize', 'fontFamily'], () => this._handleFontOptionChange(optionsService.options)));

this._screenDprMonitor = new ScreenDprMonitor(window);
this.register(this._screenDprMonitor);
this._screenDprMonitor.setListener(() => this._refreshRowsDimensions());
Expand Down Expand Up @@ -299,4 +320,41 @@ export class AccessibilityManager extends Disposable {
this._liveRegion.textContent += this._charsToAnnounce;
this._charsToAnnounce = '';
}


private _refreshFullOutput(): void {
meganrogge marked this conversation as resolved.
Show resolved Hide resolved
// Cap the number of items in full output, without this screen reader can easily lock up for 20+
// seconds, probably due to refreshing their a11y tree
const start = Math.max(this._terminal.buffer.lines.length - 100, 0);
if (!this._terminal.viewport) {
return;
}
const { bufferElements, cursorElement } = this._terminal.viewport.getBufferElements(start);
for (const element of bufferElements) {
if (element.textContent) {
element.textContent = element.textContent.replace(new RegExp(' ', 'g'), '\xA0');
}
}
this._fullOutputElement.replaceChildren(...bufferElements);
const s = document.getSelection();
if (s && cursorElement) {
s.removeAllRanges();
const r = document.createRange();
r.selectNode(bufferElements[bufferElements.length - 1]);
r.setStart(cursorElement, 0);
r.setEnd(cursorElement, 0);
s.addRange(r);
}
meganrogge marked this conversation as resolved.
Show resolved Hide resolved
this._fullOutputElement.scrollTop = this._fullOutputElement.scrollHeight;
}

private _handleColorChange(colorSet: ReadonlyColorSet): void {
this._fullOutputElement.style.backgroundColor = colorSet.background.css;
this._fullOutputElement.style.color = colorSet.foreground.css;
}

private _handleFontOptionChange(options: Required<ITerminalOptions>): void {
this._fullOutputElement.style.fontFamily = options.fontFamily;
this._fullOutputElement.style.fontSize = `${options.fontSize}px`;
}
}
5 changes: 2 additions & 3 deletions src/browser/Terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export class Terminal extends CoreTerminal implements ITerminal {
private _handleScreenReaderModeOptionChange(value: boolean): void {
if (value) {
if (!this._accessibilityManager && this._renderService) {
this._accessibilityManager = new AccessibilityManager(this, this._renderService);
this._accessibilityManager = this._instantiationService.createInstance(AccessibilityManager, this);
}
} else {
this._accessibilityManager?.dispose();
Expand Down Expand Up @@ -419,7 +419,6 @@ export class Terminal extends CoreTerminal implements ITerminal {
this.element.dir = 'ltr'; // xterm.css assumes LTR
this.element.classList.add('terminal');
this.element.classList.add('xterm');
this.element.setAttribute('tabindex', '0');
parent.appendChild(this.element);

// Performance: Use a document fragment to build the terminal
Expand Down Expand Up @@ -553,7 +552,7 @@ export class Terminal extends CoreTerminal implements ITerminal {
if (this.options.screenReaderMode) {
// Note that this must be done *after* the renderer is created in order to
// ensure the correct order of the dprchange event
this._accessibilityManager = new AccessibilityManager(this, this._renderService);
this._accessibilityManager = this._instantiationService.createInstance(AccessibilityManager, this);
}
this.register(this.optionsService.onSpecificOptionChange('screenReaderMode', e => this._handleScreenReaderModeOptionChange(e)));

Expand Down
6 changes: 6 additions & 0 deletions src/browser/TestUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@ export class MockTerminal implements ITerminal {
public write(data: string): void {
throw new Error('Method not implemented.');
}
public getBufferElements(startLine: number, endLine?: number | undefined): { bufferElements: HTMLElement[]; cursorElement?: HTMLElement | undefined; } {
throw new Error('Method not implemented.');
}
public bracketedPasteMode!: boolean;
public renderer!: IRenderer;
public linkifier2!: ILinkifier2;
Expand Down Expand Up @@ -310,6 +313,9 @@ export class MockViewport implements IViewport {
public getLinesScrolled(ev: WheelEvent): number {
throw new Error('Method not implemented.');
}
public getBufferElements(startLine: number, endLine?: number | undefined): { bufferElements: HTMLElement[]; cursorElement?: HTMLElement | undefined; } {
throw new Error('Method not implemented.');
}
}

export class MockCompositionHelper implements ICompositionHelper {
Expand Down
1 change: 1 addition & 0 deletions src/browser/Types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export interface IViewport extends IDisposable {
scrollBarWidth: number;
syncScrollArea(immediate?: boolean): void;
getLinesScrolled(ev: WheelEvent): number;
getBufferElements(startLine: number, endLine?: number): { bufferElements: HTMLElement[]; cursorElement?: HTMLElement };
handleWheel(ev: WheelEvent): boolean;
handleTouchStart(ev: TouchEvent): void;
handleTouchMove(ev: TouchEvent): boolean;
Expand Down
27 changes: 27 additions & 0 deletions src/browser/Viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,33 @@ export class Viewport extends Disposable implements IViewport {
return amount;
}


public getBufferElements(startLine: number, endLine?: number): { bufferElements: HTMLElement[]; cursorElement?: HTMLElement } {
let currentLine: string = '';
let cursorElement: HTMLElement | undefined;
const bufferElements: HTMLElement[] = [];
const end = endLine ?? this._bufferService.buffer.lines.length;
const lines = this._bufferService.buffer.lines;
for (let i = startLine; i < end; i++) {
const line = lines.get(i);
if (!line) {
continue;
}
const isWrapped = lines.get(i + 1)?.isWrapped;
currentLine += line.translateToString(!isWrapped);
if (!isWrapped || i === lines.length - 1) {
const div = document.createElement('div');
div.textContent = currentLine;
bufferElements.push(div);
if (currentLine.length > 0) {
cursorElement = div;
}
currentLine = '';
}
}
return { bufferElements, cursorElement };
}

/**
* Gets the number of pixels scrolled by the mouse event taking into account what type of delta
* is being used.
Expand Down
3 changes: 2 additions & 1 deletion src/common/services/OptionsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ export const DEFAULT_OPTIONS: Readonly<Required<ITerminalOptions>> = {
convertEol: false,
termName: 'xterm',
cancelEvents: false,
overviewRulerWidth: 0
overviewRulerWidth: 0,
accessibilityElementContentEditable: false
};

const FONT_WEIGHT_OPTIONS: Extract<FontWeight, string>[] = ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'];
Expand Down
1 change: 1 addition & 0 deletions src/common/services/Services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ export interface ITerminalOptions {
[key: string]: any;
cancelEvents: boolean;
termName: string;
accessibilityElementContentEditable?: boolean;
}

export interface ITheme {
Expand Down
57 changes: 31 additions & 26 deletions typings/xterm.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,11 @@ declare module 'xterm' {
* ruler will be hidden when not set.
*/
overviewRulerWidth?: number;

/**
* If the accessibility element is created, whether it should be content editable
*/
accessibilityElementContentEditable?: boolean;
}

/**
Expand Down Expand Up @@ -1160,32 +1165,32 @@ declare module 'xterm' {
* @param text The text of the link.
* @param range The buffer range of the link.
*/
activate(event: MouseEvent, text: string, range: IBufferRange): void;

/**
* Called when the mouse hovers the link. To use this to create a DOM-based hover tooltip,
* create the hover element within `Terminal.element` and add the `xterm-hover` class to it,
* that will cause mouse events to not fall through and activate other links.
* @param event The mouse event triggering the callback.
* @param text The text of the link.
* @param range The buffer range of the link.
*/
hover?(event: MouseEvent, text: string, range: IBufferRange): void;

/**
* Called when the mouse leaves the link.
* @param event The mouse event triggering the callback.
* @param text The text of the link.
* @param range The buffer range of the link.
*/
leave?(event: MouseEvent, text: string, range: IBufferRange): void;

/**
* Whether to receive non-HTTP URLs from LinkProvider. When false, any usage of non-HTTP URLs
* will be ignored. Enabling this option without proper protection in `activate` function
* may cause security issues such as XSS.
*/
allowNonHttpProtocols?: boolean;
activate(event: MouseEvent, text: string, range: IBufferRange): void;

/**
* Called when the mouse hovers the link. To use this to create a DOM-based hover tooltip,
* create the hover element within `Terminal.element` and add the `xterm-hover` class to it,
* that will cause mouse events to not fall through and activate other links.
* @param event The mouse event triggering the callback.
* @param text The text of the link.
* @param range The buffer range of the link.
*/
hover?(event: MouseEvent, text: string, range: IBufferRange): void;

/**
* Called when the mouse leaves the link.
* @param event The mouse event triggering the callback.
* @param text The text of the link.
* @param range The buffer range of the link.
*/
leave?(event: MouseEvent, text: string, range: IBufferRange): void;

/**
* Whether to receive non-HTTP URLs from LinkProvider. When false, any usage of non-HTTP URLs
* will be ignored. Enabling this option without proper protection in `activate` function
* may cause security issues such as XSS.
*/
allowNonHttpProtocols?: boolean;
}

/**
Expand Down