Skip to content

Commit aeb97d2

Browse files
authored
Merge pull request #1335 from mathjax/issue3406
Support tabbing to links internal to an expression. (mathjax/MathJax#3406)
2 parents ec084b0 + 99bd3aa commit aeb97d2

File tree

2 files changed

+155
-22
lines changed

2 files changed

+155
-22
lines changed

ts/a11y/explorer.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,18 @@ export function ExplorerMathDocumentMixin<
375375
'mjx-speech:focus': {
376376
outline: 'none',
377377
},
378+
'mjx-container .mjx-selected': {
379+
outline: '2px solid black',
380+
},
381+
382+
'mjx-container a[data-mjx-href]': {
383+
color: 'LinkText',
384+
cursor: 'pointer',
385+
},
386+
'mjx-container a[data-mjx-href].mjx-visited': {
387+
color: 'VisitedText',
388+
},
389+
378390
'mjx-container > mjx-help': {
379391
display: 'none',
380392
position: 'sticky',

ts/a11y/explorer/KeyExplorer.ts

Lines changed: 143 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ export class SpeechExplorer
253253
* The explorer key mapping
254254
*/
255255
protected static keyMap: Map<string, [keyMapping, boolean?]> = new Map([
256-
['Tab', [() => true]],
256+
['Tab', [(explorer, event) => explorer.tabKey(event)]],
257257
['Escape', [(explorer) => explorer.escapeKey()]],
258258
['Enter', [(explorer, event) => explorer.enterKey(event)]],
259259
['Home', [(explorer) => explorer.homeKey()]],
@@ -404,6 +404,16 @@ export class SpeechExplorer
404404
*/
405405
protected cellTypes: string[] = ['cell', 'line'];
406406

407+
/**
408+
* The anchors in this expression
409+
*/
410+
protected anchors: HTMLElement[];
411+
412+
/**
413+
* Whether the expression was focused by a back tab
414+
*/
415+
protected backTab: boolean = false;
416+
407417
/********************************************************************/
408418
/*
409419
* The event handlers
@@ -439,6 +449,7 @@ export class SpeechExplorer
439449
}
440450
if (!this.clicked) {
441451
this.Start();
452+
this.backTab = _event.target === this.img;
442453
}
443454
this.clicked = null;
444455
}
@@ -627,6 +638,41 @@ export class SpeechExplorer
627638
return true;
628639
}
629640

641+
/**
642+
* Tab to the next internal link, if any, and stop the event from
643+
* propagating, or if no more links, let it propagate so that the
644+
* browser moves to the next focusable item.
645+
*
646+
* @param {KeyboardEvent} event The event for the enter key
647+
* @returns {void | boolean} False means play the honk sound
648+
*/
649+
protected tabKey(event: KeyboardEvent): void | boolean {
650+
if (this.anchors.length === 0 || !this.current) return true;
651+
if (this.backTab) {
652+
if (!event.shiftKey) return true;
653+
const link = this.linkFor(this.anchors[this.anchors.length - 1]);
654+
if (this.anchors.length === 1 && link === this.current) {
655+
return true;
656+
}
657+
this.setCurrent(link);
658+
return;
659+
}
660+
const [anchors, position, current] = event.shiftKey
661+
? [
662+
this.anchors.slice(0).reverse(),
663+
Node.DOCUMENT_POSITION_PRECEDING,
664+
this.isLink() ? this.getAnchor() : this.current,
665+
]
666+
: [this.anchors, Node.DOCUMENT_POSITION_FOLLOWING, this.current];
667+
for (const anchor of anchors) {
668+
if (current.compareDocumentPosition(anchor) & position) {
669+
this.setCurrent(this.linkFor(anchor));
670+
return;
671+
}
672+
}
673+
return true;
674+
}
675+
630676
/**
631677
* Process Enter key events
632678
*
@@ -986,6 +1032,7 @@ export class SpeechExplorer
9861032
* @param {boolean} addDescription True if the speech node should get a description
9871033
*/
9881034
protected setCurrent(node: HTMLElement, addDescription: boolean = false) {
1035+
this.backTab = false;
9891036
this.speechType = '';
9901037
if (!document.hasFocus()) {
9911038
this.refocus = this.current;
@@ -1095,12 +1142,16 @@ export class SpeechExplorer
10951142
* @param {boolean} describe True if the description should be added
10961143
*/
10971144
protected addSpeech(node: HTMLElement, describe: boolean) {
1098-
this.img?.remove();
1099-
let speech = [
1145+
if (this.anchors.length) {
1146+
setTimeout(() => this.img?.remove(), 10);
1147+
} else {
1148+
this.img?.remove();
1149+
}
1150+
let speech = this.addComma([
11001151
node.getAttribute(SemAttr.PREFIX),
11011152
node.getAttribute(SemAttr.SPEECH),
11021153
node.getAttribute(SemAttr.POSTFIX),
1103-
]
1154+
])
11041155
.join(' ')
11051156
.trim();
11061157
if (describe) {
@@ -1119,6 +1170,20 @@ export class SpeechExplorer
11191170
this.node.setAttribute('tabindex', '-1');
11201171
}
11211172

1173+
/**
1174+
* In an array [prefix, center, postfix], the center gets a comma if
1175+
* there is a postfix.
1176+
*
1177+
* @param {string[]} words The words to check
1178+
* @returns {string[]} The modified array of words
1179+
*/
1180+
protected addComma(words: string[]): string[] {
1181+
if (words[2] && (words[1] || words[0])) {
1182+
words[1] += ',';
1183+
}
1184+
return words;
1185+
}
1186+
11221187
/**
11231188
* If there is a speech node, remove it
11241189
* and put back the top-level node, if needed.
@@ -1199,6 +1264,7 @@ export class SpeechExplorer
11991264
'aria-roledescription': item.none,
12001265
});
12011266
container.appendChild(this.img);
1267+
this.adjustAnchors();
12021268
}
12031269

12041270
/**
@@ -1211,6 +1277,34 @@ export class SpeechExplorer
12111277
for (const child of Array.from(container.childNodes) as HTMLElement[]) {
12121278
child.removeAttribute('aria-hidden');
12131279
}
1280+
this.restoreAnchors();
1281+
}
1282+
1283+
/**
1284+
* Move all the href attributes to data-mjx-href attributes
1285+
* (so they won't be focusable links, as they are aria-hidden).
1286+
*/
1287+
protected adjustAnchors() {
1288+
this.anchors = Array.from(this.node.querySelectorAll('a[href]'));
1289+
for (const anchor of this.anchors) {
1290+
const href = anchor.getAttribute('href');
1291+
anchor.setAttribute('data-mjx-href', href);
1292+
anchor.removeAttribute('href');
1293+
}
1294+
if (this.anchors.length) {
1295+
this.img.setAttribute('tabindex', '0');
1296+
}
1297+
}
1298+
1299+
/**
1300+
* Move the links back to their href attributes.
1301+
*/
1302+
protected restoreAnchors() {
1303+
for (const anchor of this.anchors) {
1304+
anchor.setAttribute('href', anchor.getAttribute('data-mjx-href'));
1305+
anchor.removeAttribute('data-mjx-href');
1306+
}
1307+
this.anchors = [];
12141308
}
12151309

12161310
/**
@@ -1474,6 +1568,42 @@ export class SpeechExplorer
14741568
return found;
14751569
}
14761570

1571+
/**
1572+
* @param {HTMLElement} node The node to test for having an href
1573+
* @returns {boolean} True if the node has a link, false otherwise
1574+
*/
1575+
protected isLink(node: HTMLElement = this.current): boolean {
1576+
return !!node?.getAttribute('data-semantic-attributes')?.includes('href:');
1577+
}
1578+
1579+
/**
1580+
* @param {HTMLElement} node The link node whose <a> node is desired
1581+
* @returns {HTMLElement} The <a> node for the given link node
1582+
*/
1583+
protected getAnchor(node: HTMLElement = this.current): HTMLElement {
1584+
const anchor = node.closest('a');
1585+
return anchor && this.node.contains(anchor) ? anchor : null;
1586+
}
1587+
1588+
/**
1589+
* @param {HTMLElement} anchor The <a> node whose speech node is desired
1590+
* @returns {HTMLElement} The node for which the <a> is handling the href
1591+
*/
1592+
protected linkFor(anchor: HTMLElement): HTMLElement {
1593+
return anchor?.querySelector('[data-semantic-attributes*="href:"]');
1594+
}
1595+
1596+
/**
1597+
* @param {HTMLElement} node A node inside a link whose top-level link node is required
1598+
* @returns {HTMLElement} The parent node with an href that contains the given node
1599+
*/
1600+
protected parentLink(node: HTMLElement): HTMLElement {
1601+
const link = node?.closest(
1602+
'[data-semantic-attributes*="href:"]'
1603+
) as HTMLElement;
1604+
return link && this.node.contains(link) ? link : null;
1605+
}
1606+
14771607
/**
14781608
* Focus the container node without activating it (e.g., when Escape is pressed)
14791609
*/
@@ -1727,18 +1857,12 @@ export class SpeechExplorer
17271857
* @returns {boolean} True if link was successfully triggered.
17281858
*/
17291859
protected triggerLink(node: HTMLElement): boolean {
1730-
const focus = node
1731-
?.getAttribute('data-semantic-postfix')
1732-
?.match(/(^| )link($| )/);
1733-
if (focus) {
1734-
while (node && node !== this.node) {
1735-
if (node instanceof HTMLAnchorElement) {
1736-
node.dispatchEvent(new MouseEvent('click'));
1737-
setTimeout(() => this.FocusOut(null), 50);
1738-
return true;
1739-
}
1740-
node = node.parentNode as HTMLElement;
1741-
}
1860+
if (this.isLink(node)) {
1861+
const anchor = this.getAnchor(node);
1862+
anchor.classList.add('mjx-visited');
1863+
setTimeout(() => this.FocusOut(null), 50);
1864+
window.location.href = anchor.getAttribute('data-mjx-href');
1865+
return true;
17421866
}
17431867
return false;
17441868
}
@@ -1749,12 +1873,9 @@ export class SpeechExplorer
17491873
* @returns {boolean} True if link was successfully triggered.
17501874
*/
17511875
protected triggerLinkMouse(): boolean {
1752-
let node = this.refocus;
1753-
while (node && node !== this.node) {
1754-
if (this.triggerLink(node)) {
1755-
return true;
1756-
}
1757-
node = node.parentNode as HTMLElement;
1876+
const link = this.parentLink(this.refocus);
1877+
if (this.triggerLink(link)) {
1878+
return true;
17581879
}
17591880
return false;
17601881
}

0 commit comments

Comments
 (0)