diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 000d2c0b29..a098a81eee 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -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 diff --git a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx index 9cc86e3d57..82646e0d95 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx @@ -647,9 +647,9 @@ describe('Composition', () => { Open Popover -
setIsDialogOpen(true)}> +
+
diff --git a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx index eade1eea4d..7e4e5bc0bd 100644 --- a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx +++ b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx @@ -210,31 +210,68 @@ export let FocusTrap = Object.assign(FocusTrapRoot, { // --- -function useRestoreFocus({ ownerDocument }: { ownerDocument: Document | null }, enabled: boolean) { - let restoreElement = useRef(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 + + 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 + } - // 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 + 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 }) +} - restoreElement.current = event.target as HTMLElement +function useRestoreElement(enabled: boolean = true) { + let localHistory = useRef(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()) }) } }, []) diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index 03c6b4a7e2..23b30ce92b 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Enable native label behavior for `` 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 diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts index 6ec8116aed..079a2acd77 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts @@ -863,7 +863,7 @@ describe('Composition', () => { Open Popover -
Open dialog
+
diff --git a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts index a09e2bca81..56b3e775e3 100644 --- a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts +++ b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts @@ -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) { + let localHistory = ref(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 }, enabled: Ref ) { - let restoreElement = ref(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 - - 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() + watchEffect( + () => { + if (enabled.value) return + + 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( diff --git a/packages/@headlessui-vue/tsconfig.json b/packages/@headlessui-vue/tsconfig.json index acaffcd595..3b566f773e 100644 --- a/packages/@headlessui-vue/tsconfig.json +++ b/packages/@headlessui-vue/tsconfig.json @@ -20,7 +20,7 @@ "*": ["src/*", "node_modules/*"] }, "esModuleInterop": true, - "target": "es5", + "target": "ESNext", "allowJs": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, diff --git a/packages/playground-react/pages/dialog/dialog.tsx b/packages/playground-react/pages/dialog/dialog.tsx index 527484f2f5..b617b59bd3 100644 --- a/packages/playground-react/pages/dialog/dialog.tsx +++ b/packages/playground-react/pages/dialog/dialog.tsx @@ -14,6 +14,16 @@ function resolveClass({ active, disabled }) { ) } +function Button(props: React.ComponentProps<'button'>) { + return ( + - - + + + {showChild && setShowChild(false)} level={level + 1} />} @@ -60,15 +64,10 @@ export default function Home() { return ( <> - - - +
+ + +
{nested && setNested(false)} />}
-

- 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? -

- - - - +
+ + +
@@ -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,