diff --git a/ts/a11y/explorer.ts b/ts/a11y/explorer.ts index 7964807c0..33048b43c 100644 --- a/ts/a11y/explorer.ts +++ b/ts/a11y/explorer.ts @@ -378,6 +378,15 @@ export function ExplorerMathDocumentMixin< 'mjx-container .mjx-selected': { outline: '2px solid black', }, + + 'mjx-container a[data-mjx-href]': { + color: 'LinkText', + cursor: 'pointer', + }, + 'mjx-container a[data-mjx-href].mjx-visited': { + color: 'VisitedText', + }, + 'mjx-container > mjx-help': { display: 'none', position: 'absolute', diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index 943def94d..fff7e38f4 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -253,7 +253,7 @@ export class SpeechExplorer * The explorer key mapping */ protected static keyMap: Map = new Map([ - ['Tab', [() => true]], + ['Tab', [(explorer, event) => explorer.tabKey(event)]], ['Escape', [(explorer) => explorer.escapeKey()]], ['Enter', [(explorer, event) => explorer.enterKey(event)]], ['Home', [(explorer) => explorer.homeKey()]], @@ -404,6 +404,16 @@ export class SpeechExplorer */ protected cellTypes: string[] = ['cell', 'line']; + /** + * The anchors in this expression + */ + protected anchors: HTMLElement[]; + + /** + * Whether the expression was focused by a back tab + */ + protected backTab: boolean = false; + /********************************************************************/ /* * The event handlers @@ -434,6 +444,7 @@ export class SpeechExplorer } if (!this.clicked) { this.Start(); + this.backTab = _event.target === this.img; } this.clicked = null; } @@ -622,6 +633,39 @@ export class SpeechExplorer return true; } + /** + * Tab to the next internal link, if any, and stop the event from + * propagating, or if no more links, let it propagate so that the + * browser moves to the next focusable item. + * + * @param {KeyboardEvent} event The event for the enter key + * @returns {void | boolean} False means play the honk sound + */ + protected tabKey(event: KeyboardEvent): void | boolean { + if (this.anchors.length === 0 || !this.current) return true; + if (this.backTab) { + if (!event.shiftKey) return true; + const link = this.linkFor(this.anchors[this.anchors.length - 1]); + if (this.anchors.length === 1 && link === this.current) { + return true; + } + this.setCurrent(link); + return; + } + const [anchors, position, current] = event.shiftKey + ? [this.anchors.slice(0).reverse(), + Node.DOCUMENT_POSITION_PRECEDING, + this.isLink() ? this.getAnchor() : this.current] + : [this.anchors, Node.DOCUMENT_POSITION_FOLLOWING, this.current]; + for (const anchor of anchors) { + if (current.compareDocumentPosition(anchor) & position) { + this.setCurrent(this.linkFor(anchor)); + return; + } + } + return true; + } + /** * Process Enter key events * @@ -981,6 +1025,7 @@ export class SpeechExplorer * @param {boolean} addDescription True if the speech node should get a description */ protected setCurrent(node: HTMLElement, addDescription: boolean = false) { + this.backTab = false; this.speechType = ''; if (!document.hasFocus()) { this.refocus = this.current; @@ -1051,21 +1096,27 @@ export class SpeechExplorer * @param {boolean} describe True if the description should be added */ protected addSpeech(node: HTMLElement, describe: boolean) { - this.img?.remove(); - let speech = [ + if (this.anchors.length) { + setTimeout(() => this.img?.remove(), 10); + } else { + this.img?.remove(); + } + let speech = this.addComma([ node.getAttribute(SemAttr.PREFIX), node.getAttribute(SemAttr.SPEECH), node.getAttribute(SemAttr.POSTFIX), - ] + ]) .join(' ') .trim(); if (describe) { let description = - this.description === this.none ? '' : ', ' + this.description; + (this.description === this.none ? '' : ', ' + this.description) + this.linkCount(); if (this.document.options.a11y.help) { description += ', press h for help'; } speech += description; + } else { + speech += this.linkCount(); } this.speak( speech, @@ -1075,6 +1126,34 @@ export class SpeechExplorer this.node.setAttribute('tabindex', '-1'); } + /** + * In an array [prefix, center, postfix], the center gets a comma if + * there is a postfix. + * + * @param {string[]} words The words to check + * @returns {string[]} The modified array of words + */ + protected addComma(words: string[]): string[] { + if (words[2]) { + words[1] += ','; + } + return words; + } + + /** + * @returns {string} A string giving the number of links within the + * currently selected node. + */ + protected linkCount(): string { + if (this.anchors.length && !this.isLink()) { + const anchors = Array.from(this.current.querySelectorAll('a')).length; + if (anchors) { + return `, with ${anchors} link${anchors === 1 ? '' : 's'}`; + } + } + return ''; + } + /** * If there is a speech node, remove it * and put back the top-level node, if needed. @@ -1155,6 +1234,7 @@ export class SpeechExplorer 'aria-roledescription': item.none, }); container.appendChild(this.img); + this.adjustAnchors(); } /** @@ -1167,6 +1247,34 @@ export class SpeechExplorer for (const child of Array.from(container.childNodes) as HTMLElement[]) { child.removeAttribute('aria-hidden'); } + this.restoreAnchors(); + } + + /** + * Move all the href attributes to data-mjx-href attributes + * (so they won't be focusable links, as they are aria-hidden). + */ + protected adjustAnchors() { + this.anchors = Array.from(this.node.querySelectorAll('a[href]')); + for (const anchor of this.anchors) { + const href = anchor.getAttribute('href'); + anchor.setAttribute('data-mjx-href', href); + anchor.removeAttribute('href'); + } + if (this.anchors.length) { + this.img.setAttribute('tabindex', '0'); + } + } + + /** + * Move the links back to their href attributes. + */ + protected restoreAnchors() { + for (const anchor of this.anchors) { + anchor.setAttribute('href', anchor.getAttribute('data-mjx-href')); + anchor.removeAttribute('data-mjx-href'); + } + this.anchors = []; } /** @@ -1430,6 +1538,40 @@ export class SpeechExplorer return found; } + /** + * @param {HTMLElement} node The node to test for having an href + * @returns {boolean} True if the node has is a link, false otherwise + */ + protected isLink(node: HTMLElement = this.current): boolean { + return !!node?.getAttribute('data-semantic-attributes')?.includes('href:'); + } + + /** + * @param {HTMLElement} node The link node whose node is desired + * @returns {HTMLElement} The node for the given link node + */ + protected getAnchor(node: HTMLElement = this.current): HTMLElement { + const anchor = node.closest('a'); + return anchor && this.node.contains(anchor) ? anchor : null; + } + + /** + * @param {HTMLElement} anchor The node whose speech node is desired + * @returns {HTMLElement} The node for which the is handling the href + */ + protected linkFor(anchor: HTMLElement): HTMLElement { + return anchor?.querySelector('[data-semantic-attributes*="href:"]'); + } + + /** + * @param {HTMLElement} node A node inside a link whose top-level link node is required + * @returns {HTMLElement} The parent node with an href that contains the given node + */ + protected parentLink(node: HTMLElement): HTMLElement { + const link = node?.closest('[data-semantic-attributes*="href:"]') as HTMLElement; + return link && this.node.contains(link) ? link : null; + } + /** * Focus the container node without activating it (e.g., when Escape is pressed) */ @@ -1679,18 +1821,12 @@ export class SpeechExplorer * @returns {boolean} True if link was successfully triggered. */ protected triggerLink(node: HTMLElement): boolean { - const focus = node - ?.getAttribute('data-semantic-postfix') - ?.match(/(^| )link($| )/); - if (focus) { - while (node && node !== this.node) { - if (node instanceof HTMLAnchorElement) { - node.dispatchEvent(new MouseEvent('click')); - setTimeout(() => this.FocusOut(null), 50); - return true; - } - node = node.parentNode as HTMLElement; - } + if (this.isLink(node)) { + const anchor = this.getAnchor(node); + anchor.classList.add('mjx-visited'); + setTimeout(() => this.FocusOut(null), 50); + window.location.href = anchor.getAttribute('data-mjx-href'); + return true; } return false; } @@ -1701,12 +1837,9 @@ export class SpeechExplorer * @returns {boolean} True if link was successfully triggered. */ protected triggerLinkMouse(): boolean { - let node = this.refocus; - while (node && node !== this.node) { - if (this.triggerLink(node)) { - return true; - } - node = node.parentNode as HTMLElement; + const link = this.parentLink(this.refocus); + if (this.triggerLink(link)) { + return true; } return false; }