Problem/Motivation

The narrow version of the main menu constrains tabbing when moving forwards in the tabbing order, but doesn't constrain it when moving backwards in the tabbing order.

A user may easily become lost if they want to close the menu, but the accidentally overshoot when tabbing backwards to the close-menu button.

Steps to reproduce

  1. Use a viewport narrow enough to force the mobile burger-menu style.
  2. Press TAB until the menu button has focus, and activate it. The narrow menu will appear.
  3. Keep pressing TAB, and observe the focus moving through the menu items. When focus is on the last item, the next TAB press will move focus to the close-menu button. Focus has moved to the start of the menu. This is fine.
  4. Here comes the bug: When pressing Shift-TAB to move backwards in the tabbing order, tabbing isn't constrained. When focus is on the close-menu button, the next Shift-TAB press moves focus to the site name, and thence to the skip-to-main-content-link, and browser controls. Eventually it will wrap around to the site footer.

Proposed resolution

Contrain tabbing in both directions: when using TAB to go forwards, and Shift-TAB to go backwards.

When focus is on the close-menu button, the next Shift-TAB press should place focus on the last item in the menu.

Remaining tasks

User interface changes

API changes

Data model changes

Release notes snippet

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

andrewmacpherson created an issue. See original summary.

larowlan’s picture

Issue tags: +Bug Smash Initiative

I feel like there's an existing issue to remove the custom tab code and use core's tabbing manager, but I can't find it

andrewmacpherson’s picture

You're thinking of #3190242: Nav should use core/drupal.tabbingmanager instead of custom focus manager. I'm not convinced that's a good idea, and it's certainly not a stable blocker or even urgent.

This one on the other hand is a must-fix, regardless of implementation details.

andrewmacpherson’s picture

Issue summary: View changes

typo in summary

andrewmacpherson’s picture

Interesting points about the existing Olivero implementation.

  • It DOES check for the shift key, but it doesn't work. Did it ever work? Git blame says that code hasn't changed since Olivero was added to core. I haven't checked the contrib history.
  • It's built with a keydown listener. That's unusual. Most of the implementations I've seen work with focus events, then compare the current target and relatedTarget.
mherchel’s picture

Status: Active » Needs review
FileSize
6.72 KB

This should fix it!

andrewmacpherson’s picture

Feels nice. I manually tested in Firefox and various Blinken browsers, on Linux.

Haven't tested this yet in: any mobile browsers, Safari (mac), IE11, MSEdge.

andrewmacpherson’s picture

Aside, some probable follow-ups....

+        var focusableNavElementsNodeList = navWrapper.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
+        var focusableNavElements = [].slice.call(focusableNavElementsNodeList);
+        focusableNavElements.unshift(navButton);
         var firstFocusableEl = focusableNavElements[0];
         var lastFocusableEl = focusableNavElements[focusableNavElements.length - 1];

This looks a bit fragile to me. How did you arrive at this list of CSS selectors?

It's missing a few elements which are normally tabbable, notably <summary>. Also others like <audio controls>, <video controls>.

It will also match a lot of things which are not expected to be tabbable: <button tabindex="-1">, <input type="hidden">, <input disabled>, and many similar combinations.


        init({
          settings,
          olivero,
          header,
          navWrapperId,
          navWrapper,
          navButton,
          body,
          overlay,
          firstFocusableEl,
          lastFocusableEl,
        });

A problem with setting the first/last tabbable elements is they might not be stable during the lifetime of the page. An <input> might become enabled or disabled. A tabindex attribute might change from 0 to -1.


Terminology nitpicks: the navigation.es6.js uses the terms "focusable" a lot. We're interested in what's tabbable, and that's not the same.

mherchel’s picture

Changes from comment #8 are remedied.

The code now dynamically queries the navigation for tabbable elements when tab is pressed.

I also moved the generation of the array of tabbable elements into its own function. It checks for all of the queries that are suggested, and also filters out elements that have visibility: hidden and display: none

In addition to the above, I changed the terminology to say "tabbable" instead of "focusable"

mherchel’s picture

mherchel’s picture

Fixing the linting errors from the previous patch.

ranjith_kumar_k_u’s picture

FileSize
2.72 MB
4.4 MB

The last patch works as expected .
Before patch
before patch

After patch
after patch

andrewmacpherson’s picture

Status: Needs review » Reviewed & tested by the community

Everything has been addressed. The approach to identifying tabbable elements is what @mherchel and I discussed in an a11y review call.

I've manually tested the Tugboat preview of patch #10 with Firefox and Chrome. The later patches don't change any logic, just keep linters happy.

Great work.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work
Issue tags: +Needs tests

It would great to add test coverage of this bug fix.

I discussed this with @nod and @lauriii and we were wondering why we're not leveraging the tabbable library that's part of core. I think we could consider refactoring to use that here - or maybe as part of follow-up. I'm not sure what makes more sense. Either way - having a test to prove we don't regress seems very worthwhile.

mherchel’s picture

we were wondering why we're not leveraging the tabbable library that's part of core

The issue for that is at #3190242: Nav should use core/drupal.tabbingmanager instead of custom focus manager

One reason is that it currently relies on jQuery and jQueryUI, but there's some accessibility reasons in there too from @andrewmacpherson in the comments.

alexpott’s picture

@mherchel thanks for linking that issue - good to see that in hand. We need to add tests here.

+++ b/core/themes/olivero/js/navigation.js
@@ -10,6 +10,20 @@
+  function getTabbableElements(el) {

FWIW this seems to be doing exactly what the tabbable library can do - see https://www.drupal.org/node/3186532

alexpott’s picture

+++ b/core/themes/olivero/js/navigation.es6.js
@@ -10,6 +10,34 @@
+  /**
+   * Creates list of elements within an element that can be tabbed to via
+   * keyboard.
+   * @param {element} el - element from which to generate list.
+   *
+   * @return {array} array of tabbable elements.
+   */
+  function getTabbableElements(el) {
+    const tabbableElementSelector =
+      'button, [href], input, select, textarea, summary, audio[controls], video[controls], [tabindex]';
+    const negateElementSelector =
+      '[tabindex^="-"], [disabled], [type="hidden"]';
+    const tabbableElements = [];
+
+    el.querySelectorAll(tabbableElementSelector).forEach((el) => {
+      const elementStyle = window.getComputedStyle(el);
+      if (
+        !el.matches(negateElementSelector) &&
+        elementStyle.visibility !== 'hidden' &&
+        elementStyle.display !== 'none'
+      ) {
+        tabbableElements.push(el);
+      }
+    });
+
+    return tabbableElements;
+  }

Actually this can be entirely replaced by the tabbable library - which at least is tested JS code. We can leave focus trap part of the discussion to the other issue but there's no reason to have this code in Olivero.

mherchel’s picture

Status: Needs work » Postponed

From what I gather, #3113649: Remove drupal.tabbingmanager's jQueryUI dependency is not yet committed. The current tabbable library has jQueryUI dependency that we do not want to introduce.

I'll postpone this until that's committed.

mherchel’s picture

alexpott’s picture

Status: Postponed » Needs work

@mherchel ah you're right we've still not added the tabbable library... we could still work on adding JS tests here and if #3113649: Remove drupal.tabbingmanager's jQueryUI dependency gets delay we could file a follow-up instead.

mherchel’s picture

mherchel’s picture

Updating patch to use the new core tabbable library was super easy. Patch and interdiff attached.

Still need tests.

brianperry’s picture

#3205434: Add Nightwatch Test Coverage for Olivero will make it possible to write a Nightwatch test to cover this issue.

mherchel’s picture

Re-roll of #22

mherchel’s picture

Now that #3205434: Add Nightwatch Test Coverage for Olivero is in, we can add tests!

I'm including

  • test only patch (which should fail).
  • interdiff
  • Full patch (which should pass)

Latest Tugboat preview: https://3191077-focus-trap-2-ojzhptztqdzoz9kfnxhzodnlcph0eo37.tugboat.qa/

mherchel’s picture

Status: Needs review » Needs work

Needs reroll after #3173900: Refactor Olivero's JavaScript Drupal behaviors to use once() was committed. Working on it.

mherchel’s picture

Status: Needs work » Needs review
FileSize
10.03 KB
proeung’s picture

Status: Needs review » Reviewed & tested by the community
FileSize
5.06 MB

Patch #27 looks good from a code perspective. I tested the patch and everything is working as expected. Users are now able to tab forward and backward in the tabbing order (see attached video screenshot). Marking as RTBC!

lauriii’s picture

Status: Reviewed & tested by the community » Needs work
Issue tags: -Needs tests
  1. +++ b/core/themes/olivero/js/navigation.es6.js
    @@ -65,21 +65,27 @@
    +        const tabbableNavElements = tabbable.tabbable(props.navWrapper);
    

    The global tabbable should be only accessed in the IIFE that's wrapping this whole file.

  2. +++ b/core/themes/olivero/js/navigation.es6.js
    @@ -104,34 +110,30 @@
    -      const navWrapperId = 'header-nav';
    -      const navWrapper = once(
    +      const headerId = 'header';
    +      const header = once(
    ...
    -        `#${navWrapperId}`,
    +        `#${headerId}`,
    ...
    +      const navWrapperId = 'header-nav';
    

    What are these changes?

  3. +++ b/core/themes/olivero/js/navigation.es6.js
    @@ -104,34 +110,30 @@
    +        header.classList.add(`${headerId}-processed`);
    

    We probably don't need this anymore given that it's not referenced anywhere.

  4. +++ b/core/themes/olivero/js/second-level-navigation.es6.js
    @@ -120,10 +120,9 @@
           );
    -      const state = button.getAttribute('aria-expanded') === 'true';
     
    -      if (state) {
    -        subNavsAreOpen = true;
    +      if (button) {
    +        subNavsAreOpen = button.getAttribute('aria-expanded') === 'true';
    

    How is this related to this issue?

mherchel’s picture

Status: Needs work » Needs review
FileSize
9.06 KB
3.01 KB

The global tabbable should be only accessed in the IIFE that's wrapping this whole file.

Good call! Done.

What are these changes?

These changes are related to

We probably don't need this anymore given that it's not referenced anywhere.

Good catch. Done

How is this related to this issue?

I haven't worked on that in probably a month, but I'm guessing I saw a console error. I removed this from this patch.

mherchel’s picture

Status: Needs review » Needs work

regarding @lauriii's question "What are these changes?" in #29... he was asking why we're attaching the event listener to the header... and the answer is that we really don't need to. I'm going to create a new patch.

mherchel’s picture

Status: Needs work » Needs review
FileSize
9.32 KB
725 bytes

OK. Found out why I'm passing in the header prop. It's because the navigation button is not a child element of the navigation wrapper, which means that the keydown event would not fire.

I'm attaching a new patch with a more verbose comment.

proeung’s picture

Status: Needs review » Reviewed & tested by the community

The revised patch #32 looks good and addresses the feedback that @lauriii mentioned in #29 with the exception of line item (3), which @mherchel noted (#32) as something that we need and should not be removed.

RTBC +1

Gauravvvv’s picture

Patch #32 seems fine to me. I am able to tab forward and backward in the tabbing order.
RTBC +1

Gauravvvv’s picture

  • lauriii committed f3674e9 on 9.2.x
    Issue #3191077 by mherchel, ranjith_kumar_k_u, proeung, Gauravmahlawat,...
lauriii’s picture

Status: Reviewed & tested by the community » Fixed

Committed f3674e9 and pushed to 9.2.x. Thanks!

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.