diff --git a/.changeset/unlucky-icons-speak.md b/.changeset/unlucky-icons-speak.md new file mode 100644 index 00000000000..b592e21f006 --- /dev/null +++ b/.changeset/unlucky-icons-speak.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +useFocusTrap - Fix bug related to restoring focus on scrolling diff --git a/packages/react/src/hooks/useFocusTrap.ts b/packages/react/src/hooks/useFocusTrap.ts index 62fbc68e636..6ad1721bb53 100644 --- a/packages/react/src/hooks/useFocusTrap.ts +++ b/packages/react/src/hooks/useFocusTrap.ts @@ -1,6 +1,7 @@ import React from 'react' import {focusTrap} from '@primer/behaviors' import {useProvidedRefOrCreate} from './useProvidedRefOrCreate' +import {useOnOutsideClick} from './useOnOutsideClick' export interface FocusTrapHookSettings { /** @@ -34,6 +35,12 @@ export interface FocusTrapHookSettings { * Overrides restoreFocusOnCleanUp */ returnFocusRef?: React.RefObject + /** + * If true, it should allow focus to escape the trap when clicking outside of the trap container and mark it as disabled. + * + * Overrides restoreFocusOnCleanUp and returnFocusRef + */ + allowOutsideClick?: boolean } /** @@ -45,6 +52,7 @@ export function useFocusTrap( settings?: FocusTrapHookSettings, dependencies: React.DependencyList = [], ): {containerRef: React.RefObject; initialFocusRef: React.RefObject} { + const [outsideClicked, setOutsideClicked] = React.useState(false) const containerRef = useProvidedRefOrCreate(settings?.containerRef) const initialFocusRef = useProvidedRefOrCreate(settings?.initialFocusRef) const disabled = settings?.disabled @@ -53,7 +61,7 @@ export function useFocusTrap( // If we are enabling a focus trap and haven't already stored the previously focused element // go ahead an do that so we can restore later when the trap is disabled. - if (!previousFocusedElement.current && !settings?.disabled) { + if (!previousFocusedElement.current && !disabled) { previousFocusedElement.current = document.activeElement } @@ -61,6 +69,9 @@ export function useFocusTrap( // to the previously-focused element (if necessary). function disableTrap() { abortController.current?.abort() + if (settings?.allowOutsideClick && outsideClicked) { + return + } if (settings?.returnFocusRef && settings.returnFocusRef.current instanceof HTMLElement) { settings.returnFocusRef.current.focus() } else if (settings?.restoreFocusOnCleanUp && previousFocusedElement.current instanceof HTMLElement) { @@ -85,6 +96,17 @@ export function useFocusTrap( // eslint-disable-next-line react-hooks/exhaustive-deps [containerRef, initialFocusRef, disabled, ...dependencies], ) + useOnOutsideClick({ + containerRef: containerRef as React.RefObject, + onClickOutside: () => { + setOutsideClicked(true) + if (settings?.allowOutsideClick) { + if (settings.returnFocusRef) settings.returnFocusRef = undefined + settings.restoreFocusOnCleanUp = false + abortController.current?.abort() + } + }, + }) return {containerRef, initialFocusRef} } diff --git a/packages/react/src/stories/useFocusTrap.stories.tsx b/packages/react/src/stories/useFocusTrap.stories.tsx index 6df61bdf5e6..4b49b66badb 100644 --- a/packages/react/src/stories/useFocusTrap.stories.tsx +++ b/packages/react/src/stories/useFocusTrap.stories.tsx @@ -3,6 +3,7 @@ import type {Meta} from '@storybook/react-vite' import {Button, Flash, Stack, Text} from '..' import {useFocusTrap} from '../hooks/useFocusTrap' +import {useOnEscapePress} from '../hooks/useOnEscapePress' import classes from './FocusTrapStories.module.css' export default { @@ -118,6 +119,95 @@ export const RestoreFocus = () => { ) } +export const RestoreFocusMinimal = () => { + const [enabled, setEnabled] = React.useState(false) + const toggleButtonRef = React.useRef(null) + const {containerRef} = useFocusTrap({ + disabled: !enabled, + restoreFocusOnCleanUp: true, + returnFocusRef: toggleButtonRef, + allowOutsideClick: true, + }) + + useOnEscapePress( + React.useCallback( + e => { + if (!enabled) return + e.preventDefault() + setEnabled(false) + }, + [enabled, setEnabled], + ), + [enabled, setEnabled], + ) + + return ( + <> + + + + Minimal focus trap example. Click to toggle. While enabled, focus stays inside the green zone. Disabling + restores focus to the toggle button. + + + +
}> + + First + Second + Third + + +
+ +
+ + ) +} + export const CustomInitialFocus = () => { const [trapEnabled, setTrapEnabled] = React.useState(false) const {containerRef, initialFocusRef} = useFocusTrap({disabled: !trapEnabled})