Skip to content

Commit 7a6220a

Browse files
authored
Prevent cancelling transitions due to focus trap (#1664)
* focus trap after transitions Whenever you focus an element mid transition, the browser will abrublty stop/cancel the transition and it will do anything it can to focus the element. This has a very annoying side effect that this causes very abrubt transitions (instant) in some browsers. To fix this, we used to use `el.focus({ preventScroll: true })` which works in Safari and it used to work in Chrome / Firefox, but there are probably other variables that we have to keep in mind here (didn't figure out _why_ this used to work and not anymore). That said, instead of trying to fight the browser, we will now wait an animation frame before even trying to focus any elements. * update changelog
1 parent fc7def3 commit 7a6220a

File tree

4 files changed

+68
-27
lines changed

4 files changed

+68
-27
lines changed

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Add `by` prop for `Listbox`, `Combobox` and `RadioGroup` ([#1482](https://github.com/tailwindlabs/headlessui/pull/1482))
1313
- Add `@headlessui/tailwindcss` plugin ([#1487](https://github.com/tailwindlabs/headlessui/pull/1487))
1414

15+
### Fixed
16+
17+
- Prevent cancelling transitions due to focus trap ([#1664](https://github.com/tailwindlabs/headlessui/pull/1664))
18+
1519
## [1.6.6] - 2022-07-07
1620

1721
### Fixed

packages/@headlessui-vue/src/components/dialog/dialog.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ global.IntersectionObserver = class FakeIntersectionObserver {
3636

3737
afterAll(() => jest.restoreAllMocks())
3838

39+
function nextFrame() {
40+
return new Promise<void>((resolve) => {
41+
requestAnimationFrame(() => {
42+
requestAnimationFrame(() => {
43+
resolve()
44+
})
45+
})
46+
})
47+
}
48+
3949
let TabSentinel = defineComponent({
4050
name: 'TabSentinel',
4151
template: html`<div :tabindex="0"></div>`,
@@ -243,6 +253,8 @@ describe('Rendering', () => {
243253

244254
await click(document.getElementById('trigger'))
245255

256+
await new Promise<void>(nextTick)
257+
246258
assertDialog({ state: DialogState.Visible, attributes: { class: 'relative bg-blue-500' } })
247259
})
248260

@@ -263,7 +275,7 @@ describe('Rendering', () => {
263275
},
264276
})
265277

266-
await new Promise<void>(nextTick)
278+
await nextFrame()
267279

268280
// Let's verify that the Dialog is already there
269281
expect(getDialog()).not.toBe(null)

packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ beforeAll(() => {
1616

1717
afterAll(() => jest.restoreAllMocks())
1818

19+
function nextFrame() {
20+
return new Promise<void>((resolve) => {
21+
requestAnimationFrame(() => {
22+
requestAnimationFrame(() => {
23+
resolve()
24+
})
25+
})
26+
})
27+
}
28+
1929
const renderTemplate = createRenderTemplate({
2030
FocusTrap,
2131
})
@@ -29,7 +39,7 @@ it('should focus the first focusable element inside the FocusTrap', async () =>
2939
`
3040
)
3141

32-
await new Promise<void>(nextTick)
42+
await nextFrame()
3343

3444
assertActiveElement(getByText('Trigger'))
3545
})
@@ -52,7 +62,7 @@ it('should focus the autoFocus element inside the FocusTrap if that exists', asy
5262
},
5363
})
5464

55-
await new Promise<void>(nextTick)
65+
await nextFrame()
5666

5767
assertActiveElement(document.getElementById('b'))
5868
})
@@ -72,7 +82,7 @@ it('should focus the initialFocus element inside the FocusTrap if that exists',
7282
},
7383
})
7484

75-
await new Promise<void>(nextTick)
85+
await nextFrame()
7686

7787
assertActiveElement(document.getElementById('c'))
7888
})
@@ -92,7 +102,7 @@ it('should focus the initialFocus element inside the FocusTrap even if another e
92102
},
93103
})
94104

95-
await new Promise<void>(nextTick)
105+
await nextFrame()
96106

97107
assertActiveElement(document.getElementById('c'))
98108
})
@@ -109,7 +119,7 @@ it('should warn when there is no focusable element inside the FocusTrap', async
109119
`
110120
)
111121

112-
await new Promise<void>(nextTick)
122+
await nextFrame()
113123

114124
expect(spy.mock.calls[0][0]).toBe('There are no focusable elements inside the <FocusTrap />')
115125
spy.mockReset()
@@ -132,7 +142,7 @@ it(
132142
`,
133143
})
134144

135-
await new Promise<void>(nextTick)
145+
await nextFrame()
136146

137147
let [a, b, c, d] = Array.from(document.querySelectorAll('input'))
138148

@@ -199,7 +209,7 @@ it('should restore the previously focused element, before entering the FocusTrap
199209
},
200210
})
201211

202-
await new Promise<void>(nextTick)
212+
await nextFrame()
203213

204214
// The input should have focus by default because of the autoFocus prop
205215
assertActiveElement(document.getElementById('item-1'))
@@ -232,7 +242,7 @@ it('should be possible to tab to the next focusable element within the focus tra
232242
`
233243
)
234244

235-
await new Promise<void>(nextTick)
245+
await nextFrame()
236246

237247
// Item A should be focused because the FocusTrap will focus the first item
238248
assertActiveElement(document.getElementById('item-a'))
@@ -265,6 +275,8 @@ it('should be possible to shift+tab to the previous focusable element within the
265275
`
266276
)
267277

278+
await nextFrame()
279+
268280
// Item A should be focused because the FocusTrap will focus the first item
269281
assertActiveElement(document.getElementById('item-a'))
270282

@@ -297,6 +309,8 @@ it('should skip the initial "hidden" elements within the focus trap', async () =
297309
`
298310
)
299311

312+
await nextFrame()
313+
300314
// Item C should be focused because the FocusTrap had to skip the first 2
301315
assertActiveElement(document.getElementById('item-c'))
302316
})
@@ -317,6 +331,8 @@ it('should be possible skip "hidden" elements within the focus trap', async () =
317331
`
318332
)
319333

334+
await nextFrame()
335+
320336
// Item A should be focused because the FocusTrap will focus the first item
321337
assertActiveElement(document.getElementById('item-a'))
322338

@@ -351,6 +367,8 @@ it('should be possible skip disabled elements within the focus trap', async () =
351367
`
352368
)
353369

370+
await nextFrame()
371+
354372
// Item A should be focused because the FocusTrap will focus the first item
355373
assertActiveElement(document.getElementById('item-a'))
356374

@@ -397,6 +415,8 @@ it('should try to focus all focusable items in order (and fail)', async () => {
397415
},
398416
})
399417

418+
await nextFrame()
419+
400420
expect(focusHandler.mock.calls).toEqual([['item-a'], ['item-b'], ['item-c'], ['item-d']])
401421
expect(spy).toHaveBeenCalledWith('There are no focusable elements inside the <FocusTrap />')
402422
spy.mockReset()
@@ -430,6 +450,8 @@ it('should end up at the last focusable element', async () => {
430450
},
431451
})
432452

453+
await nextFrame()
454+
433455
expect(focusHandler.mock.calls).toEqual([['item-a'], ['item-b'], ['item-c']])
434456
assertActiveElement(getByText('Item D'))
435457
expect(spy).not.toHaveBeenCalled()

packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { useTabDirection, Direction as TabDirection } from '../../hooks/use-tab-
2020
import { getOwnerDocument } from '../../utils/owner'
2121
import { useEventListener } from '../../hooks/use-event-listener'
2222
import { microTask } from '../../utils/micro-task'
23+
// import { disposables } from '../../utils/disposables'
2324

2425
enum Features {
2526
/** No features enabled for the focus trap. */
@@ -102,7 +103,7 @@ export let FocusTrap = Object.assign(
102103

103104
return () => {
104105
let slot = {}
105-
let ourProps = { 'data-hi': 'container', ref: container }
106+
let ourProps = { ref: container }
106107
let { features, initialFocus, containers: _containers, ...theirProps } = props
107108

108109
return h(Fragment, [
@@ -206,30 +207,32 @@ function useInitialFocus(
206207
let containerElement = dom(container)
207208
if (!containerElement) return
208209

209-
let initialFocusElement = dom(initialFocus)
210+
requestAnimationFrame(() => {
211+
let initialFocusElement = dom(initialFocus)
210212

211-
let activeElement = ownerDocument.value?.activeElement as HTMLElement
213+
let activeElement = ownerDocument.value?.activeElement as HTMLElement
212214

213-
if (initialFocusElement) {
214-
if (initialFocusElement === activeElement) {
215+
if (initialFocusElement) {
216+
if (initialFocusElement === activeElement) {
217+
previousActiveElement.value = activeElement
218+
return // Initial focus ref is already the active element
219+
}
220+
} else if (containerElement!.contains(activeElement)) {
215221
previousActiveElement.value = activeElement
216-
return // Initial focus ref is already the active element
222+
return // Already focused within Dialog
217223
}
218-
} else if (containerElement.contains(activeElement)) {
219-
previousActiveElement.value = activeElement
220-
return // Already focused within Dialog
221-
}
222224

223-
// Try to focus the initialFocus ref
224-
if (initialFocusElement) {
225-
focusElement(initialFocusElement)
226-
} else {
227-
if (focusIn(containerElement, Focus.First) === FocusResult.Error) {
228-
console.warn('There are no focusable elements inside the <FocusTrap />')
225+
// Try to focus the initialFocus ref
226+
if (initialFocusElement) {
227+
focusElement(initialFocusElement)
228+
} else {
229+
if (focusIn(containerElement!, Focus.First | Focus.NoScroll) === FocusResult.Error) {
230+
console.warn('There are no focusable elements inside the <FocusTrap />')
231+
}
229232
}
230-
}
231233

232-
previousActiveElement.value = ownerDocument.value?.activeElement as HTMLElement
234+
previousActiveElement.value = ownerDocument.value?.activeElement as HTMLElement
235+
})
233236
},
234237
{ immediate: true, flush: 'post' }
235238
)

0 commit comments

Comments
 (0)