Coder Social home page Coder Social logo

focus-layers's Introduction

Focus Layers

Tiny React hooks for isolating focus within subsections of the DOM. Useful for supporting accessible dialog widgets like modals, popouts, and alerts.

No wrapper components, no emulation, no pre-defined "list of tabbable elements", and no data attributes. Implemented entirely with native APIs and events, with no additional dependencies.

Installation

This package is published under focus-layers and can be installed with any npm-compatible package manager.

Basic Usage

Call useFocusLock inside a component and provide it a ref to the DOM node to use as the container for the focus layer. When the component mounts, it will lock focus to elements within that node, and the lock will be released when the component unmounts.

import useFocusLock from "focus-layers";

function Dialog({ children }: { children: React.ReactNode }) {
  const containerRef = React.useRef<HTMLElement>();
  useFocusLock(containerRef);

  return (
    <div ref={containerRef} tabIndex={-1}>
      {children}
    </div>
  );
}

function App() {
  const [open, setOpen] = React.useState(false);

  return (
    <div>
      <button onClick={() => setOpen(true)}>Show Dialog</button>
      {open && (
        <Dialog>
          <p>
            When this dialog is open, focus is trapped inside! Tabbing will always bring focus back
            to this button.
          </p>
          <button onClick={() => setOpen(false)}>Close Dialog</button>
        </Dialog>
      )}
    </div>
  );
}

Returning Focus

After unmounting, locks will return focus to the element that was focused when the lock was mounted. This return target can also be controlled by the second parameter of useFocusLock.

function DialogWithExplicitReturn() {
  const [open, setOpen] = React.useState(false);

  const containerRef = React.useRef<HTMLDivElement>();
  const returnRef = React.useRef<HTMLButtonElement>();
  useFocusLock(containerRef, { returnRef });

  return (
    <React.Fragment>
      <button ref={returnRef}>Focus will be returned here</button>
      <button onClick={() => setOpen(true)}>Even though this button opens the Dialog</button>
      {open && (
        <Dialog>
          <button onClick={() => setOpen(false)}>Close Dialog</button>
        </Dialog>
      )}
    </React.Fragment>
  );
}

Lock Layers

Locks Layers are quite literally layers of locks, meaning they can be stacked on top of each other to create a chain of focus traps. When the top layer unmounts, the layer below it takes over as the active lock. Layers can be removed in any order, and the top layer will always remain active.

This is useful for implementing confirmations inside of modals, or flows between multiple independent modals, where one dialog will open another, and so on.

function LayeredDialogs() {
  const [firstOpen, setFirstOpen] = React.useState(false);
  const [secondOpen, setSecondOpen] = React.useState(false);

  return (
    <div>
      <button onClick={() => setFirstOpen(true)}>Open First Dialog</button>

      {firstOpen && (
        <Dialog>
          <p>This is the first dialog that has a second confirmation after it.</p>
          <button onClick={() => setSecondOpen(true)}>Confirm</button>
        </Dialog>
      )}

      {secondOpen && (
        <Dialog>
          <p>This is the second dialog, opened by the first one.</p>
          <button onClick={() => setSecondOpen(false)}>Confirm this dialog</button>
          <button
            onClick={() => {
              setSecondOpen(false);
              setFirstOpen(false);
            }}>
            Close both dialogs
          </button>
        </Dialog>
      )}
    </div>
  );
}

Note that layers only track their own return targets. If multiple layers are unmounting, it is not always guaranteed that the original return target will be focused afterward. In this case, it is best to provide an explicit return target so that focus is not left ambiguous after unmounting.

Subscribing to the Lock Stack

Layers are managed by a global LOCK_STACK object. You can subscribe to this stack to get updates whenever any focus layers are active. This is useful for marking the rest of your app with aria-hidden when modals are active, or performing any other tasks on demand:

import { LOCK_STACK } from "focus-layers";

function App() {
  const [focusLockActive, setFocusLockActive] = React.useState(false);
  React.useEffect(() => {
    LOCK_STACK.subscribe(setFocusLockActive);
    return () => LOCK_STACK.unsubscribe(setFocusLockActive);
  }, []);

  return (
    <React.Fragment>
      // This div represents your main app content
      <div aria-hidden={focusLockActive} />
      // This div would be where the dialog layers are rendered
      <div />
    </React.Fragment>
  );
}

The subscribe and unsubscribe methods are useful for listening to stack changes outside of the React's rendering pipeline, but as a convenience, the useLockSubscription hook performs the same behavior tied to a component's lifecycle.

import { useLockSubscription } from "focus-layers";

function Component() {
  useLockSubscription((enabled) =>
    console.log(`focus locking is now ${enabled ? "enabled" : "disabled"}`),
  );
}

Edge Guards

Browsers do not provide a clean way of intercepting focus events that cause focus to leave the DOM. Specifically, there is no way to directly prevent a tab/shift-tab action from moving focus out of the document and onto the browser's controls or another window.

This can cause issues with focus isolation at the edges of the DOM, where there are no more tabbable elements past the focus lock layer for focus to move to before exiting the DOM.

Semantically, this is valid behavior, but it is often nice to ensure that focus is still locked consistently. The solution is to add hidden divs with tabindex="0" to the beginning and end of the DOM (or around the focus layer) so that there is always another element for focus to move to while inside of a focus layer.

This library provides a FocusGuard component that you can render which will automatically activate when any focus layer is active, and hide itself otherwise. It renders a div that is always visually hidden, but becomes tabbable when active. These can be added at the actual edges of the DOM, or just directly surrounding any active focus layers.

import { FocusGuard } from "focus-layers";

function App() {
  return (
    <div id="app-root">
      <FocusGuard />
      // Render the rest of your app or your modal layers here.
      <FocusGuard />
    </div>
  );
}

Focus will still be trapped even without these guards in place, but the user will be able to tab out of the page and onto their browser controls if the layer is at either the very beginning or very end of the document's tab order.

Distributed Focus Groups

Layers with multiple, unconnected container nodes are not currently supported. This means layers that have content and render a React Portal as part of the layer may not allow focus to leave the layer and reach the portal.

Rendering a layer entirely within a Portal, or by any other means where there is a single containing node, is supported.

Support for groups may be added in the future to address this issue. However, it's worth noting that Portals already do not play well with tab orders and should generally not be used as anything other than an isolated focus layer. Otherwise the entire premise that focus is locked into the layer is effectively broken anyway.

External Focus Layers

The LOCK_STACK provides a way of integrating your own layers into the system. This can be useful when integrating with other libraries or components that implement their own focus management, or manually triggering focus locks that aren't tied to a component lifecycle.

Activating a layer is as simple as calling add with a uid and an EnabledCallback, which will be called when the LOCK_STACK determines that the layer should be active. The callback will be invoked immediately by the call to add, indicating that the layer is now active. The layer can then be removed at any time in the future via the remove method.

import { LOCK_STACK } from "focus-layers";

const enabled = false;
const setEnabled = (now) => (enabled = now);

LOCK_STACK.add("custom lock", setEnabled);
// Sometime later
LOCK_STACK.remove("custom lock");

Integrating with the LOCK_STACK is a promise that your lock will enable and disable itself when it is told to (via the callback). Adding your lock to the stack is also a promise that you will remove it from the stack once the lock is "unmounted" or otherwise removed from use. Without removing your lock, all layers below your lock will be unable to regain focus.

If you are inside of a component and want to tie the focus lock to its lifecycle, you can instead use the useLockLayer hook to simplify adding and removing. In return it provides a boolean indicating whether the lock is currently enabled, and will force a re-render when that state changes:

import { useLockLayer } from "focus-layers";

function Component() {
  const enabled = useLockLayer();

  React.useEffect(() => {
    toggleCustomLock(enabled);
  }, [enabled]);

  return <p>Custom lock is {enabled ? "enabled" : "disabled"}</p>;
}

Free Focus Layers

Custom locks can also be used to implement "free focus layers" without losing the context of the focus layers that are currently in place. Free focus is a situation where focus is not locked into any subsection and can move freely throughout the document. This can be useful for single-page applications that want to preserve focus state between multiple views where previous views get removed from the DOM while another view takes its place.

A free focus layer can easily be implemented as part of a Component. In the single-page application use case mentioned above, this might happen in the base View component that wraps each view.

import { useLockLayer } from "focus-layers";

function View() {
  useLockLayer();

  return <div />;
}

The layer gets added on mount, disabling all layers below it, and since there's no new lock to activate, the return value is just ignored, and nothing else needs to happen.

Scoped Roots

By default useFocusLock will attach a focus listener to the document itself, to capture all focus events that happen on the page. Sometimes this can have unintended consequences where a utility function wants to quickly focus an external element and perform an action. For example, cross-platform copy utilities often do this (see MDN clipboard example).

Free Focus layers would be good for this, but because these hooks are based around useEffect, it's likely that the current lock layer wouldn't be disabled in time for the utility to do its job. To get around this, you can scope where useFocusLock attaches listeners via the attachTo option. A good candidate for this is the node that your app is mounted to, and then have these other utilities do their work outside of that subtree.

import { useFocusLock } from "focus-layers";

function DialogWithCopyableText() {
  const containerRef = React.useRef<HTMLDivElement>();
  useFocusLock(containerRef, { attachTo: document.getElementById("app-mount") || document });

  const text = "this is the copied text";

  return (
    <div containerRef={containerRef}>
      <button onClick={() => copy(text)}>Copy some text</button>
    </div>
  );
}

Alternatives

This library was created after multiple attempts at using other focus locking libraries and wanting something with a simpler implementation that leverages the browser's APIs as much as possible, and fewer implications on DOM and Component structures. There are multiple other options out there that perform a similar job in different ways, such as:

  • react-focus-lock: Lots of options, very flexible, but uses a lot of DOM nodes and attributes.
  • react-focus-trap: Nice and simple, but uses two div containers and does not work nicely with shift+tab navigation.
  • focus-trap-react: Lots of useful features, but relies on intercepting key and mouse events and querying for tabbable elements.

focus-layers's People

Contributors

ai avatar aweary avatar dependabot[bot] avatar emilnordling avatar faultyserver avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

focus-layers's Issues

Missleading "Returning Focus" example

Hi,

the Readme "Returning Focus" example is not working, the containerRef passed to the hook is empty and not assigned to an element. Also I guess it's now outdated.

So what would be the current "proper" way to return focus on a different element than the triggering one?

Thanks!

autoFocus doesn't work when another layer is already active

When creating a layer with useFocusLock while another layer is already active, autoFocused elements created when the new layer mounts will not end up focused after all of the effects are settled. The consumer-facing result of this looks like autoFocus is broken in a lot of cases with no real reason why, and the only workaround is to manually focus a ref afterward.

CodeSandbox Example

My current guess on why this happens is because React's autoFocus polyfill implementation runs before the effects for the new layer have run, meaning any old lock layer will still be active and will prevent focus from moving to the new layer.

cjs import does not support default import

In esm I can import like this:

import useFocusLock, { FocusGuard } from 'focus-layers'

However this does not work with cjs. In the example useFocusLock is the entire module, not just the default.

Cloned the repo and ran a build....noticed this:

jimsimacretina:focus-layers jim$ yarn build
yarn run v1.22.10
$ rm -rf dist && microbundle --external react --name useFocusLock
node-resolve: setting options.module is deprecated, please override options.mainFields instead
node-resolve: setting options.jsnext is deprecated, please override options.mainFields instead
node-resolve: setting options.module is deprecated, please override options.mainFields instead
node-resolve: setting options.jsnext is deprecated, please override options.mainFields instead
node-resolve: setting options.module is deprecated, please override options.mainFields instead
node-resolve: setting options.jsnext is deprecated, please override options.mainFields instead
Using named and default exports together. Consumers of your bundle will have to use useFocusLock['default'] to access the default export, which may not be what you want. Use `output.exports: 'named'` to disable this warning
Using named and default exports together. Consumers of your bundle will have to use useFocusLock['default'] to access the default export, which may not be what you want. Use `output.exports: 'named'` to disable this warning
Build "useFocusLock" to dist:
       1216 B: useFocusLock.js.gz
       1023 B: useFocusLock.js.br
       1229 B: useFocusLock.module.js.gz
       1052 B: useFocusLock.module.js.br
       1280 B: useFocusLock.umd.js.gz
       1084 B: useFocusLock.umd.js.br
✨  Done in 5.88s.

Based on the warning was this intentional? I cannot figure out an import that works for both esm and cjs. This is a typescript project BTW.

Add preventScroll to useFocusLock options

Scenario
We're leveraging popper.js (specifically react-popper) to position different dialogs (Callout, Menus, etc) and focus-layers to trap focus within those dialogs.

When .focus() is called most browsers will attempt to scroll the element into view. The unfortunate part is that popper.js starts with position: absolute; top: 0; left: 0; and then in a subsequent render cycle applies a transform to position the element correctly.

When focus-layers calls .focus the element hasn't always made it to the correct position. Depending on the use case the top/left: 0 could be hundreds of pixels above the final placement causing the scroll to take the focus-lock'ed element completely off the screen.

Ask
The focus() method takes a preventScroll option that prevents the scrolling. Can useFocusLock options accept an option to control preventScroll?

Other
Version: [email protected]
https://developer.mozilla.org/en-US/docs/Web/API/HTMLOrForeignElement/focus

Focus trap broken when first or last focusable element is hidden

Issue
The focus layer keyboard trap can be broken if the first or last focusable element is hidden and therefore not considered tabbable/focusable by the browser. This is a result of the tree walker filtering elements with a tabindex, but not considering whether those elements may have display: none; or visiblity:hidden; applied.

A real-world scenario where this breaks is a modal with any kind of collapsed menu contained within it, such as a menu in a video player.

Test Case
Here is a working test case of the bug with repro steps: https://vigilant-cacti.surge.sh/.
My fork of this repository contains the source for the repro example.

Proposed Fix
Adding additional checks to the tree walker filter for height and visibility in src/util/wrapFocus.tsx to cover situations where an element has tabindex and is not disabled, but might be un-focusable due to display: none; or visiblity:hidden;.

function createFocusWalker(root: HTMLElement) {
  return document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
    acceptNode: (node: HTMLElement) =>
NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP,
      node.tabIndex >= 0 && !node.disabled && node.getBoundingClientRect().height > 0 && window.getComputedStyle(node).visibility === 'visible' ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP,
  });
}

Focus trap doesn't work

Thanks for your work on this library. Unfortunately, when I tried running the demo from the readme, I couldn't get it to work. The focus does get trapped on Shift + Tab but when Tab is pressed, I'm able to escape the lock and circle around the page.

Here's a CodeSandbox with the copy-paste code, and here it is in a separate tab for testing. To repro:

  1. Tab into the "Show Dialog" button
  2. Press Enter to open the hidden div
  3. Press Tab repeatedly

It's expected that the focus stays on the "Close Dialog" button. In reality, the focus moves around the page (to search bar, to "Show Dialog", etc.).

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.