Skip to content

Conversation

@dpvc
Copy link
Member

@dpvc dpvc commented Jan 22, 2026

This PR improvers support for interactive elements within mathematical expressions, such as input areas and checkboxes. In previous versions, these could not be focused by clicking (as the clicks were processed by the explorer itself). These clicks are now allowed to be processed by the input elements. Further, the processing of tab keypresses is extended to properly tab among the focusable elements within the expression, so forward and back tabbing will cycle through the focusable elements as expected.

When exploring an expression, if the currently selected node has focusable children, then pressing tab or enter will focus that child node. If a focusable element is focused, exploration is suspended until the escape key is pressed, in which case the closes parent MathML node with speech is selected. That way, you can move in and out of the focusable items easily while exploring an expression.

Also, clicks and double clicks within the expression are no longer prevented from propagating, so that if an expression is within a button or label element, for example, the clicks on the math will still reach the containing element.

This resolves several issues: mathjax/MathJax#3501, mathjax/MathJax#3491.


Details in the code

The new tabs property holds the focusable elements that are in the expression. This is used to find the next/previous item to focus when tab is pressed. This used to be done using the anchors property, but tabs includes all focusable elements, not just the anchors. The anchors property is still needed, however, because the anchors are taken out of the output and handled separately so as not to focus an element that is inside a container that has aria-hidden set. (The other focusable items are still in an aria-hidden container, so that is technically not legal, so will need to be addressed in the future.)

The change to the FocusIn() method is to prevent the explorer from getting changing the focused item when the focused item is within an HTML fragment inside the expression.

The change at line 648 is to prevent the explorer from changing the focus when an internal HTML element is clicked (the this.clicked property will be set by the MouseDown() function in that case).

The escapeKey() function now checks if the current target is within embedded HTML, and moves to the closest parent speech node in that case.

The tabKey() function now has to take the tabs array into account. the new active variable is either the currently selected speech node, or the internal HTML focused element. This is used to find the next focusable element. The comments in the code should tell you what is happening. Most of the changes are to move to tabs from anchors, and to move some of the details to separate methods (like tabTo()).

The case of shift-tabbing when the top-level speech node is selected is a special case because we want to let the browser process the tab to get the previous focusable item, but since the speech node currently has the focus, and it is at the end of the expression we need to prevent tabbing to one of the internal inputs. The tabOut() method is used to handle this. It does so by temporarily disabling the internal HTML nodes by making them display: none so the browser will skip them when back tabbing, and then making them visible again after the tab has been processed. This can cause visual jitter, but was the only way I could find to get it to work.

The tabTo() method either selects the given node (if it is a link), or focuses the node (if it is internal HTML).

The enterKey() method is modified (818-822) to check for internal focusable items, and focuses the first one, if any.

The getTabs() and getInternaltabs() methods look up any focusable elements within a given node.

Finally, the stop function in addHtmlEvents() only stops propagation for keyboard events (this is what allows other events to propagate to the expression's containers). For mouse events, it sets this.clicked so that FocusIn() will not take over the focus. The mousedown event handler is added to support that.

@codecov
Copy link

codecov bot commented Jan 22, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.51%. Comparing base (95faa0c) to head (4c81e1a).

Additional details and impacted files
@@           Coverage Diff            @@
##           develop    #1421   +/-   ##
========================================
  Coverage    86.51%   86.51%           
========================================
  Files          340      340           
  Lines        85993    85993           
  Branches      4825     4825           
========================================
  Hits         74397    74397           
  Misses       11596    11596           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@dpvc dpvc requested a review from zorkow January 22, 2026 20:42
@dpvc dpvc added this to the v4.1.1 milestone Jan 22, 2026
Copy link
Member

@zorkow zorkow left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think in addHtmlEvents it is necessary to move from a () =>{} syntax to functiuon() {} syntax, as the arrow function should automatically bind the correct this context.
Or is there a particular reason that I miss?

protected addHtmlEvents() {
for (const html of Array.from(this.node.querySelectorAll('mjx-html'))) {
const stop = (event: Event) => {
const stop = function (event: Event) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you changing this?

}
}
};
}.bind(this);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arrow functions should automatically bind to the correct this context. So I don't think this is necessary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants