Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

focus-trap stuck on elements with positive tabIndex #375

Closed
amje opened this issue Apr 16, 2021 · 40 comments · Fixed by #974
Closed

focus-trap stuck on elements with positive tabIndex #375

amje opened this issue Apr 16, 2021 · 40 comments · Fixed by #974
Labels

Comments

@amje
Copy link

amje commented Apr 16, 2021

Hi!

It seems elements with positive tabIndex break tab/shift+tab navigation. When such elements receive focus moving forward become unavailable. While moving backward returning to that element is also unavailable, focus cycle inside container is not working.

Here's a repro example - https://codesandbox.io/s/focus-trap-tab-index-u1fxu

Current behavior

  1. Activate the trap -> focus moves to button with tabIndex="1"
  2. Press Tab -> nothing happens

Expected behavior

  1. Activate the trap -> focus moves to button with tabIndex="1"
  2. Press Tab -> focus moves to button with no tabIndex
@stefcameron
Copy link
Member

@amje Thanks for reporting this, and for the sandbox repro. I will check this out!

@stefcameron
Copy link
Member

Indeed, this is strange behavior.

@stefcameron
Copy link
Member

I did some investigating and identified the problem in branch issue375. To be continued...

@gotbahn
Copy link

gotbahn commented Jun 16, 2021

Can reproduce this as well

@stefcameron
Copy link
Member

Thanks for the input. I've got something in the works, but I think it's going to be quite a large change so may take a while for me to get through it.

stefcameron added a commit that referenced this issue Jun 27, 2021
Fixes #375

See `// DEBUG` comments in the code, particularly `// DEBUG IDEA`
for a possible elegant solution.
@stefcameron
Copy link
Member

Slow going, but I just had an idea of how to solve this, so I updated my branch and included my idea in a // DEBUG IDEA comment: https://github.com/focus-trap/focus-trap/blob/issue375/index.js#L298

Will get back to this again later. Unless someone wants to run with this idea and make a PR out of my branch. Just LMK if you're working on it.

@DaviDevMod
Copy link
Contributor

DaviDevMod commented May 15, 2022

@stefcameron I am writing a focus trap and I solved this problem. I can't link to my code because is not on GH yet (the repo is a mess), but I'll leave a snippet in here. Anyway the algorithm is what matter the most.
Unfortunately it's quite a bit of logic to add, just to support those unlikely non-zero tab indexes, but here we are.

One needs to find the firstZero, lastZero, topTabbable and bottomTabbable in each container.
Where top/bottom are simply the first and last tabbable in document order, while first and last zero are the first and last zero tab indexes in document order.

And also create an array with all the positive tab indexes in the trap, ordered by tab index, resolving ties by document order.

(I know that focus-trap outsources the search for tabbable elements to focus-trap/tabbable, so you don't have that much freedom of movement here, but still can get the desired results, manipulating the output of focus-trap/tabbable)

So then when a tab event occurs if:

  • the event target is a zero tab index, find its container and if the target is either first or last zero, give focus to the first/last zero of the previous/next container.
  • the event target is a positive tab index, find it in the array of tab indexes and give focus to the next/previous one.
  • the event target is a negative tab index, the focus needs to be assisted only if the target precedes the topTabbable of the first container or succeeds the bottomTabbable of the last container, and will go to one of these two, depending on the shiftKey.

If the event target is not an element of the trap, just pretend that its container is the one following it in document order.
The logic here would be the same, with the exceptions that if:

  • the the event target is a positive tab index, one has to find where it would be in the ordered array, in order to focus the next/previous one.
  • the event target is a negative tab index, give focus to the top/bottom tabbable of the previous/next container.
Some code

I must admit that it's not that readable, but it doesn't matter, the algorithm is what matters the most, this is just to give an idea of what an implementation would look like.

And just to give a bit of context: roots, boundaries and edges are respectively an array of containers' root, an array of firstZero, lastZero, firstZero, and so on..., and an array of topTabbable, bottomTabbable, topTabbable, and so on...

The array of positive tab indexed is concatenated at the end of boundaries.

And any logic related to radio inputs can be ignored, as focus-trap only handles checked radios.

And for the records: there are a few things, like early return statements and Number() casts, that are there just to please TypeScript.

  private assistTabbing = (event: KeyboardEvent) => {
    const { target, shiftKey } = event;
    if (!(target instanceof HTMLElement || target instanceof SVGElement)) return;
    if (this.isUpdateScheduled) this.updateTrap();
    this.isUpdateScheduled = false;
    const { roots, boundaries, edges } = this.refs;
    let rootIndex = roots.findIndex((el) => el.contains(target as Node));
    if (rootIndex === -1) {
      // Index of first root that follows target, found as the index of the first root that precedes target + 1
      rootIndex = (boundaries.findIndex((el) => target.compareDocumentPosition(el) & 2) + 1) % boundaries.length;
    }
    const firstZero = boundaries[rootIndex * 2];
    const lastZero = boundaries[rootIndex * 2 + 1];
    if (!firstZero || !lastZero) return;
    let destination: Focusable | null = null;

    // The logic adopted in this case is the same regardless of `target.tabindex` and is the right logic
    // only in case `target.tabIndex < 0` which is always the case when `this.config.lock === true`.
    // TODO: make proper logic for `target.tabIndex >= 0`.
    if (rootIndex === -1) {
      const topTabbable = edges[rootIndex * 2];
      const bottomTabbable = edges[rootIndex * 2 + 1];
      const precedesTop = target.compareDocumentPosition(topTabbable) & 4;
      const followsBottom = target.compareDocumentPosition(bottomTabbable) & 2;
      if (precedesTop || followsBottom) {
        destination =
          edges[(rootIndex * 2 + (shiftKey ? -precedesTop || followsBottom : 2 * followsBottom)) % edges.length];
      }
    } else if (target.tabIndex === 0) {
      if (
        (shiftKey && (target === firstZero || areTwoRadiosInSameGroup(target, firstZero))) ||
        (!shiftKey && (target === lastZero || areTwoRadiosInSameGroup(target, lastZero)))
      ) {
        destination = boundaries[(rootIndex * 2 - 3 * Number(shiftKey) + 2 + boundaries.length) % boundaries.length];
      }
    } else if (target.tabIndex > 0) {
      const index = boundaries.findIndex((el) => el === target);
      destination = boundaries[(index - 2 * Number(shiftKey) + 1 + boundaries.length) % boundaries.length];
    } else {
      const topmostTabbable = edges[0];
      const bottommostTabbable = edges[edges.length - 1];
      const precedesTopmost = target.compareDocumentPosition(topmostTabbable) & 4;
      const followsBottommost = target.compareDocumentPosition(bottommostTabbable) & 2;
      if (precedesTopmost || followsBottommost) destination = edges[shiftKey ? edges.length - 1 : 0];
    }

    if (destination) {
      event.preventDefault();
      if (isRadioInput(destination) && !destination.checked) {
        getTheCheckedRadio(destination)?.focus();
      } else destination.focus();
    }
  }; // End of assistTabbing();

Edit: just noticed that there is a never entered condition (the one with three lines of comment above). There was a time when it made sense cause I was assigning the index of the root for an element outside of the trap to a variable other than rootIndex, leaving the latter to -1. What a mess.
Also that comment is lying, but basically the logic in that condition is meant to be for elements outside of the trap (and it's incomplete) while the other conditions are for elements contained in the trap.

As I said, I have a messy repo to take care of and since this fix is not exactly a small one, I don't have any intention to open a PR.

Wish you the best with this issue.

@stefcameron
Copy link
Member

@DaviDevMod Thanks for sharing your solution to the problem. Indeed, it's not a trivial task to support elements with positive tab indexes, never mind that altering the tab index is kind of an accessibility faux-pas to begin with. Nonetheless, tabbable does support these elements.

I wish there was a simpler solution that leveraged the order already determined by tabbable. Like if tabbing away (i.e. blur) from an element with a positive tab index, find it in the list of tabbable elements discovered by tabbable (since it does order things too, not just search for them and dump them in a list), and then force focus to go to the next element in tabbable's list instead of where focus is wanting to go

Although focus-trap typically tries not to get in the way of where focus wants to go other than on container boundaries -- which makes me think that the issue here is probably more that focus-trap is thinking focus is somehow escaping the trap when tabbing away from an element with a positive tab index, and so it's behavior is to bring the focus back into the trap to the most-recently-focused element -- hence why the focus seems "trapped" or "stuck" on that element once it gets focus.

Not sure. A few things to investigate still. But I appreciate your code snippet since you highlight some of the challenges. 😄

@DaviDevMod
Copy link
Contributor

DaviDevMod commented May 16, 2022

Well today I worked a bit on this code and I have to say that you should absolutely not trust the sketchy logic I pointed out for the case the target is not in the trap.

With that said, regarding:

I wish there was a simpler solution that leveraged the order already determined by tabbable. Like if tabbing away (i.e. blur) from an element with a positive tab index, find it in the list of tabbable elements discovered by tabbable (since it does order things too, not just search for them and dump them in a list),

You can take the output of tabbable for each container and then find the first/last-zero, top/bottom-tabbable and all the positive tab indexes within that output and push them into arrays. The array of positive tab index would need to be sorted (but in most cases it's empty), while the other two arrays would be already sorted if the containers were sorted.

The fact is that when you tab away from a positive tab index, the next positive tab index in the trap could be anywhere.
By using an array with all the positive tabindexes in the trap, you don't have to loop through the containers to find the next one.
And by using an array of first/last zero you can take positive tab indexes out of the equation when tabbing from a container to another.
While having top/bottom tabbables for each container is simply a must, to properly handle negative tab indexes.

But now that I think about it, it's not possible to get top/bottom tabbables from the array returned by tabbable(), because it's already ordered taking into account tab indexes.
So the only way would be to query elements directly in focus-trap and then use isTabbable() in a loop.

tabbable() is an overkill anyway, because you only need to find a few tabbables per container and leave the rest up to the browser, running tabbability checks on all the elements that tabbable queries is unnecessary.

But anyway, yes, do your research and weight the options.

Edit: regarding this:

Although focus-trap typically tries not to get in the way of where focus wants to go other than on container boundaries

I think that positive tab indexes should be treated as boundaries. Any time a tab event is fired from such elements the tabbing can't be left up to the browser. There may be cases in which the browser does what the trap would do, but you still need some logic to handle the rest of the cases and this logic should not be about bringing back the focus, but rather giving it to the right element in the first place.

Another edit: actually top/bottom tabbables can be found in the array returned by tabbable(), by comparing the document order of the first tabbable with that one of the first zero tab index and every positive tab index. Though using isTabbable() would be more performant.

@DaviDevMod
Copy link
Contributor

DaviDevMod commented May 17, 2022

@stefcameron I still have a couple of days of housekeeping before I push the repo, but I pushed this branch where you can see a working logic.

The culprit of the logic is in assistTabbing() inside of packages/single-focus-trap/src/index.ts.

If you want to try it, clone the branch git clone -b preview --single-branch https://github.com/DaviDevMod/focus-trap.git, run yarn install, then yarn workspace use-simple-focus-trap-demo dev and then go to http://localhost:3000

The demo is not using single-focus-trap directly, but the logic to handle the tabbing comes from there.

I know that implementing such logic implies a lot of changes in focus-trap, but I couldn't find a simpler way to support non-zero tab indexes in my trap. Hope it helps you solve the problem in focus-trap.

Edit: if you try the demo and want to add or remove elements, you can do so from apps/use-simple-focus-trap-demo/components/home/Home.tsx

@DaviDevMod
Copy link
Contributor

DaviDevMod commented May 26, 2023

Hello there, apologies for how I exposed my ideas previously, I was having a bad time.
I did publish a focus trap package, eventually, in which I implemented and tested the handling of element with a non-zero tab index.

There is this function getDestination expecting:

  • an array of containers (that's how they are called here)
  • an origin that would be event.target
  • a direction that depends on event.shiftKey

And returning the element, within the focus trap, that should receive the focus after an event is fired by a Tab key press.

Below is the same code made simple ECMAScript, it is self contained and could be copy/pasted as it is, though the first 60 lines are made of tabbable code that should be imported to avoid maintaining it in two places.

Code:
// String used to query all the candidate focusable elements within the trap.
const candidateSelector =
  'a[href], button, input, select, textarea, [tabindex], audio[controls], video[controls], [contenteditable]:not([contenteditable="false"]), details>summary:first-of-type, details';

// <details>, <audio controls> e <video controls> get a default `tabIndex` of -1 in Chrome, yet they are
// still part of the regular tab order. Also browsers do not return `tabIndex` correctly for `contentEditable`
// nodes. In these cases the `tabIndex` is assumed to be 0 if it's not explicitly set to a valid value.
const getConsistentTabIndex = (node) =>
  (/^(AUDIO|VIDEO|DETAILS)$/.test(node.tagName) || node.isContentEditable) &&
  isNaN(parseInt(node.getAttribute("tabindex"), 10))
    ? 0
    : node.tabIndex;

// Function testing various edge cases. Returns `true` if `candidate` is actually focusable.
function isActuallyFocusable(candidate) {
  if (
    // If the element has no layout boxes (eg, it has `display: "none"`);
    !candidate.getClientRects().length ||
    // or is disabled or hidden or an uncheck radio button;
    candidate.disabled ||
    getComputedStyle(candidate).visibility === "hidden" ||
    (candidate instanceof HTMLInputElement &&
      (candidate.type === "hidden" || (candidate.type === "radio" && !candidate.checked))) ||
    // or a <details> with a <summary> (the summary gets the focus instead of the details);
    (candidate.tagName === "DETAILS" &&
      Array.prototype.slice.apply(candidate.children).some((child) => child.tagName === "SUMMARY"))
  ) {
    return false;
  }
  // Elements that are descendant of a closed <details> should not be considered focusable,
  // the only exception is the first <summary> of the top-most closed <details>.
  const matches = Element.prototype.matches || Element.prototype.webkitMatchesSelector;
  const isDirectSummary = matches.call(candidate, "details>summary:first-of-type");
  const nodeUnderDetails = isDirectSummary ? candidate.parentElement : candidate;
  if (matches.call(nodeUnderDetails, "details:not([open]) *")) {
    return false;
  }
  // Form fields in a disabled <fieldset> are not focusable unless they are
  // in the first <legend> element of the top-most disabled <fieldset>.
  if (/^(INPUT|BUTTON|SELECT|TEXTAREA)$/.test(candidate.tagName)) {
    let parentNode = candidate;
    while ((parentNode = parentNode.parentElement)) {
      // If `candidate` is nested in a disabled <fieldset>
      if (parentNode.tagName === "FIELDSET" && parentNode.disabled) {
        for (let i = 0; i < parentNode.children.length; i++) {
          // having a <legend> as direct child,
          if (parentNode.children.item(i)?.tagName === "LEGEND") {
            // If the <fieldset> is not nested in another disabled <fieldset>,
            // return whether `candidate` is a descendant of its first <legend>
            return matches.call(parentNode, "fieldset[disabled] *")
              ? false
              : parentNode.children.item(i).contains(candidate);
          }
        }
        return false; // The disabled <fieldset> has no <legend>.
      }
    }
  }
  return true;
}

const modulo = (number, modulo) => ((number % modulo) + modulo) % modulo;

const candidatesInRoot = (root) => [root, ...root.querySelectorAll(candidateSelector)];

const sortRoots = (a, b) => (a.compareDocumentPosition(b) & 4 ? -1 : 1);

const firstOrLastGenericTabbableInRoot = (root, whichOne = "FIRST", validateTabIndex = (_tabIndex) => true) => {
  return (
    candidatesInRoot(root)[whichOne === "FIRST" ? "find" : "findLast"](
      (el) => validateTabIndex(getConsistentTabIndex(el)) && isActuallyFocusable(el)
    ) ?? null // Casting `undefined` to `null` for consistency.
  );
};

const topOrBottomTabbableInRoot = (root, whichOne = "TOP") => {
  return firstOrLastGenericTabbableInRoot(root, whichOne === "TOP" ? "FIRST" : "LAST", (tabIndex) => tabIndex >= 0);
};

const firstOrLastZeroTabbableInRoot = (root, whichOne = "FIRST") => {
  return firstOrLastGenericTabbableInRoot(root, whichOne, (tabIndex) => tabIndex === 0);
};

const firstOrLastZeroTabbable = (roots, whichOne = "FIRST") => {
  let firstOrLastZero = null;

  roots[whichOne === "FIRST" ? "find" : "findLast"](
    (root) => (firstOrLastZero = firstOrLastZeroTabbableInRoot(root, whichOne))
  );

  return firstOrLastZero;
};

const positiveTabbables = (roots) => {
  return roots
    .map(candidatesInRoot)
    .reduce((prev, curr) => prev.concat(curr.filter((el) => el.tabIndex > 0)), [])
    .sort((a, b) => a.tabIndex - b.tabIndex);
};

const nextTopOrBottomTabbable = (roots, origin, direction) => {
  const originRootIndex = roots.findIndex((root) => root.contains(origin));

  // Root from which to start searching for a destination.
  let destinationRootIndex = originRootIndex;

  // If `origin` belongs to the trap.
  if (originRootIndex >= 0) {
    // Note that since looking for top/bottom tabbables makes sense only if `origin` has a negative tab index
    // (and therefore is untabbable), `origin` can't itself be a top nor a bottom tabbable.

    const bottom = topOrBottomTabbableInRoot(roots[originRootIndex], "BOTTOM");

    // If the root containing `origin` doesn't have any tabbable elements,
    // or if `origin` follows `bottom`, start the search from the succeeding root;
    if (!bottom || bottom.compareDocumentPosition(origin) & 4) destinationRootIndex++;
    // else if `origin` is in between `top` and `bottom`, leave the focus handling up to the browser.
    else if (topOrBottomTabbableInRoot(roots[originRootIndex]).compareDocumentPosition(origin) & 4) return;
  } else {
    // If `origin` doesn't belong to the trap, start the search from the first root that follows it.
    destinationRootIndex = roots.findIndex((root) => origin.compareDocumentPosition(root) & 4);

    if (destinationRootIndex === -1) destinationRootIndex = roots.length;
  }

  // In any case, if tabbing 'BACKWARD' start the search from the preceding root.
  if (direction === "BACKWARD") destinationRootIndex--;

  for (let i = destinationRootIndex; Math.abs(i) < 2 * roots.length; i += direction === "FORWARD" ? 1 : -1) {
    const topOrBottom = topOrBottomTabbableInRoot(
      roots[modulo(i, roots.length)],
      direction === "FORWARD" ? "TOP" : "BOTTOM"
    );

    if (topOrBottom) return topOrBottom;
  }

  throw new Error("There are no tabbable elements in the focus trap.");
};

const nextFirstOrLastZeroOrPositiveTabbable = (roots, origin, direction) => {
  const originRootIndex = roots.findIndex((root) => root.contains(origin));

  // Root from which to start searching for a destination.
  let destinationRootIndex = originRootIndex;

  if (originRootIndex >= 0) {
    if (direction === "FORWARD") {
      if (origin === firstOrLastZeroTabbableInRoot(roots[originRootIndex], "LAST")) destinationRootIndex++;
      else return;
    } else if (origin === firstOrLastZeroTabbableInRoot(roots[originRootIndex])) {
      destinationRootIndex--;
    } else {
      return;
    }
  } else {
    // If `origin` doesn't belong to the trap, start the search from the first root that follows it.
    destinationRootIndex = roots.findIndex((root) => origin.compareDocumentPosition(root) & 4);

    if (destinationRootIndex === -1) destinationRootIndex = roots.length;

    // If tabbing 'BACKWARD' start the search from the preceding root.
    if (direction === "BACKWARD") destinationRootIndex--;
  }

  const firstOrLastZeroInTrap = firstOrLastZeroTabbable(roots, direction === "FORWARD" ? "LAST" : "FIRST");

  for (let i = destinationRootIndex; Math.abs(i) < 2 * roots.length; i += direction === "FORWARD" ? 1 : -1) {
    const alternativeDestinationRootIndex = modulo(i, roots.length);

    if (
      !firstOrLastZeroInTrap ||
      origin === firstOrLastZeroInTrap ||
      origin.compareDocumentPosition(firstOrLastZeroInTrap) & (direction === "FORWARD" ? 2 : 4)
    ) {
      const positives = positiveTabbables(roots);

      if (positives.length) {
        return positives[direction === "FORWARD" ? 0 : positives.length - 1];
      }
    }

    const firstOrLastZeroInDestinationRoot = firstOrLastZeroTabbableInRoot(
      roots[alternativeDestinationRootIndex],
      direction === "FORWARD" ? "FIRST" : "LAST"
    );

    if (firstOrLastZeroInDestinationRoot) return firstOrLastZeroInDestinationRoot;
  }

  throw new Error("There are no tabbable elements in the focus trap.");
};

const nextPositiveOrVeryFirstOrVeryLastTabbable = (roots, origin, direction) => {
  const positives = positiveTabbables(roots);

  let index = positives.findIndex((el) => el === origin);

  if (index === -1) {
    positives.push(origin);
    positives.sort((a, b) =>
      a.tabIndex === b.tabIndex ? (a.compareDocumentPosition(b) & 4 ? -1 : 1) : a.tabIndex - b.tabIndex
    );
    index = positives.findIndex((el) => el === origin);
  }

  const nextPositive = positives[index + (direction === "FORWARD" ? 1 : -1)];

  if (nextPositive) return nextPositive;

  const firstOrLastZeroInTrap = firstOrLastZeroTabbable(roots, direction === "FORWARD" ? "FIRST" : "LAST");

  if (firstOrLastZeroInTrap) return firstOrLastZeroInTrap;

  throw new Error("There are no tabbable elements in the focus trap.");
};

const getDestination = (roots, origin, direction) => {
  const originTabIndex = getConsistentTabIndex(origin);
  const sortedRoots = roots.sort(sortRoots);

  if (originTabIndex < 0) return nextTopOrBottomTabbable(sortedRoots, origin, direction);

  if (originTabIndex === 0) return nextFirstOrLastZeroOrPositiveTabbable(sortedRoots, origin, direction);

  return nextPositiveOrVeryFirstOrVeryLastTabbable(sortedRoots, origin, direction);
};

If you care, here's a brief summary of the logic.

Let me know if you are interested in a PR.

PS:
I admit that it's quite a bit if code for a feature (handling non-zero tab indexes) that is rarely useful, but probably not a big net addition as it would replace some preexisting logic.
Regarding performance, getDestination is quite efficient and I'm sure it would be somewhat faster than the current handler, which calls checkKeyNav, which calls updateTabbableNodes, which calls tabbable on every container of the trap, meaning that every element in the trap matching candidateSelector will undergo a few tabbability checks; while getDestination runs tabbability checks only within .find/.findLast calls which are very likely to return at the first element.

EDIT: actually the code needed from tabbable is already being exported, as isTabbable.

stefcameron added a commit that referenced this issue Jun 1, 2023
Fixes #375

ALL existing cypress tests are passing.

TODO:

- [ ] get rid of DEBUG comments
- [ ] will need to share logic from tabbable about determining tabindex somehow
- [ ] check manually tested examples
- [ ] see if a cypress test can be added for the new positive-tabindex example
- [ ] add changeset
stefcameron added a commit that referenced this issue Jun 1, 2023
Fixes #375

ALL existing cypress tests are passing.

TODO:

- [ ] get rid of DEBUG comments
- [ ] will need to share logic from tabbable about determining tabindex somehow
- [ ] check manually tested examples
- [ ] see if a cypress test can be added for the new positive-tabindex example
- [ ] add changeset
@stefcameron
Copy link
Member

@DaviDevMod Thanks for your update. There seemed to be quite a number of changes in your approach still, or I couldn't make them out with the code. I had long thought this might be solvable by checking for focus escape, which is what strangely happens when a trap has an element with a positive tabindex and the user tabs from it, or somehow a non-positive tabindex element is focused and reverse tabbing, which should go to a positive tabindex element, causes a focus escape.

You're right, this is marginally useful, barely upvoted over the past 2 years (!), and not a good a11y practice. Nonetheless, tabbable does support nodes with positive tabindexes and there are probably a lot of forks out there by now that did something to support this.

So I finally had a go at it. The only significant change is a whole bunch of new code in checkFocusIn(), and a requirement to share another bit of tabbable (i.e. how it determines the tabindex of a given node) as a utility function.

Here's my draft PR that still needs polishing and the tabbable work: #974

What do you think?

@DaviDevMod
Copy link
Contributor

DaviDevMod commented Jun 1, 2023

@stefcameron Nice to see you will work on the fix!

The approach of bringing focus back only after it has escaped the trap is fine, but which element will receive the focus?

I only gave a quick look at your draft and I don't know exactly what's the algorithm used to find the right destination, however I did add your code to the setup I use to test my focus trap and the current logic doesn't pass the tests.

Since the logic to implement is very tricky I suggest you to write some tests before going forward, otherwise you'll lose your mind breaking an edge case while fixing another.

In the meantime if you want to try something with the tests in my setup, I created a branch with your code in the repo of my package.

This is the branch: https://github.com/DaviDevMod/focus-trap/tree/stef-draft

As explained here

You need to corepack enable before installing the dependencies.

Then you can run yarn workspace @davidevmod/focus-trap e2e-open

and select the build.cy.ts spec, which is the only one set up to test your code.

If you want to manual test, you can visit http://localhost:3000/ after running yarn workspace demo dev

You'll have a trap active for the groups 2 and 4 (the same used in the tests).
Can break free with Esc and rebuild it refreshing the page.

The tests curretly fail when pressing Tab from "L": the focus is given to "E" but it should be given to "M".

If you are unsure of what the right tab cycle should be, you can open another tab with my trap on the same elements (using the inputs on the right).

Your code is found here: https://github.com/DaviDevMod/focus-trap/blob/stef-draft/apps/demo/src/focus-trap/index.js

And imported here when running the dev server: https://github.com/DaviDevMod/focus-trap/blob/stef-draft/apps/demo/src/components/playground/Playground.tsx

Or here when running the tests: https://github.com/DaviDevMod/focus-trap/blob/stef-draft/apps/demo/src/components/e2e-playground/E2ePlayground.tsx

I'll have a closer look at your draft (and focus-trap logic in general) when possible.

@stefcameron
Copy link
Member

@DaviDevMod Thanks for checking it out briefly. I forgot I had recorded a video of the working solution. I've added it to the PR's description. The description also has a list of still "todo" items, including more testing. This is my preliminary foot forward, so to speak.

The logic is simple, really: Rely on what tabbable already determines is the tab order in a given container when executing tabbable(containerNode). Tabbable already does the work of determining the tab order based on tabindex, that being positive tabindex, in order, first, followed by the rest in DOM order (well, hopefully it's in DOM order; should be; seems to be).

If the most recently focused node before focus escape was on the edge of a container, then re-use the logic in checkNavKey() that figures out which container focus should move to next, as well as what should be the node to focus inside that container. That function already handles moving in both directions.

If there's no suitable node found after all that, then use the existing fallback solution of re-focusing the most recently focused node or the configured initial focused node.

Please do have a look when you have a moment.

@DaviDevMod
Copy link
Contributor

DaviDevMod commented Jun 1, 2023

Relying on tabbable and checkKeyNav is not a bulletproof fix, but it may work good enough so that no one will ever hit the limitations of the approach.
I get that in this way the fix would introduce very little novel code, and therefore less risk of breaking previously existing code.
So this may be the most cost effective solution for the library at this pint in time. If you think so, by any means, go for it.

The bulletproof way to handle positive tab indexes is to have a sorted array of all the elements with a positive tab index in the trap, find event.target in the array and focus the previous/next element (there are also a couple of caveats to take care of).
There are no containers involved here and checkKeyNav simply can't find the right destination without knowing every positive tab index in the trap.
But since positive tab indexes are rare, your approach is likely to work, for everyone, forever.

Also in case the focus escapes from an event.target with a negative tab index, relying on tabbable would be inaccurate, because the way browsers handle this scenario is to give focus to the next tabbable element in document order, ignoring tab indexes (to prove it, visit https://focus-trap-demo.vercel.app click on the C element and press Tab, the focus goes to D having 2 as tab index, rather than E having 1 as tab index).
But, again, non-zero tab indexes are so rare that probably no one will ever face the issue.

I think the approach you are going for is a good example of the Pareto principle, taking care of most of the cases with little effort, with the added bonus that non-zero tab indexes may be rare enough that "most of the cases" is actually "every real life scenario".

@stefcameron
Copy link
Member

@DaviDevMod I appreciate your thoughts on this, thank you! 😄

Relying on tabbable and checkKeyNav is not a bulletproof fix, but it may work good enough so that no one will ever hit the limitations of the approach. I get that in this way the fix would introduce very little novel code, and therefore less risk of breaking previously existing code. So this may be the most cost effective solution for the library at this pint in time. If you think so, by any means, go for it.

This is essentially what I was going for: Least amount of changes for biggest impact for a feature that should really not be used because of the a11y antipattern.

Maybe the better thing to do is to close the issue, and just "not go there" because it could open a can of worms I don't really want to support -- that being full, true replication of native browser behavior across a subset of the DOM that happens to be trapped.

Still mulling over what's best. I think focus-trap should continue to compliment the browser, not replace it, so I should probably close the issue. But if tons of forks were created (or potential consumers discounting the library) in frustration because of this, then probably good to proceed.

The bulletproof way to handle positive tab indexes is to have a sorted array of all the elements with a positive tab index in the trap, find event.target in the array and focus the previous/next element (there are also a couple of caveats to take care of). There are no containers involved here and checkKeyNav simply can't find the right destination without knowing every positive tab index in the trap. But since positive tab indexes are rare, your approach is likely to work, for everyone, forever.

You are bringing up the holes in my approach when it comes to multi-container support. More code will be required to make that work. First thought is to combine all tabbableNodes arrays of all containerGroups in the trap in given container order, resort them per tabindex. That would bubble all the positive tabindex elements to the top in tabindex order. But then I'd probably always have to do this, and checkKeyNav() would fall apart (i.e. break other already-supported use cases) because it would lose its current notion of container order.

😬 I'm now tempted to somehow limit positive tabindex support to a single container trap. Something like, whenever updateTabbableNodes() is called, check if >1 container && >0 positive tabindex nodes found, then throw Error("trap not supported").

If positive tabindexes are rare, hopefully positive tabindexes in multi-container traps are extinct... 😉

Also in case the focus escapes from an event.target with a negative tab index, relying on tabbable would be inaccurate, because the way browsers handle this scenario is to give focus to the next tabbable element in document order, ignoring tab indexes (to prove it, visit https://focus-trap-demo.vercel.app click on the C element and press Tab, the focus goes to D having 2 as tab index, rather than E having 1 as tab index). But, again, non-zero tab indexes are so rare that probably no one will ever face the issue.

Thanks for pointing this out. That's an edge case that might be fairly easy to handle in my draft code since I handle other edge cases I discovered, all based on the MRU focused node.

@DaviDevMod
Copy link
Contributor

I'm now tempted to somehow limit positive tabindex support to a single container trap. Something like, whenever updateTabbableNodes() is called, check if >1 container && >0 positive tabindex nodes found, then throw Error("trap not supported").
If positive tabindexes are rare, hopefully positive tabindexes in multi-container traps are extinct...

I agree. And I personally think that you should give in to the temptation of providing bulletproof support for positive tab indexes only in single-container traps.
It's definitely better than not providing support at all (since, most probably, the vast majority of users only need to trap the focus within a single element) and is more transparent than a "Pareto" fix in regard to how the library claims to handle positive tab indexes.

@DaviDevMod
Copy link
Contributor

@stefcameron Another option worth considering is to solve the issue upstream by allowing tabbable to be called with an array of elements.
In this way focus-trap can rely on the order established by tabbable and have to care only about the negative tab index caveat.
There would even be no need for checkKeyNav with logic about containers.

Establishing the proper order of tabbable elements across multiple containers should be easier to do upstream, thought this means introducing novel logic there.

@DaviDevMod
Copy link
Contributor

@stefcameron I may be wrong, but solving the issue upstream may be as easy as that: focus-trap/tabbable@master...DaviDevMod:tabbable:multi-container

Meaning no novel logic at all.

@stefcameron
Copy link
Member

@DaviDevMod This sounds like a great idea! Makes a lot of sense to push the multi-container support upstream to tabbable. I'll check this out further ASAP.

@stefcameron
Copy link
Member

@DaviDevMod I think you're approach is a good one! I made it into a PR which I merged into a new many-containers branch in the tabbable repo. I've since added a couple of commits for focusable() and new getTabIndex() API. I still need to add some tests for all that, but first, I'll test this out with my focus-trap draft PR.

@DaviDevMod
Copy link
Contributor

DaviDevMod commented Jun 8, 2023

@stefcameron Glad to see you liked the approach!
I've sent you a PR to add sorting of the containers prior to querying the candidates.

I just realised, while writing this comment, that it is also necessary to remove nested or duplicate containers otherwise there would be duplicate candidates.
One could also just dedupe the queried candidates, though it would be marginally slower than working on the containers.
Also, in my focus trap I log warnings in these cases, but it's not really necessary so I'll leave this deduping implementation up to you.

For reference, here is how I did it in my trap:

const isNotNestedRoot = (root: Focusable, _index: number, roots: Focusable[]) => {
  const isNotNested = roots.every((anotherRoot) => !anotherRoot.contains(root) || anotherRoot === root);

  if (!isNotNested) console.warn(`${root} is contained by another root.`);

  return isNotNested;
};

const dedupeRoots = (roots: Focusable[]) => {
  const dedupedRoots = Array.from(new Set(roots));

  if (dedupedRoots.length !== roots.length) {
    console.warn('Duplicate elements were found in the "roots" array. They have been deduplicated.');
  }

  return dedupedRoots;
};

// And later on, something like...
dedupeRoots(resolvedRoots).filter(isNotNestedRoot).sort(byDocumentOrder)

// Where "root" here is the equivalent of "container" in focus-trap.

EDIT: that byDocumentOrder is in the PR I've sent you.

@stefcameron
Copy link
Member

@DaviDevMod Thanks for the PR. I'm just requesting one tiny tweak, if you don't mind.

And good point about testing the containers for dups and nesting. I'll have to add that when I get back to this.

@stefcameron
Copy link
Member

I think another thing with the de-dup and contains thing I'll have to watch out for is that Node.contains() doesn't work with shadow DOM very well.

This is starting to get non-trivial... 😬

@DaviDevMod
Copy link
Contributor

DaviDevMod commented Jun 9, 2023

This is starting to get non-trivial... 😬

I see, but it's the shadow DOM realm that complicates things, so as a last resort I think it may be acceptable to document that multiple containers are supported only for the regular DOM.

But it's not time to throw in the towel just yet, as this particalar Node.contains() issue can easily be solved by adopting the less efficient deduping strategy I mentioned earlier:

One could also just dedupe the queried candidates, though it would be marginally slower than working on the containers.

Actually that approach would fail if the nested container precedes its parent in the array of containers and I just found out that you can't rely on compareDocumentPosition when it comes to shadow DOM.

This isNotNestedContainer should work as a .filter() for nested elements:

const isNotNestedShadowElement = (a, b) => {
  const parent = a.parentNode;

  if (parent instanceof ShadowRoot || parent === null) return true;

  if (parent === b) return false;

  return isNotNestedShadowElement(parent, b);
};

const isNotNestedContainer = (container, _index, containers) => {
  const isNotNested = containers.every(
    (anotherContainer) =>
      container === anotherContainer ||
      (container.getRootNode() instanceof ShadowRoot
        ? isNotNestedShadowElement(container, anotherContainer)
        : !anotherContainer.contains(container))
  );

  if (!isNotNested)
    console.warn(`${container} is contained by another container.`);

  return isNotNested;
};

But there is still the problem of sorting by document order.

I think that in the long run, it may be better to bundle shadom DOM utilities in a separate library that would then be imported, removing a lot of complexity from tabbable's codebase.

@stefcameron
Copy link
Member

Actually that approach would fail if the nested container precedes its parent in the array of containers and I just found out that you can't rely on compareDocumentPosition when it comes to shadow DOM.

I only had a bit of time to dedicate to this today, but I at least gave the whole thing a bit more thought. Since:

  • Node.compareDocumentPosition() doesn't work when the two nodes straddle a shadow root;
  • focus-trap's current multi-container support is such that the order in which the containers are given is the order in which focus-trap manages moving focus between them; and
  • focus-trap current multi-container support doesn't bother at all to check if containers are nested

If tabbable starts to resort containers, it will probably break expectations/behavior in focus-trap. There's a now long-standing implication that when multiple containers are given, they're given in the order in which focus should move between them, not a random order for focus-trap (and now tabbable) to sort out make "proper".

And I'm currently favoring a solution to the nesting thing that avoids checking for nesting by checking for duplicates afterward. I thought accumulating into a Set would work, but it won't for the tabbable() case where CandidateScope objects are potentially returned from the getCandidatesIteratively() call since those will always be new/distinct objects even if they represent the same scope.

But using a plain Array, it might be fairly trivial to eliminate duplicate scopes by finding them all at the top level and eliminating duplicate objects by comparing candidateScope.scopeParent properties which should be Elements representing containers.

So just loop through all containers in the order they're given, reducing the result to a single array. Then write some function to de-dup the array exhaustively. Could be like results = Array.from(new Set(candidates)) and then manually de-duping results for those CandidateScope objects that the Set will miss after easily eliminating duplicate Elements. Finally, pass the remainder through sortByOrder(remainder)

Another thought is since focus-trap uses the containers order verbatim, we might NOT want to reduce() into a single flat array; we might want to sortByOrder() each resulting array independently, and then return the concatenation in original given container order...

DaviDevMod added a commit to DaviDevMod/tabbable that referenced this issue Jun 14, 2023
It's not necessary as discussed here focus-trap/focus-trap#375 (comment):

If tabbable starts to resort containers, it will probably break expectations/behavior in focus-trap. There's a now long-standing implication that when multiple containers are given, they're given in the order in which focus should move between them, not a random order for focus-trap (and now tabbable) to sort out make "proper".
@DaviDevMod
Copy link
Contributor

There's a now long-standing implication that when multiple containers are given, they're given in the order in which focus should move between them, not a random order for focus-trap (and now tabbable) to sort out make "proper".

From a certain point of view, this is a never reported bug 😆
But your point makes sense.

With sorting out of the way, a simple Array.from(new Set()) should be enough to land the many-containers feature.
In tabbable we can dedupe after sortByOrder which returns an Element[] (by the way there are missing curly brackets here), while in focusable candidates is already an Element[] because getCandidatesIteratively is called with flatten: true.

Regarding reducing candidates into a single flat array, that's where we started from 😄
focus-trap is calling tabbable with one container at a time, which makes it hard (and very inefficient) to get the right order of tabbable elements in case there are elements with a positive tab index within the trap (so hard and inefficient that we were considering alternatives to a proper support for non-zero tab indexes).

I've sent you a PR.

@stefcameron
Copy link
Member

From a certain point of view, this is a never reported bug 😆

Agreed, depending on point of view. I smell yet another focus-trap option at some point in the future... 🤪

With sorting out of the way, a simple Array.from(new Set()) should be enough to land the many-containers feature.
In tabbable we can dedupe after sortByOrder which returns an Element[] (by the way there are missing curly brackets here), while in focusable candidates is already an Element[] because getCandidatesIteratively is called with flatten: true.

If we do that, though, isn't there a chance of actually changing the order resulting from sortByOrder()? That was a concern, hence why I didn't suggest removing duplicates at the very end.

But now, in light of the container order "feature", I'm wondering if it would be better to have tabbable() and focusable() return either a single Array<Element> if a single container is given, or Array<Array<Elelement>> if multiple containers are given (the outer array being in the order of the given containers).

Downstream in focus-trap, it's no longer necessary to call tabbable() in a loop on all containers.

But this is probably going full circle and not solving the original problem of, "what is the overall positive tab order among all containers".

But then, if container order is fixed according to the order in which they're given, that probably means the positive tab order is actually artificially altered. Now focus-trap will have to interject and set a completely different tab order, and that goes against focus-trap getting out of the way and letting the browser do its thing as much as possible, not to mention diverging from reality.

Which brings me back to: Positive tabindex elements are only supported in a single-container trap, even if we pursue the multi-container support on the many-containers branch.

@DaviDevMod
Copy link
Contributor

If we do that, though, isn't there a chance of actually changing the order resulting from sortByOrder()? That was a concern, hence why I didn't suggest removing duplicates at the very end.

If you are referring to this concern:

Actually that approach would fail if the nested container precedes its parent in the array of containers and I just found out that you whatwg/dom#320 when it comes to shadow DOM.

I was confused 🤣 What I should have said is that the approach of deduping the queried candidates works, period. The only reason why it would have failed is that the sorting part was broken, so the whole sorting+deduping would have failed to deliver the expected results.

Sometimes I just write down my thoughts as they come, before having formed a clear picture of them. I'm learning to hold my fingers.

Deduping before or after sorting should not make any difference.

The positive tab order will be the one dictated by the sorting algorithm, which currently is the one implemented in sortByOrder, meaning that elements will be sorted by ascending tab index values, with ties resolved by the order in which the elements are given in the array to sort.
If the containers are given in document order, the order is the same followed by browsers.
If the containers are not given in document order, they could be sorted (despite the shadow DOM getting in the way).

It would also be just fine to limit positive tab index support to single-container traps, and at this point there would be no need for tabbable to support multiple containers.

@stefcameron
Copy link
Member

I really appreciate all your help/input here and on many-containers. I'm seriously considering the easier approach of limiting positive tab index support to single-container traps in focus-trap.

The implications of the multi-container traps in focus-trap plus the shadow DOM stuff, and the fact that positive tab indexes should hopefully be something not widely used, and hopefully even less so across disjoint/random containers, and even less so with one in a shadow DOM and another not... It'll probably be fine, and will keep things much simpler.

@DaviDevMod
Copy link
Contributor

@stefcameron You'r welcome! It has been a stimulating discussion.

I totally agree with your arguments.

Though in the meantime I decided to learn about the Shadow DOM and I started to write some code to seamless sort elements (shadow or not). I'll send you a draft PR when I'm done.
Don't be sorry for me if by then you have decided to not support the feature, I'm actually writing the code for my own interest.

@stefcameron
Copy link
Member

@DaviDevMod I let this simmer for a bit and I've decided I'm going to drop the multi-container support in Tabbable, pursuing positive tab index support for single-container traps only in focus-trap for these reasons (which we've discussed; just restating here for posterity):

  1. Positive tab indexes are an a11y anti-pattern:
  1. Focus-trap has kind of already set a precedent that the order in which multiple containers are given to a trap is the order in which focus should flow from one container to the next, which is not necessarily the DOM order of these containers. While that's technically incorrect, it's been like this for a very long time now so diverging from this probably means yet another trap option, or a major that may upset quite a number of users.
  2. This issue was filed over 2 years and got 1 upvote. There just isn't enough demand to warrant more effort than the minimum necessary to make it work with a single-container trap.
  3. Having a multi-container trap with positive tab indexes feels like a super edge case at this point, and supporting it would push Tabbable and Focus-trap further over the line of "getting out of the way of the browser as much as possible" than I'm prepared given all of the above.

So I will pursue #974 by adding a check for positive tabindex values and container count, throwing an exception if there is more than one container.

@DaviDevMod
Copy link
Contributor

@stefcameron totally agree with your decision.

I know this issue was about positive tab indexes, but in the discussion it came out negative tab indexes aren't properly handled either.
They are easy to implement (no need for tabbable with many containers) and not discouraged.
Don't forget about them!

@stefcameron
Copy link
Member

@DaviDevMod

@stefcameron totally agree with your decision.

👍

I know this issue was about positive tab indexes, but in the discussion it came out negative tab indexes aren't properly handled either. They are easy to implement (no need for tabbable with many containers) and not discouraged. Don't forget about them!

By this, do you mean the edge case where the most recently-focused node had a negative tabindex, in which case the browser sets focus on the next element in DOM order, not tabindex order?

If so, I have that as a TODO item on the PR, though I was kind of thinking of punting it as a rare edge case in light of everything, but it might not be too difficult to support it. I'm happy to investigate. I know you've demonstrated this behavior earlier in our discussions here.

If it's something else, I'm all ears...

@DaviDevMod
Copy link
Contributor

DaviDevMod commented Jun 23, 2023

@stefcameron Yes, I mean just that edge case.

But yeah sorry, even though there would be no need for tabbable with many-containers support, there would be need to compare the document position of the elements returned by tabbable(), which means again a lot of complexity because of the shadow DOM. It's definitely better to consider it a rare edge case as you were thinking to do.

It's a shame that there is so little API available to work with the Shadow DOM, I've just learnt how useful it can be.

@stefcameron
Copy link
Member

But yeah sorry, even though there would be no need for tabbable with many-containers support, there would be need to compare the document position of the elements returned by tabbable(), which means again a lot of complexity because of the shadow DOM. It's definitely better to consider it a rare edge case as you were thinking to do.

I guess I naively thought, given the restriction to a single-container trap and that tabbable(container) does perform a sort of nodes in tab order considering Shadow DOM (provided you specify the getShadowRoot Tabbable option to enable Shadow DOM support), that I would just pick the next non-positive tabindex node in the list of tabbable nodes returned for the container, but now I realize the edge case comes when tabbing from a non-tabbable (but still focusable) node, which means it will not be in the list of tabbable nodes for the container, which means what you're implying...

So I think it might be another stated caveat of positive tabindex support...

It's a shame that there is so little API available to work with the Shadow DOM, I've just learnt how useful it can be.

I agree! Shadow DOM is powerful in its ability to shield web components for unintended manipulation (be it programmatic- or style-based), but it makes things considerably more difficult to deal with. It's tempting for creating design system components, for example, where it would be easy to ensure components don't get internally styled, changing their appearance in unintended ways counter to design guidelines, but my impression on the outset is they would end-up being more of a pain to deal with than the "in good faith" trust that consumers won't go styling things they shouldn't in the Light DOM...

@DaviDevMod
Copy link
Contributor

@stefcameron An alternative to comparing the document position of the elements returned by tabbable() (to get the first element in document order, ignoring tab indexes) may be using a focusable() + isTabbable() combo.

For example this returned object

return {

may have topTabbbableNode and bottomTabbableNode properties

topTabbableNode: focusableNodes.find((el) => isTabbable(el)),
bottomTabbableNode: focusableNodes.findLast((el) => isTabbable(el)),

to be used in place of firstTabbableNode and lastTabbableNode whenever the target of a Tab keydown event has a negative tab index.

Going a bit off topic
I noticed that (sometimes) the library gives up finding the correct container when the focus is about to be given to an element outside of the trap:

destinationNode = state.tabbableGroups[0].firstTabbableNode;

And I wasn't surprised because handling this case properly requires comparing the document position of elements, which means again a lot of added complexity because of the Shadow DOM.

It looks like missing a compareDocumentPosition for Shadow DOM is a recurring problem.

You may want to implement it once and for all.
I will give it a proper try, with tests (if and) when I find the time 😆 it's not a priority.

@stefcameron
Copy link
Member

@DaviDevMod

An alternative to comparing the document position of the elements returned by tabbable() (to get the first element in document order, ignoring tab indexes) may be using a focusable() + isTabbable() combo.
[...]
topTabbbableNode and bottomTabbableNode properties

Wouldn't we also have to add a check for a zero tabindex, like this?

topTabbableNode: focusableNodes.find((el) => isTabbable(el) && getTagIndex(el) === 0) || tabbableNodes[0],
bottomTabbableNode: focusableNodes.findLast((el) => isTabbable(el) && getTabIndex(el) === 0) || tabbableNodes[tabbableNodes.length - 1],

@DaviDevMod
Copy link
Contributor

Wouldn't we also have to add a check for a zero tabindex, like this?

No, if top/bottom tabbables end up having a positive tab index it's fine.
What the browser does, when tabbing away from a negative tab index, is to focus the closest tabbable element in document order, without looking at tab index values.

stefcameron added a commit that referenced this issue Jun 26, 2023
Fixes #375

ALL existing cypress tests are passing.

TODO:

- [ ] get rid of DEBUG comments
- [ ] will need to share logic from tabbable about determining tabindex somehow
- [ ] check manually tested examples
- [ ] see if a cypress test can be added for the new positive-tabindex example
- [ ] add changeset
@stefcameron
Copy link
Member

@DaviDevMod Alright, PR is updated, including the little demo recording, in which I added the negative tabindex edge case. Please LMK if it all looks good to you, and I think we can finally put this one to bed. 🙂

stefcameron added a commit that referenced this issue Jun 30, 2023
…r traps (#974)

* DRAFT: Support elements with positive tabindex attributes

Fixes #375

ALL existing cypress tests are passing.

TODO:

- [ ] get rid of DEBUG comments
- [ ] will need to share logic from tabbable about determining tabindex somehow
- [ ] check manually tested examples
- [ ] see if a cypress test can be added for the new positive-tabindex example
- [ ] add changeset

* Address remaining TODO items

- focus-trap limited to a single container if at least one positive tabindex
  node is found in any of the containers given to it; an exception is thrown
- handled negative tabindex edge case, setting focus to the next tabbable
  node in DOM order (should be document position, but that would require
  extensive work not worth the effort for this feature)
- Removed all DEBUG comments
- Using new tababble `getTabIndex()` API from tabbable v6.2.0
- Added Cypress test
- Added Changeset
- Manually checked "manual-check" examples

* Positive tabindex (#987)

* Fix `destinationNode` in case the `target` of the keyboard event has a negative tab index

* Refactor `findNextNavNode`

Simplified the logic a bit and introduced the following condition:

```js
containerGroup.focusableNodes.indexOf(target) >=
  containerGroup.focusableNodes.indexOf(
    containerGroup.lastTabbableNode
  )
```

checking that `target` is either the `lastTabbableNode` in the container or a focusable (and not tabbable) node preceding it in document order.

* Simplify `nextTabbableNode`

* Fix `nextTabbableNode` logic for case in which `node` is not tabbable

* Cache index of `node` within the `focusableNodes` array, in the body of `nextTabbleNode`

* Further simplify `nextTabbableNode`

The logic for the case in which `node` has a negative tab index would work also for non-negative tab indexes.
Distinguishing between the two cases may be more performant in principle, but not enough to justify any added complexity (at least in my opinion).

* Distinguish (again) between `node` with negative and non-negative tab index, in `nextTabbableNode`

This reverts commits 075b58c and f198c2b

* Revert "Refactor `findNextNavNode`"

This reverts commit 91da5ca.

* Emphasize j and k keys for custom tab keys example; update demo bundle

---------

Co-authored-by: DaviDevMod <98312056+DaviDevMod@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants