Skip to content

Commit cc3603e

Browse files
authored
Merge pull request #1421 from mathjax/feature/input-tabbing
Handle clicking and tabbing to focusable HTML elements within expressions.
2 parents 7ac5ab4 + 50e6346 commit cc3603e

1 file changed

Lines changed: 131 additions & 32 deletions

File tree

ts/a11y/explorer/KeyExplorer.ts

Lines changed: 131 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ export class SpeechExplorer
301301
*/
302302
protected static keyMap: Map<string, [keyMapping, boolean?]> = new Map([
303303
['Tab', [(explorer, event) => explorer.tabKey(event)]],
304-
['Escape', [(explorer) => explorer.escapeKey()]],
304+
['Escape', [(explorer, event) => explorer.escapeKey(event)]],
305305
['Enter', [(explorer, event) => explorer.enterKey(event)]],
306306
['Home', [(explorer) => explorer.homeKey()]],
307307
[
@@ -468,6 +468,11 @@ export class SpeechExplorer
468468
*/
469469
protected anchors: HTMLElement[];
470470

471+
/**
472+
* The elements that are focusable for tab navigation
473+
*/
474+
protected tabs: HTMLElement[];
475+
471476
/**
472477
* Whether the expression was focused by a back tab
473478
*/
@@ -498,7 +503,8 @@ export class SpeechExplorer
498503
/**
499504
* @override
500505
*/
501-
public FocusIn(_event: FocusEvent) {
506+
public FocusIn(event: FocusEvent) {
507+
if ((event.target as HTMLElement).closest('mjx-html')) return;
502508
if (this.item.outputData.nofocus) {
503509
//
504510
// we are refocusing after a menu or dialog box has closed
@@ -508,7 +514,7 @@ export class SpeechExplorer
508514
}
509515
if (!this.clicked) {
510516
this.Start();
511-
this.backTab = _event.target === this.img;
517+
this.backTab = event.target === this.img;
512518
}
513519
this.clicked = null;
514520
}
@@ -639,8 +645,7 @@ export class SpeechExplorer
639645
// focus on the clicked element when focusin occurs
640646
// start the explorer if this isn't a link
641647
//
642-
if (!clicked || this.node.contains(clicked)) {
643-
this.stopEvent(event);
648+
if (!this.clicked && (!clicked || this.node.contains(clicked))) {
644649
this.refocus = clicked;
645650
if (!this.triggerLinkMouse()) {
646651
this.Start();
@@ -658,7 +663,6 @@ export class SpeechExplorer
658663
if (hasModifiers(event) || event.buttons === 2 || direction !== 'none') {
659664
this.FocusOut(null);
660665
} else {
661-
this.stopEvent(event);
662666
this.refocus = this.rootNode();
663667
this.Start();
664668
}
@@ -696,50 +700,110 @@ export class SpeechExplorer
696700
/**
697701
* Stop exploring and focus the top element
698702
*
699-
* @returns {boolean} Don't cancel the event
703+
* @param {KeyboardEvent} event The event for the escape key
704+
* @returns {boolean} Don't cancel the event
700705
*/
701-
protected escapeKey(): boolean {
702-
this.Stop();
703-
this.focusTop();
704-
this.setCurrent(null);
706+
protected escapeKey(event: KeyboardEvent): void | boolean {
707+
if ((event.target as HTMLElement).closest('mjx-html')) {
708+
this.refocus = (event.target as HTMLElement).closest(nav);
709+
this.Start();
710+
} else {
711+
this.Stop();
712+
this.focusTop();
713+
this.setCurrent(null);
714+
}
705715
return true;
706716
}
707717

708718
/**
709-
* Tab to the next internal link, if any, and stop the event from
710-
* propagating, or if no more links, let it propagate so that the
711-
* browser moves to the next focusable item.
719+
* Tab to the next internal link or focusable HTML elelemt, if any,
720+
* and stop the event from propagating, or if no more focusable
721+
* elements, let it propagate so that the browser moves to the next
722+
* focusable item.
712723
*
713724
* @param {KeyboardEvent} event The event for the enter key
714725
* @returns {void | boolean} False means play the honk sound
715726
*/
716727
protected tabKey(event: KeyboardEvent): void | boolean {
717-
if (this.anchors.length === 0 || !this.current) return true;
728+
//
729+
// Get the currently active element in the expression
730+
//
731+
const active =
732+
this.current ??
733+
(this.node.contains(document.activeElement)
734+
? document.activeElement
735+
: null);
736+
if (this.tabs.length === 0 || !active) return true;
737+
//
738+
// If we back tabbed into the expression, tab to the first focusable item.
739+
//
718740
if (this.backTab) {
719741
if (!event.shiftKey) return true;
720-
const link = this.linkFor(this.anchors[this.anchors.length - 1]);
721-
if (this.anchors.length === 1 && link === this.current) {
722-
return true;
723-
}
724-
this.setCurrent(link);
742+
this.tabTo(this.tabs[this.tabs.length - 1]);
725743
return;
726744
}
727-
const [anchors, position, current] = event.shiftKey
745+
//
746+
// Otherwise, look through the list of focusable items to find the
747+
// next one after (or before) the active item, and tab to it.
748+
//
749+
const [tabs, position, current] = event.shiftKey
728750
? [
729-
this.anchors.slice(0).reverse(),
751+
this.tabs.slice(0).reverse(),
730752
Node.DOCUMENT_POSITION_PRECEDING,
731-
this.isLink() ? this.getAnchor() : this.current,
753+
this.current && this.isLink() ? this.getAnchor() : active,
732754
]
733-
: [this.anchors, Node.DOCUMENT_POSITION_FOLLOWING, this.current];
734-
for (const anchor of anchors) {
735-
if (current.compareDocumentPosition(anchor) & position) {
736-
this.setCurrent(this.linkFor(anchor));
755+
: [this.tabs, Node.DOCUMENT_POSITION_FOLLOWING, active];
756+
for (const tab of tabs) {
757+
if (current.compareDocumentPosition(tab) & position) {
758+
this.tabTo(tab);
737759
return;
738760
}
739761
}
762+
//
763+
// If we are shift-tabbing from the root node, set up to tab out of
764+
// the expression.
765+
//
766+
if (event.shiftKey && this.current === this.rootNode()) {
767+
this.tabOut();
768+
}
769+
//
770+
// Process the tab as normal
771+
//
740772
return true;
741773
}
742774

775+
/**
776+
* @param {HTMLElement} node The node within the expression to receive the focus
777+
*/
778+
protected tabTo(node: HTMLElement) {
779+
if (node.getAttribute('data-mjx-href')) {
780+
this.setCurrent(this.linkFor(node));
781+
} else {
782+
node.focus();
783+
}
784+
}
785+
786+
/**
787+
* Shift-Tab to previous focusable element (by temporarily making
788+
* any focusable elements in the expression have display none, so
789+
* they will be skipped by tabbing).
790+
*/
791+
protected tabOut() {
792+
const html = Array.from(
793+
this.node.querySelectorAll('mjx-html')
794+
) as HTMLElement[];
795+
if (html.length) {
796+
html.forEach((node) => {
797+
node.style.display = 'none';
798+
});
799+
setTimeout(() => {
800+
html.forEach((node) => {
801+
node.style.display = '';
802+
});
803+
}, 0);
804+
}
805+
}
806+
743807
/**
744808
* Process Enter key events
745809
*
@@ -752,11 +816,18 @@ export class SpeechExplorer
752816
this.Stop();
753817
} else {
754818
const expandable = this.actionable(this.current);
755-
if (!expandable) {
756-
return false;
819+
if (expandable) {
820+
this.refocus = expandable;
821+
expandable.dispatchEvent(new Event('click'));
822+
return;
823+
}
824+
const tabs = this.getInternalTabs(this.current).filter(
825+
(node) => !node.getAttribute('data-mjx-href')
826+
);
827+
if (tabs.length) {
828+
tabs[0].focus();
829+
return;
757830
}
758-
this.refocus = expandable;
759-
expandable.dispatchEvent(new Event('click'));
760831
}
761832
} else {
762833
this.Start();
@@ -1387,6 +1458,7 @@ export class SpeechExplorer
13871458
}
13881459
container.appendChild(this.img);
13891460
this.adjustAnchors();
1461+
this.getTabs();
13901462
}
13911463

13921464
/**
@@ -1429,6 +1501,25 @@ export class SpeechExplorer
14291501
this.anchors = [];
14301502
}
14311503

1504+
/**
1505+
* Find all the focusable elements in the expression (for tabbing)
1506+
*/
1507+
protected getTabs() {
1508+
this.tabs = this.getInternalTabs(this.node);
1509+
}
1510+
1511+
/**
1512+
* @param {HTMLElement} node The node whose internal focusable elements are to be found
1513+
* @returns {HTMLElement[]} The list of focusable element within the given one
1514+
*/
1515+
protected getInternalTabs(node: HTMLElement): HTMLElement[] {
1516+
return Array.from(
1517+
node.querySelectorAll(
1518+
'button, [data-mjx-href], input, select, textarea, [tabindex]:not([tabindex="-1"],mjx-speech)'
1519+
)
1520+
);
1521+
}
1522+
14321523
/**
14331524
* Set focus on the current node
14341525
*/
@@ -1962,9 +2053,17 @@ export class SpeechExplorer
19622053
for (const html of Array.from(this.node.querySelectorAll('mjx-html'))) {
19632054
const stop = (event: Event) => {
19642055
if (html.contains(document.activeElement)) {
1965-
event.stopPropagation();
2056+
if (event instanceof KeyboardEvent) {
2057+
this.clicked = null;
2058+
if (event.key !== 'Tab' && event.key !== 'Escape') {
2059+
event.stopPropagation();
2060+
}
2061+
} else {
2062+
this.clicked = event.target as HTMLElement;
2063+
}
19662064
}
19672065
};
2066+
html.addEventListener('mousedown', stop);
19682067
html.addEventListener('click', stop);
19692068
html.addEventListener('keydown', stop);
19702069
html.addEventListener('dblclick', stop);

0 commit comments

Comments
 (0)