focus-trap icon indicating copy to clipboard operation
focus-trap copied to clipboard

Disabling the focus trap loop

Open bakura10 opened this issue 1 year ago • 6 comments

Hi,

I have a use case where I am trying to use this library for emulating the new popover API. I would like to keep all the focus management, close on outside... of focusTrap library, except for one thing: I would like that once the tab reaches the last tabbable element, it actually deactivates instead of going back to the first tabbable element.

I tried to do it with the available events but could not find anything.

One possible API would be to add a new setFocusLoop option that, if set to false, would call the deactivate instead.

Or maybe there is already a way ?

Thanks :)

bakura10 avatar Mar 22 '24 05:03 bakura10

Hi @bakura10, the new Popover API is very cool. Having implemented a popover myself, from scratch, I can appreciate all the heavy lifting this is taking away.

Also having implemented this before, I can say that I didn't ever need to trap focus inside the popover for proper tab order to work as long as I made sure the popover itself got focus once it was displayed. After that, browsers (Chrome, Edge, FF, Safari, at least) would naturally drill into any other tabbable elements in the popover's container before exiting after the last element.

While I can see that focus-trap offers a few interesting things, like escapeDeactivates and clickOutsideDeactivates, that might help, my guess is that it will hinder your implementation more than it help it, in the long run. focus-trap is better suited for a modal (dialog) that should trap focus, and a popover is not meant to be modal.

But if I was to try to use focus-trap for this, I'd probably want a callback hook whenever the trap decides it's going to wrap/loop the focus back to the initially-focused node. Then I could decide what to do, which in your case would be to deactivate the trap.

That probably wouldn't help the case where the user is tabbing in reverse, however, as in this case, your popover should auto-dismiss itself when the user reverse-tabs off the first tabbable element in the popover...

That's why I think it's better to watch for the cases where focus is naturally leaving the popover itself and then auto-dismiss it. You can do this by listening for keydown events on the entire document, and checking to see if the event.target is still inside your popover. If not, dismiss it.

Another reason why you might not want to use focus-trap for this is that popovers are sometimes shown as a result of hovering over the popover's target/anchor (i.e. as a more sophisticated tooltip), in which case merely doing so would cause focus to move off wherever the user is and into the popover, which wouldn't be a good user experience.

stefcameron avatar Mar 22 '24 16:03 stefcameron

Hello @stefcameron ,

Thanks for your answer :). The Popover API is indeed awesome, unfortunately we still need to support from iOS 14 so I suppose that for us it will be a no-go for at least 4-5 more years, as popover usually carry information that we are too important to show only as a progressive enhancement.

I realized that my message was missing a bit of details. I am trying to use this time for a mega-menu (clicking on "Home" open the menu):

image

Until now, I used a <details> element for that, where the top-level menu (Home) was a <summary>. This had some limitations and this always felt odd, semantically speaking. I'm therefore trying to pivot to use dialog (or popover, or whatever you call them, as the two overlap).

So the mega-menu is a dialog that I want to trap focus. All the abstraction I built around FocusTrap allows me to easily handle outside click, focus trapping... But here, in this specific case, the looping is not desirable. Typically, on menu, if you enter into the menu, you want to close to the dialog when you reach the last focusable element, and restore the focus to "Home" so that the customer can navigate to the next menu item (Catalog in this example).

I agree that having a callback might be an even better solution here. Maybe adding something like onBeforeLoop hook? Or simply a loop attribute that would accept either a boolean, or a function that would be called before looping:

createFocusTrap({
  loop: true, // Always loop (default)
  loop: false, // Never loop
  loop: (lastFocusElement, nextFocusElement) => {
     // Do your own logic, and return true or false
  }
})

bakura10 avatar Mar 23 '24 02:03 bakura10

@bakura10 Thank you for explaining further. The image helps visualize this much better as well!

In my view, your menu is best suited for a popover (never modal) rather than dialog (always modal). You can make a popover look like a dialog (hence the visual crossover) but semantically, they are different, especially because the dialog has modality and the popover does/should not.

In any case, since focus shouldn't be trapped, I'm very hesitant to add code to focus-trap to manage a scenario where focus isn't trapped. I would essentially be having to support what feels like an "anti-use case" into the unbounded future of the library. If there's a way we could get this to work outside of modifying focus-trap internals, that would be the best outcome.

But here, in this specific case, the looping is not desirable. Typically, on menu, if you enter into the menu, you want to close to the dialog when you reach the last focusable element, and restore the focus to "Home" [...]

My first thought here is that since reaching the last element is your trigger/signal to say that the menu should be closed and focus returned to "Home", why not add a keydown handler on the last element in tab order (you can use tabbable to figure it out in the same way focus-trap does internally) and if the user presses TAB (vs SHIFT+TAB to reverse and stay in the menu), deactivate the trap with trap.deactivate()? (You just need to make sure the code has access to the trap, of course, but that should be doable somehow since I assume the component that owns the trap also owns the content it displays.)

stefcameron avatar Mar 28 '24 22:03 stefcameron

Thanks for the answer.

The issue is that popover is iOS 17.4+, so we can't use it, we need support up to iOS14 :(.

From my understanding, FocusTrap is not really only about dialog, it is about focus trapping management. You could have this on popover (actually, if you check the native popover, if you open a popover, the browser will move the focus to the popover, which is actually what I want here).

FocusTrap comes with a lot of features that I want to use (ensure that the focus is trapped into the menu, ensure that the click on the overlay that fills the rest of the page close the menu, ensure that clicking on escape close the modal, have the trap stack so that opening a new menu close the older one...).

This is what FocusTrap library is also super good at ;). What I would like is just turning off the loop. Unless I'm misunderstanding, the "focus trap" concept does not imply "focus loop", those are two dissociated things to me, the looping is just one option of the focus trap.

My first thought here is that since reaching the last element is your trigger/signal to say that the menu should be closed and focus returned to "Home", why not add a keydown handler on the last element in tab order (you can use [tabbable](https://github.com/focus-trap/tabbable) to figure it out in the same way focus-trap does internally) and if the user presses TAB (vs SHIFT+TAB to reverse and stay in the menu), deactivate the trap with trap.deactivate()? (You just need to make sure the code has access to the trap, of course, but that should be doable somehow since I assume the component that owns the trap also owns the content it displays.)

This might be possible indeed, but sounds much more complicated than what it needs to be, accessing the tabbable internal is something I'd like to avoid.

bakura10 avatar Mar 29 '24 01:03 bakura10

This is what FocusTrap library is also super good at ;).

Nice! 😄

What I would like is just turning off the loop. Unless I'm misunderstanding, the "focus trap" concept does not imply "focus loop", those are two dissociated things to me, the looping is just one option of the focus trap.

If there's no loop, how is there a trap? Without a loop, you're just left with how focus would naturally traverse the DOM regardless of any boundaries by way of the browser following rules for DOM node order, largely left-to-right, top-to-bottom (in a non-RTL language scenario, I would presume).

This might be possible indeed, but sounds much more complicated than what it needs to be, accessing the tabbable internal is something I'd like to avoid.

Tabbable isn't internal. Focus-trap is just using tabbable with its public API in order to get all the tabbable elements in a container in a traversal order that should be as close to the browser's order as possible.

If you know what the last element in your menu is, where you want the trap to stop, you don't even need that, but tabbable could help you make a more flexible implementation.

You just need a handler on that element to deactivate the trap when pressing TAB. That's it (or at least so it seems).

stefcameron avatar Mar 29 '24 01:03 stefcameron

If there's no loop, how is there a trap? Without a loop, you're just left with how focus would naturally traverse the DOM regardless of any boundaries by way of the browser following rules for DOM node order, largely left-to-right, top-to-bottom (in a non-RTL language scenario, I would presume).

Not necessarily. The markup of the menu might be completely disconnected from its toggler (which is the case here, the menu being in a different div far away from the toggler link).

So what I need is just trap on enter (and all the goodies of FocusTrap) :).

bakura10 avatar Mar 29 '24 01:03 bakura10

I'm going to close this issue now since I don't think a fix would be headed in a direction I'd want to go with this project. We can always re-open it in the future if there's a good way forward.

stefcameron avatar Jul 17 '24 00:07 stefcameron