Anton Korzunov

@antonkorzunov

It’s a (focus) Trap!

It is a trap! We got your focus and will not let him out! These words you must hear every time you have opened a modal dialog. But you wont…

Modal dialog

Modal dialog, Lightbox or Focused task are an UI trick to let you do something “single” — upload a file, provide an URL, “Are you sure”, “Click a button to win $$$” and so on.

Usually, you shall not, and cannot not, do anything else — you are able to perform any actions only inside hightlited modal.

This is true to native modals, but not usually true for DOM implementations.

“Design”

But, if you want to stylish your page, and your dialogs — you have to implement “modals” using the DOM API, HTML and CSS.

And to do it?

  1. Cover the entire page with a shadow.
  2. Place a dialog(window, lightbox or task) above it.

Simple and Usable. And here our story starts…

This is screen shot from react-aria-modal demo — a quite usable modal library, which will cover the one unusual thing.

Normally you cant click at elements “outside” — they are covered by shadow. But you can “Tab-out” from a modal.

In this case — modal is not very “modal”. React-aria-modal is fixing it by using react-focus-trap.

Best practices

MDN has a perfect article about building accesseble dialog boxes. It explain WHAT YOU have to do, but not explaining — HOW.

In short: you have to manage keyboard focus. And next they describe how.

jQuery UI dialog — is a perfect solution. It passes all the tests.

But where to get a good component in React/Vue/Angular/non-jQuery world?

Ant is quite strange — it handles change focus by Tab, but allows to leave a Modal by Shift+Tab.

BluePrint.js works well, but will return focus only to autofocus element, or first element with tabIndex. Otherwise — will not.

They just are using improper way to manage a focus.

The is NO ideal solution. Hooray!

PS: Except jQuery. It just works.

Focus trap

Just to recall — I’v mention the react-aria-modal library. It can show a Modal. Correctly! And it uses react-focus-trap to manage focus.

react-focus-trap is a simple React component, which will not let your Focus leave a boundaries of Modal.

But then I checked the sources — I’v found that it is not a trap.

That’s A Trap.

I found only emulation. And emulation is the worse thing you can do.

How react-focus-trap works.

It will attach global keyboard event listeners. On “Tab” it will collect all* tabbable elements and manually move focus from one to another.

By doing this it will control focus, and that is a goal.

But all* is not all. Tabbles does not include area elements, for example. And you should not emulate the browser bahevior.

How to do it in a better way?

How to lock a Focus

There is 3.5 good ways to do it.

The best way

  1. Move all elements from body into div with tabIndex=-1.
  2. By tabindex is not working for chilren, so you might set negative tabIndex to all the elements with a script, or just set inert, a new html property to disable inteactions with a whole DOM-tree… which is not supported anywhere, yet (but you can use polifills).
  3. By the time you can set pointer-events: none, and disable user-select on top node. Just to be sure.
  4. Move modal out of that div and gave a positive tabIndex.
  5. And least but not least — you should set aria-hidden to the Div, to not let the screen reader to read anything outside the modal
Screen readers should also be trapped inside of the modal to prevent accidentally escaping. And aria-hidden on “everything” is the only way to achieve it.

This is native browser behavior, and anything except Modal will be untabbale in a real.

PS: Not everything — you still can tab-out from a page. May be you want it?

The smart way

Just attach a handle to a last(and first) element, and handle Tab only on the “edges”

$(':tabbale:last').on('keydown', function (e) { 
if ($("this:focus") && (e.which == 9)) {
e.preventDefault();
$(':input:first').focus();
}
});

It will work, it will preserve browser behavior “between” edges… but will ignore tabIndex and element modifications.

This is better that “whole” emulation, but you should ignore this way.

The right way

The right way is not to emulate Tab, but to just not let him out.

Not let the focus out.

focus-trap-react is emulation, but react-focus-trap (a different one!) is not.

According to the sources react-focus-trap attaches a global listener to “focus” event.

Then something got focus it will check WHO. If focus is outside modal — it will be returned to the first element.

This solution is very close to the ideal, but it will always return a focus to the first element, and have no idea about tabIndex.

But anyway — this is the right way. Let the focus do anything inside modal, just lock it.

The Focus Lock

That means — if no good solution exists — it is time to create a new solution.

Lock and loaded…

First I planned to write an article about KISS principle, and explain the meaning of it by comparing react-focus-trap with react-focus-lock. But now I am unsure — it is will KISS-friendly or not..

But, to say the truth — this is not Focus Trap. And even not Focus-Lock. This is Focus Jail, or Focus Wall.

Focus is free inside, it just cant pass the Border Security (and Jon Snow)

React-focus-lock

React-focus-lock, the solution I’v build and I am going to talk about uses the last and not the best way to detect the focus change — onBlur/FocusOut event.

It is not as handy as FocusIn event, as long node will first lose focus, and then new component will get it — so you have to wait before check. But..

FocusOut is internal event, as long FocusIn is external.

If you can listen only on your own node — you should no it.

TabIndex and the Prisoner of Azkaban

But the trickiest thing is to handle tabIndex _without_ emulation tabIndex.

I did not found any existing solution except focus-trap-react, which is not a solution, and yet again have to build my own. Algorithm is simple:

1. Remember the last focused item.
2. On focusOut:
1. find common parent of modal and document.activeElement
2. get all tabbable element inside common parent.
3. get all tabbable elements inside the Modal
3. Find the difference between last focused item and current.
4. If diff(current-active)>1 -> return focus to the last node.
5. If current < first node -> go to the last-by-order
6. If current > last node -> go to the first-by-order
7. If first < current < last -> move cursor to the nearest-in-dirrection

The most tricky one is #7. If you use tabIndex(dont do it!) you can have:

  1. Focus outside Modal
  2. Focus inside Modal
  3. Focus outside Modal
  4. Yet again inside..

Neat :) Most of inert realizations does ignore tabIndex at all :)

Conclusion

Anyway, I’v spent few hours on weekends and create a quite usable components, which can help you to lock the Pandora Box you might have.

Usage is simple:

<FocusLock disabled={disabled}> Something inside </FocusLock>

And you can use it for React(as react-focus-lock), or for Vue(as vue-focus-lock). And get a good experience in both cases.

Here is the demo: (lock is disabled by default, or it will steal ANY focus, forcing you to close browser tab :P)

Just wrap your modal with simple component, and you will become more WAI-ARIA compatible.

PS: And you will also get auto-focus out of the box :)

After a year of development *-Focus-lock could lock anything anywhere, supports “focus groups”, autodetects React 16 Portals, and provides some extra helper component for your service. It is still the “best” focus-locking library, based on the “right” principles.

PS!

You still have to hide “everything else” with aria-hidden. Lock can’t do it for you — it is not it’s job.

You also have to wrap focus trap with element with role=’dialog’, and yet again you have to do it by yourself.

You have to make everything except you modal in-accessible. Un-reachable. Inert.

How to do it?

  • block everything except the Modal, using a “shadow”, “lightbox” or experimental HTML inert attribute. Or React-Locky (or Dom-Locky) to just disable all iterations with the rest of the page.
  • wrap everything except modal with aria-hidden to “hide it”.

A good article you should read about building accessible dialogs from Google IO:

So this is React-Aria, the modals that “just works”.

Links

You just read an artile about React-focus-lock, a jail for your focus.

This is Component with Single Responsibility, to help you “fix” your modals

And this one is for Vue.js (10 lines of code, sweet!)

PS: And dont forget about pure vanilla dom-focus-lock. Which will use focusIn event :)

And don’t forget about another part of good modals — event isolation, and preventing body from scrolling, while modal is being opened.

More by Anton Korzunov

Topics of interest

More Related Stories