diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index 029ef5ed63..3de5c0ffb3 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -29,11 +29,13 @@ import { useInertOthers } from '../../hooks/use-inert-others' import { Portal } from '../../components/portal/portal' import { ForcePortalRoot } from '../../internal/portal-force-root' import { Description, useDescriptions } from '../description/description' -import { useWindowEvent } from '../../hooks/use-window-event' 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' +import { useOwnerDocument } from '../../hooks/use-owner' +import { useEventListener } from '../../hooks/use-event-listener' enum DialogStates { Open, @@ -133,6 +135,8 @@ let DialogRoot = forwardRefWithAs(function Dialog< let internalDialogRef = useRef(null) let dialogRef = useSyncRefs(internalDialogRef, ref) + let ownerDocument = useOwnerDocument(internalDialogRef) + // Validations let hasOpen = props.hasOwnProperty('open') || usesOpenClosedState !== null let hasOnClose = props.hasOwnProperty('onClose') @@ -217,7 +221,7 @@ let DialogRoot = forwardRefWithAs(function Dialog< }) // Handle `Escape` to close - useWindowEvent('keydown', (event) => { + useEventListener(ownerDocument?.defaultView, 'keydown', (event) => { if (event.key !== Keys.Escape) return if (dialogState !== DialogStates.Open) return if (hasNestedDialogs) return @@ -231,17 +235,23 @@ 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) + if (!ownerDocument) return + + let documentElement = ownerDocument.documentElement + let ownerWindow = ownerDocument.defaultView ?? window + + let overflow = documentElement.style.overflow + let paddingRight = documentElement.style.paddingRight - let scrollbarWidth = window.innerWidth - document.documentElement.clientWidth + let scrollbarWidth = ownerWindow.innerWidth - documentElement.clientWidth - document.documentElement.style.overflow = 'hidden' - document.documentElement.style.paddingRight = `${scrollbarWidth}px` + documentElement.style.overflow = 'hidden' + documentElement.style.paddingRight = `${scrollbarWidth}px` return () => { - document.documentElement.style.overflow = overflow - document.documentElement.style.paddingRight = paddingRight + documentElement.style.overflow = overflow + 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 874d50d545..7cd4488853 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx @@ -22,12 +22,13 @@ 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' enum DisclosureStates { Open, @@ -158,7 +159,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 panelRef = useRef(null) let buttonRef = useRef(null) @@ -179,13 +191,15 @@ let DisclosureRoot = forwardRefWithAs(function Disclosure< let close = useCallback( (focusableElement?: HTMLElement | MutableRefObject) => { dispatch({ type: ActionTypes.CloseDisclosure }) + let ownerDocument = getOwnerDocument(internalDisclosureRef) + if (!ownerDocument) return 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() diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index f4540e3503..8acfd58455 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -36,6 +36,7 @@ import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useOutsideClick } from '../../hooks/use-outside-click' import { VisuallyHidden } from '../../internal/visually-hidden' import { objectToFormEntries } from '../../utils/form' +import { getOwnerDocument } from '../../utils/owner' enum ListboxStates { Open, @@ -559,7 +560,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]) @@ -704,7 +705,7 @@ 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 optionRef = useSyncRefs(ref, internalOptionRef) useIsoMorphicEffect(() => { diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index dcaf4265c0..cfeb84c9c5 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -35,6 +35,7 @@ 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 { useOwnerDocument } from '../../hooks/use-owner' enum MenuStates { Open, @@ -398,6 +399,7 @@ let Items = forwardRefWithAs(function Items) => { diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index 646429cf1b..375cbd107b 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -33,10 +33,12 @@ import { isFocusableElement, FocusableMode, } from '../../utils/focus-management' -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' +import { useOwnerDocument } from '../../hooks/use-owner' +import { useEventListener } from '../../hooks/use-event-listener' enum PopoverStates { Open, @@ -174,7 +176,9 @@ let PopoverRoot = forwardRefWithAs(function Popover< >(props: Props, ref: Ref) { let buttonId = `headlessui-popover-button-${useId()}` let panelId = `headlessui-popover-panel-${useId()}` - let popoverRef = useSyncRefs(ref) + let internalPopoverRef = useRef(null) + let popoverRef = useSyncRefs(ref, internalPopoverRef) + let ownerDocument = useOwnerDocument(internalPopoverRef) let reducerBag = useReducer(stateReducer, { popoverState: PopoverStates.Closed, @@ -198,14 +202,17 @@ let PopoverRoot = forwardRefWithAs(function Popover< let isFocusWithinPopoverGroup = useCallback(() => { return ( groupContext?.isFocusWithinPopoverGroup() ?? - (button?.contains(document.activeElement) || panel?.contains(document.activeElement)) + (ownerDocument?.activeElement && + (button?.contains(ownerDocument.activeElement) || + panel?.contains(ownerDocument.activeElement))) ) }, [groupContext, button, panel]) useEffect(() => registerPopover?.(registerBag), [registerPopover, registerBag]) // Handle focus out - useWindowEvent( + useEventListener( + ownerDocument?.defaultView, 'focus', () => { if (popoverState !== PopoverStates.Open) return @@ -308,17 +315,17 @@ let Button = forwardRefWithAs(function Button dispatch({ type: ActionTypes.SetButton, button }) ) let withinPanelButtonRef = useSyncRefs(internalButtonRef, ref) + let ownerDocument = useOwnerDocument(internalButtonRef) // TODO: Revisit when handling Tab/Shift+Tab when using Portal's let activeElementRef = useRef(null) - let previousActiveElementRef = useRef( - typeof window === 'undefined' ? null : document.activeElement - ) - useWindowEvent( + let previousActiveElementRef = useRef(null) + useEventListener( + ownerDocument?.defaultView, 'focus', () => { previousActiveElementRef.current = activeElementRef.current - activeElementRef.current = document.activeElement + activeElementRef.current = ownerDocument?.activeElement as HTMLElement }, true ) @@ -350,7 +357,12 @@ let Button = forwardRefWithAs(function Button { dispatch({ type: ActionTypes.SetPanel, panel }) }) + let ownerDocument = useOwnerDocument(internalPanelRef) let usesOpenClosedState = useOpenClosed() let visible = (() => { @@ -602,7 +615,12 @@ let Panel = forwardRefWithAs(function Panel { + useEventListener(ownerDocument?.defaultView, 'keydown', (event) => { if (state.popoverState !== PopoverStates.Open) return if (!internalPanelRef.current) return if (event.key !== Keys.Tab) return - if (!document.activeElement) return + if (!ownerDocument?.activeElement) return if (!internalPanelRef.current) return - if (!internalPanelRef.current.contains(document.activeElement)) return + if (!internalPanelRef.current.contains(ownerDocument.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, @@ -659,7 +677,7 @@ let Panel = forwardRefWithAs(function Panel { if (!focus) return if (state.popoverState !== PopoverStates.Open) return if (!internalPanelRef.current) return - if (internalPanelRef.current?.contains(document.activeElement as HTMLElement)) return + if ( + ownerDocument?.activeElement && + internalPanelRef.current?.contains(ownerDocument.activeElement) + ) { + return + } dispatch({ type: ActionTypes.ClosePopover }) }, true @@ -753,15 +777,17 @@ let Group = forwardRefWithAs(function Group { - let element = document.activeElement as HTMLElement + let ownerDocument = getOwnerDocument(internalGroupRef) + if (!ownerDocument) return false + 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..992ee15dd2 100644 --- a/packages/@headlessui-react/src/components/portal/portal.tsx +++ b/packages/@headlessui-react/src/components/portal/portal.tsx @@ -9,6 +9,7 @@ import React, { ElementType, MutableRefObject, Ref, + useRef, } from 'react' import { createPortal } from 'react-dom' @@ -17,33 +18,39 @@ import { forwardRefWithAs, render } from '../../utils/render' 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 { optionalRef, 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]) + }, [target, ownerDocument]) useEffect(() => { if (forceInRoot) return @@ -63,11 +70,18 @@ let PortalRoot = forwardRefWithAs(function Portal< TTag extends ElementType = typeof DEFAULT_PORTAL_TAG >(props: Props, ref: Ref) { let passthroughProps = props - let target = usePortalTarget() + let internalPortalRootRef = useRef(null) + let portalRef = useSyncRefs( + optionalRef((ref) => { + internalPortalRootRef.current = ref + }), + ref + ) + let ownerDocument = useOwnerDocument(internalPortalRootRef) + let target = usePortalTarget(internalPortalRootRef) 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 5ec51a96a3..1788946225 100644 --- a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx +++ b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx @@ -28,6 +28,7 @@ import { useTreeWalker } from '../../hooks/use-tree-walker' import { useSyncRefs } from '../../hooks/use-sync-refs' import { VisuallyHidden } from '../../internal/visually-hidden' import { objectToFormEntries } from '../../utils/form' +import { getOwnerDocument } from '../../utils/owner' interface Option { id: string @@ -174,6 +175,8 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< let container = internalRadioGroupRef.current if (!container) return + let ownerDocument = getOwnerDocument(container) + let all = options .filter((option) => option.propsRef.current.disabled === false) .map((radio) => radio.element.current) as HTMLElement[] @@ -189,7 +192,7 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< if (result === FocusResult.Success) { let activeOption = options.find( - (option) => option.element.current === document.activeElement + (option) => option.element.current === ownerDocument?.activeElement ) if (activeOption) triggerChange(activeOption.propsRef.current.value) } @@ -206,7 +209,7 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< if (result === FocusResult.Success) { let activeOption = options.find( - (option) => option.element.current === document.activeElement + (option) => option.element.current === ownerDocument?.activeElement ) if (activeOption) triggerChange(activeOption.propsRef.current.value) } @@ -219,7 +222,7 @@ let RadioGroupRoot = forwardRefWithAs(function RadioGroup< event.stopPropagation() let activeOption = options.find( - (option) => option.element.current === document.activeElement + (option) => option.element.current === ownerDocument?.activeElement ) if (activeOption) triggerChange(activeOption.propsRef.current.value) } diff --git a/packages/@headlessui-react/src/hooks/use-event-listener.ts b/packages/@headlessui-react/src/hooks/use-event-listener.ts new file mode 100644 index 0000000000..303fc375fb --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-event-listener.ts @@ -0,0 +1,23 @@ +import { useEffect } from 'react' + +import { useLatestValue } from './use-latest-value' + +export function useEventListener( + element: HTMLElement | Document | Window | EventTarget | null | undefined, + type: TType, + listener: (event: WindowEventMap[TType]) => any, + options?: boolean | AddEventListenerOptions +) { + let listenerRef = useLatestValue(listener) + + useEffect(() => { + element = element ?? window + + function handler(event: WindowEventMap[TType]) { + listenerRef.current(event) + } + + element.addEventListener(type, handler as any, options) + return () => element!.removeEventListener(type, handler as any, options) + }, [element, type, options]) +} diff --git a/packages/@headlessui-react/src/hooks/use-focus-trap.ts b/packages/@headlessui-react/src/hooks/use-focus-trap.ts index 14e2f0109f..32d72ff941 100644 --- a/packages/@headlessui-react/src/hooks/use-focus-trap.ts +++ b/packages/@headlessui-react/src/hooks/use-focus-trap.ts @@ -7,8 +7,9 @@ import { import { Keys } from '../components/keyboard' import { focusElement, focusIn, Focus, FocusResult } from '../utils/focus-management' -import { useWindowEvent } from './use-window-event' +import { useEventListener } from './use-event-listener' 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,11 +97,11 @@ 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) => { + useEventListener(ownerDocument?.defaultView, 'keydown', (event) => { if (!(features & Features.TabLock)) return if (!container.current) return @@ -112,12 +115,13 @@ 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 } }) // Prevent programmatically escaping the container - useWindowEvent( + useEventListener( + ownerDocument?.defaultView, 'focus', (event) => { if (!(features & Features.FocusLock)) return diff --git a/packages/@headlessui-react/src/hooks/use-inert-others.ts b/packages/@headlessui-react/src/hooks/use-inert-others.ts index a90f87686b..c598ec0dea 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() @@ -29,6 +30,8 @@ export function useInertOthers( if (!container.current) return let element = container.current + let ownerDocument = getOwnerDocument(element) + if (!ownerDocument) return // Mark myself as an interactable element interactables.add(element) @@ -42,7 +45,7 @@ export function useInertOthers( } // Collect direct children of the body - document.querySelectorAll('body > *').forEach((child) => { + ownerDocument.querySelectorAll('body > *').forEach((child) => { if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements // Skip the interactables, and the parents of the interactables @@ -71,7 +74,7 @@ export function useInertOthers( // will become inert as well. if (interactables.size > 0) { // Collect direct children of the body - document.querySelectorAll('body > *').forEach((child) => { + ownerDocument!.querySelectorAll('body > *').forEach((child) => { if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements // Skip already inert parents 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..0a4313a2bb --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-owner.ts @@ -0,0 +1,6 @@ +import { useMemo } from 'react' +import { getOwnerDocument } from '../utils/owner' + +export function useOwnerDocument(...args: Parameters) { + return useMemo(() => getOwnerDocument(...args), [...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..7ccf7c8597 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 @@ -30,13 +31,20 @@ export function useTreeWalker({ useIsoMorphicEffect(() => { if (!container) return if (!enabled) return + let ownerDocument = getOwnerDocument(container) + if (!ownerDocument) return let accept = acceptRef.current 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 = ownerDocument.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..8d0c717ac4 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..296975e4b5 --- /dev/null +++ b/packages/@headlessui-react/src/utils/owner.ts @@ -0,0 +1,13 @@ +import { MutableRefObject } from 'react' + +export function getOwnerDocument>( + element: T | null | undefined +) { + if (typeof window === 'undefined') return null + 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/dialog/dialog.ts b/packages/@headlessui-vue/src/components/dialog/dialog.ts index 63bc16f3da..df8c9b6066 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.ts @@ -22,7 +22,6 @@ import { Keys } from '../../keyboard' import { useId } from '../../hooks/use-id' import { useFocusTrap, Features as FocusTrapFeatures } from '../../hooks/use-focus-trap' import { useInertOthers } from '../../hooks/use-inert-others' -import { useWindowEvent } from '../../hooks/use-window-event' import { Portal, PortalGroup } from '../portal/portal' import { StackMessage, useStackProvider } from '../../internal/stack-context' import { match } from '../../utils/match' @@ -31,6 +30,8 @@ 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' +import { useEventListener } from '../../hooks/use-event-listener' 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 @@ -196,7 +198,7 @@ export let Dialog = defineComponent({ }) // Handle `Escape` to close - useWindowEvent('keydown', (event) => { + useEventListener(ownerDocument.value?.defaultView, 'keydown', (event) => { if (event.key !== Keys.Escape) return if (dialogState.value !== DialogStates.Open) return if (hasNestedDialogs.value) return @@ -210,17 +212,23 @@ 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 + if (!owner) return - let scrollbarWidth = window.innerWidth - document.documentElement.clientWidth + let documentElement = owner?.documentElement + let ownerWindow = owner.defaultView ?? window - document.documentElement.style.overflow = 'hidden' - document.documentElement.style.paddingRight = `${scrollbarWidth}px` + let overflow = documentElement.style.overflow + let paddingRight = documentElement.style.paddingRight + + let scrollbarWidth = ownerWindow.innerWidth - documentElement.clientWidth + + documentElement.style.overflow = 'hidden' + documentElement.style.paddingRight = `${scrollbarWidth}px` onInvalidate(() => { - document.documentElement.style.overflow = overflow - document.documentElement.style.paddingRight = paddingRight + documentElement.style.overflow = overflow + documentElement.style.paddingRight = paddingRight }) }) diff --git a/packages/@headlessui-vue/src/components/popover/popover.ts b/packages/@headlessui-vue/src/components/popover/popover.ts index 79095bdc21..fc77499efe 100644 --- a/packages/@headlessui-vue/src/components/popover/popover.ts +++ b/packages/@headlessui-vue/src/components/popover/popover.ts @@ -25,10 +25,11 @@ import { FocusableMode, } from '../../utils/focus-management' import { dom } from '../../utils/dom' -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' +import { useEventListener } from '../../hooks/use-event-listener' enum PopoverStates { Open, @@ -95,9 +96,12 @@ export let Popover = defineComponent({ let buttonId = `headlessui-popover-button-${useId()}` let panelId = `headlessui-popover-panel-${useId()}` + let internalPopoverRef = ref(null) + let popoverState = ref(PopoverStates.Closed) let button = ref(null) let panel = ref(null) + let ownerDocument = computed(() => getOwnerDocument(internalPopoverRef)) let api = { popoverState, @@ -154,15 +158,17 @@ export let Popover = defineComponent({ function isFocusWithinPopoverGroup() { return ( groupContext?.isFocusWithinPopoverGroup() ?? - (dom(button)?.contains(document.activeElement) || - dom(panel)?.contains(document.activeElement)) + (ownerDocument.value?.activeElement && + (dom(button)?.contains(ownerDocument.value.activeElement) || + dom(panel)?.contains(ownerDocument.value.activeElement))) ) } watchEffect(() => registerPopover?.(registerBag)) // Handle focus out - useWindowEvent( + useEventListener( + ownerDocument.value?.defaultView, 'focus', () => { if (popoverState.value !== PopoverStates.Open) return @@ -189,7 +195,16 @@ export let Popover = defineComponent({ return () => { let slot = { open: popoverState.value === PopoverStates.Open, close: api.close } - return render({ props, slot, slots, attrs, name: 'Popover' }) + return render({ + props: { + ...props, + ref: internalPopoverRef, + }, + slot, + slots, + attrs, + name: 'Popover', + }) } }, }) @@ -204,6 +219,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 @@ -213,15 +229,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 - ) + let previousActiveElementRef = ref() - useWindowEvent( + useEventListener( + ownerDocument.value?.defaultView, 'focus', () => { previousActiveElementRef.value = activeElementRef.value - activeElementRef.value = document.activeElement + activeElementRef.value = ownerDocument.value?.activeElement as HTMLElement }, true ) @@ -265,7 +280,11 @@ 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 ( + ownerDocument.value?.activeElement && + !dom(api.button)?.contains(ownerDocument.value.activeElement) + ) + return event.preventDefault() event.stopPropagation() api.closePopover() @@ -284,7 +303,7 @@ export let PopoverButton = defineComponent({ if (dom(api.panel)?.contains(previousActiveElementRef.value)) return // Check if the last focused element is *after* the button in the DOM - let focusableElements = getFocusableElements() + let focusableElements = getFocusableElements(ownerDocument.value?.body) let previousIdx = focusableElements.indexOf( previousActiveElementRef.value as HTMLElement ) @@ -328,7 +347,7 @@ export let PopoverButton = defineComponent({ if (dom(api.panel)?.contains(previousActiveElementRef.value)) return // Check if the last focused element is *after* the button in the DOM - let focusableElements = getFocusableElements() + let focusableElements = getFocusableElements(ownerDocument.value?.body) let previousIdx = focusableElements.indexOf(previousActiveElementRef.value as HTMLElement) let buttonIdx = focusableElements.indexOf(dom(api.button)!) if (buttonIdx > previousIdx) return @@ -446,6 +465,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,20 +479,20 @@ 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) }) // Handle Tab / Shift+Tab focus positioning - useWindowEvent('keydown', (event: KeyboardEvent) => { + useEventListener(ownerDocument.value?.defaultView, 'keydown', (event: KeyboardEvent) => { if (api.popoverState.value !== PopoverStates.Open) return 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, @@ -487,7 +507,7 @@ export let PopoverPanel = defineComponent({ } else if (result === FocusResult.Overflow) { if (!dom(api.button)) return - let elements = getFocusableElements() + let elements = getFocusableElements(ownerDocument.value.body) let buttonIdx = elements.indexOf(dom(api.button)!) let nextElements = elements @@ -500,19 +520,24 @@ 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) } } }) // Handle focus out when we are in special "focus" mode - useWindowEvent( + useEventListener( + ownerDocument.value?.defaultView, 'focus', () => { 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 ( + ownerDocument.value?.activeElement && + dom(api.panel)?.contains(ownerDocument.value.activeElement as HTMLElement) + ) + return api.closePopover() }, true @@ -532,7 +557,8 @@ 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 (ownerDocument.value && !dom(api.panel)?.contains(ownerDocument.value.activeElement)) + return event.preventDefault() event.stopPropagation() api.closePopover() @@ -576,6 +602,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 +617,17 @@ export let PopoverGroup = defineComponent({ } function isFocusWithinPopoverGroup() { - let element = document.activeElement as HTMLElement + let owner = ownerDocument.value + if (!owner) return false + let element = owner.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) + owner!.getElementById(bag.buttonId)?.contains(element) || + owner!.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..a18e006e3d 100644 --- a/packages/@headlessui-vue/src/components/portal/portal.ts +++ b/packages/@headlessui-vue/src/components/portal/portal.ts @@ -12,19 +12,27 @@ 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) + if (!ownerDocument) { + throw new Error( + `[Headless UI]: Cannot find ownerDocument for contextElement: ${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 +41,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 +60,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 a473c5553d..deaf751875 100644 --- a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts +++ b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts @@ -25,6 +25,7 @@ import { Description, useDescriptions } from '../description/description' import { useTreeWalker } from '../../hooks/use-tree-walker' import { VisuallyHidden } from '../../internal/visually-hidden' import { objectToFormEntries } from '../../utils/form' +import { getOwnerDocument } from '../../utils/owner' interface Option { id: string @@ -147,7 +148,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) } @@ -164,7 +165,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) } @@ -177,7 +178,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-event-listener.ts b/packages/@headlessui-vue/src/hooks/use-event-listener.ts new file mode 100644 index 0000000000..7f761a3114 --- /dev/null +++ b/packages/@headlessui-vue/src/hooks/use-event-listener.ts @@ -0,0 +1,17 @@ +import { watchEffect } from 'vue' + +export function useEventListener( + element: HTMLElement | Document | Window | EventTarget | null | undefined, + type: TType, + listener: (event: WindowEventMap[TType]) => any, + options?: boolean | AddEventListenerOptions +) { + if (typeof window === 'undefined') return + + watchEffect((onInvalidate) => { + element = element ?? window + + element.addEventListener(type, listener as any, options) + onInvalidate(() => element!.removeEventListener(type, listener as any, options)) + }) +} diff --git a/packages/@headlessui-vue/src/hooks/use-focus-trap.ts b/packages/@headlessui-vue/src/hooks/use-focus-trap.ts index ad7b84fc1c..f2e3969303 100644 --- a/packages/@headlessui-vue/src/hooks/use-focus-trap.ts +++ b/packages/@headlessui-vue/src/hooks/use-focus-trap.ts @@ -10,8 +10,8 @@ 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' +import { useEventListener } from './use-event-listener' 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,14 +117,14 @@ export function useFocusTrap( } } - previousActiveElement.value = document.activeElement as HTMLElement + previousActiveElement.value = ownerDocument.value?.activeElement as HTMLElement }, { immediate: true } ) }) // Handle Tab & Shift+Tab keyboard events - useWindowEvent('keydown', (event) => { + useEventListener(ownerDocument.value?.defaultView, 'keydown', (event) => { if (!(features.value & Features.TabLock)) return if (!container.value) return @@ -136,12 +138,13 @@ 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 } }) // Prevent programmatically escaping - useWindowEvent( + useEventListener( + ownerDocument.value?.defaultView, 'focus', (event) => { if (!(features.value & Features.FocusLock)) return diff --git a/packages/@headlessui-vue/src/hooks/use-inert-others.ts b/packages/@headlessui-vue/src/hooks/use-inert-others.ts index 5d28bf7d2a..c7c2989ff1 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 > *' @@ -37,6 +38,8 @@ export function useInertOthers( if (!container.value) return let element = container.value + let ownerDocument = getOwnerDocument(element) + if (!ownerDocument) return // Mark myself as an interactable element interactables.add(element) @@ -50,7 +53,7 @@ export function useInertOthers( } // Collect direct children of the body - document.querySelectorAll(CHILDREN_SELECTOR).forEach((child) => { + ownerDocument.querySelectorAll(CHILDREN_SELECTOR).forEach((child) => { if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements // Skip the interactables, and the parents of the interactables @@ -79,7 +82,7 @@ export function useInertOthers( // will become inert as well. if (interactables.size > 0) { // Collect direct children of the body - document.querySelectorAll(CHILDREN_SELECTOR).forEach((child) => { + ownerDocument!.querySelectorAll(CHILDREN_SELECTOR).forEach((child) => { if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements // Skip already inert parents diff --git a/packages/@headlessui-vue/src/hooks/use-tree-walker.ts b/packages/@headlessui-vue/src/hooks/use-tree-walker.ts index 27f45bd5a2..806bf58d99 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 @@ -22,10 +23,17 @@ export function useTreeWalker({ let root = container.value if (!root) return if (enabled !== undefined && !enabled.value) return + let ownerDocument = getOwnerDocument(container) + if (!ownerDocument) 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 = ownerDocument.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..c7da796d94 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..afbe9d736b --- /dev/null +++ b/packages/@headlessui-vue/src/utils/owner.ts @@ -0,0 +1,15 @@ +import { Ref } from 'vue' +import { dom } from './dom' + +export function getOwnerDocument>( + element: T | null | undefined +) { + if (typeof window === 'undefined') return null + if (element instanceof Node) return element.ownerDocument + if (element?.hasOwnProperty('value')) { + let domElement = dom(element) + if (domElement) return domElement.ownerDocument + } + + return document +}