MathJax icon indicating copy to clipboard operation
MathJax copied to clipboard

Can not Shift-Tab out of a reference label

Open salbeira opened this issue 6 months ago • 3 comments

Issue Summary

When you have a single math entry that contains just a reference to another label like this:

$\eqref{eq:einstein}$

When the focus moves into the reference you can no longer SHIFT-TAB back out of the element. It just refuses the verb. You can "Tab"-onwards though.

In addition: How is a keyboard-only user supposed to "click" the link when the focus goes into the formula but not the link created by the eqref?

Steps to Reproduce:

  1. Create a formula with a label.
  2. Create a reference to the label.
  3. Tab into the reference.
  4. Try to Shift-Tab out of the reference.

What I expect to happen instead:

The user can Shift-Tab to the previous formula. As the formula contains only a link, there are not many additional button presses necessary to move the focus onto the link so the user can activate it with the ENTER key or something similar.

Technical details:

  • MathJax Version: 4.0
  • Client OS: Linux Mint
  • Browser: Firefox and Chrome

I am using the following MathJax configuration:

I better just link the thing that assembles the configuration here:

https://github.com/decker-edu/decker/blob/be5f094a096abb569cdf11a7a8e9a159fb156114/resource/decker/support/plugins/math/math.js

Supporting information:

Image

salbeira avatar Aug 06 '25 15:08 salbeira

Thanks for your issue report. You are right, there is an issue with SHIFT-TAB when there is a link (of any kind) in the expression.

Here's what's happening: the element that has the focus isn't actually the anchor node itself, which is before the node with the actual focus. So when you SHIFT-TAB the browsers takes you to the actual anchor, and MathJax takes that as activating the expression itself. So you are trapped within the formula, as you have reported.

There is also an issue that the anchor node is actually inside a node that has aria-hidden="true", which is also problematic. The expression explorer is completely rewritten in v4.0, and clearly there is more work to be done with links. I have some ideas how to handle this, and will look into it further.

How is a keyboard-only user supposed to "click" the link when the focus goes into the formula but not the link created by the eqref?

As for activating the link, pressing Return is supposed to do that, and it does so for me in Ubuntu 24 and MacOS with the formula you give. If the link is in a sub-expression, you need to navigate to that before you can press enter to activate the link. E.g., with $x + \href{test.html}{y}$, you would need to down arrow and then right arrow twice before you were on the link that could be "clicked" by using the Return key. When the explorer produces text that ends with "link", then Return should activate the link.

dpvc avatar Aug 06 '25 18:08 dpvc

I've ben working on a potential solution for the handling of links. Trying to find a technique that work with tabbing but that doesn't interfere with reading the page as a whole is quite a challenge. A solution that works in one browser/os/screen-reader combination frequently doesn't work in other ones, and we are trying to support 13 different combinations!

Below is a configuration that implements the best approach I have been able to come up with, but it is not perfect. It handles tabbing reasonably well, though backward tabbing to an expression focuses that top level of the expression, not the last link in the expression, so back tabbing again moves to ther previous expression without even moving through the interior links. I thik this is probably OK, and I have added a "with n links" to the descrption so that you can tell whether an expression has internal links or not.

Another issue is that the links in an expression will not show up in the list of links for the page, as they are managed by MathJax rather than being true links. I tried hard to find a way to allow them to show up, but that invariably interfered with reading the page as a whole in some screen readers, so I had to abandon that hope.

Because MathJax is managing the links, they dont get the visited-link color, and if you want to style them, you have to make Mathjax-specific styles, rather than just styling anchor elements. It woudl be possible to MathJax to track which links it has processed while on a particuarl page and mark them with the visited-link color, but that information would only be temporary so would not be kept if you visited the page again later.

So there are some drawbacks, but mostly I think this fits the bill.

MathJax = {
  startup: {
    ready() {
      const {SpeechExplorer} = MathJax._.a11y.explorer.KeyExplorer;
      const {SemAttr} = MathJax._.a11y.speech.SpeechUtil;
      SpeechExplorer.keyMap.set('Tab', [(explorer, event) => explorer.tabKey(event)]);
      Object.assign(SpeechExplorer.prototype, {
        tabKey(event) {
          if (this.anchors.length === 0) return true;
          return event.shiftKey ? this.backTab(event.target) : this.forwardTab(event.target);
        },
        backTab(target) {
          const current = this.isLink() ? this.getAnchor() : this.current;
          for (const anchor of this.anchors.slice(0).reverse()) {
            if (current.compareDocumentPosition(anchor) & Node.DOCUMENT_POSITION_PRECEDING) {
              this.setCurrent(this.linkFor(anchor));
              return;
            }
          }
          return true;
        },
        forwardTab(target) {
          for (const anchor of this.anchors) {
            if (this.current.compareDocumentPosition(anchor) & Node.DOCUMENT_POSITION_FOLLOWING) {
              this.setCurrent(this.linkFor(anchor));
              return;
            }
          }
          return true;
        },
        addSpeech(node, describe) {
          this.img?.remove();
          let speech = this.addComma([
            node.getAttribute(SemAttr.PREFIX),
            node.getAttribute(SemAttr.SPEECH),
            node.getAttribute(SemAttr.POSTFIX),
          ])
            .join(' ')
            .trim();
          if (describe) {
            let description =
              (this.description === this.none ? '' : ', ' + this.description) + this.linkCount();
            if (this.document.options.a11y.help) {
              description += ', press h for help';
            }
            speech += description;
          } else {
            speech += this.linkCount();
          }
          this.speak(
            speech,
            node.getAttribute(SemAttr.BRAILLE),
            this.SsmlAttributes(node, SemAttr.SPEECH_SSML)
          );
          this.node.setAttribute('tabindex', '-1');
        },
        addComma(words) {
          if (words[2]) {
            words[1] += ',';
          }
          return words;
        },
        linkCount() {
          if (this.anchors.length && !this.isLink()) {
            const anchors = Array.from(this.current.querySelectorAll('a')).length;
            if (anchors) {
              return `, with ${anchors} link${anchors === 1 ? '' : 's'}`;
            }
          }
          return  '';
        },
        _attachSpeech: SpeechExplorer.prototype.attachSpeech,
        attachSpeech() {
          this._attachSpeech();
          this.adjustAnchors();
        },
        _detachSpeech: SpeechExplorer.prototype.detachSpeech,
        detachSpeech() {
          this._detachSpeech();
          this.restoreAnchors();
        },
        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');
          }
        },
        restoreAnchors() {
          for (const anchor of this.anchors) {
            anchor.setAttribute('href', anchor.getAttribute('data-mjx-href'));
            anchor.removeAttribute('data-mjx-href');
          }
          this.anchors = [];
        },
        isLink(node = this.current) {
          return !!node?.getAttribute('data-semantic-attributes')?.includes('href:');
        },
        getAnchor(node = this.current) {
          const anchor = node.closest('a');
          return this.node.contains(anchor) ? anchor : null;
        },
        linkFor(anchor) {
          return anchor.querySelector('[data-semantic-attributes*="href:"]');
        },
        triggerLink(node) {
          const anchor = this.getAnchor(node);          
          if (anchor) {
            anchor.classList.add('mjx-visited');
            setTimeout(() => this.FocusOut(null), 50);
            window.location.href = anchor.getAttribute('data-mjx-href');
            return true;
          }
          return false;
        }
      });
      MathJax.startup.defaultReady();
      Object.assign(MathJax.startup.document.styles[0], {
        'mjx-container a[data-mjx-href]': {
          color: 'LinkText',
          cursor: 'pointer',
        },
        'mjx-container a[data-mjx-href].mjx-visited': {
          color: 'VisitedText',
        },
      });
    }
  }
};

dpvc avatar Aug 09 '25 15:08 dpvc

I updated the code above to include the visitor coloring, since it was not hard.

dpvc avatar Aug 09 '25 16:08 dpvc