Skip to content

Commit

Permalink
Fix restore focus to buttons in Safari, when Dialog component closes (
Browse files Browse the repository at this point in the history
#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
  • Loading branch information
RobinMalfait authored Mar 3, 2023
1 parent af86b69 commit 7e150e4
Show file tree
Hide file tree
Showing 9 changed files with 165 additions and 100 deletions.
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)
})
}

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

0 comments on commit 7e150e4

Please sign in to comment.