Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
165 changes: 143 additions & 22 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,41 @@ 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 +1027,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,12 +1098,16 @@ 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) {
Expand All @@ -1075,6 +1126,20 @@ 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] || words[0])) {
words[1] += ',';
}
return words;
}

/**
* If there is a speech node, remove it
* and put back the top-level node, if needed.
Expand Down Expand Up @@ -1155,6 +1220,7 @@ export class SpeechExplorer
'aria-roledescription': item.none,
});
container.appendChild(this.img);
this.adjustAnchors();
}

/**
Expand All @@ -1167,6 +1233,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 +1524,42 @@ 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 +1809,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 +1825,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