Skip to content

Commit 32faa80

Browse files
pksjcesiddharthkp
andauthored
[Bug] Fix useFocusTrap scrolling issue (#7242)
Co-authored-by: Siddharth Kshetrapal <[email protected]>
1 parent 7785821 commit 32faa80

File tree

3 files changed

+118
-1
lines changed

3 files changed

+118
-1
lines changed

.changeset/unlucky-icons-speak.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/react": patch
3+
---
4+
5+
useFocusTrap - Fix [bug related to restoring focus on scrolling](https://github.com/github/primer/issues/5200)

packages/react/src/hooks/useFocusTrap.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react'
22
import {focusTrap} from '@primer/behaviors'
33
import {useProvidedRefOrCreate} from './useProvidedRefOrCreate'
4+
import {useOnOutsideClick} from './useOnOutsideClick'
45

56
export interface FocusTrapHookSettings {
67
/**
@@ -34,6 +35,12 @@ export interface FocusTrapHookSettings {
3435
* Overrides restoreFocusOnCleanUp
3536
*/
3637
returnFocusRef?: React.RefObject<HTMLElement | null>
38+
/**
39+
* If true, it should allow focus to escape the trap when clicking outside of the trap container and mark it as disabled.
40+
*
41+
* Overrides restoreFocusOnCleanUp and returnFocusRef
42+
*/
43+
allowOutsideClick?: boolean
3744
}
3845

3946
/**
@@ -45,6 +52,7 @@ export function useFocusTrap(
4552
settings?: FocusTrapHookSettings,
4653
dependencies: React.DependencyList = [],
4754
): {containerRef: React.RefObject<HTMLElement | null>; initialFocusRef: React.RefObject<HTMLElement | null>} {
55+
const [outsideClicked, setOutsideClicked] = React.useState(false)
4856
const containerRef = useProvidedRefOrCreate(settings?.containerRef)
4957
const initialFocusRef = useProvidedRefOrCreate(settings?.initialFocusRef)
5058
const disabled = settings?.disabled
@@ -53,14 +61,17 @@ export function useFocusTrap(
5361

5462
// If we are enabling a focus trap and haven't already stored the previously focused element
5563
// go ahead an do that so we can restore later when the trap is disabled.
56-
if (!previousFocusedElement.current && !settings?.disabled) {
64+
if (!previousFocusedElement.current && !disabled) {
5765
previousFocusedElement.current = document.activeElement
5866
}
5967

6068
// This function removes the event listeners that enable the focus trap and restores focus
6169
// to the previously-focused element (if necessary).
6270
function disableTrap() {
6371
abortController.current?.abort()
72+
if (settings?.allowOutsideClick && outsideClicked) {
73+
return
74+
}
6475
if (settings?.returnFocusRef && settings.returnFocusRef.current instanceof HTMLElement) {
6576
settings.returnFocusRef.current.focus()
6677
} else if (settings?.restoreFocusOnCleanUp && previousFocusedElement.current instanceof HTMLElement) {
@@ -85,6 +96,17 @@ export function useFocusTrap(
8596
// eslint-disable-next-line react-hooks/exhaustive-deps
8697
[containerRef, initialFocusRef, disabled, ...dependencies],
8798
)
99+
useOnOutsideClick({
100+
containerRef: containerRef as React.RefObject<HTMLDivElement>,
101+
onClickOutside: () => {
102+
setOutsideClicked(true)
103+
if (settings?.allowOutsideClick) {
104+
if (settings.returnFocusRef) settings.returnFocusRef = undefined
105+
settings.restoreFocusOnCleanUp = false
106+
abortController.current?.abort()
107+
}
108+
},
109+
})
88110

89111
return {containerRef, initialFocusRef}
90112
}

packages/react/src/stories/useFocusTrap.stories.tsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {Meta} from '@storybook/react-vite'
33

44
import {Button, Flash, Stack, Text} from '..'
55
import {useFocusTrap} from '../hooks/useFocusTrap'
6+
import {useOnEscapePress} from '../hooks/useOnEscapePress'
67
import classes from './FocusTrap.stories.module.css'
78

89
export default {
@@ -118,6 +119,95 @@ export const RestoreFocus = () => {
118119
)
119120
}
120121

122+
export const RestoreFocusMinimal = () => {
123+
const [enabled, setEnabled] = React.useState(false)
124+
const toggleButtonRef = React.useRef<HTMLButtonElement>(null)
125+
const {containerRef} = useFocusTrap({
126+
disabled: !enabled,
127+
restoreFocusOnCleanUp: true,
128+
returnFocusRef: toggleButtonRef,
129+
allowOutsideClick: true,
130+
})
131+
132+
useOnEscapePress(
133+
React.useCallback(
134+
e => {
135+
if (!enabled) return
136+
e.preventDefault()
137+
setEnabled(false)
138+
},
139+
[enabled, setEnabled],
140+
),
141+
[enabled, setEnabled],
142+
)
143+
144+
return (
145+
<>
146+
<HelperGlobalStyling />
147+
<Stack direction="vertical" gap="normal">
148+
<Flash style={{marginBottom: 'var(--base-size-12)'}}>
149+
Minimal focus trap example. Click to toggle focus trap to toggle. While enabled, focus stays inside the green
150+
zone. Disabling restores focus to the toggle button.
151+
</Flash>
152+
<Button
153+
ref={toggleButtonRef}
154+
onClick={() => {
155+
if (enabled) {
156+
setEnabled(false)
157+
} else {
158+
setEnabled(true)
159+
}
160+
}}
161+
>
162+
{enabled ? 'Disable' : 'Enable'} focus trap
163+
</Button>
164+
<div
165+
style={{
166+
height: '900px',
167+
overflow: 'auto',
168+
border: '1px dashed var(--borderColor-default)',
169+
padding: 'var(--base-size-16)',
170+
background: 'var(--bgColor-muted)',
171+
}}
172+
aria-hidden="true"
173+
>
174+
<Text
175+
as="p"
176+
style={{
177+
fontSize: '12px',
178+
lineHeight: '1.25',
179+
margin: 0,
180+
}}
181+
>
182+
Scroll down to reach the trap zone. This spacer exists so that when the trap zone becomes active you can
183+
scroll such that the original toggle button is no longer visible. When you press Escape or the Close trap
184+
button, focus will still restore to the toggle button and the browser will scroll it back into view.
185+
</Text>
186+
<Text
187+
as="p"
188+
style={{
189+
fontSize: '12px',
190+
lineHeight: '1.25',
191+
margin: 0,
192+
}}
193+
>
194+
(Content intentionally verbose to create vertical space.)
195+
</Text>
196+
</div>
197+
<div className={classes.TrapZone} ref={containerRef as React.RefObject<HTMLDivElement>}>
198+
<Stack direction="vertical" gap="normal">
199+
<MarginButton>First</MarginButton>
200+
<MarginButton>Second</MarginButton>
201+
<MarginButton>Third</MarginButton>
202+
<Button onClick={() => setEnabled(false)}>Close trap</Button>
203+
</Stack>
204+
</div>
205+
<Button>Click here to escape trap</Button>
206+
</Stack>
207+
</>
208+
)
209+
}
210+
121211
export const CustomInitialFocus = () => {
122212
const [trapEnabled, setTrapEnabled] = React.useState(false)
123213
const {containerRef, initialFocusRef} = useFocusTrap({disabled: !trapEnabled})

0 commit comments

Comments
 (0)