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:
@@ -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',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
+56
-4
@@ -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, {})
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user