Skip to content

Commit 7e150e4

Browse files
authored
Fix restore focus to buttons in Safari, when Dialog component closes (#2326)
* update dialog playground example Includes a generic `Button` component that has explicit focus styles. * keep track of "focus" history Safari doesn't "focus" buttons when you mousedown on them. This means that we don't capture the correct element to restore focus to when closing a `Dialog` for example. Now, we will make sure to keep track of a list of last "focused" items. We do this by also capturing elements when you "click", "mousedown" or "focus". * let's use a button instead of a div in tests * make `target` for Vue consistent with React * update changelog
1 parent af86b69 commit 7e150e4

File tree

9 files changed

+165
-100
lines changed

9 files changed

+165
-100
lines changed

packages/@headlessui-react/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Allow root containers from the `Dialog` component in the `FocusTrap` component ([#2322](https://github.com/tailwindlabs/headlessui/pull/2322))
1515
- Fix `XYZPropsWeControl` and cleanup internal TypeScript types ([#2329](https://github.com/tailwindlabs/headlessui/pull/2329))
1616
- Fix invalid warning when using multiple `Popover.Button` components inside a `Popover.Panel` ([#2333](https://github.com/tailwindlabs/headlessui/pull/2333))
17+
- Fix restore focus to buttons in Safari, when `Dialog` component closes ([#2326](https://github.com/tailwindlabs/headlessui/pull/2326))
1718

1819
## [1.7.12] - 2023-02-24
1920

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -647,9 +647,9 @@ describe('Composition', () => {
647647
<Popover>
648648
<Popover.Button>Open Popover</Popover.Button>
649649
<Popover.Panel>
650-
<div id="openDialog" onClick={() => setIsDialogOpen(true)}>
650+
<button id="openDialog" onClick={() => setIsDialogOpen(true)}>
651651
Open dialog
652-
</div>
652+
</button>
653653
</Popover.Panel>
654654
</Popover>
655655

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

+52-16
Original file line numberDiff line numberDiff line change
@@ -210,31 +210,68 @@ export let FocusTrap = Object.assign(FocusTrapRoot, {
210210

211211
// ---
212212

213-
function useRestoreFocus({ ownerDocument }: { ownerDocument: Document | null }, enabled: boolean) {
214-
let restoreElement = useRef<HTMLElement | null>(null)
213+
let history: HTMLElement[] = []
214+
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
215+
function handle(e: Event) {
216+
if (!(e.target instanceof HTMLElement)) return
217+
if (e.target === document.body) return
218+
if (history[0] === e.target) return
219+
220+
history.unshift(e.target)
221+
222+
// Filter out DOM Nodes that don't exist anymore
223+
history = history.filter((x) => x != null && x.isConnected)
224+
history.splice(10) // Only keep the 10 most recent items
225+
}
215226

216-
// Capture the currently focused element, before we try to move the focus inside the FocusTrap.
217-
useEventListener(
218-
ownerDocument?.defaultView,
219-
'focusout',
220-
(event) => {
221-
if (!enabled) return
222-
if (restoreElement.current) return
227+
window.addEventListener('click', handle, { capture: true })
228+
window.addEventListener('mousedown', handle, { capture: true })
229+
window.addEventListener('focus', handle, { capture: true })
230+
231+
document.body.addEventListener('click', handle, { capture: true })
232+
document.body.addEventListener('mousedown', handle, { capture: true })
233+
document.body.addEventListener('focus', handle, { capture: true })
234+
}
223235

224-
restoreElement.current = event.target as HTMLElement
236+
function useRestoreElement(enabled: boolean = true) {
237+
let localHistory = useRef<HTMLElement[]>(history.slice())
238+
239+
useWatch(
240+
([newEnabled], [oldEnabled]) => {
241+
// We are disabling the restore element, so we need to clear it.
242+
if (oldEnabled === true && newEnabled === false) {
243+
// However, let's schedule it in a microTask, so that we can still read the value in the
244+
// places where we are restoring the focus.
245+
microTask(() => {
246+
localHistory.current.splice(0)
247+
})
248+
}
249+
250+
// We are enabling the restore element, so we need to set it to the last "focused" element.
251+
if (oldEnabled === false && newEnabled === true) {
252+
localHistory.current = history.slice()
253+
}
225254
},
226-
true
255+
[enabled, history, localHistory]
227256
)
228257

258+
// We want to return the last element that is still connected to the DOM, so we can restore the
259+
// focus to it.
260+
return useEvent(() => {
261+
return localHistory.current.find((x) => x != null && x.isConnected) ?? null
262+
})
263+
}
264+
265+
function useRestoreFocus({ ownerDocument }: { ownerDocument: Document | null }, enabled: boolean) {
266+
let getRestoreElement = useRestoreElement(enabled)
267+
229268
// Restore the focus to the previous element when `enabled` becomes false again
230269
useWatch(() => {
231270
if (enabled) return
232271

233272
if (ownerDocument?.activeElement === ownerDocument?.body) {
234-
focusElement(restoreElement.current)
273+
focusElement(getRestoreElement())
235274
}
236-
237-
restoreElement.current = null
238275
}, [enabled])
239276

240277
// Restore the focus to the previous element when the component is unmounted
@@ -247,8 +284,7 @@ function useRestoreFocus({ ownerDocument }: { ownerDocument: Document | null },
247284
microTask(() => {
248285
if (!trulyUnmounted.current) return
249286

250-
focusElement(restoreElement.current)
251-
restoreElement.current = null
287+
focusElement(getRestoreElement())
252288
})
253289
}
254290
}, [])

packages/@headlessui-vue/CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Enable native label behavior for `<Switch>` where possible ([#2265](https://github.com/tailwindlabs/headlessui/pull/2265))
1313
- Allow root containers from the `Dialog` component in the `FocusTrap` component ([#2322](https://github.com/tailwindlabs/headlessui/pull/2322))
1414
- Cleanup internal TypeScript types ([#2329](https://github.com/tailwindlabs/headlessui/pull/2329))
15+
- Fix restore focus to buttons in Safari, when `Dialog` component closes ([#2326](https://github.com/tailwindlabs/headlessui/pull/2326))
1516

1617
## [1.7.11] - 2023-02-24
1718

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -863,7 +863,7 @@ describe('Composition', () => {
863863
<Popover>
864864
<PopoverButton>Open Popover</PopoverButton>
865865
<PopoverPanel>
866-
<div id="openDialog" @click="isDialogOpen = true">Open dialog</div>
866+
<button id="openDialog" @click="isDialogOpen = true">Open dialog</button>
867867
</PopoverPanel>
868868
</Popover>
869869

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

+66-26
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import {
88
watch,
99

1010
// Types
11-
PropType,
1211
Fragment,
12+
PropType,
1313
Ref,
14+
watchEffect,
1415
} from 'vue'
1516
import { render } from '../../utils/render'
1617
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
@@ -202,44 +203,83 @@ export let FocusTrap = Object.assign(
202203
{ features: Features }
203204
)
204205

206+
let history: HTMLElement[] = []
207+
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
208+
function handle(e: Event) {
209+
if (!(e.target instanceof HTMLElement)) return
210+
if (e.target === document.body) return
211+
if (history[0] === e.target) return
212+
213+
history.unshift(e.target)
214+
215+
// Filter out DOM Nodes that don't exist anymore
216+
history = history.filter((x) => x != null && x.isConnected)
217+
history.splice(10) // Only keep the 10 most recent items
218+
}
219+
220+
window.addEventListener('click', handle, { capture: true })
221+
window.addEventListener('mousedown', handle, { capture: true })
222+
window.addEventListener('focus', handle, { capture: true })
223+
224+
document.body.addEventListener('click', handle, { capture: true })
225+
document.body.addEventListener('mousedown', handle, { capture: true })
226+
document.body.addEventListener('focus', handle, { capture: true })
227+
}
228+
229+
function useRestoreElement(enabled: Ref<boolean>) {
230+
let localHistory = ref<HTMLElement[]>(history.slice())
231+
232+
watch(
233+
[enabled],
234+
([newEnabled], [oldEnabled]) => {
235+
// We are disabling the restore element, so we need to clear it.
236+
if (oldEnabled === true && newEnabled === false) {
237+
// However, let's schedule it in a microTask, so that we can still read the value in the
238+
// places where we are restoring the focus.
239+
microTask(() => {
240+
localHistory.value.splice(0)
241+
})
242+
}
243+
244+
// We are enabling the restore element, so we need to set it to the last "focused" element.
245+
else if (oldEnabled === false && newEnabled === true) {
246+
localHistory.value = history.slice()
247+
}
248+
},
249+
{ flush: 'post' }
250+
)
251+
252+
// We want to return the last element that is still connected to the DOM, so we can restore the
253+
// focus to it.
254+
return () => {
255+
return localHistory.value.find((x) => x != null && x.isConnected) ?? null
256+
}
257+
}
258+
205259
function useRestoreFocus(
206260
{ ownerDocument }: { ownerDocument: Ref<Document | null> },
207261
enabled: Ref<boolean>
208262
) {
209-
let restoreElement = ref<HTMLElement | null>(null)
210-
211-
function captureFocus() {
212-
if (restoreElement.value) return
213-
restoreElement.value = ownerDocument.value?.activeElement as HTMLElement
214-
}
263+
let getRestoreElement = useRestoreElement(enabled)
215264

216265
// Restore the focus to the previous element
217-
function restoreFocusIfNeeded() {
218-
if (!restoreElement.value) return
219-
focusElement(restoreElement.value)
220-
restoreElement.value = null
221-
}
222-
223266
onMounted(() => {
224-
watch(
225-
enabled,
226-
(newValue, prevValue) => {
227-
if (newValue === prevValue) return
228-
229-
if (newValue) {
230-
// The FocusTrap has become enabled which means we're going to move the focus into the trap
231-
// We need to capture the current focus before we do that so we can restore it when done
232-
captureFocus()
233-
} else {
234-
restoreFocusIfNeeded()
267+
watchEffect(
268+
() => {
269+
if (enabled.value) return
270+
271+
if (ownerDocument.value?.activeElement === ownerDocument.value?.body) {
272+
focusElement(getRestoreElement())
235273
}
236274
},
237-
{ immediate: true }
275+
{ flush: 'post' }
238276
)
239277
})
240278

241279
// Restore the focus when we unmount the component
242-
onUnmounted(restoreFocusIfNeeded)
280+
onUnmounted(() => {
281+
focusElement(getRestoreElement())
282+
})
243283
}
244284

245285
function useInitialFocus(

packages/@headlessui-vue/tsconfig.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"*": ["src/*", "node_modules/*"]
2121
},
2222
"esModuleInterop": true,
23-
"target": "es5",
23+
"target": "ESNext",
2424
"allowJs": true,
2525
"skipLibCheck": true,
2626
"forceConsistentCasingInFileNames": true,

packages/playground-react/pages/dialog/dialog.tsx

+17-18
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ function resolveClass({ active, disabled }) {
1414
)
1515
}
1616

17+
function Button(props: React.ComponentProps<'button'>) {
18+
return (
19+
<button
20+
type="button"
21+
className="rounded bg-gray-200 px-2 py-1 ring-gray-500 ring-offset-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2"
22+
{...props}
23+
/>
24+
)
25+
}
26+
1727
function Nested({ onClose, level = 0 }) {
1828
let [showChild, setShowChild] = useState(false)
1929

@@ -29,15 +39,9 @@ function Nested({ onClose, level = 0 }) {
2939
>
3040
<p>Level: {level}</p>
3141
<div className="space-x-4">
32-
<button className="rounded bg-gray-200 px-2 py-1" onClick={() => setShowChild(true)}>
33-
Open (1)
34-
</button>
35-
<button className="rounded bg-gray-200 px-2 py-1" onClick={() => setShowChild(true)}>
36-
Open (2)
37-
</button>
38-
<button className="rounded bg-gray-200 px-2 py-1" onClick={() => setShowChild(true)}>
39-
Open (3)
40-
</button>
42+
<Button onClick={() => setShowChild(true)}>Open (1)</Button>
43+
<Button onClick={() => setShowChild(true)}>Open (2)</Button>
44+
<Button onClick={() => setShowChild(true)}>Open (3)</Button>
4145
</div>
4246
</div>
4347
{showChild && <Nested onClose={() => setShowChild(false)} level={level + 1} />}
@@ -60,15 +64,10 @@ export default function Home() {
6064

6165
return (
6266
<>
63-
<button
64-
type="button"
65-
onClick={() => setIsOpen((v) => !v)}
66-
className="focus:shadow-outline-blue m-12 rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5"
67-
>
68-
Toggle!
69-
</button>
70-
71-
<button onClick={() => setNested(true)}>Show nested</button>
67+
<div className="flex gap-4 p-12">
68+
<Button onClick={() => setIsOpen((v) => !v)}>Toggle!</Button>
69+
<Button onClick={() => setNested(true)}>Show nested</Button>
70+
</div>
7271
{nested && <Nested onClose={() => setNested(false)} />}
7372

7473
<div

0 commit comments

Comments
 (0)