From 87be4dd4e6a62ce810235b04ae8a33db6d8424ed Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 23 Apr 2024 00:30:58 +0200 Subject: [PATCH 01/22] move duplicated `useScrollLock` to dedicated hook --- .../src/components/dialog/dialog.tsx | 12 +----------- .../@headlessui-react/src/hooks/use-scroll-lock.ts | 11 +++++++++++ packages/@headlessui-react/src/internal/modal.tsx | 12 +----------- 3 files changed, 13 insertions(+), 22 deletions(-) create mode 100644 packages/@headlessui-react/src/hooks/use-scroll-lock.ts diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index 0f250d27c9..8e9511703b 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -18,7 +18,6 @@ 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' @@ -28,6 +27,7 @@ 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 +106,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) } 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 index baae8bdb06..fb487d57df 100644 --- a/packages/@headlessui-react/src/internal/modal.tsx +++ b/packages/@headlessui-react/src/internal/modal.tsx @@ -10,11 +10,11 @@ import React, { } 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 { useScrollLock } from '../hooks/use-scroll-lock' import { useSyncRefs } from '../hooks/use-sync-refs' import { HoistFormFields } from '../internal/form-fields' import type { Props } from '../types' @@ -29,16 +29,6 @@ import { 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, From 89573214b5ee0ffe440d70d33d95cd08b2b59a44 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 23 Apr 2024 15:55:27 +0200 Subject: [PATCH 02/22] accept `enabled` prop on `Portal` component This way we can always use ``, but enable / disable it conditionally. --- .../src/components/portal/portal.tsx | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/@headlessui-react/src/components/portal/portal.tsx b/packages/@headlessui-react/src/components/portal/portal.tsx index adc38e8502..c584c914a2 100644 --- a/packages/@headlessui-react/src/components/portal/portal.tsx +++ b/packages/@headlessui-react/src/components/portal/portal.tsx @@ -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,24 @@ function PortalFn( }), element ) +}) + +function PortalFn( + props: PortalProps, + ref: Ref +) { + let { enabled = true, ...theirProps } = props + return enabled ? ( + + ) : ( + render({ + ourProps: { ref }, + theirProps, + slot: {}, + defaultTag: DEFAULT_PORTAL_TAG, + name: 'Portal', + }) + ) } // --- From 4a51768fea12ede46ee7f046a0f1291d9e0aae25 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 23 Apr 2024 17:17:44 +0200 Subject: [PATCH 03/22] use `useSyncRefs` in portal This allows us to _not_ provide the ref is no ref was passed in. --- packages/@headlessui-react/src/components/portal/portal.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/@headlessui-react/src/components/portal/portal.tsx b/packages/@headlessui-react/src/components/portal/portal.tsx index c584c914a2..1d625e4b73 100644 --- a/packages/@headlessui-react/src/components/portal/portal.tsx +++ b/packages/@headlessui-react/src/components/portal/portal.tsx @@ -151,12 +151,14 @@ function PortalFn( props: PortalProps, ref: Ref ) { + let portalRef = useSyncRefs(ref) + let { enabled = true, ...theirProps } = props return enabled ? ( - + ) : ( render({ - ourProps: { ref }, + ourProps: { ref: portalRef }, theirProps, slot: {}, defaultTag: DEFAULT_PORTAL_TAG, From 31c1098f4a82ed94f04d488d164ddeb36663b03f Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 23 Apr 2024 16:34:51 +0200 Subject: [PATCH 04/22] refactor inner workings of `useInert` moved logic from the `useEffect`, to module scope. We will re-use this logic in a future commit. --- .../@headlessui-react/src/hooks/use-inert.tsx | 80 ++++++++++--------- 1 file changed, 41 insertions(+), 39 deletions(-) diff --git a/packages/@headlessui-react/src/hooks/use-inert.tsx b/packages/@headlessui-react/src/hooks/use-inert.tsx index 5ae7dda565..2aa817a52b 100644 --- a/packages/@headlessui-react/src/hooks/use-inert.tsx +++ b/packages/@headlessui-react/src/hooks/use-inert.tsx @@ -4,56 +4,58 @@ 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 +function markInert(element: HTMLElement) { + // Increase count + let count = counts.get(element) ?? 0 + counts.set(element, count + 1) - let element = typeof node === 'function' ? node() : node.current - if (!element) return + // Already marked as inert, no need to do it again + if (count !== 0) return () => markNotInert(element) - function cleanup() { - if (!element) return // Should never happen + // 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, + }) - // 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 + // Mark as inert + element.setAttribute('aria-hidden', 'true') + element.inert = true - // Not the last one, so we don't restore the original values (yet) - if (count !== 1) return + return () => markNotInert(element) +} - let original = originals.get(element) - if (!original) return // Should never happen +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 - // Restore original values - if (original['aria-hidden'] === null) element.removeAttribute('aria-hidden') - else element.setAttribute('aria-hidden', original['aria-hidden']) - element.inert = original.inert + // Not the last one, so we don't restore the original values (yet) + if (count !== 1) return - // Remove tracking of original values - originals.delete(element) - } + let original = originals.get(element) + if (!original) return // Should never happen - // Increase count - let count = counts.get(element) ?? 0 - counts.set(element, count + 1) + // Restore original values + if (original['aria-hidden'] === null) element.removeAttribute('aria-hidden') + else element.setAttribute('aria-hidden', original['aria-hidden']) + element.inert = original.inert - // Already marked as inert, no need to do it again - if (count !== 0) return cleanup + // Remove tracking of original values + originals.delete(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, - }) +export function useInert( + node: MutableRefObject | (() => TElement | null), + enabled = true +) { + useIsoMorphicEffect(() => { + if (!enabled) return - // Mark as inert - element.setAttribute('aria-hidden', 'true') - element.inert = true + let element = typeof node === 'function' ? node() : node.current + if (!element) return - return cleanup + return markInert(element) }, [node, enabled]) } From dec0d7a1146099dd6268d0ad7cbf09ec6990cd30 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 23 Apr 2024 17:13:35 +0200 Subject: [PATCH 05/22] 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
``` --- .../@headlessui-react/src/hooks/use-inert.tsx | 69 ++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/@headlessui-react/src/hooks/use-inert.tsx b/packages/@headlessui-react/src/hooks/use-inert.tsx index 2aa817a52b..e4e096310a 100644 --- a/packages/@headlessui-react/src/hooks/use-inert.tsx +++ b/packages/@headlessui-react/src/hooks/use-inert.tsx @@ -1,4 +1,6 @@ -import type { MutableRefObject } from 'react' +import { type MutableRefObject } from 'react' +import { disposables } from '../utils/disposables' +import { getOwnerDocument } from '../utils/owner' import { useIsoMorphicEffect } from './use-iso-morphic-effect' let originals = new Map() @@ -59,3 +61,68 @@ export function useInert( return markInert(element) }, [node, enabled]) } + +/** + * 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( + resolveAllowedContainers: () => (HTMLElement | null)[], + enabled = true +) { + useIsoMorphicEffect(() => { + if (!enabled) return + + let d = disposables() + let elements = resolveAllowedContainers() + + for (let element of elements) { + if (!element) continue + + let ownerDocument = getOwnerDocument(element) + if (!ownerDocument) continue + + let parent = element.parentElement + while (parent) { + // 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 (elements.some((el) => node.contains(el))) continue + + // Mark the node as inert + d.add(markInert(node as HTMLElement)) + } + + // Stop if we reach the body + if (parent === ownerDocument.body) break + + // Move up the tree + parent = parent.parentElement + } + } + + return d.dispose + }, [enabled, resolveAllowedContainers]) +} From 9bc11fc157d2be6bac51a7fa0c9b85666b63ec06 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 23 Apr 2024 16:09:19 +0200 Subject: [PATCH 06/22] 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`. --- .../src/components/menu/menu.tsx | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index 0e085c5f21..a5adef3148 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' 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( + 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', })} - +
) } From 85274b65603c00677d752ca5c1cf7a350e826fd5 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 23 Apr 2024 16:17:45 +0200 Subject: [PATCH 07/22] 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`. --- .../src/components/listbox/listbox.tsx | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index a8f3fa87ea..db1efa747c 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' 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( + 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', })} - + ) } From 6561718fe00462320a19c40e4382fc798fe5aa35 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 23 Apr 2024 19:47:00 +0200 Subject: [PATCH 08/22] 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`. --- .../src/components/combobox/combobox.tsx | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 0e515da98a..5319627aa3 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' 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,15 @@ 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( + 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 +1640,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', + })} + + ) } // --- From 351092219e3fa075249f1324267e38b65f70934f Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 23 Apr 2024 21:13:15 +0200 Subject: [PATCH 09/22] 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. --- .../src/components/popover/popover.tsx | 34 +++++++------------ 1 file changed, 12 insertions(+), 22 deletions(-) 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} /> )} - + ) From 92dbcd907ed090c1ab649c7c536f235274e83a10 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 23 Apr 2024 21:14:21 +0200 Subject: [PATCH 10/22] simplify popover playground, use provided `anchor` prop --- playgrounds/react/pages/popover/popover.tsx | 54 ++++++++------------- 1 file changed, 20 insertions(+), 34 deletions(-) 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) => ( + + ))} + From 9d0218055de320716f8124864c89694b3c895c54 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 23 Apr 2024 21:16:40 +0200 Subject: [PATCH 11/22] remove internal `Modal` component This is now implemented on a per component basis with some hooks. --- .../@headlessui-react/src/internal/modal.tsx | 209 ------------------ 1 file changed, 209 deletions(-) delete mode 100644 packages/@headlessui-react/src/internal/modal.tsx diff --git a/packages/@headlessui-react/src/internal/modal.tsx b/packages/@headlessui-react/src/internal/modal.tsx deleted file mode 100644 index fb487d57df..0000000000 --- a/packages/@headlessui-react/src/internal/modal.tsx +++ /dev/null @@ -1,209 +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 { 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 { useScrollLock } from '../hooks/use-scroll-lock' -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' - -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, {}) From a5e2f0694a980b040041f17d09d6ad9803a8d787 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 23 Apr 2024 21:17:08 +0200 Subject: [PATCH 12/22] 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`. --- packages/@headlessui-react/src/components/dialog/dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index 8e9511703b..700821ca07 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -380,7 +380,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), From 6ef8117917dbca7c338ad4bdddaaeb1b1caa582e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 23 Apr 2024 23:41:16 +0200 Subject: [PATCH 13/22] 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 ``. --- packages/@headlessui-react/src/components/portal/portal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@headlessui-react/src/components/portal/portal.tsx b/packages/@headlessui-react/src/components/portal/portal.tsx index 1d625e4b73..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 From 6b3b62f6cb43645d8f328c44ee7bb01b2fea85e6 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 24 Apr 2024 00:40:24 +0200 Subject: [PATCH 14/22] update changelog --- packages/@headlessui-react/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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 From d7c5396c7e634cc88423cbab712e6a8209e07c7c Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 24 Apr 2024 00:53:42 +0200 Subject: [PATCH 15/22] add tests for `useInertOthers` --- .../src/hooks/use-inert.test.tsx | 56 ++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/@headlessui-react/src/hooks/use-inert.test.tsx b/packages/@headlessui-react/src/hooks/use-inert.test.tsx index d31ac5bc71..c958de4d03 100644 --- a/packages/@headlessui-react/src/hooks/use-inert.test.tsx +++ b/packages/@headlessui-react/src/hooks/use-inert.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 { useInert, useInertOthers } from './use-inert' beforeEach(() => { jest.restoreAllMocks() @@ -139,3 +139,57 @@ 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')) }) + +describe('use inert others', () => { + it('should be possible to mark everything but allowed containers as inert', async () => { + function Example({ children }: { children: ReactNode }) { + let [enabled, setEnabled] = useState(false) + useInertOthers( + () => [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) + }) +}) From 4fdef30d0a90b6f8ffd9cb5230ec011a94210cce Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 24 Apr 2024 11:43:06 +0200 Subject: [PATCH 16/22] 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`. --- packages/@headlessui-react/src/hooks/use-inert.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/@headlessui-react/src/hooks/use-inert.tsx b/packages/@headlessui-react/src/hooks/use-inert.tsx index e4e096310a..444edec350 100644 --- a/packages/@headlessui-react/src/hooks/use-inert.tsx +++ b/packages/@headlessui-react/src/hooks/use-inert.tsx @@ -104,7 +104,7 @@ export function useInertOthers( if (!ownerDocument) continue let parent = element.parentElement - while (parent) { + 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 @@ -115,9 +115,6 @@ export function useInertOthers( d.add(markInert(node as HTMLElement)) } - // Stop if we reach the body - if (parent === ownerDocument.body) break - // Move up the tree parent = parent.parentElement } From 3a212cab63e8c94a6884dc893498aa8fd691811b Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 24 Apr 2024 16:08:09 +0200 Subject: [PATCH 17/22] 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. --- .../src/components/combobox/combobox.tsx | 8 ++- .../src/components/listbox/listbox.tsx | 2 +- .../src/components/menu/menu.tsx | 2 +- .../src/hooks/use-inert.test.tsx | 2 +- .../@headlessui-react/src/hooks/use-inert.tsx | 57 ++++++++++++------- 5 files changed, 48 insertions(+), 23 deletions(-) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 5319627aa3..e38ea0142e 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -1587,7 +1587,13 @@ function OptionsFn( // Mark other elements as inert when the combobox is visible, and `modal` is enabled useInertOthers( - useEvent(() => [data.inputRef.current, data.buttonRef.current, data.optionsRef.current]), + { + allowed: useEvent(() => [ + data.inputRef.current, + data.buttonRef.current, + data.optionsRef.current, + ]), + }, modal && data.comboboxState === ComboboxState.Open ) diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index db1efa747c..171edfb775 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -918,7 +918,7 @@ function OptionsFn( // Mark other elements as inert when the listbox is visible, and `modal` is enabled useInertOthers( - useEvent(() => [data.buttonRef.current, data.optionsRef.current]), + { allowed: useEvent(() => [data.buttonRef.current, data.optionsRef.current]) }, modal && data.listboxState === ListboxStates.Open ) diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index a5adef3148..412f8fcf3c 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -630,7 +630,7 @@ function ItemsFn( // Mark other elements as inert when the menu is visible, and `modal` is enabled useInertOthers( - useEvent(() => [state.buttonRef.current, state.itemsRef.current]), + { allowed: useEvent(() => [state.buttonRef.current, state.itemsRef.current]) }, modal && state.menuState === MenuStates.Open ) diff --git a/packages/@headlessui-react/src/hooks/use-inert.test.tsx b/packages/@headlessui-react/src/hooks/use-inert.test.tsx index c958de4d03..e406cbc6ae 100644 --- a/packages/@headlessui-react/src/hooks/use-inert.test.tsx +++ b/packages/@headlessui-react/src/hooks/use-inert.test.tsx @@ -145,7 +145,7 @@ describe('use inert others', () => { function Example({ children }: { children: ReactNode }) { let [enabled, setEnabled] = useState(false) useInertOthers( - () => [document.getElementById('a-a-b')!, document.getElementById('a-a-c')!], + { allowed: () => [document.getElementById('a-a-b')!, document.getElementById('a-a-c')!] }, enabled ) diff --git a/packages/@headlessui-react/src/hooks/use-inert.tsx b/packages/@headlessui-react/src/hooks/use-inert.tsx index 444edec350..16bf2c12ad 100644 --- a/packages/@headlessui-react/src/hooks/use-inert.tsx +++ b/packages/@headlessui-react/src/hooks/use-inert.tsx @@ -88,38 +88,57 @@ export function useInert( * ``` */ export function useInertOthers( - resolveAllowedContainers: () => (HTMLElement | null)[], + { + allowed, + disallowed, + }: { allowed?: () => (HTMLElement | null)[]; disallowed?: () => (HTMLElement | null)[] } = { + allowed: () => [], + disallowed: () => [], + }, enabled = true ) { useIsoMorphicEffect(() => { if (!enabled) return let d = disposables() - let elements = resolveAllowedContainers() - for (let element of elements) { - if (!element) continue + // Mark all disallowed elements as inert + if (disallowed) { + for (let element of disallowed()) { + if (!element) continue - let ownerDocument = getOwnerDocument(element) - if (!ownerDocument) continue + d.add(markInert(element)) + } + } - 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 (elements.some((el) => node.contains(el))) continue + // Mark all siblings of allowed elements (and parents) as inert + if (allowed) { + let allowedElements = allowed() - // Mark the node as inert - d.add(markInert(node as HTMLElement)) - } + for (let element of allowedElements) { + if (!element) continue + + let ownerDocument = getOwnerDocument(element) + if (!ownerDocument) continue - // Move up the tree - parent = parent.parentElement + 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, resolveAllowedContainers]) + }, [enabled, allowed, disallowed]) } From e04655071984f08b633da4263eb5c9d9e56e84f5 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 24 Apr 2024 16:06:43 +0200 Subject: [PATCH 18/22] simplify `useInertOthers` in `Dialog` code --- .../src/components/dialog/dialog.tsx | 47 ++++++++----------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index 700821ca07..43ef6bc91c 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, @@ -21,7 +20,7 @@ import React, { 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' import { useIsTouchDevice } from '../../hooks/use-is-touch-device' import { useOnDisappear } from '../../hooks/use-on-disappear' import { useOutsideClick } from '../../hooks/use-outside-click' @@ -262,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 = (() => { From 6c15c99f08b93c2aef9261722a23103d26b917e2 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 24 Apr 2024 16:12:42 +0200 Subject: [PATCH 19/22] update `use-inert` tests to always use `useInertOthers` --- .../src/hooks/use-inert.test.tsx | 106 +++++++++--------- 1 file changed, 52 insertions(+), 54 deletions(-) diff --git a/packages/@headlessui-react/src/hooks/use-inert.test.tsx b/packages/@headlessui-react/src/hooks/use-inert.test.tsx index e406cbc6ae..53959040fb 100644 --- a/packages/@headlessui-react/src/hooks/use-inert.test.tsx +++ b/packages/@headlessui-react/src/hooks/use-inert.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, useInertOthers } from './use-inert' +import { useInertOthers } from './use-inert' 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 (
@@ -140,56 +140,54 @@ it('should mark the element as not inert anymore, once all references are gone', assertNotInert(document.getElementById('parent')) }) -describe('use inert others', () => { - 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 } +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')) - 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) - }) + // 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) }) From c8d96112061b812d1d3775490ec76ceeaea35413 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 24 Apr 2024 16:13:15 +0200 Subject: [PATCH 20/22] remove `useInert` hook in favor of `useInertOthers` --- .../@headlessui-react/src/hooks/use-inert.tsx | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/packages/@headlessui-react/src/hooks/use-inert.tsx b/packages/@headlessui-react/src/hooks/use-inert.tsx index 16bf2c12ad..5d727d55cf 100644 --- a/packages/@headlessui-react/src/hooks/use-inert.tsx +++ b/packages/@headlessui-react/src/hooks/use-inert.tsx @@ -1,4 +1,3 @@ -import { type MutableRefObject } from 'react' import { disposables } from '../utils/disposables' import { getOwnerDocument } from '../utils/owner' import { useIsoMorphicEffect } from './use-iso-morphic-effect' @@ -48,20 +47,6 @@ function markNotInert(element: HTMLElement) { originals.delete(element) } -export function useInert( - node: MutableRefObject | (() => TElement | null), - enabled = true -) { - useIsoMorphicEffect(() => { - if (!enabled) return - - let element = typeof node === 'function' ? node() : node.current - if (!element) return - - return markInert(element) - }, [node, enabled]) -} - /** * Mark all elements on the page as inert, except for the ones that are allowed. * From b8931b2806e45595f6de9f07b6e714821af9c747 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 24 Apr 2024 16:16:43 +0200 Subject: [PATCH 21/22] rename `use-inert` to `use-inert-others` --- packages/@headlessui-react/src/components/combobox/combobox.tsx | 2 +- packages/@headlessui-react/src/components/dialog/dialog.tsx | 2 +- packages/@headlessui-react/src/components/listbox/listbox.tsx | 2 +- packages/@headlessui-react/src/components/menu/menu.tsx | 2 +- .../src/hooks/{use-inert.test.tsx => use-inert-others.test.tsx} | 2 +- .../src/hooks/{use-inert.tsx => use-inert-others.tsx} | 0 6 files changed, 5 insertions(+), 5 deletions(-) rename packages/@headlessui-react/src/hooks/{use-inert.test.tsx => use-inert-others.test.tsx} (98%) rename packages/@headlessui-react/src/hooks/{use-inert.tsx => use-inert-others.tsx} (100%) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index e38ea0142e..aa8d757f3b 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -29,7 +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' +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' diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index 43ef6bc91c..bc7c9e31d1 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -20,7 +20,7 @@ import React, { import { useEvent } from '../../hooks/use-event' import { useEventListener } from '../../hooks/use-event-listener' import { useId } from '../../hooks/use-id' -import { useInertOthers } 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' diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 171edfb775..5afabe5fe6 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -29,7 +29,7 @@ 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' +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' diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index 412f8fcf3c..df70af8d8d 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -26,7 +26,7 @@ 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' +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' 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 98% rename from packages/@headlessui-react/src/hooks/use-inert.test.tsx rename to packages/@headlessui-react/src/hooks/use-inert-others.test.tsx index 53959040fb..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 { useInertOthers } from './use-inert' +import { useInertOthers } from './use-inert-others' beforeEach(() => { jest.restoreAllMocks() diff --git a/packages/@headlessui-react/src/hooks/use-inert.tsx b/packages/@headlessui-react/src/hooks/use-inert-others.tsx similarity index 100% rename from packages/@headlessui-react/src/hooks/use-inert.tsx rename to packages/@headlessui-react/src/hooks/use-inert-others.tsx From bf7497efea3008cb08b802626f0ceb8c4ee7b961 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 24 Apr 2024 16:36:39 +0200 Subject: [PATCH 22/22] cleanup default values for `useInertOthers` --- .../src/hooks/use-inert-others.tsx | 53 ++++++++----------- 1 file changed, 23 insertions(+), 30 deletions(-) diff --git a/packages/@headlessui-react/src/hooks/use-inert-others.tsx b/packages/@headlessui-react/src/hooks/use-inert-others.tsx index 5d727d55cf..7f96149dad 100644 --- a/packages/@headlessui-react/src/hooks/use-inert-others.tsx +++ b/packages/@headlessui-react/src/hooks/use-inert-others.tsx @@ -74,12 +74,9 @@ function markNotInert(element: HTMLElement) { */ export function useInertOthers( { - allowed, - disallowed, - }: { allowed?: () => (HTMLElement | null)[]; disallowed?: () => (HTMLElement | null)[] } = { - allowed: () => [], - disallowed: () => [], - }, + allowed = () => [], + disallowed = () => [], + }: { allowed?: () => (HTMLElement | null)[]; disallowed?: () => (HTMLElement | null)[] } = {}, enabled = true ) { useIsoMorphicEffect(() => { @@ -88,39 +85,35 @@ export function useInertOthers( let d = disposables() // Mark all disallowed elements as inert - if (disallowed) { - for (let element of disallowed()) { - if (!element) continue + for (let element of disallowed()) { + if (!element) continue - d.add(markInert(element)) - } + d.add(markInert(element)) } // Mark all siblings of allowed elements (and parents) as inert - if (allowed) { - let allowedElements = allowed() - - for (let element of allowedElements) { - if (!element) continue + let allowedElements = allowed() - let ownerDocument = getOwnerDocument(element) - if (!ownerDocument) continue + for (let element of allowedElements) { + if (!element) 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 + let ownerDocument = getOwnerDocument(element) + if (!ownerDocument) continue - // Mark the node as inert - d.add(markInert(node as HTMLElement)) - } + 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 - // Move up the tree - parent = parent.parentElement + // Mark the node as inert + d.add(markInert(node as HTMLElement)) } + + // Move up the tree + parent = parent.parentElement } }