From 1cceda605299c3dd82593f16a39a8d4b8c9a2932 Mon Sep 17 00:00:00 2001 From: Daniel Imms Date: Wed, 24 Jan 2018 16:34:39 -0800 Subject: [PATCH] Change navigation mode to work using focus instead of activedescendant --- src/AccessibilityManager.ts | 275 ++++++++++++++---------------------- src/xterm.css | 3 +- 2 files changed, 108 insertions(+), 170 deletions(-) diff --git a/src/AccessibilityManager.ts b/src/AccessibilityManager.ts index e1bc841a..fa3a094d 100644 --- a/src/AccessibilityManager.ts +++ b/src/AccessibilityManager.ts @@ -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 = e.target; + const beforeBoundaryElement = 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', e.relatedTarget); + if (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; - } -} diff --git a/src/xterm.css b/src/xterm.css index fffc6b85..1057dadc 100644 --- a/src/xterm.css +++ b/src/xterm.css @@ -124,7 +124,8 @@ cursor: text; } -.xterm .xterm-accessibility { +.xterm .xterm-accessibility, +.xterm .xterm-message { position: absolute; left: 0; top: 0;