Skip to content

Commit 7283d8f

Browse files
committed
Handle clicking and tabbing to focusable HTML elements within expressions.
1 parent 95faa0c commit 7283d8f

1 file changed

Lines changed: 125 additions & 30 deletions

File tree

ts/a11y/explorer/KeyExplorer.ts

Lines changed: 125 additions & 30 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
*
@@ -751,6 +815,11 @@ export class SpeechExplorer
751815
if (this.triggerLinkKeyboard(event)) {
752816
this.Stop();
753817
} else {
818+
const tabs = this.getInternalTabs(this.current);
819+
if (tabs.length) {
820+
tabs[0].focus();
821+
return;
822+
}
754823
const expandable = this.actionable(this.current);
755824
if (!expandable) {
756825
return false;
@@ -1387,6 +1456,7 @@ export class SpeechExplorer
13871456
}
13881457
container.appendChild(this.img);
13891458
this.adjustAnchors();
1459+
this.getTabs();
13901460
}
13911461

13921462
/**
@@ -1429,6 +1499,23 @@ export class SpeechExplorer
14291499
this.anchors = [];
14301500
}
14311501

1502+
/**
1503+
* Find all the focusable elements in the expression (for tabbing)
1504+
*/
1505+
protected getTabs() {
1506+
this.tabs = this.getInternalTabs(this.node);
1507+
}
1508+
1509+
/**
1510+
* @param {HTMLElement} node The node whose internal focusable elements are to be found
1511+
* @returns {HTMLElement[]} The list of focusable element within the given one
1512+
*/
1513+
protected getInternalTabs(node: HTMLElement): HTMLElement[] {
1514+
return Array.from(node.querySelectorAll(
1515+
'button, [data-mjx-href], input, select, textarea, [tabindex]:not([tabindex="-1"],mjx-speech)'
1516+
));
1517+
}
1518+
14321519
/**
14331520
* Set focus on the current node
14341521
*/
@@ -1960,11 +2047,19 @@ export class SpeechExplorer
19602047
*/
19612048
protected addHtmlEvents() {
19622049
for (const html of Array.from(this.node.querySelectorAll('mjx-html'))) {
1963-
const stop = (event: Event) => {
2050+
const stop = function (event: Event) {
19642051
if (html.contains(document.activeElement)) {
1965-
event.stopPropagation();
2052+
if (event instanceof KeyboardEvent) {
2053+
this.clicked = null;
2054+
if (event.key !== 'Tab' && event.key !== 'Escape') {
2055+
event.stopPropagation();
2056+
}
2057+
} else {
2058+
this.clicked = event.target;
2059+
}
19662060
}
1967-
};
2061+
}.bind(this);
2062+
html.addEventListener('mousedown', stop);
19682063
html.addEventListener('click', stop);
19692064
html.addEventListener('keydown', stop);
19702065
html.addEventListener('dblclick', stop);

0 commit comments

Comments
 (0)