Skip to content

Commit b6aa1d6

Browse files
authored
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: #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`
1 parent 166e862 commit b6aa1d6

File tree

13 files changed

+349
-419
lines changed

13 files changed

+349
-419
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4444
- Close the `Combobox`, `Dialog`, `Listbox`, `Menu` and `Popover` components when the trigger disappears ([#3075](https://github.com/tailwindlabs/headlessui/pull/3075))
4545
- Add new `CloseButton` component and `useClose` hook ([#3096](https://github.com/tailwindlabs/headlessui/pull/3096))
4646
- Allow passing a boolean to the `anchor` prop ([#3121](https://github.com/tailwindlabs/headlessui/pull/3121))
47+
- Add `portal` prop to `Combobox`, `Listbox`, `Menu` and `Popover` components ([#3124](https://github.com/tailwindlabs/headlessui/pull/3124))
4748

4849
## [1.7.19] - 2024-04-15
4950

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,15 @@ import { useElementSize } from '../../hooks/use-element-size'
2929
import { useEvent } from '../../hooks/use-event'
3030
import { useFrameDebounce } from '../../hooks/use-frame-debounce'
3131
import { useId } from '../../hooks/use-id'
32+
import { useInertOthers } from '../../hooks/use-inert-others'
3233
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
3334
import { useLatestValue } from '../../hooks/use-latest-value'
3435
import { useOnDisappear } from '../../hooks/use-on-disappear'
3536
import { useOutsideClick } from '../../hooks/use-outside-click'
3637
import { useOwnerDocument } from '../../hooks/use-owner'
3738
import { useRefocusableInput } from '../../hooks/use-refocusable-input'
3839
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
40+
import { useScrollLock } from '../../hooks/use-scroll-lock'
3941
import { useSyncRefs } from '../../hooks/use-sync-refs'
4042
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
4143
import { useTreeWalker } from '../../hooks/use-tree-walker'
@@ -73,6 +75,7 @@ import { useDescribedBy } from '../description/description'
7375
import { Keys } from '../keyboard'
7476
import { Label, useLabelledBy, useLabels, type _internal_ComponentLabel } from '../label/label'
7577
import { MouseButton } from '../mouse'
78+
import { Portal } from '../portal/portal'
7679

7780
enum ComboboxState {
7881
Open,
@@ -1540,6 +1543,8 @@ export type ComboboxOptionsProps<TTag extends ElementType = typeof DEFAULT_OPTIO
15401543
PropsForFeatures<typeof OptionsRenderFeatures> & {
15411544
hold?: boolean
15421545
anchor?: AnchorProps
1546+
portal?: boolean
1547+
modal?: boolean
15431548
}
15441549
>
15451550

@@ -1552,6 +1557,8 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
15521557
id = `headlessui-combobox-options-${internalId}`,
15531558
hold = false,
15541559
anchor: rawAnchor,
1560+
portal = false,
1561+
modal = true,
15551562
...theirProps
15561563
} = props
15571564
let data = useData('Combobox.Options')
@@ -1561,6 +1568,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
15611568
let [floatingRef, style] = useFloatingPanel(anchor)
15621569
let getFloatingPanelProps = useFloatingPanelProps()
15631570
let optionsRef = useSyncRefs(data.optionsRef, ref, anchor ? floatingRef : null)
1571+
let ownerDocument = useOwnerDocument(data.optionsRef)
15641572

15651573
let usesOpenClosedState = useOpenClosed()
15661574
let visible = (() => {
@@ -1574,6 +1582,21 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
15741582
// Ensure we close the combobox as soon as the input becomes hidden
15751583
useOnDisappear(data.inputRef, actions.closeCombobox, visible)
15761584

1585+
// Enable scroll locking when the combobox is visible, and `modal` is enabled
1586+
useScrollLock(ownerDocument, modal && data.comboboxState === ComboboxState.Open)
1587+
1588+
// Mark other elements as inert when the combobox is visible, and `modal` is enabled
1589+
useInertOthers(
1590+
{
1591+
allowed: useEvent(() => [
1592+
data.inputRef.current,
1593+
data.buttonRef.current,
1594+
data.optionsRef.current,
1595+
]),
1596+
},
1597+
modal && data.comboboxState === ComboboxState.Open
1598+
)
1599+
15771600
useIsoMorphicEffect(() => {
15781601
data.optionsPropsRef.current.static = props.static ?? false
15791602
}, [data.optionsPropsRef, props.static])
@@ -1623,15 +1646,19 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
16231646
})
16241647
}
16251648

1626-
return render({
1627-
ourProps,
1628-
theirProps,
1629-
slot,
1630-
defaultTag: DEFAULT_OPTIONS_TAG,
1631-
features: OptionsRenderFeatures,
1632-
visible,
1633-
name: 'Combobox.Options',
1634-
})
1649+
return (
1650+
<Portal enabled={visible && portal}>
1651+
{render({
1652+
ourProps,
1653+
theirProps,
1654+
slot,
1655+
defaultTag: DEFAULT_OPTIONS_TAG,
1656+
features: OptionsRenderFeatures,
1657+
visible,
1658+
name: 'Combobox.Options',
1659+
})}
1660+
</Portal>
1661+
)
16351662
}
16361663

16371664
// ---

packages/@headlessui-react/src/components/dialog/dialog.tsx

Lines changed: 22 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import React, {
55
createContext,
66
createRef,
7-
useCallback,
87
useContext,
98
useEffect,
109
useMemo,
@@ -18,16 +17,16 @@ import React, {
1817
type Ref,
1918
type RefObject,
2019
} from 'react'
21-
import { useDocumentOverflowLockedEffect } from '../../hooks/document-overflow/use-document-overflow'
2220
import { useEvent } from '../../hooks/use-event'
2321
import { useEventListener } from '../../hooks/use-event-listener'
2422
import { useId } from '../../hooks/use-id'
25-
import { useInert } from '../../hooks/use-inert'
23+
import { useInertOthers } from '../../hooks/use-inert-others'
2624
import { useIsTouchDevice } from '../../hooks/use-is-touch-device'
2725
import { useOnDisappear } from '../../hooks/use-on-disappear'
2826
import { useOutsideClick } from '../../hooks/use-outside-click'
2927
import { useOwnerDocument } from '../../hooks/use-owner'
3028
import { useRootContainers } from '../../hooks/use-root-containers'
29+
import { useScrollLock } from '../../hooks/use-scroll-lock'
3130
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
3231
import { useSyncRefs } from '../../hooks/use-sync-refs'
3332
import { CloseProvider } from '../../internal/close-provider'
@@ -106,16 +105,6 @@ function useDialogContext(component: string) {
106105
return context
107106
}
108107

109-
function useScrollLock(
110-
ownerDocument: Document | null,
111-
enabled: boolean,
112-
resolveAllowedContainers: () => HTMLElement[] = () => [document.body]
113-
) {
114-
useDocumentOverflowLockedEffect(ownerDocument, enabled, (meta) => ({
115-
containers: [...(meta.containers ?? []), resolveAllowedContainers],
116-
}))
117-
}
118-
119108
function stateReducer(state: StateDefinition, action: Actions) {
120109
return match(action.type, reducers, state, action)
121110
}
@@ -272,34 +261,28 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
272261
usesOpenClosedState !== null ? (usesOpenClosedState & State.Closing) === State.Closing : false
273262

274263
// Ensure other elements can't be interacted with
275-
let inertOthersEnabled = (() => {
276-
// Nested dialogs should not modify the `inert` property, only the root one should.
277-
if (hasParentDialog) return false
264+
let inertEnabled = (() => {
265+
// Only the top-most dialog should be allowed, all others should be inert
266+
if (hasNestedDialogs) return false
278267
if (isClosing) return false
279268
return enabled
280269
})()
281-
let resolveRootOfMainTreeNode = useCallback(() => {
282-
return (Array.from(ownerDocument?.querySelectorAll('body > *') ?? []).find((root) => {
283-
// Skip the portal root, we don't want to make that one inert
284-
if (root.id === 'headlessui-portal-root') return false
285-
286-
// Find the root of the main tree node
287-
return root.contains(mainTreeNodeRef.current) && root instanceof HTMLElement
288-
}) ?? null) as HTMLElement | null
289-
}, [mainTreeNodeRef])
290-
useInert(resolveRootOfMainTreeNode, inertOthersEnabled)
291-
292-
// This would mark the parent dialogs as inert
293-
let inertParentDialogs = (() => {
294-
if (hasNestedDialogs) return true
295-
return enabled
296-
})()
297-
let resolveRootOfParentDialog = useCallback(() => {
298-
return (Array.from(ownerDocument?.querySelectorAll('[data-headlessui-portal]') ?? []).find(
299-
(root) => root.contains(mainTreeNodeRef.current) && root instanceof HTMLElement
300-
) ?? null) as HTMLElement | null
301-
}, [mainTreeNodeRef])
302-
useInert(resolveRootOfParentDialog, inertParentDialogs)
270+
271+
useInertOthers(
272+
{
273+
allowed: useEvent(() => [
274+
// Allow the headlessui-portal of the Dialog to be interactive. This
275+
// contains the current dialog and the necessary focus guard elements.
276+
internalDialogRef.current?.closest<HTMLElement>('[data-headlessui-portal]') ?? null,
277+
]),
278+
disallowed: useEvent(() => [
279+
// Disallow the "main" tree root node
280+
mainTreeNodeRef.current?.closest<HTMLElement>('body > *:not(#headlessui-portal-root)') ??
281+
null,
282+
]),
283+
},
284+
inertEnabled
285+
)
303286

304287
// Close Dialog on outside click
305288
let outsideClickEnabled = (() => {
@@ -390,7 +373,7 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
390373
enabled={dialogState === DialogStates.Open}
391374
element={internalDialogRef}
392375
onUpdate={useEvent((message, type) => {
393-
if (type !== 'Dialog' && type !== 'Modal') return
376+
if (type !== 'Dialog') return
394377

395378
match(message, {
396379
[StackMessage.Add]: () => setNestedDialogCount((count) => count + 1),

packages/@headlessui-react/src/components/listbox/listbox.tsx

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,14 @@ import { useDisposables } from '../../hooks/use-disposables'
2929
import { useElementSize } from '../../hooks/use-element-size'
3030
import { useEvent } from '../../hooks/use-event'
3131
import { useId } from '../../hooks/use-id'
32+
import { useInertOthers } from '../../hooks/use-inert-others'
3233
import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
3334
import { useLatestValue } from '../../hooks/use-latest-value'
3435
import { useOnDisappear } from '../../hooks/use-on-disappear'
3536
import { useOutsideClick } from '../../hooks/use-outside-click'
37+
import { useOwnerDocument } from '../../hooks/use-owner'
3638
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
39+
import { useScrollLock } from '../../hooks/use-scroll-lock'
3740
import { useSyncRefs } from '../../hooks/use-sync-refs'
3841
import { useTextValue } from '../../hooks/use-text-value'
3942
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
@@ -49,7 +52,6 @@ import {
4952
} from '../../internal/floating'
5053
import { FormFields } from '../../internal/form-fields'
5154
import { useProvidedId } from '../../internal/id'
52-
import { Modal, type ModalProps } from '../../internal/modal'
5355
import { OpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed'
5456
import type { EnsureArray, Props } from '../../types'
5557
import { isDisabledReactIssue7711 } from '../../utils/bugs'
@@ -870,6 +872,7 @@ export type ListboxOptionsProps<TTag extends ElementType = typeof DEFAULT_OPTION
870872
OptionsPropsWeControl,
871873
{
872874
anchor?: AnchorPropsWithSelection
875+
portal?: boolean
873876
modal?: boolean
874877
} & PropsForFeatures<typeof OptionsRenderFeatures>
875878
>
@@ -882,19 +885,22 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
882885
let {
883886
id = `headlessui-listbox-options-${internalId}`,
884887
anchor: rawAnchor,
885-
modal,
888+
portal = false,
889+
modal = true,
886890
...theirProps
887891
} = props
888892
let anchor = useResolvedAnchor(rawAnchor)
889893

890-
// Always use `modal` when `anchor` is passed in
891-
if (modal == null) {
892-
modal = Boolean(anchor)
894+
// Always enable `portal` functionality, when `anchor` is enabled
895+
if (anchor) {
896+
portal = true
893897
}
894898

895899
let data = useData('Listbox.Options')
896900
let actions = useActions('Listbox.Options')
897901

902+
let ownerDocument = useOwnerDocument(data.optionsRef)
903+
898904
let usesOpenClosedState = useOpenClosed()
899905
let visible = (() => {
900906
if (usesOpenClosedState !== null) {
@@ -907,6 +913,15 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
907913
// Ensure we close the listbox as soon as the button becomes hidden
908914
useOnDisappear(data.buttonRef, actions.closeListbox, visible)
909915

916+
// Enable scroll locking when the listbox is visible, and `modal` is enabled
917+
useScrollLock(ownerDocument, modal && data.listboxState === ListboxStates.Open)
918+
919+
// Mark other elements as inert when the listbox is visible, and `modal` is enabled
920+
useInertOthers(
921+
{ allowed: useEvent(() => [data.buttonRef.current, data.optionsRef.current]) },
922+
modal && data.listboxState === ListboxStates.Open
923+
)
924+
910925
let initialOption = useRef<number | null>(null)
911926

912927
useEffect(() => {
@@ -1066,11 +1081,6 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
10661081
} as CSSProperties,
10671082
})
10681083

1069-
let Wrapper = modal ? Modal : anchor ? Portal : Fragment
1070-
let wrapperProps = modal
1071-
? ({ enabled: data.listboxState === ListboxStates.Open } satisfies ModalProps)
1072-
: {}
1073-
10741084
// Frozen state, the selected value will only update visually when the user re-opens the <Listbox />
10751085
let [frozenValue, setFrozenValue] = useState(data.value)
10761086
if (
@@ -1085,7 +1095,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
10851095
})
10861096

10871097
return (
1088-
<Wrapper {...wrapperProps}>
1098+
<Portal enabled={visible && portal}>
10891099
<ListboxDataContext.Provider
10901100
value={data.mode === ValueMode.Multi ? data : { ...data, isSelected }}
10911101
>
@@ -1099,7 +1109,7 @@ function OptionsFn<TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG>(
10991109
name: 'Listbox.Options',
11001110
})}
11011111
</ListboxDataContext.Provider>
1102-
</Wrapper>
1112+
</Portal>
11031113
)
11041114
}
11051115

0 commit comments

Comments
 (0)