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 `<Portal>`, 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`.

```
<body>                                    <!-- Stop at body -->
  <header></header>                       <!-- Inert, sibling of parent of allowed element -->
  <main>                                  <!-- Not inert, parent of allowed element -->
    <div>Sidebar</div>                    <!-- Inert, sibling of parent of allowed element -->
    <div>                                 <!-- Not inert, parent of allowed element -->
      <Listbox>                           <!-- Not inert, parent of allowed element -->
        <ListboxButton></ListboxButton>   <!-- Not inert, allowed element -->
        <ListboxOptions></ListboxOptions> <!-- Not inert, allowed element -->
      </Listbox>
    </div>
  </main>
  <footer></footer>                       <!-- Inert, sibling of parent of allowed element -->
</body>
```

* 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 `<Portal/>`
inside of a `<Dialog/>`.

* 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`
This commit is contained in:
Robin Malfait
2024-04-24 17:10:41 +02:00
committed by GitHub
parent 166e862f01
commit b6aa1d6d24
13 changed files with 348 additions and 418 deletions
+1
View File
@@ -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
@@ -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<TTag extends ElementType = typeof DEFAULT_OPTIO
PropsForFeatures<typeof OptionsRenderFeatures> & {
hold?: boolean
anchor?: AnchorProps
portal?: boolean
modal?: boolean
}
>
@@ -1552,6 +1557,8 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
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<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
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<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
// 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<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
})
}
return render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_OPTIONS_TAG,
features: OptionsRenderFeatures,
visible,
name: 'Combobox.Options',
})
return (
<Portal enabled={visible && portal}>
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_OPTIONS_TAG,
features: OptionsRenderFeatures,
visible,
name: 'Combobox.Options',
})}
</Portal>
)
}
// ---
@@ -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<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
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<HTMLElement>('[data-headlessui-portal]') ?? null,
]),
disallowed: useEvent(() => [
// Disallow the "main" tree root node
mainTreeNodeRef.current?.closest<HTMLElement>('body > *:not(#headlessui-portal-root)') ??
null,
]),
},
inertEnabled
)
// Close Dialog on outside click
let outsideClickEnabled = (() => {
@@ -390,7 +373,7 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
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),
@@ -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<TTag extends ElementType = typeof DEFAULT_OPTION
OptionsPropsWeControl,
{
anchor?: AnchorPropsWithSelection
portal?: boolean
modal?: boolean
} & PropsForFeatures<typeof OptionsRenderFeatures>
>
@@ -882,19 +885,22 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
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<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
// 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<number | null>(null)
useEffect(() => {
@@ -1066,11 +1081,6 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
} 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 <Listbox />
let [frozenValue, setFrozenValue] = useState(data.value)
if (
@@ -1085,7 +1095,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
})
return (
<Wrapper {...wrapperProps}>
<Portal enabled={visible && portal}>
<ListboxDataContext.Provider
value={data.mode === ValueMode.Multi ? data : { ...data, isSelected }}
>
@@ -1099,7 +1109,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
name: 'Listbox.Options',
})}
</ListboxDataContext.Provider>
</Wrapper>
</Portal>
)
}
@@ -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<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>
ItemsPropsWeControl,
{
anchor?: AnchorProps
portal?: boolean
modal?: boolean
// ItemsRenderFeatures
@@ -593,7 +595,8 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
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<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
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<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
// 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<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
} as CSSProperties,
})
let Wrapper = modal ? Modal : anchor ? Portal : Fragment
let wrapperProps = modal
? ({
features: ModalFeatures.ScrollLock,
enabled: state.menuState === MenuStates.Open,
} satisfies ModalProps)
: {}
return (
<Wrapper {...wrapperProps}>
<Portal enabled={visible && portal}>
{render({
ourProps,
theirProps,
@@ -785,7 +789,7 @@ function ItemsFn<TTag extends ElementType = typeof DEFAULT_ITEMS_TAG>(
visible: panelEnabled,
name: 'Menu.Items',
})}
</Wrapper>
</Portal>
)
}
@@ -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<TTag extends ElementType = typeof DEFAULT_PANEL_TA
{
focus?: boolean
anchor?: AnchorProps
portal?: boolean
modal?: boolean
// ItemsRenderFeatures
@@ -817,7 +816,8 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
id = `headlessui-popover-panel-${internalId}`,
focus = false,
anchor: rawAnchor,
modal,
portal = false,
modal = false,
...theirProps
} = props
@@ -832,9 +832,9 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
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<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
// 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<HTMLButtonElement>) => {
switch (event.key) {
case Keys.Escape:
@@ -1014,23 +1017,10 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
}
})
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 (
<PopoverPanelContext.Provider value={id}>
<PopoverAPIContext.Provider value={{ close, isPortalled }}>
<Wrapper {...wrapperProps}>
<Portal enabled={visible && portal}>
{visible && isPortalled && (
<Hidden
id={beforePanelSentinelId}
@@ -1063,7 +1053,7 @@ function PanelFn<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
onFocus={handleAfterFocus}
/>
)}
</Wrapper>
</Portal>
</PopoverAPIContext.Provider>
</PopoverPanelContext.Provider>
)
@@ -33,7 +33,7 @@ function usePortalTarget(ref: MutableRefObject<HTMLElement | null>): 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<TTag extends ElementType = typeof DEFAULT_PORTAL_TAG> = Props<
TTag,
PortalRenderPropArg,
PortalPropsWeControl
PortalPropsWeControl,
{
enabled?: boolean
}
>
function PortalFn<TTag extends ElementType = typeof DEFAULT_PORTAL_TAG>(
props: PortalProps<TTag>,
ref: Ref<HTMLElement>
) {
let InternalPortalFn = forwardRefWithAs(function InternalPortalFn<
TTag extends ElementType = typeof DEFAULT_PORTAL_TAG,
>(props: PortalProps<TTag>, ref: Ref<HTMLElement>) {
let theirProps = props
let internalPortalRootRef = useRef<HTMLElement | null>(null)
let portalRef = useSyncRefs(
@@ -143,6 +145,26 @@ function PortalFn<TTag extends ElementType = typeof DEFAULT_PORTAL_TAG>(
}),
element
)
})
function PortalFn<TTag extends ElementType = typeof DEFAULT_PORTAL_TAG>(
props: PortalProps<TTag>,
ref: Ref<HTMLElement>
) {
let portalRef = useSyncRefs(ref)
let { enabled = true, ...theirProps } = props
return enabled ? (
<InternalPortalFn {...theirProps} ref={portalRef} />
) : (
render({
ourProps: { ref: portalRef },
theirProps,
slot: {},
defaultTag: DEFAULT_PORTAL_TAG,
name: 'Portal',
})
)
}
// ---
@@ -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 (
<div ref={ref} id="main">
@@ -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 (
<div ref={ref} id="main">
@@ -95,7 +95,7 @@ it('should mark the element as not inert anymore, once all references are gone',
let ref = useRef<HTMLDivElement | null>(null)
let [enabled, setEnabled] = useState(false)
useInert(() => ref.current?.parentElement ?? null, enabled)
useInertOthers({ disallowed: () => [ref.current?.parentElement ?? null] }, enabled)
return (
<div ref={ref}>
@@ -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 (
<div>
{children}
<button onClick={() => setEnabled((v) => !v)}>toggle</button>
</div>
)
}
render(
<Example>
<div id="a">
<div id="a-a">
<div id="a-a-a"></div>
<div id="a-a-b"></div>
<div id="a-a-c"></div>
</div>
<div id="a-b"></div>
<div id="a-c"></div>
</div>
</Example>,
{ 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)
})
@@ -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<HTMLElement, { 'aria-hidden': string | null; inert: boolean }>()
let counts = new Map<HTMLElement, number>()
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
* <body> <!-- Stop at body -->
* <header></header> <!-- Inert, sibling of parent -->
* <main> <!-- Not inert, parent of allowed element -->
* <div>Sidebar</div> <!-- Inert, sibling of parent -->
* <div> <!-- Not inert, parent of allowed element -->
* <listbox> <!-- Not inert, parent of allowed element -->
* <button></button> <!-- Not inert, allowed element -->
* <options></options> <!-- Not inert, allowed element -->
* </listbox>
* </div>
* </main>
* <footer></footer> <!-- Inert, sibling of parent -->
* </body>
* ```
*/
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])
}
@@ -1,59 +0,0 @@
import type { MutableRefObject } from 'react'
import { useIsoMorphicEffect } from './use-iso-morphic-effect'
let originals = new Map<HTMLElement, { 'aria-hidden': string | null; inert: boolean }>()
let counts = new Map<HTMLElement, number>()
export function useInert<TElement extends HTMLElement>(
node: MutableRefObject<TElement | null> | (() => 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])
}
@@ -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],
}))
}
@@ -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<TTag extends ElementType = typeof DEFAULT_MODAL_TAG> = Props<
TTag,
ModalRenderPropArg,
ModalPropsWeControl,
PropsForFeatures<typeof ModalRenderFeatures> & {
enabled?: boolean
features?: ModalFeatures
focusTrapFeatures?: FocusTrapFeatures
initialFocus?: MutableRefObject<HTMLElement | null>
role?: 'dialog' | 'alertdialog'
}
>
function ModalFn<TTag extends ElementType = typeof DEFAULT_MODAL_TAG>(
props: ModalProps<TTag>,
ref: Ref<HTMLDivElement>
) {
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 <Modal />. Only \`dialog\` and and \`alertdialog\` are supported. Using \`dialog\` instead.`
)
}
return 'dialog'
})()
let internalModalRef = useRef<HTMLDivElement | null>(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<HTMLElement> = {
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 (
<StackProvider type="Modal" enabled={enabled} element={internalModalRef}>
<ForcePortalRoot force={true}>
<Portal>
<FocusTrap
initialFocus={initialFocus}
containers={resolveRootContainers}
features={
Boolean(features & ModalFeatures.FocusTrap)
? focusTrapFeatures
: FocusTrapFeatures.None
}
>
<ForcePortalRoot force={false}>
<PortalWrapper>
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_MODAL_TAG,
features: ModalRenderFeatures,
name: 'Modal',
})}
</PortalWrapper>
</ForcePortalRoot>
</FocusTrap>
</Portal>
</ForcePortalRoot>
<HoistFormFields>
<MainTreeNode />
</HoistFormFields>
</StackProvider>
)
}
// ---
export interface _internal_ComponentModal extends HasDisplayName {
<TTag extends ElementType = typeof DEFAULT_MODAL_TAG>(
props: ModalProps<TTag> & RefProp<typeof ModalFn>
): JSX.Element
}
let ModalRoot = forwardRefWithAs(ModalFn) as _internal_ComponentModal
export let Modal = Object.assign(ModalRoot, {})
+20 -34
View File
@@ -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<HTMLButtonElement>) => {
@@ -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() {
</Popover>
<Popover as="div" className="relative">
<Button ref={reference1}>Portal</Button>
<Portal>
<Popover.Panel
ref={popper1}
className="flex w-64 flex-col border-2 border-blue-900 bg-gray-100"
>
{items.map((item) => (
<Button key={item}>Portal - {item}</Button>
))}
</Popover.Panel>
</Portal>
<Button>Portal</Button>
<Popover.Panel
anchor={{ to: 'bottom start', gap: 4 }}
className="flex w-64 flex-col border-2 border-blue-900 bg-gray-100"
>
{items.map((item) => (
<Button key={item}>Portal - {item}</Button>
))}
</Popover.Panel>
</Popover>
<Popover as="div" className="relative">
<Button ref={reference2}>Focus in Portal</Button>
<Portal>
<Popover.Panel
ref={popper2}
focus
className="flex w-64 flex-col border-2 border-blue-900 bg-gray-100"
>
{items.map((item) => (
<Button key={item}>Focus in Portal - {item}</Button>
))}
</Popover.Panel>
</Portal>
<Button>Focus in Portal</Button>
<Popover.Panel
anchor={{ to: 'bottom start', gap: 4 }}
focus
className="flex w-64 flex-col border-2 border-blue-900 bg-gray-100"
>
{items.map((item) => (
<Button key={item}>Focus in Portal - {item}</Button>
))}
</Popover.Panel>
</Popover>
</Popover.Group>