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
This commit is contained in:
Robin Malfait
2023-03-03 18:24:57 +01:00
committed by GitHub
parent af86b69d0d
commit 7e150e408c
9 changed files with 164 additions and 99 deletions
+1
View File
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Allow root containers from the `Dialog` component in the `FocusTrap` component ([#2322](https://github.com/tailwindlabs/headlessui/pull/2322))
- Fix `XYZPropsWeControl` and cleanup internal TypeScript types ([#2329](https://github.com/tailwindlabs/headlessui/pull/2329))
- Fix invalid warning when using multiple `Popover.Button` components inside a `Popover.Panel` ([#2333](https://github.com/tailwindlabs/headlessui/pull/2333))
- Fix restore focus to buttons in Safari, when `Dialog` component closes ([#2326](https://github.com/tailwindlabs/headlessui/pull/2326))
## [1.7.12] - 2023-02-24
@@ -647,9 +647,9 @@ describe('Composition', () => {
<Popover>
<Popover.Button>Open Popover</Popover.Button>
<Popover.Panel>
<div id="openDialog" onClick={() => setIsDialogOpen(true)}>
<button id="openDialog" onClick={() => setIsDialogOpen(true)}>
Open dialog
</div>
</button>
</Popover.Panel>
</Popover>
@@ -210,31 +210,68 @@ export let FocusTrap = Object.assign(FocusTrapRoot, {
// ---
function useRestoreFocus({ ownerDocument }: { ownerDocument: Document | null }, enabled: boolean) {
let restoreElement = useRef<HTMLElement | null>(null)
let history: HTMLElement[] = []
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
function handle(e: Event) {
if (!(e.target instanceof HTMLElement)) return
if (e.target === document.body) return
if (history[0] === e.target) return
// Capture the currently focused element, before we try to move the focus inside the FocusTrap.
useEventListener(
ownerDocument?.defaultView,
'focusout',
(event) => {
if (!enabled) return
if (restoreElement.current) return
history.unshift(e.target)
restoreElement.current = event.target as HTMLElement
// Filter out DOM Nodes that don't exist anymore
history = history.filter((x) => x != null && x.isConnected)
history.splice(10) // Only keep the 10 most recent items
}
window.addEventListener('click', handle, { capture: true })
window.addEventListener('mousedown', handle, { capture: true })
window.addEventListener('focus', handle, { capture: true })
document.body.addEventListener('click', handle, { capture: true })
document.body.addEventListener('mousedown', handle, { capture: true })
document.body.addEventListener('focus', handle, { capture: true })
}
function useRestoreElement(enabled: boolean = true) {
let localHistory = useRef<HTMLElement[]>(history.slice())
useWatch(
([newEnabled], [oldEnabled]) => {
// We are disabling the restore element, so we need to clear it.
if (oldEnabled === true && newEnabled === false) {
// However, let's schedule it in a microTask, so that we can still read the value in the
// places where we are restoring the focus.
microTask(() => {
localHistory.current.splice(0)
})
}
// We are enabling the restore element, so we need to set it to the last "focused" element.
if (oldEnabled === false && newEnabled === true) {
localHistory.current = history.slice()
}
},
true
[enabled, history, localHistory]
)
// We want to return the last element that is still connected to the DOM, so we can restore the
// focus to it.
return useEvent(() => {
return localHistory.current.find((x) => x != null && x.isConnected) ?? null
})
}
function useRestoreFocus({ ownerDocument }: { ownerDocument: Document | null }, enabled: boolean) {
let getRestoreElement = useRestoreElement(enabled)
// Restore the focus to the previous element when `enabled` becomes false again
useWatch(() => {
if (enabled) return
if (ownerDocument?.activeElement === ownerDocument?.body) {
focusElement(restoreElement.current)
focusElement(getRestoreElement())
}
restoreElement.current = null
}, [enabled])
// Restore the focus to the previous element when the component is unmounted
@@ -247,8 +284,7 @@ function useRestoreFocus({ ownerDocument }: { ownerDocument: Document | null },
microTask(() => {
if (!trulyUnmounted.current) return
focusElement(restoreElement.current)
restoreElement.current = null
focusElement(getRestoreElement())
})
}
}, [])
+1
View File
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Enable native label behavior for `<Switch>` where possible ([#2265](https://github.com/tailwindlabs/headlessui/pull/2265))
- Allow root containers from the `Dialog` component in the `FocusTrap` component ([#2322](https://github.com/tailwindlabs/headlessui/pull/2322))
- Cleanup internal TypeScript types ([#2329](https://github.com/tailwindlabs/headlessui/pull/2329))
- Fix restore focus to buttons in Safari, when `Dialog` component closes ([#2326](https://github.com/tailwindlabs/headlessui/pull/2326))
## [1.7.11] - 2023-02-24
@@ -863,7 +863,7 @@ describe('Composition', () => {
<Popover>
<PopoverButton>Open Popover</PopoverButton>
<PopoverPanel>
<div id="openDialog" @click="isDialogOpen = true">Open dialog</div>
<button id="openDialog" @click="isDialogOpen = true">Open dialog</button>
</PopoverPanel>
</Popover>
@@ -8,9 +8,10 @@ import {
watch,
// Types
PropType,
Fragment,
PropType,
Ref,
watchEffect,
} from 'vue'
import { render } from '../../utils/render'
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
@@ -202,44 +203,83 @@ export let FocusTrap = Object.assign(
{ features: Features }
)
let history: HTMLElement[] = []
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
function handle(e: Event) {
if (!(e.target instanceof HTMLElement)) return
if (e.target === document.body) return
if (history[0] === e.target) return
history.unshift(e.target)
// Filter out DOM Nodes that don't exist anymore
history = history.filter((x) => x != null && x.isConnected)
history.splice(10) // Only keep the 10 most recent items
}
window.addEventListener('click', handle, { capture: true })
window.addEventListener('mousedown', handle, { capture: true })
window.addEventListener('focus', handle, { capture: true })
document.body.addEventListener('click', handle, { capture: true })
document.body.addEventListener('mousedown', handle, { capture: true })
document.body.addEventListener('focus', handle, { capture: true })
}
function useRestoreElement(enabled: Ref<boolean>) {
let localHistory = ref<HTMLElement[]>(history.slice())
watch(
[enabled],
([newEnabled], [oldEnabled]) => {
// We are disabling the restore element, so we need to clear it.
if (oldEnabled === true && newEnabled === false) {
// However, let's schedule it in a microTask, so that we can still read the value in the
// places where we are restoring the focus.
microTask(() => {
localHistory.value.splice(0)
})
}
// We are enabling the restore element, so we need to set it to the last "focused" element.
else if (oldEnabled === false && newEnabled === true) {
localHistory.value = history.slice()
}
},
{ flush: 'post' }
)
// We want to return the last element that is still connected to the DOM, so we can restore the
// focus to it.
return () => {
return localHistory.value.find((x) => x != null && x.isConnected) ?? null
}
}
function useRestoreFocus(
{ ownerDocument }: { ownerDocument: Ref<Document | null> },
enabled: Ref<boolean>
) {
let restoreElement = ref<HTMLElement | null>(null)
function captureFocus() {
if (restoreElement.value) return
restoreElement.value = ownerDocument.value?.activeElement as HTMLElement
}
let getRestoreElement = useRestoreElement(enabled)
// Restore the focus to the previous element
function restoreFocusIfNeeded() {
if (!restoreElement.value) return
focusElement(restoreElement.value)
restoreElement.value = null
}
onMounted(() => {
watch(
enabled,
(newValue, prevValue) => {
if (newValue === prevValue) return
watchEffect(
() => {
if (enabled.value) return
if (newValue) {
// The FocusTrap has become enabled which means we're going to move the focus into the trap
// We need to capture the current focus before we do that so we can restore it when done
captureFocus()
} else {
restoreFocusIfNeeded()
if (ownerDocument.value?.activeElement === ownerDocument.value?.body) {
focusElement(getRestoreElement())
}
},
{ immediate: true }
{ flush: 'post' }
)
})
// Restore the focus when we unmount the component
onUnmounted(restoreFocusIfNeeded)
onUnmounted(() => {
focusElement(getRestoreElement())
})
}
function useInitialFocus(
+1 -1
View File
@@ -20,7 +20,7 @@
"*": ["src/*", "node_modules/*"]
},
"esModuleInterop": true,
"target": "es5",
"target": "ESNext",
"allowJs": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
@@ -14,6 +14,16 @@ function resolveClass({ active, disabled }) {
)
}
function Button(props: React.ComponentProps<'button'>) {
return (
<button
type="button"
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"
{...props}
/>
)
}
function Nested({ onClose, level = 0 }) {
let [showChild, setShowChild] = useState(false)
@@ -29,15 +39,9 @@ function Nested({ onClose, level = 0 }) {
>
<p>Level: {level}</p>
<div className="space-x-4">
<button className="rounded bg-gray-200 px-2 py-1" onClick={() => setShowChild(true)}>
Open (1)
</button>
<button className="rounded bg-gray-200 px-2 py-1" onClick={() => setShowChild(true)}>
Open (2)
</button>
<button className="rounded bg-gray-200 px-2 py-1" onClick={() => setShowChild(true)}>
Open (3)
</button>
<Button onClick={() => setShowChild(true)}>Open (1)</Button>
<Button onClick={() => setShowChild(true)}>Open (2)</Button>
<Button onClick={() => setShowChild(true)}>Open (3)</Button>
</div>
</div>
{showChild && <Nested onClose={() => setShowChild(false)} level={level + 1} />}
@@ -60,15 +64,10 @@ export default function Home() {
return (
<>
<button
type="button"
onClick={() => setIsOpen((v) => !v)}
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"
>
Toggle!
</button>
<button onClick={() => setNested(true)}>Show nested</button>
<div className="flex gap-4 p-12">
<Button onClick={() => setIsOpen((v) => !v)}>Toggle!</Button>
<Button onClick={() => setNested(true)}>Show nested</Button>
</div>
{nested && <Nested onClose={() => setNested(false)} />}
<div
@@ -1,25 +1,8 @@
<template>
<p
v-for="i in Array(15)
.fill(null)
.map((_, i) => i)"
:key="i"
className="m-4"
>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam numquam beatae, maiores sint
est perferendis molestiae deleniti dolorem, illum vel, quam atque facilis! Necessitatibus
nostrum recusandae nemo corrupti, odio eius?
</p>
<button
type="button"
@click="toggleIsOpen()"
class="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"
>
Toggle!
</button>
<button @click="nested = true">Show nested</button>
<div className="flex gap-4 p-12">
<Button @click="toggleIsOpen()">Toggle!</Button>
<Button @click="nested = true">Show nested</Button>
</div>
<Nested v-if="nested" @close="nested = false" />
<TransitionRoot :show="isOpen" as="template">
@@ -224,6 +207,22 @@ function resolveClass({ active, disabled }) {
)
}
let Button = defineComponent({
setup(props, { slots }) {
return () =>
h(
'button',
{
type: 'button',
class:
'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',
...props,
},
slots.default?.()
)
},
})
let Nested = defineComponent({
components: { Dialog, DialogOverlay },
emits: ['close'],
@@ -247,21 +246,9 @@ let Nested = defineComponent({
[
h('p', `Level: ${level}`),
h('div', { class: 'space-x-4' }, [
h(
'button',
{ class: 'rounded bg-gray-200 px-2 py-1', onClick: () => (showChild.value = true) },
`Open ${level + 1} a`
),
h(
'button',
{ class: 'rounded bg-gray-200 px-2 py-1', onClick: () => (showChild.value = true) },
`Open ${level + 1} b`
),
h(
'button',
{ class: 'rounded bg-gray-200 px-2 py-1', onClick: () => (showChild.value = true) },
`Open ${level + 1} c`
),
h(Button, { onClick: () => (showChild.value = true) }, () => `Open ${level + 1} a`),
h(Button, { onClick: () => (showChild.value = true) }, () => `Open ${level + 1} b`),
h(Button, { onClick: () => (showChild.value = true) }, () => `Open ${level + 1} c`),
]),
]
),
@@ -277,6 +264,7 @@ let Nested = defineComponent({
export default {
components: {
Button,
Nested,
Dialog,
DialogTitle,