Skip to content

Commit 14fd6ba

Browse files
committed
Address focus trap review feedback
Copilot-Session: b43bee82-7a2d-40d0-95c2-9b053bdc6075
1 parent 16a7629 commit 14fd6ba

2 files changed

Lines changed: 74 additions & 15 deletions

File tree

packages/react/src/hooks/useFocusTrap.test.tsx

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ describe('useFocusTrap', () => {
3333
})
3434

3535
it('preserves provided refs', () => {
36-
const containerRef = React.createRef<HTMLElement>()
37-
const initialFocusRef = React.createRef<HTMLElement>()
36+
const containerRef = React.createRef<HTMLDivElement>()
37+
const initialFocusRef = React.createRef<HTMLButtonElement>()
3838
const {result} = renderHook(() => useFocusTrap({containerRef, initialFocusRef, disabled: true}))
3939

4040
expect(result.current.containerRef).toBe(containerRef)
@@ -43,11 +43,11 @@ describe('useFocusTrap', () => {
4343

4444
it('passes generated ref elements to the focus trap', () => {
4545
const TestComponent = () => {
46-
const {containerRef, initialFocusRef} = useFocusTrap()
46+
const {containerRef, initialFocusRef} = useFocusTrap<HTMLDivElement, HTMLButtonElement>()
4747

4848
return (
49-
<div ref={containerRef as React.RefObject<HTMLDivElement | null>}>
50-
<button ref={initialFocusRef as React.RefObject<HTMLButtonElement | null>} />
49+
<div ref={containerRef}>
50+
<button ref={initialFocusRef} />
5151
</div>
5252
)
5353
}
@@ -56,4 +56,57 @@ describe('useFocusTrap', () => {
5656

5757
expect(mockFocusTrap).toHaveBeenCalledWith(container.querySelector('div'), container.querySelector('button'))
5858
})
59+
60+
it('starts and aborts the focus trap when disabled changes', () => {
61+
const abortController = new AbortController()
62+
const abortSpy = jest.spyOn(abortController, 'abort')
63+
mockFocusTrap.mockReturnValue(abortController)
64+
65+
const TestComponent = ({disabled}: {disabled: boolean}) => {
66+
const {containerRef} = useFocusTrap<HTMLDivElement>({disabled})
67+
return <div ref={containerRef} />
68+
}
69+
70+
const {rerender} = render(<TestComponent disabled />)
71+
72+
expect(mockFocusTrap).not.toHaveBeenCalled()
73+
74+
rerender(<TestComponent disabled={false} />)
75+
76+
expect(mockFocusTrap).toHaveBeenCalledTimes(1)
77+
78+
rerender(<TestComponent disabled />)
79+
80+
expect(abortSpy).toHaveBeenCalled()
81+
})
82+
83+
it('captures fresh focus after cleaning up a non-HTMLElement active element', () => {
84+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
85+
svg.setAttribute('tabindex', '0')
86+
document.body.append(svg)
87+
svg.focus()
88+
89+
const nextFocusedElement = document.createElement('button')
90+
document.body.append(nextFocusedElement)
91+
92+
const TestComponent = ({disabled}: {disabled: boolean}) => {
93+
const {containerRef} = useFocusTrap<HTMLDivElement>({disabled, restoreFocusOnCleanUp: true})
94+
return <div ref={containerRef} />
95+
}
96+
97+
const {rerender} = render(<TestComponent disabled={false} />)
98+
99+
rerender(<TestComponent disabled />)
100+
nextFocusedElement.focus()
101+
102+
const focusSpy = jest.spyOn(nextFocusedElement, 'focus')
103+
104+
rerender(<TestComponent disabled={false} />)
105+
rerender(<TestComponent disabled />)
106+
107+
expect(focusSpy).toHaveBeenCalled()
108+
109+
svg.remove()
110+
nextFocusedElement.remove()
111+
})
59112
})

packages/react/src/hooks/useFocusTrap.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,21 @@ import React from 'react'
22
import {focusTrap} from '@primer/behaviors'
33
import {useProvidedRefOrCreate} from './useRef'
44

5-
export interface FocusTrapHookSettings {
5+
export interface FocusTrapHookSettings<
6+
ContainerElement extends HTMLElement = HTMLElement,
7+
InitialFocusElement extends HTMLElement = HTMLElement,
8+
> {
69
/**
710
* Ref object that will be used for the trapping container. If not provided, one will
811
* be created by this hook and returned.
912
*/
10-
containerRef?: React.RefObject<HTMLElement | null>
13+
containerRef?: React.RefObject<ContainerElement | null>
1114

1215
/**
1316
* Ref object for the element that should receive focus when the focus trap is first enabled.
1417
* If not provided, one will be created by this hook and returned. Its use is optional.
1518
*/
16-
initialFocusRef?: React.RefObject<HTMLElement | null>
19+
initialFocusRef?: React.RefObject<InitialFocusElement | null>
1720

1821
/**
1922
* Set to true to disable the focus trap and clean up listeners. Can be re-enabled at any time.
@@ -31,15 +34,18 @@ export interface FocusTrapHookSettings {
3134
* that should trap focus.
3235
* @param settings {FocusTrapHookSettings}
3336
*/
34-
export function useFocusTrap(
35-
settings?: FocusTrapHookSettings,
37+
export function useFocusTrap<
38+
ContainerElement extends HTMLElement = HTMLElement,
39+
InitialFocusElement extends HTMLElement = HTMLElement,
40+
>(
41+
settings?: FocusTrapHookSettings<ContainerElement, InitialFocusElement>,
3642
dependencies: React.DependencyList = [],
3743
): {
38-
containerRef: React.RefObject<HTMLElement | null>
39-
initialFocusRef: React.RefObject<HTMLElement | null>
44+
containerRef: React.RefObject<ContainerElement | null>
45+
initialFocusRef: React.RefObject<InitialFocusElement | null>
4046
} {
41-
const containerRef = useProvidedRefOrCreate<HTMLElement | null>(settings?.containerRef)
42-
const initialFocusRef = useProvidedRefOrCreate<HTMLElement | null>(settings?.initialFocusRef)
47+
const containerRef = useProvidedRefOrCreate<ContainerElement | null>(settings?.containerRef)
48+
const initialFocusRef = useProvidedRefOrCreate<InitialFocusElement | null>(settings?.initialFocusRef)
4349
const disabled = settings?.disabled
4450
const abortController = React.useRef<AbortController | null>(null)
4551
const previousFocusedElement = React.useRef<Element | null>(null)
@@ -50,8 +56,8 @@ export function useFocusTrap(
5056
abortController.current?.abort()
5157
if (settings?.restoreFocusOnCleanUp && previousFocusedElement.current instanceof HTMLElement) {
5258
previousFocusedElement.current.focus()
53-
previousFocusedElement.current = null
5459
}
60+
previousFocusedElement.current = null
5561
}
5662

5763
React.useEffect(

0 commit comments

Comments
 (0)