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:
@@ -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())
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user