Skip to content

Commit d31bb5c

Browse files
authored
Fix FocusTrap escape due to strange tabindex values (#2093)
* sort DOM nodes using tabIndex first It will still keep the same DOM order if tabIndex matches, thanks to stable sorts! * refactor `focusIn` API All the arguments resulted in usage like `focusIn(container, Focus.First, true, null)`, and to make things worse, we need to add something else to this list in the future. Instead, let's keep the `container` and the type of `Focus` as known params, all the other things can sit in an options object. * fix FocusTrap escape due to strange tabindex values This code will now ensure that we can't escape the FocusTrap if you use `<tab>` and you happen to tab to an element outside of the FocusTrap because the next item in line happens to be outside of the FocusTrap and we never hit any of the focus guard elements. How it works is as follows: 1. The `onBlur` is implemented on the `FocusTrap` itself, this will give us some information in the event itself. - `e.target` is the element that is being blurred (think of it as `from`) - `e.currentTarget` is the element with the event listener (the dialog) - `e.relatedTarget` is the element we are going to (think of it as `to`) 2. If the blur happened due to a `<tab>` or `<shift>+<tab>`, then we will move focus back inside the FocusTrap, and go from the `e.target` to the next or previous value. 3. If the blur happened programmatically (so no tab keys are involved, aka no direction is known), then the focus is restored to the `e.target` value. Fixes: #1656 * update changelog
1 parent 1f2de63 commit d31bb5c

File tree

12 files changed

+347
-182
lines changed

12 files changed

+347
-182
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Fix regression where `displayValue` crashes ([#2087](https://github.com/tailwindlabs/headlessui/pull/2087))
1313
- Fix `displayValue` syncing when `Combobox.Input` is unmounted and re-mounted in different trees ([#2090](https://github.com/tailwindlabs/headlessui/pull/2090))
14+
- Fix FocusTrap escape due to strange tabindex values ([#2093](https://github.com/tailwindlabs/headlessui/pull/2093))
1415

1516
## [1.7.5] - 2022-12-08
1617

packages/@headlessui-react/src/components/dialog/dialog.test.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,6 @@ describe('Rendering', () => {
218218
})
219219

220220
it('should be possible to use a different render strategy for the Dialog', async () => {
221-
let focusCounter = jest.fn()
222221
function Example() {
223222
let [isOpen, setIsOpen] = useState(false)
224223

@@ -228,7 +227,7 @@ describe('Rendering', () => {
228227
Trigger
229228
</button>
230229
<Dialog open={isOpen} onClose={setIsOpen} unmount={false}>
231-
<input onFocus={focusCounter} />
230+
<input />
232231
</Dialog>
233232
</>
234233
)
@@ -239,17 +238,14 @@ describe('Rendering', () => {
239238
await nextFrame()
240239

241240
assertDialog({ state: DialogState.InvisibleHidden })
242-
expect(focusCounter).toHaveBeenCalledTimes(0)
243241

244242
// Let's open the Dialog, to see if it is not hidden anymore
245243
await click(document.getElementById('trigger'))
246-
expect(focusCounter).toHaveBeenCalledTimes(1)
247244

248245
assertDialog({ state: DialogState.Visible })
249246

250247
// Let's close the Dialog
251248
await press(Keys.Escape)
252-
expect(focusCounter).toHaveBeenCalledTimes(1)
253249

254250
assertDialog({ state: DialogState.InvisibleHidden })
255251
})

packages/@headlessui-react/src/components/focus-trap/focus-trap.test.tsx

Lines changed: 131 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
1-
import React, { useState, useRef, FocusEvent } from 'react'
1+
import React, { useState, useRef } from 'react'
22
import { render, screen } from '@testing-library/react'
33

44
import { FocusTrap } from './focus-trap'
55
import { assertActiveElement } from '../../test-utils/accessibility-assertions'
66
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
77
import { click, press, shift, Keys } from '../../test-utils/interactions'
88

9+
beforeAll(() => {
10+
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any)
11+
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any)
12+
})
13+
14+
afterAll(() => jest.restoreAllMocks())
15+
916
function nextFrame() {
1017
return new Promise<void>((resolve) => {
1118
requestAnimationFrame(() => {
@@ -365,76 +372,134 @@ it('should be possible skip disabled elements within the focus trap', async () =
365372
assertActiveElement(document.getElementById('item-a'))
366373
})
367374

368-
it('should try to focus all focusable items (and fail)', async () => {
369-
let spy = jest.spyOn(console, 'warn').mockImplementation(jest.fn())
370-
let focusHandler = jest.fn()
371-
function handleFocus(e: FocusEvent) {
372-
let target = e.target as HTMLElement
373-
focusHandler(target.id)
374-
screen.getByText('After')?.focus()
375-
}
375+
it(
376+
'should not be possible to programmatically escape the focus trap',
377+
suppressConsoleLogs(async () => {
378+
function Example() {
379+
return (
380+
<>
381+
<input id="a" autoFocus />
376382

377-
render(
378-
<>
379-
<button id="before">Before</button>
380-
<FocusTrap>
381-
<button id="item-a" onFocus={handleFocus}>
382-
Item A
383-
</button>
384-
<button id="item-b" onFocus={handleFocus}>
385-
Item B
386-
</button>
387-
<button id="item-c" onFocus={handleFocus}>
388-
Item C
389-
</button>
390-
<button id="item-d" onFocus={handleFocus}>
391-
Item D
392-
</button>
393-
</FocusTrap>
394-
<button>After</button>
395-
</>
396-
)
383+
<FocusTrap>
384+
<input id="b" />
385+
<input id="c" />
386+
<input id="d" />
387+
</FocusTrap>
388+
</>
389+
)
390+
}
397391

398-
await nextFrame()
392+
render(<Example />)
399393

400-
expect(focusHandler.mock.calls).toEqual([['item-a'], ['item-b'], ['item-c'], ['item-d']])
401-
expect(spy).toHaveBeenCalledWith('There are no focusable elements inside the <FocusTrap />')
402-
spy.mockReset()
403-
})
394+
await nextFrame()
404395

405-
it('should end up at the last focusable element', async () => {
406-
let spy = jest.spyOn(console, 'warn').mockImplementation(jest.fn())
396+
let [a, b, c, d] = Array.from(document.querySelectorAll('input'))
407397

408-
let focusHandler = jest.fn()
409-
function handleFocus(e: FocusEvent) {
410-
let target = e.target as HTMLElement
411-
focusHandler(target.id)
412-
screen.getByText('After')?.focus()
413-
}
398+
// Ensure that input-b is the active element
399+
assertActiveElement(b)
414400

415-
render(
416-
<>
417-
<button id="before">Before</button>
418-
<FocusTrap>
419-
<button id="item-a" onFocus={handleFocus}>
420-
Item A
421-
</button>
422-
<button id="item-b" onFocus={handleFocus}>
423-
Item B
424-
</button>
425-
<button id="item-c" onFocus={handleFocus}>
426-
Item C
427-
</button>
428-
<button id="item-d">Item D</button>
429-
</FocusTrap>
430-
<button>After</button>
431-
</>
432-
)
401+
// Tab to the next item
402+
await press(Keys.Tab)
433403

434-
await nextFrame()
404+
// Ensure that input-c is the active element
405+
assertActiveElement(c)
435406

436-
expect(focusHandler.mock.calls).toEqual([['item-a'], ['item-b'], ['item-c']])
437-
assertActiveElement(screen.getByText('Item D'))
438-
expect(spy).not.toHaveBeenCalled()
439-
spy.mockReset()
440-
})
407+
// Try to move focus
408+
a?.focus()
409+
410+
// Ensure that input-c is still the active element
411+
assertActiveElement(c)
412+
413+
// Click on an element within the FocusTrap
414+
await click(b)
415+
416+
// Ensure that input-b is the active element
417+
assertActiveElement(b)
418+
419+
// Try to move focus again
420+
a?.focus()
421+
422+
// Ensure that input-b is still the active element
423+
assertActiveElement(b)
424+
425+
// Focus on an element within the FocusTrap
426+
d?.focus()
427+
428+
// Ensure that input-d is the active element
429+
assertActiveElement(d)
430+
431+
// Try to move focus again
432+
a?.focus()
433+
434+
// Ensure that input-d is still the active element
435+
assertActiveElement(d)
436+
})
437+
)
438+
439+
it(
440+
'should not be possible to escape the FocusTrap due to strange tabIndex usage',
441+
suppressConsoleLogs(async () => {
442+
function Example() {
443+
return (
444+
<>
445+
<div tabIndex={-1}>
446+
<input tabIndex={2} id="a" />
447+
<input tabIndex={1} id="b" />
448+
</div>
449+
450+
<FocusTrap>
451+
<input tabIndex={1} id="c" />
452+
<input id="d" />
453+
</FocusTrap>
454+
</>
455+
)
456+
}
457+
458+
render(<Example />)
459+
460+
await nextFrame()
461+
462+
let [_a, _b, c, d] = Array.from(document.querySelectorAll('input'))
463+
464+
// First item in the FocusTrap should be the active one
465+
assertActiveElement(c)
466+
467+
// Tab to the next item
468+
await press(Keys.Tab)
469+
470+
// Ensure that input-d is the active element
471+
assertActiveElement(d)
472+
473+
// Tab to the next item
474+
await press(Keys.Tab)
475+
476+
// Ensure that input-c is the active element
477+
assertActiveElement(c)
478+
479+
// Tab to the next item
480+
await press(Keys.Tab)
481+
482+
// Ensure that input-d is the active element
483+
assertActiveElement(d)
484+
485+
// Let's go the other way
486+
487+
// Tab to the previous item
488+
await press(shift(Keys.Tab))
489+
490+
// Ensure that input-c is the active element
491+
assertActiveElement(c)
492+
493+
// Tab to the previous item
494+
await press(shift(Keys.Tab))
495+
496+
// Ensure that input-d is the active element
497+
assertActiveElement(d)
498+
499+
// Tab to the previous item
500+
await press(shift(Keys.Tab))
501+
502+
// Ensure that input-c is the active element
503+
assertActiveElement(c)
504+
})
505+
)

packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import React, {
66
ElementType,
77
MutableRefObject,
88
Ref,
9+
FocusEvent as ReactFocusEvent,
910
} from 'react'
1011

1112
import { Props } from '../../types'
@@ -22,6 +23,7 @@ import { useOwnerDocument } from '../../hooks/use-owner'
2223
import { useEventListener } from '../../hooks/use-event-listener'
2324
import { microTask } from '../../utils/micro-task'
2425
import { useWatch } from '../../hooks/use-watch'
26+
import { useDisposables } from '../../hooks/use-disposables'
2527

2628
let DEFAULT_FOCUS_TRAP_TAG = 'div' as const
2729

@@ -75,34 +77,77 @@ export let FocusTrap = Object.assign(
7577
)
7678

7779
let direction = useTabDirection()
78-
let handleFocus = useEvent(() => {
80+
let handleFocus = useEvent((e: ReactFocusEvent) => {
7981
let el = container.current as HTMLElement
8082
if (!el) return
8183

8284
// TODO: Cleanup once we are using real browser tests
83-
if (process.env.NODE_ENV === 'test') {
84-
microTask(() => {
85-
match(direction.current, {
86-
[TabDirection.Forwards]: () => focusIn(el, Focus.First),
87-
[TabDirection.Backwards]: () => focusIn(el, Focus.Last),
88-
})
89-
})
90-
} else {
85+
let wrapper = process.env.NODE_ENV === 'test' ? microTask : (cb: Function) => cb()
86+
wrapper(() => {
9187
match(direction.current, {
92-
[TabDirection.Forwards]: () => focusIn(el, Focus.First),
93-
[TabDirection.Backwards]: () => focusIn(el, Focus.Last),
88+
[TabDirection.Forwards]: () =>
89+
focusIn(el, Focus.First, { skipElements: [e.relatedTarget as HTMLElement] }),
90+
[TabDirection.Backwards]: () =>
91+
focusIn(el, Focus.Last, { skipElements: [e.relatedTarget as HTMLElement] }),
9492
})
95-
}
93+
})
9694
})
9795

98-
let ourProps = { ref: focusTrapRef }
96+
let d = useDisposables()
97+
let recentlyUsedTabKey = useRef(false)
98+
let ourProps = {
99+
ref: focusTrapRef,
100+
onKeyDown(e: KeyboardEvent) {
101+
if (e.key == 'Tab') {
102+
recentlyUsedTabKey.current = true
103+
d.requestAnimationFrame(() => {
104+
recentlyUsedTabKey.current = false
105+
})
106+
}
107+
},
108+
onBlur(e: ReactFocusEvent) {
109+
let allContainers = new Set(containers?.current)
110+
allContainers.add(container)
111+
112+
let relatedTarget = e.relatedTarget as HTMLElement | null
113+
if (!relatedTarget) return
114+
115+
// Known guards, leave them alone!
116+
if (relatedTarget.dataset.headlessuiFocusGuard === 'true') {
117+
return
118+
}
119+
120+
// Blur is triggered due to focus on relatedTarget, and the relatedTarget is not inside any
121+
// of the dialog containers. In other words, let's move focus back in!
122+
if (!contains(allContainers, relatedTarget)) {
123+
// Was the blur invoke via the keyboard? Redirect to the next in line.
124+
if (recentlyUsedTabKey.current) {
125+
focusIn(
126+
container.current as HTMLElement,
127+
match(direction.current, {
128+
[TabDirection.Forwards]: () => Focus.Next,
129+
[TabDirection.Backwards]: () => Focus.Previous,
130+
}) | Focus.WrapAround,
131+
{ relativeTo: e.target as HTMLElement }
132+
)
133+
}
134+
135+
// It was invoke via something else (e.g.: click, programmatically, ...). Redirect to the
136+
// previous active item in the FocusTrap
137+
else if (e.target instanceof HTMLElement) {
138+
focusElement(e.target)
139+
}
140+
}
141+
},
142+
}
99143

100144
return (
101145
<>
102146
{Boolean(features & Features.TabLock) && (
103147
<Hidden
104148
as="button"
105149
type="button"
150+
data-headlessui-focus-guard
106151
onFocus={handleFocus}
107152
features={HiddenFeatures.Focusable}
108153
/>
@@ -117,6 +162,7 @@ export let FocusTrap = Object.assign(
117162
<Hidden
118163
as="button"
119164
type="button"
165+
data-headlessui-focus-guard
120166
onFocus={handleFocus}
121167
features={HiddenFeatures.Focusable}
122168
/>

packages/@headlessui-react/src/components/popover/popover.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -792,7 +792,7 @@ let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DE
792792
}
793793
}
794794

795-
focusIn(combined, Focus.First, false)
795+
focusIn(combined, Focus.First, { sorted: false })
796796
},
797797
[TabDirection.Backwards]: () => focusIn(el, Focus.Last),
798798
})

0 commit comments

Comments
 (0)