@@ -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