diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 2d620e5fed..b23faf9f25 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Close the `Combobox`, `Dialog`, `Listbox`, `Menu` and `Popover` components when the trigger disappears ([#3075](https://github.com/tailwindlabs/headlessui/pull/3075)) - Add new `CloseButton` component and `useClose` hook ([#3096](https://github.com/tailwindlabs/headlessui/pull/3096)) - Allow passing a boolean to the `anchor` prop ([#3121](https://github.com/tailwindlabs/headlessui/pull/3121)) +- Add `portal` prop to `Combobox`, `Listbox`, `Menu` and `Popover` components ([#3124](https://github.com/tailwindlabs/headlessui/pull/3124)) ## [1.7.19] - 2024-04-15 diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 0e515da98a..aa8d757f3b 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -29,6 +29,7 @@ import { useElementSize } from '../../hooks/use-element-size' import { useEvent } from '../../hooks/use-event' import { useFrameDebounce } from '../../hooks/use-frame-debounce' import { useId } from '../../hooks/use-id' +import { useInertOthers } from '../../hooks/use-inert-others' import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect' import { useLatestValue } from '../../hooks/use-latest-value' import { useOnDisappear } from '../../hooks/use-on-disappear' @@ -36,6 +37,7 @@ import { useOutsideClick } from '../../hooks/use-outside-click' import { useOwnerDocument } from '../../hooks/use-owner' import { useRefocusableInput } from '../../hooks/use-refocusable-input' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' +import { useScrollLock } from '../../hooks/use-scroll-lock' import { useSyncRefs } from '../../hooks/use-sync-refs' import { useTrackedPointer } from '../../hooks/use-tracked-pointer' import { useTreeWalker } from '../../hooks/use-tree-walker' @@ -73,6 +75,7 @@ import { useDescribedBy } from '../description/description' import { Keys } from '../keyboard' import { Label, useLabelledBy, useLabels, type _internal_ComponentLabel } from '../label/label' import { MouseButton } from '../mouse' +import { Portal } from '../portal/portal' enum ComboboxState { Open, @@ -1540,6 +1543,8 @@ export type ComboboxOptionsProps & { hold?: boolean anchor?: AnchorProps + portal?: boolean + modal?: boolean } > @@ -1552,6 +1557,8 @@ function OptionsFn( id = `headlessui-combobox-options-${internalId}`, hold = false, anchor: rawAnchor, + portal = false, + modal = true, ...theirProps } = props let data = useData('Combobox.Options') @@ -1561,6 +1568,7 @@ function OptionsFn( let [floatingRef, style] = useFloatingPanel(anchor) let getFloatingPanelProps = useFloatingPanelProps() let optionsRef = useSyncRefs(data.optionsRef, ref, anchor ? floatingRef : null) + let ownerDocument = useOwnerDocument(data.optionsRef) let usesOpenClosedState = useOpenClosed() let visible = (() => { @@ -1574,6 +1582,21 @@ function OptionsFn( // Ensure we close the combobox as soon as the input becomes hidden useOnDisappear(data.inputRef, actions.closeCombobox, visible) + // Enable scroll locking when the combobox is visible, and `modal` is enabled + useScrollLock(ownerDocument, modal && data.comboboxState === ComboboxState.Open) + + // Mark other elements as inert when the combobox is visible, and `modal` is enabled + useInertOthers( + { + allowed: useEvent(() => [ + data.inputRef.current, + data.buttonRef.current, + data.optionsRef.current, + ]), + }, + modal && data.comboboxState === ComboboxState.Open + ) + useIsoMorphicEffect(() => { data.optionsPropsRef.current.static = props.static ?? false }, [data.optionsPropsRef, props.static]) @@ -1623,15 +1646,19 @@ function OptionsFn( }) } - return render({ - ourProps, - theirProps, - slot, - defaultTag: DEFAULT_OPTIONS_TAG, - features: OptionsRenderFeatures, - visible, - name: 'Combobox.Options', - }) + return ( + + {render({ + ourProps, + theirProps, + slot, + defaultTag: DEFAULT_OPTIONS_TAG, + features: OptionsRenderFeatures, + visible, + name: 'Combobox.Options', + })} + + ) } // --- diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index 0f250d27c9..bc7c9e31d1 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -4,7 +4,6 @@ import React, { createContext, createRef, - useCallback, useContext, useEffect, useMemo, @@ -18,16 +17,16 @@ import React, { type Ref, type RefObject, } from 'react' -import { useDocumentOverflowLockedEffect } from '../../hooks/document-overflow/use-document-overflow' import { useEvent } from '../../hooks/use-event' import { useEventListener } from '../../hooks/use-event-listener' import { useId } from '../../hooks/use-id' -import { useInert } from '../../hooks/use-inert' +import { useInertOthers } from '../../hooks/use-inert-others' import { useIsTouchDevice } from '../../hooks/use-is-touch-device' import { useOnDisappear } from '../../hooks/use-on-disappear' import { useOutsideClick } from '../../hooks/use-outside-click' import { useOwnerDocument } from '../../hooks/use-owner' import { useRootContainers } from '../../hooks/use-root-containers' +import { useScrollLock } from '../../hooks/use-scroll-lock' import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete' import { useSyncRefs } from '../../hooks/use-sync-refs' import { CloseProvider } from '../../internal/close-provider' @@ -106,16 +105,6 @@ function useDialogContext(component: string) { return context } -function useScrollLock( - ownerDocument: Document | null, - enabled: boolean, - resolveAllowedContainers: () => HTMLElement[] = () => [document.body] -) { - useDocumentOverflowLockedEffect(ownerDocument, enabled, (meta) => ({ - containers: [...(meta.containers ?? []), resolveAllowedContainers], - })) -} - function stateReducer(state: StateDefinition, action: Actions) { return match(action.type, reducers, state, action) } @@ -272,34 +261,28 @@ function DialogFn( usesOpenClosedState !== null ? (usesOpenClosedState & State.Closing) === State.Closing : false // Ensure other elements can't be interacted with - let inertOthersEnabled = (() => { - // Nested dialogs should not modify the `inert` property, only the root one should. - if (hasParentDialog) return false + let inertEnabled = (() => { + // Only the top-most dialog should be allowed, all others should be inert + if (hasNestedDialogs) return false if (isClosing) return false return enabled })() - let resolveRootOfMainTreeNode = useCallback(() => { - return (Array.from(ownerDocument?.querySelectorAll('body > *') ?? []).find((root) => { - // Skip the portal root, we don't want to make that one inert - if (root.id === 'headlessui-portal-root') return false - - // Find the root of the main tree node - return root.contains(mainTreeNodeRef.current) && root instanceof HTMLElement - }) ?? null) as HTMLElement | null - }, [mainTreeNodeRef]) - useInert(resolveRootOfMainTreeNode, inertOthersEnabled) - - // This would mark the parent dialogs as inert - let inertParentDialogs = (() => { - if (hasNestedDialogs) return true - return enabled - })() - let resolveRootOfParentDialog = useCallback(() => { - return (Array.from(ownerDocument?.querySelectorAll('[data-headlessui-portal]') ?? []).find( - (root) => root.contains(mainTreeNodeRef.current) && root instanceof HTMLElement - ) ?? null) as HTMLElement | null - }, [mainTreeNodeRef]) - useInert(resolveRootOfParentDialog, inertParentDialogs) + + useInertOthers( + { + allowed: useEvent(() => [ + // Allow the headlessui-portal of the Dialog to be interactive. This + // contains the current dialog and the necessary focus guard elements. + internalDialogRef.current?.closest('[data-headlessui-portal]') ?? null, + ]), + disallowed: useEvent(() => [ + // Disallow the "main" tree root node + mainTreeNodeRef.current?.closest('body > *:not(#headlessui-portal-root)') ?? + null, + ]), + }, + inertEnabled + ) // Close Dialog on outside click let outsideClickEnabled = (() => { @@ -390,7 +373,7 @@ function DialogFn( enabled={dialogState === DialogStates.Open} element={internalDialogRef} onUpdate={useEvent((message, type) => { - if (type !== 'Dialog' && type !== 'Modal') return + if (type !== 'Dialog') return match(message, { [StackMessage.Add]: () => setNestedDialogCount((count) => count + 1), diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index a8f3fa87ea..5afabe5fe6 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -29,11 +29,14 @@ import { useDisposables } from '../../hooks/use-disposables' import { useElementSize } from '../../hooks/use-element-size' import { useEvent } from '../../hooks/use-event' import { useId } from '../../hooks/use-id' +import { useInertOthers } from '../../hooks/use-inert-others' import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect' import { useLatestValue } from '../../hooks/use-latest-value' import { useOnDisappear } from '../../hooks/use-on-disappear' import { useOutsideClick } from '../../hooks/use-outside-click' +import { useOwnerDocument } from '../../hooks/use-owner' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' +import { useScrollLock } from '../../hooks/use-scroll-lock' import { useSyncRefs } from '../../hooks/use-sync-refs' import { useTextValue } from '../../hooks/use-text-value' import { useTrackedPointer } from '../../hooks/use-tracked-pointer' @@ -49,7 +52,6 @@ import { } from '../../internal/floating' import { FormFields } from '../../internal/form-fields' import { useProvidedId } from '../../internal/id' -import { Modal, type ModalProps } from '../../internal/modal' import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' import type { EnsureArray, Props } from '../../types' import { isDisabledReactIssue7711 } from '../../utils/bugs' @@ -870,6 +872,7 @@ export type ListboxOptionsProps > @@ -882,19 +885,22 @@ function OptionsFn( let { id = `headlessui-listbox-options-${internalId}`, anchor: rawAnchor, - modal, + portal = false, + modal = true, ...theirProps } = props let anchor = useResolvedAnchor(rawAnchor) - // Always use `modal` when `anchor` is passed in - if (modal == null) { - modal = Boolean(anchor) + // Always enable `portal` functionality, when `anchor` is enabled + if (anchor) { + portal = true } let data = useData('Listbox.Options') let actions = useActions('Listbox.Options') + let ownerDocument = useOwnerDocument(data.optionsRef) + let usesOpenClosedState = useOpenClosed() let visible = (() => { if (usesOpenClosedState !== null) { @@ -907,6 +913,15 @@ function OptionsFn( // Ensure we close the listbox as soon as the button becomes hidden useOnDisappear(data.buttonRef, actions.closeListbox, visible) + // Enable scroll locking when the listbox is visible, and `modal` is enabled + useScrollLock(ownerDocument, modal && data.listboxState === ListboxStates.Open) + + // Mark other elements as inert when the listbox is visible, and `modal` is enabled + useInertOthers( + { allowed: useEvent(() => [data.buttonRef.current, data.optionsRef.current]) }, + modal && data.listboxState === ListboxStates.Open + ) + let initialOption = useRef(null) useEffect(() => { @@ -1066,11 +1081,6 @@ function OptionsFn( } as CSSProperties, }) - let Wrapper = modal ? Modal : anchor ? Portal : Fragment - let wrapperProps = modal - ? ({ enabled: data.listboxState === ListboxStates.Open } satisfies ModalProps) - : {} - // Frozen state, the selected value will only update visually when the user re-opens the let [frozenValue, setFrozenValue] = useState(data.value) if ( @@ -1085,7 +1095,7 @@ function OptionsFn( }) return ( - + @@ -1099,7 +1109,7 @@ function OptionsFn( name: 'Listbox.Options', })} - + ) } diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index 0e085c5f21..df70af8d8d 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -26,11 +26,13 @@ import { useDisposables } from '../../hooks/use-disposables' import { useElementSize } from '../../hooks/use-element-size' import { useEvent } from '../../hooks/use-event' import { useId } from '../../hooks/use-id' +import { useInertOthers } from '../../hooks/use-inert-others' import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect' import { useOnDisappear } from '../../hooks/use-on-disappear' import { useOutsideClick } from '../../hooks/use-outside-click' import { useOwnerDocument } from '../../hooks/use-owner' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' +import { useScrollLock } from '../../hooks/use-scroll-lock' import { useSyncRefs } from '../../hooks/use-sync-refs' import { useTextValue } from '../../hooks/use-text-value' import { useTrackedPointer } from '../../hooks/use-tracked-pointer' @@ -44,7 +46,6 @@ import { useResolvedAnchor, type AnchorProps, } from '../../internal/floating' -import { Modal, ModalFeatures, type ModalProps } from '../../internal/modal' import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' import type { Props } from '../../types' import { isDisabledReactIssue7711 } from '../../utils/bugs' @@ -577,6 +578,7 @@ export type MenuItemsProps ItemsPropsWeControl, { anchor?: AnchorProps + portal?: boolean modal?: boolean // ItemsRenderFeatures @@ -593,7 +595,8 @@ function ItemsFn( let { id = `headlessui-menu-items-${internalId}`, anchor: rawAnchor, - modal, + portal = false, + modal = true, ...theirProps } = props let anchor = useResolvedAnchor(rawAnchor) @@ -603,9 +606,9 @@ function ItemsFn( let itemsRef = useSyncRefs(state.itemsRef, ref, anchor ? floatingRef : null) let ownerDocument = useOwnerDocument(state.itemsRef) - // Always use `modal` when `anchor` is passed in - if (modal == null) { - modal = Boolean(anchor) + // Always enable `portal` functionality, when `anchor` is enabled + if (anchor) { + portal = true } let searchDisposables = useDisposables() @@ -622,6 +625,15 @@ function ItemsFn( // Ensure we close the menu as soon as the button becomes hidden useOnDisappear(state.buttonRef, () => dispatch({ type: ActionTypes.CloseMenu }), visible) + // Enable scroll locking when the menu is visible, and `modal` is enabled + useScrollLock(ownerDocument, modal && state.menuState === MenuStates.Open) + + // Mark other elements as inert when the menu is visible, and `modal` is enabled + useInertOthers( + { allowed: useEvent(() => [state.buttonRef.current, state.itemsRef.current]) }, + modal && state.menuState === MenuStates.Open + ) + // We keep track whether the button moved or not, we only check this when the menu state becomes // closed. If the button moved, then we want to cancel pending transitions to prevent that the // attached `MenuItems` is still transitioning while the button moved away. @@ -766,16 +778,8 @@ function ItemsFn( } as CSSProperties, }) - let Wrapper = modal ? Modal : anchor ? Portal : Fragment - let wrapperProps = modal - ? ({ - features: ModalFeatures.ScrollLock, - enabled: state.menuState === MenuStates.Open, - } satisfies ModalProps) - : {} - return ( - + {render({ ourProps, theirProps, @@ -785,7 +789,7 @@ function ItemsFn( visible: panelEnabled, name: 'Menu.Items', })} - + ) } diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index 3cf529adc2..2822b042fb 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -3,7 +3,6 @@ import { useFocusRing } from '@react-aria/focus' import { useHover } from '@react-aria/interactions' import React, { - Fragment, createContext, createRef, useContext, @@ -34,6 +33,7 @@ import { useOutsideClick } from '../../hooks/use-outside-click' import { useOwnerDocument } from '../../hooks/use-owner' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useMainTreeNode, useRootContainers } from '../../hooks/use-root-containers' +import { useScrollLock } from '../../hooks/use-scroll-lock' import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs' import { Direction as TabDirection, useTabDirection } from '../../hooks/use-tab-direction' import { CloseProvider } from '../../internal/close-provider' @@ -46,7 +46,6 @@ import { type AnchorProps, } from '../../internal/floating' import { Hidden, HiddenFeatures } from '../../internal/hidden' -import { Modal, ModalFeatures as ModalRenderFeatures, type ModalProps } from '../../internal/modal' import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' import type { Props } from '../../types' import { isDisabledReactIssue7711 } from '../../utils/bugs' @@ -71,7 +70,6 @@ import { type PropsForFeatures, type RefProp, } from '../../utils/render' -import { FocusTrapFeatures } from '../focus-trap/focus-trap' import { Keys } from '../keyboard' import { Portal, useNestedPortals } from '../portal/portal' @@ -800,6 +798,7 @@ export type PopoverPanelProps( id = `headlessui-popover-panel-${internalId}`, focus = false, anchor: rawAnchor, - modal, + portal = false, + modal = false, ...theirProps } = props @@ -832,9 +832,9 @@ function PanelFn( let [floatingRef, style] = useFloatingPanel(anchor) let getFloatingPanelProps = useFloatingPanelProps() - // Always use `modal` when `anchor` is passed in - if (modal == null) { - modal = Boolean(anchor) + // Always enable `portal` functionality, when `anchor` is enabled + if (anchor) { + portal = true } let panelRef = useSyncRefs(internalPanelRef, ref, anchor ? floatingRef : null, (panel) => { @@ -862,6 +862,9 @@ function PanelFn( // Ensure we close the popover as soon as the button becomes hidden useOnDisappear(state.button, () => dispatch({ type: ActionTypes.ClosePopover }), visible) + // Enable scroll locking when the popover is visible, and `modal` is enabled + useScrollLock(ownerDocument, modal && visible) + let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { switch (event.key) { case Keys.Escape: @@ -1014,23 +1017,10 @@ function PanelFn( } }) - let Wrapper = modal ? Modal : anchor ? Portal : Fragment - let wrapperProps = modal - ? ({ - focusTrapFeatures: FocusTrapFeatures.None, - features: ModalRenderFeatures.ScrollLock, - enabled: state.popoverState === PopoverStates.Open, - } satisfies ModalProps) - : {} - - if (Wrapper === Portal || Wrapper === Modal) { - isPortalled = true - } - return ( - + {visible && isPortalled && ( ( onFocus={handleAfterFocus} /> )} - + ) diff --git a/packages/@headlessui-react/src/components/portal/portal.tsx b/packages/@headlessui-react/src/components/portal/portal.tsx index adc38e8502..5517827c43 100644 --- a/packages/@headlessui-react/src/components/portal/portal.tsx +++ b/packages/@headlessui-react/src/components/portal/portal.tsx @@ -33,7 +33,7 @@ function usePortalTarget(ref: MutableRefObject): HTMLElement let [target, setTarget] = useState(() => { // Group context is used, but still null - if (!forceInRoot && groupTarget !== null) return null + if (!forceInRoot && groupTarget !== null) return groupTarget.current ?? null // No group context is used, let's create a default portal root if (env.isServer) return null @@ -74,13 +74,15 @@ type PortalPropsWeControl = never export type PortalProps = Props< TTag, PortalRenderPropArg, - PortalPropsWeControl + PortalPropsWeControl, + { + enabled?: boolean + } > -function PortalFn( - props: PortalProps, - ref: Ref -) { +let InternalPortalFn = forwardRefWithAs(function InternalPortalFn< + TTag extends ElementType = typeof DEFAULT_PORTAL_TAG, +>(props: PortalProps, ref: Ref) { let theirProps = props let internalPortalRootRef = useRef(null) let portalRef = useSyncRefs( @@ -143,6 +145,26 @@ function PortalFn( }), element ) +}) + +function PortalFn( + props: PortalProps, + ref: Ref +) { + let portalRef = useSyncRefs(ref) + + let { enabled = true, ...theirProps } = props + return enabled ? ( + + ) : ( + render({ + ourProps: { ref: portalRef }, + theirProps, + slot: {}, + defaultTag: DEFAULT_PORTAL_TAG, + name: 'Portal', + }) + ) } // --- diff --git a/packages/@headlessui-react/src/hooks/use-inert.test.tsx b/packages/@headlessui-react/src/hooks/use-inert-others.test.tsx similarity index 63% rename from packages/@headlessui-react/src/hooks/use-inert.test.tsx rename to packages/@headlessui-react/src/hooks/use-inert-others.test.tsx index d31ac5bc71..d30d6b5f04 100644 --- a/packages/@headlessui-react/src/hooks/use-inert.test.tsx +++ b/packages/@headlessui-react/src/hooks/use-inert-others.test.tsx @@ -2,7 +2,7 @@ import { render } from '@testing-library/react' import React, { useRef, useState, type ReactNode } from 'react' import { assertInert, assertNotInert, getByText } from '../test-utils/accessibility-assertions' import { click } from '../test-utils/interactions' -import { useInert } from './use-inert' +import { useInertOthers } from './use-inert-others' beforeEach(() => { jest.restoreAllMocks() @@ -13,7 +13,7 @@ it('should be possible to inert an element', async () => { function Example() { let ref = useRef(null) let [enabled, setEnabled] = useState(true) - useInert(ref, enabled) + useInertOthers({ disallowed: () => [ref.current] }, enabled) return (
@@ -59,7 +59,7 @@ it('should not mark an element as inert when the hook is disabled', async () => function Example() { let ref = useRef(null) let [enabled, setEnabled] = useState(false) - useInert(ref, enabled) + useInertOthers({ disallowed: () => [ref.current] }, enabled) return (
@@ -95,7 +95,7 @@ it('should mark the element as not inert anymore, once all references are gone', let ref = useRef(null) let [enabled, setEnabled] = useState(false) - useInert(() => ref.current?.parentElement ?? null, enabled) + useInertOthers({ disallowed: () => [ref.current?.parentElement ?? null] }, enabled) return (
@@ -139,3 +139,55 @@ it('should mark the element as not inert anymore, once all references are gone', // Parent should not be inert because both A and B are disabled assertNotInert(document.getElementById('parent')) }) + +it('should be possible to mark everything but allowed containers as inert', async () => { + function Example({ children }: { children: ReactNode }) { + let [enabled, setEnabled] = useState(false) + useInertOthers( + { allowed: () => [document.getElementById('a-a-b')!, document.getElementById('a-a-c')!] }, + enabled + ) + + return ( +
+ {children} + +
+ ) + } + + render( + +
+
+
+
+
+
+
+
+
+
, + { container: document.body } + ) + + let a = document.getElementById('a')! + let aa = document.getElementById('a-a')! + let aaa = document.getElementById('a-a-a')! + let aab = document.getElementById('a-a-b')! + let aac = document.getElementById('a-a-c')! + let ab = document.getElementById('a-b')! + let ac = document.getElementById('a-c')! + + // Nothing should be inert + for (let el of [a, aa, aaa, aab, aac, ab, ac]) assertNotInert(el) + + // Toggle inert state + await click(getByText('toggle')) + + // Every sibling of `a-a-b` and `a-a-c` should be inert, and all the + // siblings of the parents of `a-a-b` and `a-a-c` should be inert as well. + // The path to the body should not be marked as inert. + for (let el of [a, aa, aab, aac]) assertNotInert(el) + for (let el of [aaa, ab, ac]) assertInert(el) +}) diff --git a/packages/@headlessui-react/src/hooks/use-inert-others.tsx b/packages/@headlessui-react/src/hooks/use-inert-others.tsx new file mode 100644 index 0000000000..7f96149dad --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-inert-others.tsx @@ -0,0 +1,122 @@ +import { disposables } from '../utils/disposables' +import { getOwnerDocument } from '../utils/owner' +import { useIsoMorphicEffect } from './use-iso-morphic-effect' + +let originals = new Map() +let counts = new Map() + +function markInert(element: HTMLElement) { + // Increase count + let count = counts.get(element) ?? 0 + counts.set(element, count + 1) + + // Already marked as inert, no need to do it again + if (count !== 0) return () => markNotInert(element) + + // Keep track of previous values, so that we can restore them when we are done + originals.set(element, { + 'aria-hidden': element.getAttribute('aria-hidden'), + inert: element.inert, + }) + + // Mark as inert + element.setAttribute('aria-hidden', 'true') + element.inert = true + + return () => markNotInert(element) +} + +function markNotInert(element: HTMLElement) { + // Decrease counts + let count = counts.get(element) ?? 1 // Should always exist + if (count === 1) counts.delete(element) // We are the last one, so we can delete the count + else counts.set(element, count - 1) // We are not the last one + + // Not the last one, so we don't restore the original values (yet) + if (count !== 1) return + + let original = originals.get(element) + if (!original) return // Should never happen + + // Restore original values + if (original['aria-hidden'] === null) element.removeAttribute('aria-hidden') + else element.setAttribute('aria-hidden', original['aria-hidden']) + element.inert = original.inert + + // Remove tracking of original values + originals.delete(element) +} + +/** + * Mark all elements on the page as inert, except for the ones that are allowed. + * + * We move up the tree from the allowed elements, and mark all their siblings as + * inert. If any of the children happens to be a parent of one of the elements, + * then that child will not be marked as inert. + * + * E.g.: + * + * ```html + * + *
+ *
+ *
Sidebar
+ *
+ * + * + * + * + *
+ *
+ *
+ * + * ``` + */ +export function useInertOthers( + { + allowed = () => [], + disallowed = () => [], + }: { allowed?: () => (HTMLElement | null)[]; disallowed?: () => (HTMLElement | null)[] } = {}, + enabled = true +) { + useIsoMorphicEffect(() => { + if (!enabled) return + + let d = disposables() + + // Mark all disallowed elements as inert + for (let element of disallowed()) { + if (!element) continue + + d.add(markInert(element)) + } + + // Mark all siblings of allowed elements (and parents) as inert + let allowedElements = allowed() + + for (let element of allowedElements) { + if (!element) continue + + let ownerDocument = getOwnerDocument(element) + if (!ownerDocument) continue + + let parent = element.parentElement + while (parent && parent !== ownerDocument.body) { + // Mark all siblings as inert + for (let node of parent.childNodes) { + // If the node contains any of the elements we should not mark it as inert + // because it would make the elements unreachable. + if (allowedElements.some((el) => node.contains(el))) continue + + // Mark the node as inert + d.add(markInert(node as HTMLElement)) + } + + // Move up the tree + parent = parent.parentElement + } + } + + return d.dispose + }, [enabled, allowed, disallowed]) +} diff --git a/packages/@headlessui-react/src/hooks/use-inert.tsx b/packages/@headlessui-react/src/hooks/use-inert.tsx deleted file mode 100644 index 5ae7dda565..0000000000 --- a/packages/@headlessui-react/src/hooks/use-inert.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import type { MutableRefObject } from 'react' -import { useIsoMorphicEffect } from './use-iso-morphic-effect' - -let originals = new Map() -let counts = new Map() - -export function useInert( - node: MutableRefObject | (() => TElement | null), - enabled = true -) { - useIsoMorphicEffect(() => { - if (!enabled) return - - let element = typeof node === 'function' ? node() : node.current - if (!element) return - - function cleanup() { - if (!element) return // Should never happen - - // Decrease counts - let count = counts.get(element) ?? 1 // Should always exist - if (count === 1) counts.delete(element) // We are the last one, so we can delete the count - else counts.set(element, count - 1) // We are not the last one - - // Not the last one, so we don't restore the original values (yet) - if (count !== 1) return - - let original = originals.get(element) - if (!original) return // Should never happen - - // Restore original values - if (original['aria-hidden'] === null) element.removeAttribute('aria-hidden') - else element.setAttribute('aria-hidden', original['aria-hidden']) - element.inert = original.inert - - // Remove tracking of original values - originals.delete(element) - } - - // Increase count - let count = counts.get(element) ?? 0 - counts.set(element, count + 1) - - // Already marked as inert, no need to do it again - if (count !== 0) return cleanup - - // Keep track of previous values, so that we can restore them when we are done - originals.set(element, { - 'aria-hidden': element.getAttribute('aria-hidden'), - inert: element.inert, - }) - - // Mark as inert - element.setAttribute('aria-hidden', 'true') - element.inert = true - - return cleanup - }, [node, enabled]) -} diff --git a/packages/@headlessui-react/src/hooks/use-scroll-lock.ts b/packages/@headlessui-react/src/hooks/use-scroll-lock.ts new file mode 100644 index 0000000000..b2e882a763 --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-scroll-lock.ts @@ -0,0 +1,11 @@ +import { useDocumentOverflowLockedEffect } from './document-overflow/use-document-overflow' + +export function useScrollLock( + ownerDocument: Document | null, + enabled: boolean, + resolveAllowedContainers: () => HTMLElement[] = () => [document.body] +) { + useDocumentOverflowLockedEffect(ownerDocument, enabled, (meta) => ({ + containers: [...(meta.containers ?? []), resolveAllowedContainers], + })) +} diff --git a/packages/@headlessui-react/src/internal/modal.tsx b/packages/@headlessui-react/src/internal/modal.tsx deleted file mode 100644 index baae8bdb06..0000000000 --- a/packages/@headlessui-react/src/internal/modal.tsx +++ /dev/null @@ -1,219 +0,0 @@ -// WAI-ARIA: https://www.w3.org/WAI/ARIA/apg/patterns/modalmodal/ -import React, { - useCallback, - useMemo, - useRef, - type ElementType, - type MutableRefObject, - type Ref, - type RefObject, -} from 'react' -import { FocusTrap, FocusTrapFeatures } from '../components/focus-trap/focus-trap' -import { Portal, useNestedPortals } from '../components/portal/portal' -import { useDocumentOverflowLockedEffect } from '../hooks/document-overflow/use-document-overflow' -import { useId } from '../hooks/use-id' -import { useInert } from '../hooks/use-inert' -import { useOwnerDocument } from '../hooks/use-owner' -import { useRootContainers } from '../hooks/use-root-containers' -import { useSyncRefs } from '../hooks/use-sync-refs' -import { HoistFormFields } from '../internal/form-fields' -import type { Props } from '../types' -import { - RenderFeatures, - forwardRefWithAs, - render, - type HasDisplayName, - type PropsForFeatures, - type RefProp, -} from '../utils/render' -import { ForcePortalRoot } from './portal-force-root' -import { StackProvider } from './stack-context' - -function useScrollLock( - ownerDocument: Document | null, - enabled: boolean, - resolveAllowedContainers: () => HTMLElement[] = () => [document.body] -) { - useDocumentOverflowLockedEffect(ownerDocument, enabled, (meta) => ({ - containers: [...(meta.containers ?? []), resolveAllowedContainers], - })) -} - -export enum ModalFeatures { - /** No modal features */ - None = 0, - - /** Make the whole page but the Modal `inert` */ - Inert = 1 << 0, - - /** Enable scroll locking to prevent scrolling the rest off the page (the body) */ - ScrollLock = 1 << 1, - - /** - * Enable focus trapping, focus trapping features can be configured via the `focusTrapFeatures` - * prop - */ - FocusTrap = 1 << 2, - - All = Inert | ScrollLock | FocusTrap, -} - -// --- - -let DEFAULT_MODAL_TAG = 'div' as const -type ModalRenderPropArg = {} -type ModalPropsWeControl = 'aria-dialog' - -let ModalRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static - -export type ModalProps = Props< - TTag, - ModalRenderPropArg, - ModalPropsWeControl, - PropsForFeatures & { - enabled?: boolean - features?: ModalFeatures - focusTrapFeatures?: FocusTrapFeatures - initialFocus?: MutableRefObject - role?: 'dialog' | 'alertdialog' - } -> - -function ModalFn( - props: ModalProps, - ref: Ref -) { - let internalId = useId() - let { - id = `headlessui-modal-${internalId}`, - initialFocus, - role = 'dialog', - features = ModalFeatures.All, - enabled = true, - focusTrapFeatures = FocusTrapFeatures.All, - ...theirProps - } = props - - if (!enabled) { - features = ModalFeatures.None - } - - let didWarnOnRole = useRef(false) - - role = (function () { - if (role === 'dialog' || role === 'alertdialog') { - return role - } - - if (!didWarnOnRole.current) { - didWarnOnRole.current = true - console.warn( - `Invalid role [${role}] passed to . Only \`dialog\` and and \`alertdialog\` are supported. Using \`dialog\` instead.` - ) - } - - return 'dialog' - })() - - let internalModalRef = useRef(null) - let modalRef = useSyncRefs(internalModalRef, ref) - - let ownerDocument = useOwnerDocument(internalModalRef) - - let [portals, PortalWrapper] = useNestedPortals() - - // We use this because reading these values during initial render(s) - // can result in `null` rather then the actual elements - let defaultContainer: RefObject = { - get current() { - return internalModalRef.current - }, - } - - let { - resolveContainers: resolveRootContainers, - mainTreeNodeRef, - MainTreeNode, - } = useRootContainers({ - portals, - defaultContainers: [defaultContainer], - }) - - // Ensure other elements can't be interacted with - let resolveRootOfMainTreeNode = useCallback(() => { - return (Array.from(ownerDocument?.querySelectorAll('body > *') ?? []).find((root) => { - // Skip the portal root, we don't want to make that one inert - if (root.id === 'headlessui-portal-root') return false - - // Find the root of the main tree node - return root.contains(mainTreeNodeRef.current) && root instanceof HTMLElement - }) ?? null) as HTMLElement | null - }, [mainTreeNodeRef]) - useInert(resolveRootOfMainTreeNode, Boolean(features & ModalFeatures.Inert)) - - // This would mark the parent modals as inert - let resolveRootOfParentModal = useCallback(() => { - return (Array.from(ownerDocument?.querySelectorAll('[data-headlessui-portal]') ?? []).find( - (root) => root.contains(mainTreeNodeRef.current) && root instanceof HTMLElement - ) ?? null) as HTMLElement | null - }, [mainTreeNodeRef]) - useInert(resolveRootOfParentModal, Boolean(features & ModalFeatures.Inert)) - - // Scroll lock - useScrollLock(ownerDocument, Boolean(features & ModalFeatures.ScrollLock), resolveRootContainers) - - let slot = useMemo(() => ({}) satisfies ModalRenderPropArg, []) - - let ourProps = { - ref: modalRef, - id, - role, - 'aria-modal': enabled || undefined, - } - - return ( - - - - - - - {render({ - ourProps, - theirProps, - slot, - defaultTag: DEFAULT_MODAL_TAG, - features: ModalRenderFeatures, - name: 'Modal', - })} - - - - - - - - - - ) -} - -// --- - -export interface _internal_ComponentModal extends HasDisplayName { - ( - props: ModalProps & RefProp - ): JSX.Element -} - -let ModalRoot = forwardRefWithAs(ModalFn) as _internal_ComponentModal - -export let Modal = Object.assign(ModalRoot, {}) diff --git a/playgrounds/react/pages/popover/popover.tsx b/playgrounds/react/pages/popover/popover.tsx index 930ecdfec5..868751d822 100644 --- a/playgrounds/react/pages/popover/popover.tsx +++ b/playgrounds/react/pages/popover/popover.tsx @@ -1,6 +1,5 @@ -import { Popover, Portal, Transition } from '@headlessui/react' +import { Popover, Transition } from '@headlessui/react' import React, { forwardRef } from 'react' -import { usePopper } from '../../utils/hooks/use-popper' let Button = forwardRef( (props: React.ComponentProps<'button'>, ref: React.MutableRefObject) => { @@ -15,15 +14,6 @@ let Button = forwardRef( ) export default function Home() { - let options = { - placement: 'bottom-start' as const, - strategy: 'fixed' as const, - modifiers: [], - } - - let [reference1, popper1] = usePopper(options) - let [reference2, popper2] = usePopper(options) - let items = ['First', 'Second', 'Third', 'Fourth'] return ( @@ -68,32 +58,28 @@ export default function Home() { - - - - {items.map((item) => ( - - ))} - - + + + {items.map((item) => ( + + ))} + - - - - {items.map((item) => ( - - ))} - - + + + {items.map((item) => ( + + ))} +