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 (
+
+ )
+}
+
function Nested({ onClose, level = 0 }) {
let [showChild, setShowChild] = useState(false)
@@ -29,15 +39,9 @@ function Nested({ onClose, level = 0 }) {
>
- 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?
-