Skip to content

Commit

Permalink
Add portal prop to Combobox, Listbox, Menu and Popover comp…
Browse files Browse the repository at this point in the history
…onents (#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: #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`
  • Loading branch information
RobinMalfait authored Apr 24, 2024
1 parent 166e862 commit b6aa1d6
Show file tree
Hide file tree
Showing 13 changed files with 349 additions and 419 deletions.
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 36 additions & 9 deletions packages/@headlessui-react/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ 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'
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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1540,6 +1543,8 @@ export type ComboboxOptionsProps<TTag extends ElementType = typeof DEFAULT_OPTIO
PropsForFeatures<typeof OptionsRenderFeatures> & {
hold?: boolean
anchor?: AnchorProps
portal?: boolean
modal?: boolean
}
>

Expand All @@ -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')
Expand All @@ -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 = (() => {
Expand All @@ -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])
Expand Down Expand Up @@ -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>
)
}

// ---
Expand Down
61 changes: 22 additions & 39 deletions packages/@headlessui-react/src/components/dialog/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import React, {
createContext,
createRef,
useCallback,
useContext,
useEffect,
useMemo,
Expand All @@ -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'
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 = (() => {
Expand Down Expand Up @@ -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),
Expand Down
34 changes: 22 additions & 12 deletions packages/@headlessui-react/src/components/listbox/listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -870,6 +872,7 @@ export type ListboxOptionsProps<TTag extends ElementType = typeof DEFAULT_OPTION
OptionsPropsWeControl,
{
anchor?: AnchorPropsWithSelection
portal?: boolean
modal?: boolean
} & PropsForFeatures<typeof OptionsRenderFeatures>
>
Expand All @@ -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) {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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 (
Expand All @@ -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 }}
>
Expand All @@ -1099,7 +1109,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
name: 'Listbox.Options',
})}
</ListboxDataContext.Provider>
</Wrapper>
</Portal>
)
}

Expand Down
Loading

0 comments on commit b6aa1d6

Please sign in to comment.