From 07f05f55c71db534cb2b3cf3b2334cd0517e499f Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 31 Aug 2023 15:17:59 +0200 Subject: [PATCH] ensure we see the "relatedTarget" in Safari Safari doesn't fire a `focus` event when clicking a button, therefore it does not become the `document.activeElement`, and events like `blur` or `focus` doesn't set the button as the `event.relatedTarget`. Keeping track of a history like this solves that problem. We already had the code for the `FocusTrap` component. --- .../src/components/combobox/combobox.tsx | 15 +++++++---- .../src/components/focus-trap/focus-trap.tsx | 25 +------------------ .../src/utils/active-element-history.ts | 24 ++++++++++++++++++ .../src/components/combobox/combobox.ts | 20 +++++++-------- .../src/components/focus-trap/focus-trap.ts | 25 +------------------ .../src/utils/active-element-history.ts | 24 ++++++++++++++++++ 6 files changed, 70 insertions(+), 63 deletions(-) create mode 100644 packages/@headlessui-react/src/utils/active-element-history.ts create mode 100644 packages/@headlessui-vue/src/utils/active-element-history.ts diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index c0a727a10..88f5b1c91 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -29,6 +29,7 @@ import { useOutsideClick } from '../../hooks/use-outside-click' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useSyncRefs } from '../../hooks/use-sync-refs' import { useTreeWalker } from '../../hooks/use-tree-walker' +import { history } from '../../utils/active-element-history' import { calculateActiveIndex, Focus } from '../../utils/calculate-active-index' import { disposables } from '../../utils/disposables' @@ -1043,14 +1044,16 @@ function InputFn< }) let handleBlur = useEvent((event: ReactFocusEvent) => { + let relatedTarget = + (event.relatedTarget as HTMLElement) ?? history.find((x) => x !== event.currentTarget) isTyping.current = false // Focus is moved into the list, we don't want to close yet. - if (data.optionsRef.current?.contains(event.relatedTarget)) { + if (data.optionsRef.current?.contains(relatedTarget)) { return } - if (data.buttonRef.current?.contains(event.relatedTarget)) { + if (data.buttonRef.current?.contains(relatedTarget)) { return } @@ -1078,8 +1081,10 @@ function InputFn< }) let handleFocus = useEvent((event: ReactFocusEvent) => { - if (data.buttonRef.current?.contains(event.relatedTarget as HTMLElement)) return - if (data.optionsRef.current?.contains(event.relatedTarget as HTMLElement)) return + let relatedTarget = + (event.relatedTarget as HTMLElement) ?? history.find((x) => x !== event.currentTarget) + if (data.buttonRef.current?.contains(relatedTarget)) return + if (data.optionsRef.current?.contains(relatedTarget)) return if (data.disabled) return if (!data.immediate) return @@ -1214,7 +1219,7 @@ function ButtonFn( } }) - let handleClick = useEvent((event: ReactMouseEvent) => { + let handleClick = useEvent((event: ReactMouseEvent) => { if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() if (data.comboboxState === ComboboxState.Open) { actions.closeCombobox() 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 88a2362fd..7e0ad8edf 100644 --- a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx +++ b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx @@ -23,8 +23,8 @@ import { useEventListener } from '../../hooks/use-event-listener' import { microTask } from '../../utils/micro-task' import { useWatch } from '../../hooks/use-watch' import { useDisposables } from '../../hooks/use-disposables' -import { onDocumentReady } from '../../utils/document-ready' import { useOnUnmount } from '../../hooks/use-on-unmount' +import { history } from '../../utils/active-element-history' type Containers = // Lazy resolved containers @@ -212,29 +212,6 @@ export let FocusTrap = Object.assign(FocusTrapRoot, { // --- -let history: HTMLElement[] = [] -onDocumentReady(() => { - 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: boolean = true) { let localHistory = useRef(history.slice()) diff --git a/packages/@headlessui-react/src/utils/active-element-history.ts b/packages/@headlessui-react/src/utils/active-element-history.ts new file mode 100644 index 000000000..d75dbb399 --- /dev/null +++ b/packages/@headlessui-react/src/utils/active-element-history.ts @@ -0,0 +1,24 @@ +import { onDocumentReady } from './document-ready' + +export let history: HTMLElement[] = [] +onDocumentReady(() => { + 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 }) +}) diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index 765cfa681..2fa7850af 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -39,6 +39,7 @@ import { useTrackedPointer } from '../../hooks/use-tracked-pointer' import { isMobile } from '../../utils/platform' import { disposables } from '../../utils/disposables' import { getOwnerDocument } from '../../utils/owner' +import { history } from '../../utils/active-element-history' function defaultComparator(a: T, z: T): boolean { return a === z @@ -996,20 +997,16 @@ export let ComboboxInput = defineComponent({ } function handleBlur(event: FocusEvent) { + let relatedTarget = + (event.relatedTarget as HTMLElement) ?? history.find((x) => x !== event.currentTarget) isTyping.value = false // Focus is moved into the list, we don't want to close yet. - if ( - event.relatedTarget instanceof Node && - dom(api.optionsRef)?.contains(event.relatedTarget) - ) { + if (dom(api.optionsRef)?.contains(relatedTarget)) { return } - if ( - event.relatedTarget instanceof Node && - dom(api.buttonRef)?.contains(event.relatedTarget) - ) { + if (dom(api.buttonRef)?.contains(relatedTarget)) { return } @@ -1037,8 +1034,11 @@ export let ComboboxInput = defineComponent({ } function handleFocus(event: FocusEvent) { - if (dom(api.buttonRef)?.contains(event.relatedTarget as HTMLElement)) return - if (dom(api.optionsRef)?.contains(event.relatedTarget as HTMLElement)) return + let relatedTarget = + (event.relatedTarget as HTMLElement) ?? history.find((x) => x !== event.currentTarget) + + if (dom(api.buttonRef)?.contains(relatedTarget)) return + if (dom(api.optionsRef)?.contains(relatedTarget)) return if (api.disabled.value) return if (!api.immediate.value) return 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 bdeca3037..677f60531 100644 --- a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts +++ b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.ts @@ -22,7 +22,7 @@ import { useTabDirection, Direction as TabDirection } from '../../hooks/use-tab- import { getOwnerDocument } from '../../utils/owner' import { useEventListener } from '../../hooks/use-event-listener' import { microTask } from '../../utils/micro-task' -import { onDocumentReady } from '../../utils/document-ready' +import { history } from '../../utils/active-element-history' type Containers = // Lazy resolved containers @@ -209,29 +209,6 @@ export let FocusTrap = Object.assign( { features: Features } ) -let history: HTMLElement[] = [] -onDocumentReady(() => { - 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()) diff --git a/packages/@headlessui-vue/src/utils/active-element-history.ts b/packages/@headlessui-vue/src/utils/active-element-history.ts new file mode 100644 index 000000000..d75dbb399 --- /dev/null +++ b/packages/@headlessui-vue/src/utils/active-element-history.ts @@ -0,0 +1,24 @@ +import { onDocumentReady } from './document-ready' + +export let history: HTMLElement[] = [] +onDocumentReady(() => { + 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 }) +})