mirror of
https://github.com/wavetermdev/xterm.js.git
synced 2026-08-05 13:43:48 -07:00
Change navigation mode to work using focus instead of activedescendant
This commit is contained in:
+106
-169
@@ -12,6 +12,11 @@ import { addDisposableListener } from './utils/Dom';
|
||||
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;
|
||||
@@ -21,7 +26,10 @@ export class AccessibilityManager implements IDisposable {
|
||||
private _liveRegionLineCount: number = 0;
|
||||
|
||||
private _renderRowsDebouncer: RenderDebouncer;
|
||||
private _navigationMode: NavigationMode;
|
||||
// private _navigationMode: NavigationMode;
|
||||
|
||||
private _topBoundaryFocusListener: (e: FocusEvent) => void;
|
||||
private _bottomBoundaryFocusListener: (e: FocusEvent) => void;
|
||||
|
||||
private _disposables: IDisposable[] = [];
|
||||
|
||||
@@ -41,6 +49,7 @@ export class AccessibilityManager implements IDisposable {
|
||||
this._accessibilityTreeRoot.classList.add('xterm-accessibility');
|
||||
|
||||
this._moreRowsElement = document.createElement('div');
|
||||
this._moreRowsElement.classList.add('xterm-message');
|
||||
this._moreRowsElement.style.clip = 'clip(0 0 0 0)';
|
||||
this._moreRowsElement.textContent = Strings.navigationModeMoreRows;
|
||||
this._accessibilityTreeRoot.appendChild(this._moreRowsElement);
|
||||
@@ -51,13 +60,19 @@ export class AccessibilityManager implements IDisposable {
|
||||
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._navigationMode = new NavigationMode(this._terminal, this._rowContainer, this._rowElements, this);
|
||||
// this._navigationMode = new NavigationMode(this._terminal, this._rowContainer, this._rowElements, this);
|
||||
|
||||
this._liveRegion = document.createElement('div');
|
||||
this._liveRegion.classList.add('live-region');
|
||||
@@ -67,7 +82,7 @@ export class AccessibilityManager implements IDisposable {
|
||||
this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityTreeRoot);
|
||||
|
||||
this._disposables.push(this._renderRowsDebouncer);
|
||||
this._disposables.push(this._navigationMode);
|
||||
// this._disposables.push(this._navigationMode);
|
||||
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()));
|
||||
@@ -98,12 +113,86 @@ export class AccessibilityManager implements IDisposable {
|
||||
this._rowElements = null;
|
||||
}
|
||||
|
||||
private _onBoundaryFocus(e: FocusEvent, position: BoundaryPosition): void {
|
||||
const boundaryElement = <HTMLElement>e.target;
|
||||
const beforeBoundaryElement = <HTMLElement>this._rowElements[position === BoundaryPosition.Top ? 1 : this._rowElements.length - 2];
|
||||
|
||||
// Don't scroll if the buffer top has been reached
|
||||
const posInSet = this._rowElements[0].getAttribute('aria-posinset');
|
||||
if (posInSet === '1') {
|
||||
return;
|
||||
}
|
||||
console.log('posInSet', posInSet);
|
||||
|
||||
// Don't scroll when the last focused item was not the second row (focus is going the other
|
||||
// direction)
|
||||
console.log('related', <HTMLElement>e.relatedTarget);
|
||||
if (<HTMLElement>e.relatedTarget !== beforeBoundaryElement) {
|
||||
console.log('cancel');
|
||||
return;
|
||||
}
|
||||
|
||||
boundaryElement.removeEventListener('focus', this._topBoundaryFocusListener);
|
||||
let oldLastElement: HTMLElement;
|
||||
// TODO: oldLastElement.removeEventListener(...)
|
||||
|
||||
if (position === BoundaryPosition.Top) {
|
||||
oldLastElement = this._rowElements.pop();
|
||||
this._rowElements.unshift(this._createAccessibilityTreeNode());
|
||||
this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener);
|
||||
this._rowContainer.insertAdjacentElement('afterbegin', this._rowElements[0]);
|
||||
} else {
|
||||
oldLastElement = this._rowElements.shift();
|
||||
this._rowElements.push(this._createAccessibilityTreeNode());
|
||||
this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._topBoundaryFocusListener);
|
||||
this._rowContainer.appendChild(this._rowElements[this._rowElements.length - 1]);
|
||||
}
|
||||
this._rowContainer.removeChild(oldLastElement);
|
||||
|
||||
|
||||
|
||||
|
||||
// TODO: Add bottom boundary listeners and remove in both cases
|
||||
|
||||
|
||||
|
||||
|
||||
// Scroll up
|
||||
this._terminal.scrollLines(position === BoundaryPosition.Top ? -1 : 1);
|
||||
|
||||
// TODO: Only refresh single
|
||||
this._refreshRowsDimensions();
|
||||
|
||||
// Focus the new active element
|
||||
// this._rowContainer.setAttribute('aria-activedescendant', this._activeItemId);
|
||||
// this._focusedElement = this._rowElements[1];
|
||||
// this._focusedElement.id = this._activeItemId;
|
||||
|
||||
// Focus new boundary before element
|
||||
this._rowElements[position === BoundaryPosition.Top ? 1 : this._rowElements.length - 2].focus();
|
||||
|
||||
// Prevent the standard behavior
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
public get isNavigationModeActive(): boolean {
|
||||
return this._navigationMode.isActive;
|
||||
// TODO: Remove this function
|
||||
return true;
|
||||
// return this._navigationMode.isActive;
|
||||
}
|
||||
|
||||
public enterNavigationMode(): void {
|
||||
this._navigationMode.enter();
|
||||
// this._navigationMode.enter();
|
||||
|
||||
// this._isNavigationModeActive = true;
|
||||
this.announce('Entered line navigation mode');
|
||||
// this._rowContainer.tabIndex = 0;
|
||||
// this._rowContainer.setAttribute('role', 'list');
|
||||
// this._rowContainer.setAttribute('aria-activedescendant', this._activeItemId);
|
||||
// this._navigateToElement(this._terminal.buffer.ydisp + this._terminal.buffer.y);
|
||||
// this._rowContainer.focus();
|
||||
this._rowElements[this._rowElements.length - 1].focus();
|
||||
}
|
||||
|
||||
private _onResize(cols: number, rows: number): void {
|
||||
@@ -117,12 +206,15 @@ export class AccessibilityManager implements IDisposable {
|
||||
this._rowContainer.removeChild(this._rowElements.pop());
|
||||
}
|
||||
|
||||
// TODO: Fix up boundary listeners
|
||||
|
||||
this._refreshRowsDimensions();
|
||||
}
|
||||
|
||||
private _createAccessibilityTreeNode(): HTMLElement {
|
||||
public _createAccessibilityTreeNode(): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
element.setAttribute('role', 'menuitem');
|
||||
element.setAttribute('role', 'listitem');
|
||||
element.tabIndex = -1;
|
||||
return element;
|
||||
}
|
||||
|
||||
@@ -156,7 +248,6 @@ export class AccessibilityManager implements IDisposable {
|
||||
if (char === '\n') {
|
||||
this._liveRegionLineCount++;
|
||||
if (this._liveRegionLineCount === MAX_ROWS_TO_READ + 1) {
|
||||
// TODO: Enable localization
|
||||
this._liveRegion.textContent += Strings.tooMuchOutput;
|
||||
}
|
||||
}
|
||||
@@ -198,19 +289,20 @@ export class AccessibilityManager implements IDisposable {
|
||||
const setSize = (buffer.lines.length).toString();
|
||||
for (let i = start; i <= end; i++) {
|
||||
const lineData = buffer.translateBufferLineToString(buffer.ydisp + i, true);
|
||||
this._rowElements[i].textContent = lineData;
|
||||
this._rowElements[i].textContent = lineData.length === 0 ? 'Blank line' : lineData;
|
||||
const posInSet = (buffer.ydisp + i + 1).toString();
|
||||
this._rowElements[i].setAttribute('aria-posinset', posInSet);
|
||||
this._rowElements[i].setAttribute('aria-setsize', setSize);
|
||||
}
|
||||
// TODO: Clean up
|
||||
}
|
||||
|
||||
public rotateRows(): void {
|
||||
this._rowContainer.removeChild(this._rowElements.shift());
|
||||
const newRowIndex = this._rowElements.length;
|
||||
this._rowElements[newRowIndex] = this._createAccessibilityTreeNode();
|
||||
this._rowContainer.appendChild(this._rowElements[newRowIndex]);
|
||||
this._refreshRowsDimensions();
|
||||
// this._rowContainer.removeChild(this._rowElements.shift());
|
||||
// const newRowIndex = this._rowElements.length;
|
||||
// this._rowElements[newRowIndex] = this._createAccessibilityTreeNode();
|
||||
// this._rowContainer.appendChild(this._rowElements[newRowIndex]);
|
||||
// this._refreshRowsDimensions();
|
||||
}
|
||||
|
||||
private _refreshRowsDimensions(): void {
|
||||
@@ -226,158 +318,3 @@ export class AccessibilityManager implements IDisposable {
|
||||
this._liveRegion.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
class NavigationMode implements IDisposable {
|
||||
private _activeItemId: string;
|
||||
private _isNavigationModeActive: boolean = false;
|
||||
private _absoluteFocusedRow: number;
|
||||
private _focusedElement: HTMLElement;
|
||||
|
||||
private _disposables: IDisposable[] = [];
|
||||
|
||||
constructor(
|
||||
private _terminal: ITerminal,
|
||||
private _rowContainer: HTMLElement,
|
||||
private _rowElements: HTMLElement[],
|
||||
private _accessibilityManager: AccessibilityManager
|
||||
) {
|
||||
this._activeItemId = ACTIVE_ITEM_ID_PREFIX + Math.floor((Math.random() * 100000));
|
||||
|
||||
this._disposables.push(addDisposableListener(this._rowContainer, 'keyup', e => {
|
||||
if (this.isActive) {
|
||||
return this.onKeyUp(e);
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
this._disposables.push(addDisposableListener(this._rowContainer, 'keydown', e => {
|
||||
if (this.isActive) {
|
||||
return this.onKeyDown(e);
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this._disposables.forEach(d => d.dispose());
|
||||
this._disposables = null;
|
||||
}
|
||||
|
||||
public enter(): void {
|
||||
// TODO: Should entering navigation mode send ydisp to ybase?
|
||||
this._isNavigationModeActive = true;
|
||||
this._accessibilityManager.announce('Entered line navigation mode');
|
||||
this._rowContainer.tabIndex = 0;
|
||||
this._rowContainer.setAttribute('role', 'menu');
|
||||
this._rowContainer.setAttribute('aria-activedescendant', this._activeItemId);
|
||||
this._navigateToElement(this._terminal.buffer.ydisp + this._terminal.buffer.y);
|
||||
this._rowContainer.focus();
|
||||
}
|
||||
|
||||
public leave(): void {
|
||||
this._isNavigationModeActive = false;
|
||||
this._accessibilityManager.announce('Left line navigation mode');
|
||||
this._rowContainer.removeAttribute('tabindex');
|
||||
this._rowContainer.removeAttribute('aria-activedescendant');
|
||||
this._rowContainer.removeAttribute('role');
|
||||
if (this._focusedElement) {
|
||||
this._focusedElement.removeAttribute('id');
|
||||
}
|
||||
this._terminal.textarea.focus();
|
||||
}
|
||||
|
||||
public get isActive(): boolean {
|
||||
return this._isNavigationModeActive;
|
||||
}
|
||||
|
||||
public onKeyDown(e: KeyboardEvent): boolean {
|
||||
return this._onKey(e, e => {
|
||||
if (this._isNavigationModeActive) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
public onKeyUp(e: KeyboardEvent): boolean {
|
||||
return this._onKey(e, e => {
|
||||
if (this._isNavigationModeActive) {
|
||||
switch (e.keyCode) {
|
||||
case 27: return this._onEscape(e);
|
||||
case 33: return this._onPageUp(e);
|
||||
case 34: return this._onPageDown(e);
|
||||
case 35: return this._onEnd(e);
|
||||
case 36: return this._onHome(e);
|
||||
case 38: return this._onArrowUp(e);
|
||||
case 40: return this._onArrowDown(e);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private _onKey(e: KeyboardEvent, handler: (e: KeyboardEvent) => boolean): boolean {
|
||||
if (handler && handler(e)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private _onEscape(e: KeyboardEvent): boolean {
|
||||
this.leave();
|
||||
return true;
|
||||
}
|
||||
|
||||
private _onArrowUp(e: KeyboardEvent): boolean {
|
||||
return this._focusRow(this._absoluteFocusedRow - 1);
|
||||
}
|
||||
|
||||
private _onArrowDown(e: KeyboardEvent): boolean {
|
||||
return this._focusRow(this._absoluteFocusedRow + 1);
|
||||
}
|
||||
|
||||
private _onPageUp(e: KeyboardEvent): boolean {
|
||||
return this._focusRow(this._absoluteFocusedRow - this._terminal.rows);
|
||||
}
|
||||
|
||||
private _onPageDown(e: KeyboardEvent): boolean {
|
||||
return this._focusRow(this._absoluteFocusedRow + this._terminal.rows);
|
||||
}
|
||||
|
||||
private _onHome(e: KeyboardEvent): boolean {
|
||||
return this._focusRow(0);
|
||||
}
|
||||
|
||||
private _onEnd(e: KeyboardEvent): boolean {
|
||||
return this._focusRow(this._terminal.buffer.lines.length - 1);
|
||||
}
|
||||
|
||||
private _focusRow(row: number): boolean {
|
||||
this._navigateToElement(row);
|
||||
this._rowContainer.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
private _navigateToElement(absoluteRow: number): void {
|
||||
// Make sure there is no active element when scroll and rotate happens
|
||||
if (this._focusedElement) {
|
||||
this._rowContainer.removeAttribute('aria-activedescendant');
|
||||
this._focusedElement.removeAttribute('id');
|
||||
}
|
||||
|
||||
// Rotate rows to ensure the next focused item is read out correctly
|
||||
if (absoluteRow < this._terminal.buffer.ydisp || absoluteRow >= this._terminal.buffer.ydisp + this._terminal.rows) {
|
||||
this._accessibilityManager.rotateRows();
|
||||
}
|
||||
|
||||
// Scroll to row if it's outside of the viewport
|
||||
absoluteRow = this._terminal.scrollToRow(absoluteRow);
|
||||
this._absoluteFocusedRow = absoluteRow;
|
||||
|
||||
// Focus the new active element
|
||||
this._rowContainer.setAttribute('aria-activedescendant', this._activeItemId);
|
||||
this._focusedElement = this._rowElements[absoluteRow - this._terminal.buffer.ydisp];
|
||||
this._focusedElement.id = this._activeItemId;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -124,7 +124,8 @@
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility {
|
||||
.xterm .xterm-accessibility,
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
Reference in New Issue
Block a user