Skip to content

Support tabbing to links internal to an expression. (mathjax/MathJax#3406) #1335

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

Open
wants to merge 2 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
9 changes: 9 additions & 0 deletions ts/a11y/explorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
179 changes: 156 additions & 23 deletions ts/a11y/explorer/KeyExplorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ export class SpeechExplorer
* The explorer key mapping
*/
protected static keyMap: Map<string, [keyMapping, boolean?]> = new Map([
['Tab', [() => true]],
['Tab', [(explorer, event) => explorer.tabKey(event)]],
['Escape', [(explorer) => explorer.escapeKey()]],
['Enter', [(explorer, event) => explorer.enterKey(event)]],
['Home', [(explorer) => explorer.homeKey()]],
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -434,6 +444,7 @@ export class SpeechExplorer
}
if (!this.clicked) {
this.Start();
this.backTab = _event.target === this.img;
}
this.clicked = null;
}
Expand Down Expand Up @@ -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
*
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -1155,6 +1234,7 @@ export class SpeechExplorer
'aria-roledescription': item.none,
});
container.appendChild(this.img);
this.adjustAnchors();
}

/**
Expand All @@ -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 = [];
}

/**
Expand Down Expand Up @@ -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 <a> node is desired
* @returns {HTMLElement} The <a> 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 <a> node whose speech node is desired
* @returns {HTMLElement} The node for which the <a> 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)
*/
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down