-
Notifications
You must be signed in to change notification settings - Fork 647
[Bug] Fix useFocusTrap scrolling issue #7242
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
Changes from all commits
64cd089
d846dcd
7615e85
8f0174b
79d95f4
950d57e
d2a13a9
c57b958
6aef135
953e02e
22b2fb4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@primer/react": patch | ||
| --- | ||
|
|
||
| useFocusTrap - Fix [bug related to restoring focus on scrolling](https://github.com/github/primer/issues/5200) |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -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<HTMLElement | null> | ||||||||
| /** | ||||||||
| * 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 | ||||||||
|
Comment on lines
+38
to
+43
|
||||||||
| } | ||||||||
|
|
||||||||
| /** | ||||||||
|
|
@@ -45,6 +52,7 @@ export function useFocusTrap( | |||||||
| settings?: FocusTrapHookSettings, | ||||||||
| dependencies: React.DependencyList = [], | ||||||||
| ): {containerRef: React.RefObject<HTMLElement | null>; initialFocusRef: React.RefObject<HTMLElement | null>} { | ||||||||
| const [outsideClicked, setOutsideClicked] = React.useState(false) | ||||||||
|
||||||||
| const containerRef = useProvidedRefOrCreate(settings?.containerRef) | ||||||||
| const initialFocusRef = useProvidedRefOrCreate(settings?.initialFocusRef) | ||||||||
| const disabled = settings?.disabled | ||||||||
|
|
@@ -53,14 +61,17 @@ 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 | ||||||||
| } | ||||||||
|
|
||||||||
| // This function removes the event listeners that enable the focus trap and restores focus | ||||||||
| // 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<HTMLDivElement>, | ||||||||
| onClickOutside: () => { | ||||||||
| setOutsideClicked(true) | ||||||||
| if (settings?.allowOutsideClick) { | ||||||||
| if (settings.returnFocusRef) settings.returnFocusRef = undefined | ||||||||
| settings.restoreFocusOnCleanUp = false | ||||||||
|
Comment on lines
+104
to
+105
|
||||||||
| abortController.current?.abort() | ||||||||
| } | ||||||||
| }, | ||||||||
|
||||||||
| }, | |
| }, | |
| disabled: !settings?.allowOutsideClick, |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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 './FocusTrap.stories.module.css' | ||||||
|
|
||||||
| export default { | ||||||
|
|
@@ -118,6 +119,95 @@ export const RestoreFocus = () => { | |||||
| ) | ||||||
| } | ||||||
|
|
||||||
| export const RestoreFocusMinimal = () => { | ||||||
| const [enabled, setEnabled] = React.useState(false) | ||||||
| const toggleButtonRef = React.useRef<HTMLButtonElement>(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 ( | ||||||
| <> | ||||||
| <HelperGlobalStyling /> | ||||||
| <Stack direction="vertical" gap="normal"> | ||||||
| <Flash style={{marginBottom: 'var(--base-size-12)'}}> | ||||||
| Minimal focus trap example. Click to toggle focus trap to toggle. While enabled, focus stays inside the green | ||||||
|
||||||
| Minimal focus trap example. Click to toggle focus trap to toggle. While enabled, focus stays inside the green | |
| Minimal focus trap example. Click the button to toggle the focus trap. While enabled, focus stays inside the green |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new
allowOutsideClickfunctionality lacks test coverage. Since there are no existing tests foruseFocusTrapin the__tests__directory, and this introduces significant new behavior that could affect focus restoration, tests should be added to verify:allowOutsideClick: trueallowOutsideClick: falseor undefinedallowOutsideClickandreturnFocusRef/restoreFocusOnCleanUpConsider adding comprehensive tests for this hook to ensure the focus restoration logic works correctly in all scenarios.