From c716aa5cdfc8dc1380a75672e85a4d9b7035b0b2 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Sat, 26 Feb 2022 02:28:00 +0100 Subject: [PATCH] use `ownerDocument` instead of `document` This should ensure that in iframes and new windows the correct document is being used. --- .../src/components/combobox/combobox.tsx | 10 +- .../src/components/dialog/dialog.tsx | 18 ++-- .../src/components/disclosure/disclosure.tsx | 31 +++++-- .../src/components/listbox/listbox.tsx | 19 ++-- .../src/components/menu/menu.tsx | 22 +++-- .../src/components/popover/popover.tsx | 36 ++++--- .../src/components/portal/portal.tsx | 27 ++++-- .../components/radio-group/radio-group.tsx | 9 +- .../src/hooks/use-focus-trap.ts | 15 +-- .../src/hooks/use-inert-others.ts | 79 ++++++++-------- .../@headlessui-react/src/hooks/use-owner.ts | 8 ++ .../src/hooks/use-sync-refs.ts | 15 ++- .../src/hooks/use-tree-walker.ts | 10 +- .../src/hooks/use-window-event.ts | 11 ++- .../src/utils/focus-management.ts | 13 ++- packages/@headlessui-react/src/utils/owner.ts | 12 +++ .../src/components/combobox/combobox.ts | 4 +- .../src/components/dialog/dialog.ts | 19 ++-- .../src/components/listbox/listbox.ts | 8 +- .../src/components/menu/menu.ts | 9 +- .../src/components/popover/popover.ts | 32 ++++--- .../src/components/portal/portal.ts | 22 +++-- .../src/components/radio-group/radio-group.ts | 7 +- .../src/hooks/use-focus-trap.ts | 12 ++- .../src/hooks/use-inert-others.ts | 79 ++++++++-------- .../src/hooks/use-tree-walker.ts | 10 +- .../src/hooks/use-window-event.ts | 5 +- packages/@headlessui-vue/src/utils/dom.ts | 4 +- .../src/utils/focus-management.ts | 14 ++- packages/@headlessui-vue/src/utils/owner.ts | 14 +++ packages/playground-react/pages/tmp.tsx | 93 +++++++++++++++++++ 31 files changed, 456 insertions(+), 211 deletions(-) create mode 100644 packages/@headlessui-react/src/hooks/use-owner.ts create mode 100644 packages/@headlessui-react/src/utils/owner.ts create mode 100644 packages/@headlessui-vue/src/utils/owner.ts create mode 100644 packages/playground-react/pages/tmp.tsx diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index e3dab395e2..b55a75507f 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -36,6 +36,7 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useLatestValue } from '../../hooks/use-latest-value' import { useTreeWalker } from '../../hooks/use-tree-walker' import { sortByDomNode } from '../../utils/focus-management' +import { useOwnerDocument } from '../../hooks/use-owner' enum ComboboxStates { Open, @@ -863,6 +864,7 @@ let Option = forwardRefWithAs(function Option< let internalOptionRef = useRef(null) let bag = useRef({ disabled, value, domRef: internalOptionRef }) let optionRef = useSyncRefs(ref, internalOptionRef) + let ownerDocument = useOwnerDocument(internalOptionRef) useIsoMorphicEffect(() => { bag.current.disabled = disabled @@ -871,8 +873,8 @@ let Option = forwardRefWithAs(function Option< bag.current.value = value }, [bag, value]) useIsoMorphicEffect(() => { - bag.current.textValue = document.getElementById(id)?.textContent?.toLowerCase() - }, [bag, id]) + bag.current.textValue = ownerDocument?.getElementById(id)?.textContent?.toLowerCase() + }, [bag, id, ownerDocument]) let select = useCallback(() => actions.selectOption(id), [actions, id]) @@ -904,10 +906,10 @@ let Option = forwardRefWithAs(function Option< if (state.activationTrigger === ActivationTrigger.Pointer) return let d = disposables() d.requestAnimationFrame(() => { - document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' }) + ownerDocument?.getElementById(id)?.scrollIntoView?.({ block: 'nearest' }) }) return d.dispose - }, [id, active, state.comboboxState, state.activationTrigger, /* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ state.activeOptionIndex]) + }, [id, active, state.comboboxState, ownerDocument, state.activationTrigger, /* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ state.activeOptionIndex]) let handleClick = useCallback( (event: { preventDefault: Function }) => { diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index 029ef5ed63..697fe33fe2 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -34,6 +34,7 @@ import { useOpenClosed, State } from '../../internal/open-closed' import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete' import { StackProvider, StackMessage } from '../../internal/stack-context' import { useOutsideClick } from '../../hooks/use-outside-click' +import { getOwnerDocument } from '../../utils/owner' enum DialogStates { Open, @@ -231,17 +232,20 @@ let DialogRoot = forwardRefWithAs(function Dialog< if (dialogState !== DialogStates.Open) return if (hasParentDialog) return - let overflow = document.documentElement.style.overflow - let paddingRight = document.documentElement.style.paddingRight + let ownerDocument = getOwnerDocument(internalDialogRef.current) + let ownerWindow = ownerDocument.defaultView ?? window - let scrollbarWidth = window.innerWidth - document.documentElement.clientWidth + let overflow = ownerDocument.documentElement.style.overflow + let paddingRight = ownerDocument.documentElement.style.paddingRight - document.documentElement.style.overflow = 'hidden' - document.documentElement.style.paddingRight = `${scrollbarWidth}px` + let scrollbarWidth = ownerWindow.innerWidth - ownerDocument.documentElement.clientWidth + + ownerDocument.documentElement.style.overflow = 'hidden' + ownerDocument.documentElement.style.paddingRight = `${scrollbarWidth}px` return () => { - document.documentElement.style.overflow = overflow - document.documentElement.style.paddingRight = paddingRight + ownerDocument.documentElement.style.overflow = overflow + ownerDocument.documentElement.style.paddingRight = paddingRight } }, [dialogState, hasParentDialog]) diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx index 5ae2d571a6..b942d5f261 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx @@ -22,12 +22,14 @@ import React, { import { Props } from '../../types' import { match } from '../../utils/match' import { forwardRefWithAs, render, Features, PropsForFeatures } from '../../utils/render' -import { useSyncRefs } from '../../hooks/use-sync-refs' +import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs' import { useId } from '../../hooks/use-id' import { Keys } from '../keyboard' import { isDisabledReactIssue7711 } from '../../utils/bugs' import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' +import { getOwnerDocument } from '../../utils/owner' +import { useOwnerDocument } from '../../hooks/use-owner' enum DisclosureStates { Open, @@ -155,7 +157,18 @@ let DisclosureRoot = forwardRefWithAs(function Disclosure< let { defaultOpen = false, ...passthroughProps } = props let buttonId = `headlessui-disclosure-button-${useId()}` let panelId = `headlessui-disclosure-panel-${useId()}` - let disclosureRef = useSyncRefs(ref) + let internalDisclosureRef = useRef(null) + let disclosureRef = useSyncRefs( + ref, + optionalRef( + (ref) => { + internalDisclosureRef.current = ref as unknown as HTMLElement | null + }, + props.as === undefined || + // @ts-expect-error The `as` prop _can_ be a Fragment + props.as === React.Fragment + ) + ) let reducerBag = useReducer(stateReducer, { disclosureState: defaultOpen ? DisclosureStates.Open : DisclosureStates.Closed, @@ -171,13 +184,14 @@ let DisclosureRoot = forwardRefWithAs(function Disclosure< let close = useCallback( (focusableElement?: HTMLElement | MutableRefObject) => { dispatch({ type: ActionTypes.CloseDisclosure }) + let ownerDocument = getOwnerDocument(internalDisclosureRef) let restoreElement = (() => { - if (!focusableElement) return document.getElementById(buttonId) + if (!focusableElement) return ownerDocument.getElementById(buttonId) if (focusableElement instanceof HTMLElement) return focusableElement if (focusableElement.current instanceof HTMLElement) return focusableElement.current - return document.getElementById(buttonId) + return ownerDocument.getElementById(buttonId) })() restoreElement?.focus() @@ -234,6 +248,7 @@ let Button = forwardRefWithAs(function Button(null) let buttonRef = useSyncRefs(internalButtonRef, ref) + let ownerDocument = useOwnerDocument(internalButtonRef) let panelContext = useDisclosurePanelContext() let isWithinPanel = panelContext === null ? false : panelContext === state.panelId @@ -249,7 +264,7 @@ let Button = forwardRefWithAs(function Button) => { @@ -284,12 +299,12 @@ let Button = forwardRefWithAs(function Button( diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index eb973a6d8e..2b33a12965 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -33,6 +33,8 @@ import { isFocusableElement, FocusableMode, sortByDomNode } from '../../utils/fo import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useOutsideClick } from '../../hooks/use-outside-click' +import { getOwnerDocument } from '../../utils/owner' +import { useOwnerDocument } from '../../hooks/use-owner' enum ListboxStates { Open, @@ -530,7 +532,7 @@ let Options = forwardRefWithAs(function Options< let container = state.optionsRef.current if (!container) return if (state.listboxState !== ListboxStates.Open) return - if (container === document.activeElement) return + if (container === getOwnerDocument(container).activeElement) return container.focus({ preventScroll: true }) }, [state.listboxState, state.optionsRef]) @@ -675,7 +677,8 @@ let Option = forwardRefWithAs(function Option< let active = state.activeOptionIndex !== null ? state.options[state.activeOptionIndex].id === id : false let selected = state.propsRef.current.value === value - let internalOptionRef = useRef(null) + let internalOptionRef = useRef(null) + let ownerDocument = useOwnerDocument(internalOptionRef) let optionRef = useSyncRefs(ref, internalOptionRef) let bag = useRef({ disabled, value, domRef: internalOptionRef }) @@ -687,8 +690,8 @@ let Option = forwardRefWithAs(function Option< bag.current.value = value }, [bag, value]) useIsoMorphicEffect(() => { - bag.current.textValue = document.getElementById(id)?.textContent?.toLowerCase() - }, [bag, id]) + bag.current.textValue = ownerDocument?.getElementById(id)?.textContent?.toLowerCase() + }, [bag, id, ownerDocument]) let select = useCallback(() => state.propsRef.current.onChange(value), [state.propsRef, value]) @@ -701,8 +704,8 @@ let Option = forwardRefWithAs(function Option< if (state.listboxState !== ListboxStates.Open) return if (!selected) return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) - document.getElementById(id)?.focus?.() - }, [state.listboxState]) + ownerDocument?.getElementById(id)?.focus?.() + }, [state.listboxState, ownerDocument]) useIsoMorphicEffect(() => { if (state.listboxState !== ListboxStates.Open) return @@ -710,10 +713,10 @@ let Option = forwardRefWithAs(function Option< if (state.activationTrigger === ActivationTrigger.Pointer) return let d = disposables() d.requestAnimationFrame(() => { - document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' }) + ownerDocument?.getElementById(id)?.scrollIntoView?.({ block: 'nearest' }) }) return d.dispose - }, [id, active, state.listboxState, state.activationTrigger, /* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ state.activeOptionIndex]) + }, [id, active, state.listboxState, ownerDocument, state.activationTrigger, /* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ state.activeOptionIndex]) let handleClick = useCallback( (event: { preventDefault: Function }) => { diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index 253bd9646e..9a480853db 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -35,6 +35,8 @@ import { useOutsideClick } from '../../hooks/use-outside-click' import { useTreeWalker } from '../../hooks/use-tree-walker' import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' +import { getOwnerDocument } from '../../utils/owner' +import { useOwnerDocument } from '../../hooks/use-owner' enum MenuStates { Open, @@ -398,6 +400,7 @@ let Items = forwardRefWithAs(function Items state.buttonRef.current?.focus({ preventScroll: true })) break @@ -501,7 +504,7 @@ let Items = forwardRefWithAs(function Items) => { @@ -571,8 +574,9 @@ let Item = forwardRefWithAs(function Item(null) + let internalItemRef = useRef(null) let itemRef = useSyncRefs(ref, internalItemRef) + let ownerDocument = getOwnerDocument(internalItemRef.current) useIsoMorphicEffect(() => { if (state.menuState !== MenuStates.Open) return @@ -580,10 +584,10 @@ let Item = forwardRefWithAs(function Item { - document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' }) + ownerDocument.getElementById(id)?.scrollIntoView?.({ block: 'nearest' }) }) return d.dispose - }, [id, active, state.menuState, state.activationTrigger, /* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ state.activeItemIndex]) + }, [id, active, state.menuState, ownerDocument, state.activationTrigger, /* We also want to trigger this when the position of the active item changes so that we can re-trigger the scrollIntoView */ state.activeItemIndex]) let bag = useRef({ disabled, domRef: internalItemRef }) @@ -592,8 +596,8 @@ let Item = forwardRefWithAs(function Item { - bag.current.textValue = document.getElementById(id)?.textContent?.toLowerCase() - }, [bag, id]) + bag.current.textValue = ownerDocument.getElementById(id)?.textContent?.toLowerCase() + }, [bag, id, ownerDocument]) useIsoMorphicEffect(() => { dispatch({ type: ActionTypes.RegisterItem, id, dataRef: bag }) diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index 646429cf1b..c79e700878 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -37,6 +37,7 @@ import { useWindowEvent } from '../../hooks/use-window-event' import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useOutsideClick } from '../../hooks/use-outside-click' +import { getOwnerDocument } from '../../utils/owner' enum PopoverStates { Open, @@ -198,7 +199,8 @@ let PopoverRoot = forwardRefWithAs(function Popover< let isFocusWithinPopoverGroup = useCallback(() => { return ( groupContext?.isFocusWithinPopoverGroup() ?? - (button?.contains(document.activeElement) || panel?.contains(document.activeElement)) + (button?.contains(getOwnerDocument(button).activeElement) || + panel?.contains(getOwnerDocument(panel).activeElement)) ) }, [groupContext, button, panel]) @@ -311,14 +313,12 @@ let Button = forwardRefWithAs(function Button(null) - let previousActiveElementRef = useRef( - typeof window === 'undefined' ? null : document.activeElement - ) + let previousActiveElementRef = useRef(null) useWindowEvent( 'focus', () => { previousActiveElementRef.current = activeElementRef.current - activeElementRef.current = document.activeElement + activeElementRef.current = getOwnerDocument(internalButtonRef).activeElement as HTMLElement }, true ) @@ -350,7 +350,10 @@ let Button = forwardRefWithAs(function Button { - let element = document.activeElement as HTMLElement + let ownerDocument = getOwnerDocument(internalGroupRef) + let element = ownerDocument.activeElement if (internalGroupRef.current?.contains(element)) return true // Check if the focus is in one of the button or panel elements. This is important in case you are rendering inside a Portal. return popovers.some((bag) => { return ( - document.getElementById(bag.buttonId)?.contains(element) || - document.getElementById(bag.panelId)?.contains(element) + ownerDocument.getElementById(bag.buttonId)?.contains(element) || + ownerDocument.getElementById(bag.panelId)?.contains(element) ) }) }, [internalGroupRef, popovers]) diff --git a/packages/@headlessui-react/src/components/portal/portal.tsx b/packages/@headlessui-react/src/components/portal/portal.tsx index 7fc234a04b..be4fee2194 100644 --- a/packages/@headlessui-react/src/components/portal/portal.tsx +++ b/packages/@headlessui-react/src/components/portal/portal.tsx @@ -18,30 +18,36 @@ import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect' import { usePortalRoot } from '../../internal/portal-force-root' import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete' import { useSyncRefs } from '../../hooks/use-sync-refs' +import { useOwnerDocument } from '../../hooks/use-owner' -function usePortalTarget(): HTMLElement | null { +function usePortalTarget(ref: MutableRefObject): HTMLElement | null { let forceInRoot = usePortalRoot() let groupTarget = useContext(PortalGroupContext) + + let ownerDocument = useOwnerDocument(ref) + let [target, setTarget] = useState(() => { // Group context is used, but still null if (!forceInRoot && groupTarget !== null) return null // No group context is used, let's create a default portal root if (typeof window === 'undefined') return null - let existingRoot = document.getElementById('headlessui-portal-root') + let existingRoot = ownerDocument?.getElementById('headlessui-portal-root') if (existingRoot) return existingRoot - let root = document.createElement('div') + if (ownerDocument === null) return null + + let root = ownerDocument.createElement('div') root.setAttribute('id', 'headlessui-portal-root') - return document.body.appendChild(root) + return ownerDocument.body.appendChild(root) }) // Ensure the portal root is always in the DOM useEffect(() => { if (target === null) return - if (!document.body.contains(target)) { - document.body.appendChild(target) + if (!ownerDocument?.body.contains(target)) { + ownerDocument?.body.appendChild(target) } }, [target]) @@ -63,11 +69,14 @@ let PortalRoot = forwardRefWithAs(function Portal< TTag extends ElementType = typeof DEFAULT_PORTAL_TAG >(props: Props, ref: Ref) { let passthroughProps = props - let target = usePortalTarget() + let portalRef = useSyncRefs(ref) + // @ts-expect-error figure out the correct type here, + let ownerDocument = useOwnerDocument(ref) + // @ts-expect-error figure out the correct type here, + let target = usePortalTarget(ref) let [element] = useState(() => - typeof window === 'undefined' ? null : document.createElement('div') + typeof window === 'undefined' ? null : ownerDocument?.createElement('div') ?? null ) - let portalRef = useSyncRefs(ref) let ready = useServerHandoffComplete() diff --git a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx index 375868e71e..b84a458ba0 100644 --- a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx +++ b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx @@ -26,6 +26,7 @@ import { Label, useLabels } from '../../components/label/label' import { Description, useDescriptions } from '../../components/description/description' import { useTreeWalker } from '../../hooks/use-tree-walker' import { useSyncRefs } from '../../hooks/use-sync-refs' +import { getOwnerDocument } from '../../utils/owner' interface Option { id: string @@ -186,7 +187,8 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< if (result === FocusResult.Success) { let activeOption = options.find( - (option) => option.element.current === document.activeElement + (option) => + option.element.current === getOwnerDocument(option.element).activeElement ) if (activeOption) triggerChange(activeOption.propsRef.current.value) } @@ -203,7 +205,8 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< if (result === FocusResult.Success) { let activeOption = options.find( - (option) => option.element.current === document.activeElement + (option) => + option.element.current === getOwnerDocument(option.element).activeElement ) if (activeOption) triggerChange(activeOption.propsRef.current.value) } @@ -216,7 +219,7 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< event.stopPropagation() let activeOption = options.find( - (option) => option.element.current === document.activeElement + (option) => option.element.current === getOwnerDocument(option.element).activeElement ) if (activeOption) triggerChange(activeOption.propsRef.current.value) } diff --git a/packages/@headlessui-react/src/hooks/use-focus-trap.ts b/packages/@headlessui-react/src/hooks/use-focus-trap.ts index 14e2f0109f..8fac91866f 100644 --- a/packages/@headlessui-react/src/hooks/use-focus-trap.ts +++ b/packages/@headlessui-react/src/hooks/use-focus-trap.ts @@ -9,6 +9,7 @@ import { Keys } from '../components/keyboard' import { focusElement, focusIn, Focus, FocusResult } from '../utils/focus-management' import { useWindowEvent } from './use-window-event' import { useIsMounted } from './use-is-mounted' +import { useOwnerDocument } from './use-owner' export enum Features { /** No features enabled for the `useFocusTrap` hook. */ @@ -48,14 +49,16 @@ export function useFocusTrap( let featuresRestoreFocus = Boolean(features & Features.RestoreFocus) let featuresInitialFocus = Boolean(features & Features.InitialFocus) + let ownerDocument = useOwnerDocument(container) + // Capture the currently focused element, before we enable the focus trap. useEffect(() => { if (!featuresRestoreFocus) return if (!restoreElement.current) { - restoreElement.current = document.activeElement as HTMLElement + restoreElement.current = ownerDocument?.activeElement as HTMLElement } - }, [featuresRestoreFocus]) + }, [featuresRestoreFocus, ownerDocument]) // Restore the focus when we unmount the component. useEffect(() => { @@ -73,7 +76,7 @@ export function useFocusTrap( let containerElement = container.current if (!containerElement) return - let activeElement = document.activeElement as HTMLElement + let activeElement = ownerDocument?.activeElement as HTMLElement if (initialFocus?.current) { if (initialFocus?.current === activeElement) { @@ -94,8 +97,8 @@ export function useFocusTrap( } } - previousActiveElement.current = document.activeElement as HTMLElement - }, [container, initialFocus, featuresInitialFocus]) + previousActiveElement.current = ownerDocument?.activeElement as HTMLElement + }, [container, initialFocus, featuresInitialFocus, ownerDocument]) // Handle `Tab` & `Shift+Tab` keyboard events useWindowEvent('keydown', (event) => { @@ -112,7 +115,7 @@ export function useFocusTrap( (event.shiftKey ? Focus.Previous : Focus.Next) | Focus.WrapAround ) === FocusResult.Success ) { - previousActiveElement.current = document.activeElement as HTMLElement + previousActiveElement.current = ownerDocument?.activeElement as HTMLElement } }) diff --git a/packages/@headlessui-react/src/hooks/use-inert-others.ts b/packages/@headlessui-react/src/hooks/use-inert-others.ts index a90f87686b..70aed4a618 100644 --- a/packages/@headlessui-react/src/hooks/use-inert-others.ts +++ b/packages/@headlessui-react/src/hooks/use-inert-others.ts @@ -1,4 +1,5 @@ import { MutableRefObject } from 'react' +import { getOwnerDocument } from '../utils/owner' import { useIsoMorphicEffect } from './use-iso-morphic-effect' let interactables = new Set() @@ -42,26 +43,28 @@ export function useInertOthers( } // Collect direct children of the body - document.querySelectorAll('body > *').forEach((child) => { - if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements - - // Skip the interactables, and the parents of the interactables - for (let interactable of interactables) { - if (child.contains(interactable)) return - } + getOwnerDocument(container) + .querySelectorAll('body > *') + .forEach((child) => { + if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements + + // Skip the interactables, and the parents of the interactables + for (let interactable of interactables) { + if (child.contains(interactable)) return + } - // Keep track of the elements - if (interactables.size === 1) { - originals.set(child, { - 'aria-hidden': child.getAttribute('aria-hidden'), - // @ts-expect-error `inert` does not exist on HTMLElement (yet!) - inert: child.inert, - }) + // Keep track of the elements + if (interactables.size === 1) { + originals.set(child, { + 'aria-hidden': child.getAttribute('aria-hidden'), + // @ts-expect-error `inert` does not exist on HTMLElement (yet!) + inert: child.inert, + }) - // Mutate the element - inert(child) - } - }) + // Mutate the element + inert(child) + } + }) return () => { // Inert is disabled on the current element @@ -71,26 +74,28 @@ export function useInertOthers( // will become inert as well. if (interactables.size > 0) { // Collect direct children of the body - document.querySelectorAll('body > *').forEach((child) => { - if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements - - // Skip already inert parents - if (originals.has(child)) return - - // Skip the interactables, and the parents of the interactables - for (let interactable of interactables) { - if (child.contains(interactable)) return - } - - originals.set(child, { - 'aria-hidden': child.getAttribute('aria-hidden'), - // @ts-expect-error `inert` does not exist on HTMLElement (yet!) - inert: child.inert, + getOwnerDocument(container) + .querySelectorAll('body > *') + .forEach((child) => { + if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements + + // Skip already inert parents + if (originals.has(child)) return + + // Skip the interactables, and the parents of the interactables + for (let interactable of interactables) { + if (child.contains(interactable)) return + } + + originals.set(child, { + 'aria-hidden': child.getAttribute('aria-hidden'), + // @ts-expect-error `inert` does not exist on HTMLElement (yet!) + inert: child.inert, + }) + + // Mutate the element + inert(child) }) - - // Mutate the element - inert(child) - }) } else { for (let element of originals.keys()) { // Restore diff --git a/packages/@headlessui-react/src/hooks/use-owner.ts b/packages/@headlessui-react/src/hooks/use-owner.ts new file mode 100644 index 0000000000..38f5736feb --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-owner.ts @@ -0,0 +1,8 @@ +import { useMemo } from 'react' +import { getOwnerDocument } from '../utils/owner' +import { useServerHandoffComplete } from './use-server-handoff-complete' + +export function useOwnerDocument(...args: Parameters) { + let ready = useServerHandoffComplete() + return useMemo(() => (ready ? getOwnerDocument(...args) : null), [ready, ...args]) +} diff --git a/packages/@headlessui-react/src/hooks/use-sync-refs.ts b/packages/@headlessui-react/src/hooks/use-sync-refs.ts index d2769a0935..2b3cc8cae8 100644 --- a/packages/@headlessui-react/src/hooks/use-sync-refs.ts +++ b/packages/@headlessui-react/src/hooks/use-sync-refs.ts @@ -1,5 +1,11 @@ import { useRef, useEffect, useCallback } from 'react' +let Optional = Symbol() + +export function optionalRef(cb: (ref: T) => void, isOptional = true) { + return Object.assign(cb, { [Optional]: isOptional }) +} + export function useSyncRefs( ...refs: (React.MutableRefObject | ((instance: TType) => void) | null)[] ) { @@ -20,5 +26,12 @@ export function useSyncRefs( [cache] ) - return refs.every((ref) => ref == null) ? undefined : syncRefs + return refs.every( + (ref) => + ref == null || + // @ts-expect-error + ref?.[Optional] + ) + ? undefined + : syncRefs } diff --git a/packages/@headlessui-react/src/hooks/use-tree-walker.ts b/packages/@headlessui-react/src/hooks/use-tree-walker.ts index dfe0123830..22e3c098d5 100644 --- a/packages/@headlessui-react/src/hooks/use-tree-walker.ts +++ b/packages/@headlessui-react/src/hooks/use-tree-walker.ts @@ -1,5 +1,6 @@ import { useRef, useEffect } from 'react' import { useIsoMorphicEffect } from './use-iso-morphic-effect' +import { getOwnerDocument } from '../utils/owner' type AcceptNode = ( node: HTMLElement @@ -35,8 +36,13 @@ export function useTreeWalker({ let walk = walkRef.current let acceptNode = Object.assign((node: HTMLElement) => accept(node), { acceptNode: accept }) - // @ts-expect-error This `false` is a simple small fix for older browsers - let walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, acceptNode, false) + let walker = getOwnerDocument(container).createTreeWalker( + container, + NodeFilter.SHOW_ELEMENT, + acceptNode, + // @ts-expect-error This `false` is a simple small fix for older browsers + false + ) while (walker.nextNode()) walk(walker.currentNode as HTMLElement) }, [container, enabled, acceptRef, walkRef]) diff --git a/packages/@headlessui-react/src/hooks/use-window-event.ts b/packages/@headlessui-react/src/hooks/use-window-event.ts index f3dda15858..db441df357 100644 --- a/packages/@headlessui-react/src/hooks/use-window-event.ts +++ b/packages/@headlessui-react/src/hooks/use-window-event.ts @@ -1,16 +1,17 @@ -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' + +import { useLatestValue } from './use-latest-value' export function useWindowEvent( type: TType, - listener: (this: Window, ev: WindowEventMap[TType]) => any, + listener: (ev: WindowEventMap[TType]) => any, options?: boolean | AddEventListenerOptions ) { - let listenerRef = useRef(listener) - listenerRef.current = listener + let listenerRef = useLatestValue(listener) useEffect(() => { function handler(event: WindowEventMap[TType]) { - listenerRef.current.call(window, event) + listenerRef.current(event) } window.addEventListener(type, handler, options) diff --git a/packages/@headlessui-react/src/utils/focus-management.ts b/packages/@headlessui-react/src/utils/focus-management.ts index 0c2773f5b3..38493e8b18 100644 --- a/packages/@headlessui-react/src/utils/focus-management.ts +++ b/packages/@headlessui-react/src/utils/focus-management.ts @@ -1,4 +1,5 @@ import { match } from './match' +import { getOwnerDocument } from './owner' // Credit: // - https://stackoverflow.com/a/30753870 @@ -79,7 +80,7 @@ export function isFocusableElement( element: HTMLElement, mode: FocusableMode = FocusableMode.Strict ) { - if (element === document.body) return false + if (element === getOwnerDocument(element).body) return false return match(mode, { [FocusableMode.Strict]() { @@ -121,10 +122,16 @@ export function sortByDomNode( } export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) { + let ownerDocument = Array.isArray(container) + ? container.length > 0 + ? container[0].ownerDocument + : document + : container.ownerDocument + let elements = Array.isArray(container) ? sortByDomNode(container) : getFocusableElements(container) - let active = document.activeElement as HTMLElement + let active = ownerDocument.activeElement as HTMLElement let direction = (() => { if (focus & (Focus.First | Focus.Next)) return Direction.Next @@ -167,7 +174,7 @@ export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) { // Try the next one in line offset += direction - } while (next !== document.activeElement) + } while (next !== ownerDocument.activeElement) // This is a little weird, but let me try and explain: There are a few scenario's // in chrome for example where a focused `` tag does not get the default focus diff --git a/packages/@headlessui-react/src/utils/owner.ts b/packages/@headlessui-react/src/utils/owner.ts new file mode 100644 index 0000000000..457757f9e6 --- /dev/null +++ b/packages/@headlessui-react/src/utils/owner.ts @@ -0,0 +1,12 @@ +import { MutableRefObject } from 'react' + +export function getOwnerDocument>( + element: T | null | undefined +) { + if (element instanceof Node) return element.ownerDocument + if (element?.hasOwnProperty('current')) { + if (element.current instanceof Node) return element.current.ownerDocument + } + + return document +} diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index ba9a22f7f6..cfed81eb71 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -28,6 +28,7 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useTreeWalker } from '../../hooks/use-tree-walker' import { sortByDomNode } from '../../utils/focus-management' import { useOutsideClick } from '../../hooks/use-outside-click' +import { getOwnerDocument } from '../../utils/owner' enum ComboboxStates { Open, @@ -644,6 +645,7 @@ export let ComboboxOption = defineComponent({ let api = useComboboxContext('ComboboxOption') let id = `headlessui-combobox-option-${useId()}` let internalOptionRef = ref(null) + let ownerDocument = computed(() => getOwnerDocument(internalOptionRef)) let active = computed(() => { return api.activeOptionIndex.value !== null @@ -678,7 +680,7 @@ export let ComboboxOption = defineComponent({ if (api.comboboxState.value !== ComboboxStates.Open) return if (!active.value) return if (api.activationTrigger.value === ActivationTrigger.Pointer) return - nextTick(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' })) + nextTick(() => ownerDocument.value.getElementById(id)?.scrollIntoView?.({ block: 'nearest' })) }) function handleClick(event: MouseEvent) { diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.ts b/packages/@headlessui-vue/src/components/dialog/dialog.ts index 63bc16f3da..79ab523fd4 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.ts @@ -31,6 +31,7 @@ import { Description, useDescriptions } from '../description/description' import { dom } from '../../utils/dom' import { useOpenClosed, State } from '../../internal/open-closed' import { useOutsideClick } from '../../hooks/use-outside-click' +import { getOwnerDocument } from '../../utils/owner' enum DialogStates { Open, @@ -91,6 +92,7 @@ export let Dialog = defineComponent({ let containers = ref>>(new Set()) let internalDialogRef = ref(null) + let ownerDocument = computed(() => getOwnerDocument(internalDialogRef)) // Validations let hasOpen = props.open !== Missing || usesOpenClosedState !== null @@ -210,17 +212,20 @@ export let Dialog = defineComponent({ if (dialogState.value !== DialogStates.Open) return if (hasParentDialog) return - let overflow = document.documentElement.style.overflow - let paddingRight = document.documentElement.style.paddingRight + let owner = ownerDocument.value + let ownerWindow = owner.defaultView ?? window - let scrollbarWidth = window.innerWidth - document.documentElement.clientWidth + let overflow = owner.documentElement.style.overflow + let paddingRight = owner.documentElement.style.paddingRight - document.documentElement.style.overflow = 'hidden' - document.documentElement.style.paddingRight = `${scrollbarWidth}px` + let scrollbarWidth = ownerWindow.innerWidth - owner.documentElement.clientWidth + + owner.documentElement.style.overflow = 'hidden' + owner.documentElement.style.paddingRight = `${scrollbarWidth}px` onInvalidate(() => { - document.documentElement.style.overflow = overflow - document.documentElement.style.paddingRight = paddingRight + owner.documentElement.style.overflow = overflow + owner.documentElement.style.paddingRight = paddingRight }) }) diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts index 9173cdb3ab..94e8c8f1be 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.ts +++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts @@ -26,6 +26,7 @@ import { match } from '../../utils/match' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { FocusableMode, isFocusableElement, sortByDomNode } from '../../utils/focus-management' import { useOutsideClick } from '../../hooks/use-outside-click' +import { getOwnerDocument } from '../../utils/owner' enum ListboxStates { Open, @@ -545,6 +546,7 @@ export let ListboxOption = defineComponent({ let api = useListboxContext('ListboxOption') let id = `headlessui-listbox-option-${useId()}` let internalOptionRef = ref(null) + let ownerDocument = computed(() => getOwnerDocument(internalOptionRef)) let active = computed(() => { return api.activeOptionIndex.value !== null @@ -561,7 +563,7 @@ export let ListboxOption = defineComponent({ domRef: internalOptionRef, })) onMounted(() => { - let textValue = document.getElementById(id)?.textContent?.toLowerCase().trim() + let textValue = ownerDocument.value.getElementById(id)?.textContent?.toLowerCase().trim() if (textValue !== undefined) dataRef.value.textValue = textValue }) @@ -575,7 +577,7 @@ export let ListboxOption = defineComponent({ if (api.listboxState.value !== ListboxStates.Open) return if (!selected.value) return api.goToOption(Focus.Specific, id) - document.getElementById(id)?.focus?.() + ownerDocument.value.getElementById(id)?.focus?.() }, { immediate: true } ) @@ -585,7 +587,7 @@ export let ListboxOption = defineComponent({ if (api.listboxState.value !== ListboxStates.Open) return if (!active.value) return if (api.activationTrigger.value === ActivationTrigger.Pointer) return - nextTick(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' })) + nextTick(() => ownerDocument.value.getElementById(id)?.scrollIntoView?.({ block: 'nearest' })) }) function handleClick(event: MouseEvent) { diff --git a/packages/@headlessui-vue/src/components/menu/menu.ts b/packages/@headlessui-vue/src/components/menu/menu.ts index 631b1a9c7e..7c4613b49a 100644 --- a/packages/@headlessui-vue/src/components/menu/menu.ts +++ b/packages/@headlessui-vue/src/components/menu/menu.ts @@ -24,6 +24,7 @@ import { match } from '../../utils/match' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { FocusableMode, isFocusableElement, sortByDomNode } from '../../utils/focus-management' import { useOutsideClick } from '../../hooks/use-outside-click' +import { getOwnerDocument } from '../../utils/owner' enum MenuStates { Open, @@ -334,6 +335,7 @@ export let MenuItems = defineComponent({ let api = useMenuContext('MenuItems') let id = `headlessui-menu-items-${useId()}` let searchDebounce = ref | null>(null) + let ownerDocument = computed(() => getOwnerDocument(api.buttonRef)) useTreeWalker({ container: computed(() => dom(api.itemsRef)), @@ -367,7 +369,7 @@ export let MenuItems = defineComponent({ event.stopPropagation() if (api.activeItemIndex.value !== null) { let { id } = api.items.value[api.activeItemIndex.value] - document.getElementById(id)?.click() + ownerDocument.value.getElementById(id)?.click() } api.closeMenu() nextTick(() => dom(api.buttonRef)?.focus({ preventScroll: true })) @@ -477,6 +479,7 @@ export let MenuItem = defineComponent({ let api = useMenuContext('MenuItem') let id = `headlessui-menu-item-${useId()}` let internalItemRef = ref(null) + let ownerDocument = computed(() => getOwnerDocument(internalItemRef)) let active = computed(() => { return api.activeItemIndex.value !== null @@ -490,7 +493,7 @@ export let MenuItem = defineComponent({ domRef: internalItemRef, })) onMounted(() => { - let textValue = document.getElementById(id)?.textContent?.toLowerCase().trim() + let textValue = ownerDocument.value.getElementById(id)?.textContent?.toLowerCase().trim() if (textValue !== undefined) dataRef.value.textValue = textValue }) @@ -501,7 +504,7 @@ export let MenuItem = defineComponent({ if (api.menuState.value !== MenuStates.Open) return if (!active.value) return if (api.activationTrigger.value === ActivationTrigger.Pointer) return - nextTick(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' })) + nextTick(() => ownerDocument.value.getElementById(id)?.scrollIntoView?.({ block: 'nearest' })) }) function handleClick(event: MouseEvent) { diff --git a/packages/@headlessui-vue/src/components/popover/popover.ts b/packages/@headlessui-vue/src/components/popover/popover.ts index 79095bdc21..bdfb328e9c 100644 --- a/packages/@headlessui-vue/src/components/popover/popover.ts +++ b/packages/@headlessui-vue/src/components/popover/popover.ts @@ -29,6 +29,7 @@ import { useWindowEvent } from '../../hooks/use-window-event' import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useOutsideClick } from '../../hooks/use-outside-click' +import { getOwnerDocument } from '../../utils/owner' enum PopoverStates { Open, @@ -154,8 +155,8 @@ export let Popover = defineComponent({ function isFocusWithinPopoverGroup() { return ( groupContext?.isFocusWithinPopoverGroup() ?? - (dom(button)?.contains(document.activeElement) || - dom(panel)?.contains(document.activeElement)) + (dom(button)?.contains(getOwnerDocument(button).activeElement) || + dom(panel)?.contains(getOwnerDocument(panel).activeElement)) ) } @@ -204,6 +205,7 @@ export let PopoverButton = defineComponent({ }, setup(props, { attrs, slots }) { let api = usePopoverContext('PopoverButton') + let ownerDocument = computed(() => getOwnerDocument(api.button)) let groupContext = usePopoverGroupContext() let closeOthers = groupContext?.closeOthers @@ -214,14 +216,14 @@ export let PopoverButton = defineComponent({ // TODO: Revisit when handling Tab/Shift+Tab when using Portal's let activeElementRef = ref(null) let previousActiveElementRef = ref( - typeof window === 'undefined' ? null : document.activeElement + typeof window === 'undefined' ? null : ownerDocument.value.activeElement ) useWindowEvent( 'focus', () => { previousActiveElementRef.value = activeElementRef.value - activeElementRef.value = document.activeElement + activeElementRef.value = ownerDocument.value.activeElement }, true ) @@ -265,7 +267,7 @@ export let PopoverButton = defineComponent({ case Keys.Escape: if (api.popoverState.value !== PopoverStates.Open) return closeOthers?.(api.buttonId) if (!dom(api.button)) return - if (!dom(api.button)?.contains(document.activeElement)) return + if (!dom(api.button)?.contains(ownerDocument.value.activeElement)) return event.preventDefault() event.stopPropagation() api.closePopover() @@ -446,6 +448,7 @@ export let PopoverPanel = defineComponent({ setup(props, { attrs, slots }) { let { focus } = props let api = usePopoverContext('PopoverPanel') + let ownerDocument = computed(() => getOwnerDocument(api.panel)) provide(PopoverPanelContext, api.panelId) @@ -459,7 +462,7 @@ export let PopoverPanel = defineComponent({ if (api.popoverState.value !== PopoverStates.Open) return if (!api.panel) return - let activeElement = document.activeElement as HTMLElement + let activeElement = ownerDocument.value.activeElement as HTMLElement if (dom(api.panel)?.contains(activeElement)) return // Already focused within Dialog focusIn(dom(api.panel)!, Focus.First) @@ -471,8 +474,8 @@ export let PopoverPanel = defineComponent({ if (!dom(api.panel)) return if (event.key !== Keys.Tab) return - if (!document.activeElement) return - if (!dom(api.panel)?.contains(document.activeElement)) return + if (!ownerDocument.value.activeElement) return + if (!dom(api.panel)?.contains(ownerDocument.value.activeElement)) return // We will take-over the default tab behaviour so that we have a bit // control over what is focused next. It will behave exactly the same, @@ -500,7 +503,7 @@ export let PopoverPanel = defineComponent({ // focusable). Therefore we will try and focus the very first item in // the document.body. if (focusIn(nextElements, Focus.First) === FocusResult.Error) { - focusIn(document.body, Focus.First) + focusIn(ownerDocument.value.body, Focus.First) } } }) @@ -512,7 +515,7 @@ export let PopoverPanel = defineComponent({ if (!focus) return if (api.popoverState.value !== PopoverStates.Open) return if (!dom(api.panel)) return - if (dom(api.panel)?.contains(document.activeElement as HTMLElement)) return + if (dom(api.panel)?.contains(ownerDocument.value.activeElement as HTMLElement)) return api.closePopover() }, true @@ -532,7 +535,7 @@ export let PopoverPanel = defineComponent({ case Keys.Escape: if (api.popoverState.value !== PopoverStates.Open) return if (!dom(api.panel)) return - if (!dom(api.panel)?.contains(document.activeElement)) return + if (!dom(api.panel)?.contains(ownerDocument.value.activeElement)) return event.preventDefault() event.stopPropagation() api.closePopover() @@ -576,6 +579,7 @@ export let PopoverGroup = defineComponent({ setup(props, { attrs, slots }) { let groupRef = ref(null) let popovers = ref([]) + let ownerDocument = computed(() => getOwnerDocument(groupRef)) function unregisterPopover(registerBag: PopoverRegisterBag) { let idx = popovers.value.indexOf(registerBag) @@ -590,15 +594,15 @@ export let PopoverGroup = defineComponent({ } function isFocusWithinPopoverGroup() { - let element = document.activeElement as HTMLElement + let element = ownerDocument.value.activeElement as HTMLElement if (dom(groupRef)?.contains(element)) return true // Check if the focus is in one of the button or panel elements. This is important in case you are rendering inside a Portal. return popovers.value.some((bag) => { return ( - document.getElementById(bag.buttonId)?.contains(element) || - document.getElementById(bag.panelId)?.contains(element) + ownerDocument.value.getElementById(bag.buttonId)?.contains(element) || + ownerDocument.value.getElementById(bag.panelId)?.contains(element) ) }) } diff --git a/packages/@headlessui-vue/src/components/portal/portal.ts b/packages/@headlessui-vue/src/components/portal/portal.ts index 88cdf6e539..1ba3a02459 100644 --- a/packages/@headlessui-vue/src/components/portal/portal.ts +++ b/packages/@headlessui-vue/src/components/portal/portal.ts @@ -12,19 +12,22 @@ import { // Types InjectionKey, PropType, + computed, } from 'vue' import { render } from '../../utils/render' import { usePortalRoot } from '../../internal/portal-force-root' +import { getOwnerDocument } from '../../utils/owner' // --- -function getPortalRoot() { - let existingRoot = document.getElementById('headlessui-portal-root') +function getPortalRoot(contextElement?: Element | null) { + let ownerDocument = getOwnerDocument(contextElement) + let existingRoot = ownerDocument.getElementById('headlessui-portal-root') if (existingRoot) return existingRoot - let root = document.createElement('div') + let root = ownerDocument.createElement('div') root.setAttribute('id', 'headlessui-portal-root') - return document.body.appendChild(root) + return ownerDocument.body.appendChild(root) } export let Portal = defineComponent({ @@ -33,13 +36,16 @@ export let Portal = defineComponent({ as: { type: [Object, String], default: 'div' }, }, setup(props, { slots, attrs }) { + let element = ref(null) + let ownerDocument = computed(() => getOwnerDocument(element)) + let forcePortalRoot = usePortalRoot() let groupContext = inject(PortalGroupContext, null) let myTarget = ref( forcePortalRoot === true - ? getPortalRoot() + ? getPortalRoot(element.value) : groupContext === null - ? getPortalRoot() + ? getPortalRoot(element.value) : groupContext.resolveTarget() ) @@ -49,10 +55,8 @@ export let Portal = defineComponent({ myTarget.value = groupContext.resolveTarget() }) - let element = ref(null) - onUnmounted(() => { - let root = document.getElementById('headlessui-portal-root') + let root = ownerDocument.value.getElementById('headlessui-portal-root') if (!root) return if (myTarget.value !== root) return diff --git a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts index e4a231bb90..03baad88a3 100644 --- a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts +++ b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts @@ -21,6 +21,7 @@ import { omit, render } from '../../utils/render' import { Label, useLabels } from '../label/label' import { Description, useDescriptions } from '../description/description' import { useTreeWalker } from '../../hooks/use-tree-walker' +import { getOwnerDocument } from '../../utils/owner' interface Option { id: string @@ -142,7 +143,7 @@ export let RadioGroup = defineComponent({ if (result === FocusResult.Success) { let activeOption = options.value.find( - (option) => option.element === document.activeElement + (option) => option.element === getOwnerDocument(radioGroupRef).activeElement ) if (activeOption) api.change(activeOption.propsRef.value) } @@ -159,7 +160,7 @@ export let RadioGroup = defineComponent({ if (result === FocusResult.Success) { let activeOption = options.value.find( - (option) => option.element === document.activeElement + (option) => option.element === getOwnerDocument(option.element).activeElement ) if (activeOption) api.change(activeOption.propsRef.value) } @@ -172,7 +173,7 @@ export let RadioGroup = defineComponent({ event.stopPropagation() let activeOption = options.value.find( - (option) => option.element === document.activeElement + (option) => option.element === getOwnerDocument(option.element).activeElement ) if (activeOption) api.change(activeOption.propsRef.value) } diff --git a/packages/@headlessui-vue/src/hooks/use-focus-trap.ts b/packages/@headlessui-vue/src/hooks/use-focus-trap.ts index ad7b84fc1c..1c79ef7b70 100644 --- a/packages/@headlessui-vue/src/hooks/use-focus-trap.ts +++ b/packages/@headlessui-vue/src/hooks/use-focus-trap.ts @@ -11,7 +11,7 @@ import { import { Keys } from '../keyboard' import { focusElement, focusIn, Focus, FocusResult } from '../utils/focus-management' import { useWindowEvent } from '../hooks/use-window-event' -// import { contains } from '../internal/dom-containers' +import { getOwnerDocument } from '../utils/owner' export enum Features { /** No features enabled for the `useFocusTrap` hook. */ @@ -49,6 +49,8 @@ export function useFocusTrap( let featuresRestoreFocus = computed(() => Boolean(features.value & Features.RestoreFocus)) let featuresInitialFocus = computed(() => Boolean(features.value & Features.InitialFocus)) + let ownerDocument = computed(() => getOwnerDocument(container)) + onMounted(() => { // Capture the currently focused element, before we enable the focus trap. watch( @@ -60,7 +62,7 @@ export function useFocusTrap( mounted.value = true if (!restoreElement.value) { - restoreElement.value = document.activeElement as HTMLElement + restoreElement.value = ownerDocument.value.activeElement as HTMLElement } }, { immediate: true } @@ -94,7 +96,7 @@ export function useFocusTrap( let containerElement = container.value if (!containerElement) return - let activeElement = document.activeElement as HTMLElement + let activeElement = ownerDocument.value.activeElement as HTMLElement if (options.value.initialFocus?.value) { if (options.value.initialFocus?.value === activeElement) { @@ -115,7 +117,7 @@ export function useFocusTrap( } } - previousActiveElement.value = document.activeElement as HTMLElement + previousActiveElement.value = ownerDocument.value.activeElement as HTMLElement }, { immediate: true } ) @@ -136,7 +138,7 @@ export function useFocusTrap( (event.shiftKey ? Focus.Previous : Focus.Next) | Focus.WrapAround ) === FocusResult.Success ) { - previousActiveElement.value = document.activeElement as HTMLElement + previousActiveElement.value = ownerDocument.value.activeElement as HTMLElement } }) diff --git a/packages/@headlessui-vue/src/hooks/use-inert-others.ts b/packages/@headlessui-vue/src/hooks/use-inert-others.ts index 5d28bf7d2a..f89e21385c 100644 --- a/packages/@headlessui-vue/src/hooks/use-inert-others.ts +++ b/packages/@headlessui-vue/src/hooks/use-inert-others.ts @@ -5,6 +5,7 @@ import { // Types Ref, } from 'vue' +import { getOwnerDocument } from '../utils/owner' // TODO: Figure out a nice way to attachTo document.body in the tests without automagically inserting a div with data-v-app let CHILDREN_SELECTOR = process.env.NODE_ENV === 'test' ? '[data-v-app=""] > *' : 'body > *' @@ -50,26 +51,28 @@ export function useInertOthers( } // Collect direct children of the body - document.querySelectorAll(CHILDREN_SELECTOR).forEach((child) => { - if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements - - // Skip the interactables, and the parents of the interactables - for (let interactable of interactables) { - if (child.contains(interactable)) return - } + getOwnerDocument(container) + .querySelectorAll(CHILDREN_SELECTOR) + .forEach((child) => { + if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements + + // Skip the interactables, and the parents of the interactables + for (let interactable of interactables) { + if (child.contains(interactable)) return + } - // Keep track of the elements - if (interactables.size === 1) { - originals.set(child, { - 'aria-hidden': child.getAttribute('aria-hidden'), - // @ts-expect-error `inert` does not exist on HTMLElement (yet!) - inert: child.inert, - }) + // Keep track of the elements + if (interactables.size === 1) { + originals.set(child, { + 'aria-hidden': child.getAttribute('aria-hidden'), + // @ts-expect-error `inert` does not exist on HTMLElement (yet!) + inert: child.inert, + }) - // Mutate the element - inert(child) - } - }) + // Mutate the element + inert(child) + } + }) onInvalidate(() => { // Inert is disabled on the current element @@ -79,26 +82,28 @@ export function useInertOthers( // will become inert as well. if (interactables.size > 0) { // Collect direct children of the body - document.querySelectorAll(CHILDREN_SELECTOR).forEach((child) => { - if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements - - // Skip already inert parents - if (originals.has(child)) return - - // Skip the interactables, and the parents of the interactables - for (let interactable of interactables) { - if (child.contains(interactable)) return - } - - originals.set(child, { - 'aria-hidden': child.getAttribute('aria-hidden'), - // @ts-expect-error `inert` does not exist on HTMLElement (yet!) - inert: child.inert, + getOwnerDocument(container) + .querySelectorAll(CHILDREN_SELECTOR) + .forEach((child) => { + if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements + + // Skip already inert parents + if (originals.has(child)) return + + // Skip the interactables, and the parents of the interactables + for (let interactable of interactables) { + if (child.contains(interactable)) return + } + + originals.set(child, { + 'aria-hidden': child.getAttribute('aria-hidden'), + // @ts-expect-error `inert` does not exist on HTMLElement (yet!) + inert: child.inert, + }) + + // Mutate the element + inert(child) }) - - // Mutate the element - inert(child) - }) } else { for (let element of originals.keys()) { // Restore diff --git a/packages/@headlessui-vue/src/hooks/use-tree-walker.ts b/packages/@headlessui-vue/src/hooks/use-tree-walker.ts index 27f45bd5a2..396c12be97 100644 --- a/packages/@headlessui-vue/src/hooks/use-tree-walker.ts +++ b/packages/@headlessui-vue/src/hooks/use-tree-walker.ts @@ -1,4 +1,5 @@ import { watchEffect, ComputedRef } from 'vue' +import { getOwnerDocument } from '../utils/owner' type AcceptNode = ( node: HTMLElement @@ -24,8 +25,13 @@ export function useTreeWalker({ if (enabled !== undefined && !enabled.value) return let acceptNode = Object.assign((node: HTMLElement) => accept(node), { acceptNode: accept }) - // @ts-expect-error This `false` is a simple small fix for older browsers - let walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, acceptNode, false) + let walker = getOwnerDocument(container).createTreeWalker( + root, + NodeFilter.SHOW_ELEMENT, + acceptNode, + // @ts-expect-error This `false` is a simple small fix for older browsers + false + ) while (walker.nextNode()) walk(walker.currentNode as HTMLElement) }) diff --git a/packages/@headlessui-vue/src/hooks/use-window-event.ts b/packages/@headlessui-vue/src/hooks/use-window-event.ts index 987802e92e..5c9f971999 100644 --- a/packages/@headlessui-vue/src/hooks/use-window-event.ts +++ b/packages/@headlessui-vue/src/hooks/use-window-event.ts @@ -9,9 +9,6 @@ export function useWindowEvent( watchEffect((onInvalidate) => { window.addEventListener(type, listener, options) - - onInvalidate(() => { - window.removeEventListener(type, listener, options) - }) + onInvalidate(() => window.removeEventListener(type, listener, options)) }) } diff --git a/packages/@headlessui-vue/src/utils/dom.ts b/packages/@headlessui-vue/src/utils/dom.ts index 5d34de0eed..ccf76c4e61 100644 --- a/packages/@headlessui-vue/src/utils/dom.ts +++ b/packages/@headlessui-vue/src/utils/dom.ts @@ -1,8 +1,6 @@ import { Ref, ComponentPublicInstance } from 'vue' -export function dom( - ref?: Ref -): T | null { +export function dom(ref?: Ref): T | null { if (ref == null) return null if (ref.value == null) return null diff --git a/packages/@headlessui-vue/src/utils/focus-management.ts b/packages/@headlessui-vue/src/utils/focus-management.ts index b14b18c4db..5349f98857 100644 --- a/packages/@headlessui-vue/src/utils/focus-management.ts +++ b/packages/@headlessui-vue/src/utils/focus-management.ts @@ -1,4 +1,5 @@ import { match } from './match' +import { getOwnerDocument } from './owner' // Credit: // - https://stackoverflow.com/a/30753870 @@ -72,7 +73,7 @@ export function isFocusableElement( element: HTMLElement, mode: FocusableMode = FocusableMode.Strict ) { - if (element === document.body) return false + if (element === getOwnerDocument(element).body) return false return match(mode, { [FocusableMode.Strict]() { @@ -114,10 +115,17 @@ export function sortByDomNode( } export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) { + let ownerDocument = + (Array.isArray(container) + ? container.length > 0 + ? container[0].ownerDocument + : document + : container?.ownerDocument) ?? document + let elements = Array.isArray(container) ? sortByDomNode(container) : getFocusableElements(container) - let active = document.activeElement as HTMLElement + let active = ownerDocument.activeElement as HTMLElement let direction = (() => { if (focus & (Focus.First | Focus.Next)) return Direction.Next @@ -160,7 +168,7 @@ export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) { // Try the next one in line offset += direction - } while (next !== document.activeElement) + } while (next !== ownerDocument.activeElement) // This is a little weird, but let me try and explain: There are a few scenario's // in chrome for example where a focused `` tag does not get the default focus diff --git a/packages/@headlessui-vue/src/utils/owner.ts b/packages/@headlessui-vue/src/utils/owner.ts new file mode 100644 index 0000000000..bfdcccd05d --- /dev/null +++ b/packages/@headlessui-vue/src/utils/owner.ts @@ -0,0 +1,14 @@ +import { Ref } from 'vue' +import { dom } from './dom' + +export function getOwnerDocument>( + element: T | null | undefined +) { + if (element instanceof Node) return element.ownerDocument + if (element?.hasOwnProperty('value')) { + let domElement = dom(element) + if (domElement) return domElement.ownerDocument + } + + return document +} diff --git a/packages/playground-react/pages/tmp.tsx b/packages/playground-react/pages/tmp.tsx new file mode 100644 index 0000000000..c81e1072fd --- /dev/null +++ b/packages/playground-react/pages/tmp.tsx @@ -0,0 +1,93 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import { FocusTrap } from '@headlessui/react' + +function Window({ onClose, children }: { onClose: () => void; children: (window: Window) => any }) { + const newWindow = useMemo(() => window.open('', 'window', 'width=300'), []) + + const [_, _forceUpdate] = useState({}) + const forceUpdate = useCallback(() => _forceUpdate({}), []) + + useEffect(() => { + if (newWindow) return () => newWindow.close() + }, [newWindow]) + + useEffect(() => { + if (newWindow) { + newWindow.addEventListener('beforeunload', onClose) + newWindow.addEventListener('load', forceUpdate) + + return () => { + newWindow.removeEventListener('load', forceUpdate) + newWindow.removeEventListener('beforeunload', onClose) + } + } + + // document is required to re-add these event listeners after initial + // navigation + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [newWindow, newWindow?.document, onClose, forceUpdate]) + + return newWindow ? createPortal(<>{children(newWindow)}, newWindow.document.body) : null +} + +function FocusTrapper() { + const [focusTrapped, setFocusTrapped] = useState(false) + + const contents = ( + <> + + + + + + ) + + return focusTrapped ? {contents} : contents +} + +export default function App() { + const [windowOpen, setWindowOpen] = useState(false) + const onWindowClose = useCallback(() => setWindowOpen(false), []) + + const windowRender = useCallback( + () => ( + <> + + + + ), + [windowOpen] + ) + + return ( +
+ +
+ +
+ {windowOpen && {windowRender}} +
+ ) +}