Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add portal prop to Combobox, Listbox, Menu and Popover components #3124

Merged
merged 22 commits into from
Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
87be4dd
move duplicated `useScrollLock` to dedicated hook
RobinMalfait Apr 22, 2024
8957321
accept `enabled` prop on `Portal` component
RobinMalfait Apr 23, 2024
4a51768
use `useSyncRefs` in portal
RobinMalfait Apr 23, 2024
31c1098
refactor inner workings of `useInert`
RobinMalfait Apr 23, 2024
dec0d7a
add `useInertOthers` hook
RobinMalfait Apr 23, 2024
9bc11fc
add `portal` prop, and change meaning of `modal` prop on `MenuItems`
RobinMalfait Apr 23, 2024
85274b6
add `portal` prop, and change meaning of `modal` prop on `ListboxOpti…
RobinMalfait Apr 23, 2024
6561718
add `portal` and `modal` prop on `ComboboxOptions`
RobinMalfait Apr 23, 2024
3510922
add `portal` prop, and change meaning of `modal` prop on `PopoverPanel`
RobinMalfait Apr 23, 2024
92dbcd9
simplify popover playground, use provided `anchor` prop
RobinMalfait Apr 23, 2024
9d02180
remove internal `Modal` component
RobinMalfait Apr 23, 2024
a5e2f06
remove `Modal` handling from `Dialog`
RobinMalfait Apr 23, 2024
6ef8117
ensure we use `groupTarget` if it is already available
RobinMalfait Apr 23, 2024
6b3b62f
update changelog
RobinMalfait Apr 23, 2024
d7c5396
add tests for `useInertOthers`
RobinMalfait Apr 23, 2024
4fdef30
ensure we stop before the `body`
RobinMalfait Apr 24, 2024
3a212ca
add `allowed` and `disallowed` to `useInertOthers`
RobinMalfait Apr 24, 2024
e046550
simplify `useInertOthers` in `Dialog` code
RobinMalfait Apr 24, 2024
6c15c99
update `use-inert` tests to always use `useInertOthers`
RobinMalfait Apr 24, 2024
c8d9611
remove `useInert` hook in favor of `useInertOthers`
RobinMalfait Apr 24, 2024
b8931b2
rename `use-inert` to `use-inert-others`
RobinMalfait Apr 24, 2024
bf7497e
cleanup default values for `useInertOthers`
RobinMalfait Apr 24, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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
Loading