@@ -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 ( / ( ^ | ) l i n k ( $ | ) / ) ;
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