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

Replace react-focus-trap with internally managed Focus Management #1703

Closed
scurker opened this issue Oct 4, 2024 · 1 comment · Fixed by #1730
Closed

Replace react-focus-trap with internally managed Focus Management #1703

scurker opened this issue Oct 4, 2024 · 1 comment · Fixed by #1730

Comments

@scurker
Copy link
Member

scurker commented Oct 4, 2024

We've been encountering a number of issues with react-focus-trap over the years. There's some limiting factors to react-focus-trap, such as:

  • focus-trap-options only being read upon initial renders
  • A bit finicky with rendering non-ref children, can cause issues when used in combination with ClickOutsideListener
  • containerElements requiring a list of elements and not immediately working with refs
  • Can create actual focus traps in some rare scenarios (does not handle web components correctly, does not handle nameless radio groups)

Having our own mechanism of trapping/managing focus that better adapts to the paradigms that we use would help to reduce this friction, as well as provide a mechanism for this to be used for future behaviors outside of Cauldron.

As an inspiration, Adobe Spectrum has a FocusScope and useFocusManager hook that would be much better aligned with how we write our components today. This would provide much greater flexibility than what react-focus-trap currently offers today.

Concepts

At a high level the necessary behaviors could be contained within individual hooks to encapsulate containing focus plus any necessary configuration options to initialize and configure focus traps:

type useFocusTrapProps<
  TargetElement extends Element = Element,
  FocusElement extends HTMLElement = HTMLElement
> = (
  target: ElementOrRef<TargetElement>,
  options: {
    /**
     * When set to false, deactivates the focus trap. This can be necessary if
     * a component needs to conditionally manage focus traps.
     */
    disabled?: boolean;
    /**
     * When the trap is activated, an optional custom element or ref
     * can be provided to override the default initial focus element behavior.
     */
    initialFocusElement?: ElementOrRef<FocusElement>;
    /**
     * When set to true and the trap is deactivated, this will return focus
     * back to the original active element or the return focus element if
     * provided.
     */
    returnFocus?: boolean;
    /**
     * When the trap is deactivated, an optional custom element or ref
     * can be provided to override the default active element behavior.
     */
    returnFocusElement?: ElementOrRef<FocusElement>;
  }
) => void

This could replace the focus trap in the following components:

  • Dialog
  • Drawer
  • Popover
  • TwoColumnPanel

The advantage of utilizing a hook vs a component is removing any complexities that from "focus scope management" having to figure out what the containing child should be and trying to align on a single responsibility of just focus management. This hook should not be concerned with clicking outside, whether or not things outside of the scope are aria-hidden, but purely focused on just containing focus within the element.

@yhafez
Copy link
Contributor

yhafez commented Oct 8, 2024

A potentially related topic:

Is there a more React-like way to find the first focusable element in a form than what I do here (using querySelectorAll)? I of course, could apply a ref to the first focusable element, but the first focusable element is nested in this form in children component and it changes dynamically, so I don't want to have to drill down into each component to set refs on the first select/input/button/etc. manually.

...
const formRef = useRef<HTMLFormElement>(null);
...
const focusFirstFocusableElement = () => {
    const form = formRef.current;
    if (form) {
      const focusableElements = Array.from(
        form.querySelectorAll(
          'input:not([type="hidden"]):not([disabled]), select, textarea, button, a'
        )
      ) as HTMLElement[];

      if (focusableElements.length > 0) {
        focusableElements[0].focus();
      }
    }
  };

  useEffect(() => {
    if (!isLoading) {
      focusFirstFocusableElement();
    }
  }, [isLoading, isStepDataLoading]);

Original slack thread

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
2 participants