From b6aa1d6d24217db5edb83c451a988480be7874ce Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 24 Apr 2024 17:10:41 +0200 Subject: [PATCH] Add `portal` prop to `Combobox`, `Listbox`, `Menu` and `Popover` components (#3124) * move duplicated `useScrollLock` to dedicated hook * accept `enabled` prop on `Portal` component This way we can always use ``, but enable / disable it conditionally. * use `useSyncRefs` in portal This allows us to _not_ provide the ref is no ref was passed in. * refactor inner workings of `useInert` moved logic from the `useEffect`, to module scope. We will re-use this logic in a future commit. * add `useInertOthers` hook 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`. ```
Sidebar
``` * add `portal` prop, and change meaning of `modal` prop on `MenuItems` - This adds a `portal` prop that renders the `MenuItems` in a portal. Defaults to `false`. - If you pass an `anchor` prop, the `portal` prop will always be set to `true`. - The `modal` prop enables the following behavior: - Scroll locking is enabled when the `modal` prop is passed and the `Menu` is open. - Other elements but the `Menu` are marked as `inert`. * add `portal` prop, and change meaning of `modal` prop on `ListboxOptions` - This adds a `portal` prop that renders the `ListboxOptions` in a portal. Defaults to `false`. - If you pass an `anchor` prop, the `portal` prop will always be set to `true`. - The `modal` prop enables the following behavior: - Scroll locking is enabled when the `modal` prop is passed and the `Listbox` is open. - Other elements but the `Listbox` are marked as `inert`. * add `portal` and `modal` prop on `ComboboxOptions` - This adds a `portal` prop that renders the `ComboboxOptions` in a portal. Defaults to `false`. - If you pass an `anchor` prop, the `portal` prop will always be set to `true`. - The `modal` prop enables the following behavior: - Scroll locking is enabled when the `modal` prop is passed and the `Combobox` is open. - Other elements but the `Combobox` are marked as `inert`. * add `portal` prop, and change meaning of `modal` prop on `PopoverPanel` - This adds a `portal` prop that renders the `PopoverPanel` in a portal. Defaults to `false`. - If you pass an `anchor` prop, the `portal` prop will always be set to `true`. - The `modal` prop enables the following behavior: - Scroll locking is enabled when the `modal` prop is passed and the `Panel` is open. * simplify popover playground, use provided `anchor` prop * remove internal `Modal` component This is now implemented on a per component basis with some hooks. * remove `Modal` handling from `Dialog` The `Modal` component is removed, so there is no need to handle this in the `Dialog`. It's also safe to remove because the components with "portals" that are rendered inside the `Dialog` are portalled into the `Dialog` and not as a sibling of the `Dialog`. * ensure we use `groupTarget` if it is already available Before this, we were waiting for a "next render" to mount the portal if it was used inside a specific group. This happens when using `` inside of a ``. * update changelog * add tests for `useInertOthers` * ensure we stop before the `body` We used to have a `useInertOthers` hook, but it also made everything inside `document.body` inert. This means that third party packages or browser extensions that inject something in the `document.body` were also marked as `inert`. This is something we don't want. We fixed that previously by introducing a simpler `useInert` where we explicitly marked certain elements as inert: https://github.com/tailwindlabs/headlessui/pull/2290 But I believe this new implementation is better, especially with this commit where we stop once we hit `document.body`. This means that we will never mark `body > *` elements as `inert`. * add `allowed` and `disallowed` to `useInertOthers` This way we have a list of allowed and disallowed containers. The `disallowed` elements will be marked as inert as-is. The allowed elements will not be marked as `inert`, but it will mark its children as inert. Then goes op the parent tree and repeats the process. * simplify `useInertOthers` in `Dialog` code * update `use-inert` tests to always use `useInertOthers` * remove `useInert` hook in favor of `useInertOthers` * rename `use-inert` to `use-inert-others` * cleanup default values for `useInertOthers` --- packages/@headlessui-react/CHANGELOG.md | 1 + .../src/components/combobox/combobox.tsx | 45 +++- .../src/components/dialog/dialog.tsx | 59 ++--- .../src/components/listbox/listbox.tsx | 34 ++- .../src/components/menu/menu.tsx | 34 +-- .../src/components/popover/popover.tsx | 34 +-- .../src/components/portal/portal.tsx | 34 ++- ...ert.test.tsx => use-inert-others.test.tsx} | 60 ++++- .../src/hooks/use-inert-others.tsx | 122 ++++++++++ .../@headlessui-react/src/hooks/use-inert.tsx | 59 ----- .../src/hooks/use-scroll-lock.ts | 11 + .../@headlessui-react/src/internal/modal.tsx | 219 ------------------ playgrounds/react/pages/popover/popover.tsx | 54 ++--- 13 files changed, 348 insertions(+), 418 deletions(-) rename packages/@headlessui-react/src/hooks/{use-inert.test.tsx => use-inert-others.test.tsx} (63%) create mode 100644 packages/@headlessui-react/src/hooks/use-inert-others.tsx delete mode 100644 packages/@headlessui-react/src/hooks/use-inert.tsx create mode 100644 packages/@headlessui-react/src/hooks/use-scroll-lock.ts delete mode 100644 packages/@headlessui-react/src/internal/modal.tsx diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 00fb3ff..758a8dc 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -44,6 +44,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 15f13b2..204a2bc 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 0f250d2..bc7c9e3 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 a8f3fa8..5afabe5 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 0e085c5..df70af8 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 3cf529a..2822b04 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 adc38e8..5517827 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 d31ac5b..d30d6b5 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 0000000..7f96149 --- /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 5ae7dda..0000000 --- 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 0000000..b2e882a --- /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 baae8bd..0000000 --- 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 930ecdf..868751d 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) => ( + + ))} +