Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix restore focus to buttons in Safari, when Dialog component closes #2326

Merged
merged 5 commits into from
Mar 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down
68 changes: 52 additions & 16 deletions packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

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<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)
})
thecrypticace marked this conversation as resolved.
Show resolved Hide resolved
}

// 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
Expand All @@ -247,8 +284,7 @@ function useRestoreFocus({ ownerDocument }: { ownerDocument: Document | null },
microTask(() => {
if (!trulyUnmounted.current) return

focusElement(restoreElement.current)
restoreElement.current = null
focusElement(getRestoreElement())
})
}
}, [])
Expand Down
1 change: 1 addition & 0 deletions packages/@headlessui-vue/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>

Expand Down
92 changes: 66 additions & 26 deletions packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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

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(
Expand Down
2 changes: 1 addition & 1 deletion packages/@headlessui-vue/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"*": ["src/*", "node_modules/*"]
},
"esModuleInterop": true,
"target": "es5",
"target": "ESNext",
"allowJs": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
Expand Down
35 changes: 17 additions & 18 deletions packages/playground-react/pages/dialog/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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} />}
Expand All @@ -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
Expand Down
Loading