Skip to content

Commit

Permalink
Implement sibling <Dialog /> components (#3242)
Browse files Browse the repository at this point in the history
* add `DefaultMap` implementation

* add `useHierarchy` hook

* start `FocusTrapFeatures.None` with `0` instead of `1`

* simplify `Dialog`'s implementation

By making use of the new `useHierarchy` hook.

* delete `StackContext` and `StackProvider` components

They are now replaced by the new `useHierarchy` hook.

* use `useHierarchy` in `useOutsideClick` hook

This way we can scope the hierarchy inside of the `useOutsideClick`
hook. This now ensures that we only enable the `useOutsideClick` on the
top-most element (the one that last enabled it).

* use `useHierarchy` in `useInertOthers` hook

* add new `useEscape` hook

* use new `useEscape` hook

* use `useHierarchy` in `useEscape` hook

* use `useHierarchy` in `useScrollLock` hook

* pass features instead of `enabled` boolean

* simplify demo mode feature flags

No need to setup focus feature flags and then disable it all again if
demo mode is enabled.

* use similar signature for hooks with `enabled` parameter

Whenever a hook requires an `enabled` state, the `enabled` parameter is
moved to the front. Initially this was the last argument and enabled by
default but everywhere we use these hooks we have to pass a dedicated
boolean anyway.

This makes sure these hooks follow a similar pattern. Bonus points
because Prettier can now improve formatting the usage of these hooks.
The reason why is because there is no additional argument after the
potential last callback.

Before:
```ts
let enabled = data.__demoMode ? false : modal && data.comboboxState === ComboboxState.Open
useInertOthers(
  {
    allowed: useEvent(() => [
      data.inputRef.current,
      data.buttonRef.current,
      data.optionsRef.current,
    ]),
  },
  enabled
)
```

After:
```ts
let enabled = data.__demoMode ? false : modal && data.comboboxState === ComboboxState.Open
useInertOthers(enabled, {
  allowed: useEvent(() => [
    data.inputRef.current,
    data.buttonRef.current,
    data.optionsRef.current,
  ]),
})
```

* move `focusTrapFeatures` parameter to the front

* drop `FocusTrapFeatures.All`, list them explicitly

The `All` feature didn't include all feature flags (didn't include
`FocusTrapFeatures.AutoFocus` for example).

* always enable `FocusTrapFeatures.RestoreFocus` when enabled

This way we can get rid of the `Position.HasChild` check, which will
allow us to do more cleanup soon in the `useHierarchy` hook.

* use `useHierarchy` in `<FocusTrap>` component

* drop `useHierarchy` from `<Dialog>` component

* simplify focusTrapFeatures setup

* simplify `useHierarchy`

The `useHierarchy` hook allowed us to determine whether we are in the
root, in the middle, a leaf, have a parent, have a child, ... but we
only ever checked whether we are a leaf node or not.

In other words, you can think of it like "are we the top layer"

This simplifies the implementation and usage of this new hook.

* move `enabled`-like argument to front

Just to be consistent with the other hooks.

* polyfill `toSpliced` for older Node versions

* add sibling dialogs playground

* rename `useHierarchy` to `useIsTopLayer`

* inline variable

* remove `unstable_batchedUpdates`

This is not necessary.

* add tiny bit of information to dialog

Because it might not be super obvious that we are going to close the
`<Dialog />`.

* update changelog

* re-add internal `PortalGroup`

This is necessary to make sure that a component like a `<MenuItems
anchor />` is rendered inside of the `<Dialog />` and not as a sibling.

While this all works from a functional perspective, if you rely on a
CSS variable that was defined on the `<Dialog />` and you use it in the
`<MenuItems />` then without this change it wouldn't work.
  • Loading branch information
RobinMalfait authored May 27, 2024
1 parent 1ee4cfd commit 6ac6930
Show file tree
Hide file tree
Showing 12 changed files with 337 additions and 169 deletions.
4 changes: 3 additions & 1 deletion packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Nothing yet!
### Added

- Add ability to render multiple `<Dialog />` components at once (without nesting them) ([#3242](https://github.com/tailwindlabs/headlessui/pull/3242))

## [2.0.4] - 2024-05-25

Expand Down
99 changes: 23 additions & 76 deletions packages/@headlessui-react/src/components/dialog/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,15 @@ import React, {
useMemo,
useReducer,
useRef,
useState,
type ContextType,
type ElementType,
type MutableRefObject,
type MouseEvent as ReactMouseEvent,
type Ref,
type RefObject,
} from 'react'
import { useEscape } from '../../hooks/use-escape'
import { useEvent } from '../../hooks/use-event'
import { useEventListener } from '../../hooks/use-event-listener'
import { useId } from '../../hooks/use-id'
import { useInertOthers } from '../../hooks/use-inert-others'
import { useIsTouchDevice } from '../../hooks/use-is-touch-device'
Expand All @@ -33,7 +32,6 @@ import { CloseProvider } from '../../internal/close-provider'
import { HoistFormFields } from '../../internal/form-fields'
import { State, useOpenClosed } from '../../internal/open-closed'
import { ForcePortalRoot } from '../../internal/portal-force-root'
import { StackMessage, StackProvider } from '../../internal/stack-context'
import type { Props } from '../../types'
import { match } from '../../utils/match'
import {
Expand All @@ -50,8 +48,7 @@ import {
type _internal_ComponentDescription,
} from '../description/description'
import { FocusTrap, FocusTrapFeatures } from '../focus-trap/focus-trap'
import { Keys } from '../keyboard'
import { Portal, useNestedPortals } from '../portal/portal'
import { Portal, PortalGroup, useNestedPortals } from '../portal/portal'

enum DialogStates {
Open,
Expand Down Expand Up @@ -147,7 +144,6 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
__demoMode = false,
...theirProps
} = props
let [nestedDialogCount, setNestedDialogCount] = useState(0)

let didWarnOnRole = useRef(false)

Expand Down Expand Up @@ -224,8 +220,6 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(

let ready = useServerHandoffComplete()
let enabled = ready ? dialogState === DialogStates.Open : false
let hasNestedDialogs = nestedDialogCount > 1 // 1 is the current dialog
let hasParentDialog = useContext(DialogContext) !== null
let [portals, PortalWrapper] = useNestedPortals()

// We use this because reading these values during initial render(s)
Expand All @@ -247,10 +241,6 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
defaultContainers: [defaultContainer],
})

// If there are multiple dialogs, then you can be the root, the leaf or one
// in between. We only care about whether you are the top most one or not.
let position = !hasNestedDialogs ? 'leaf' : 'parent'

// When the `Dialog` is wrapped in a `Transition` (or another Headless UI component that exposes
// the OpenClosed state) then we get some information via context about its state. When the
// `Transition` is about to close, then the `State.Closing` state will be exposed. This allows us
Expand All @@ -260,13 +250,7 @@ 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 = (() => {
if (__demoMode) return false
// Only the top-most dialog should be allowed, all others should be inert
if (hasNestedDialogs) return false
if (isClosing) return false
return enabled
})()
let inertOthersEnabled = __demoMode ? false : isClosing ? false : enabled
useInertOthers(inertOthersEnabled, {
allowed: useEvent(() => [
// Allow the headlessui-portal of the Dialog to be interactive. This
Expand All @@ -281,26 +265,13 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
})

// Close Dialog on outside click
let outsideClickEnabled = (() => {
if (!enabled) return false
if (hasNestedDialogs) return false
return true
})()
useOutsideClick(outsideClickEnabled, resolveRootContainers, (event) => {
useOutsideClick(enabled, resolveRootContainers, (event) => {
event.preventDefault()
close()
})

// Handle `Escape` to close
let escapeToCloseEnabled = (() => {
if (hasNestedDialogs) return false
if (dialogState !== DialogStates.Open) return false
return true
})()
useEventListener(ownerDocument?.defaultView, 'keydown', (event) => {
if (!escapeToCloseEnabled) return
if (event.defaultPrevented) return
if (event.key !== Keys.Escape) return
useEscape(enabled, ownerDocument?.defaultView, (event) => {
event.preventDefault()
event.stopPropagation()

Expand All @@ -322,12 +293,7 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
})

// Scroll lock
let scrollLockEnabled = (() => {
if (isClosing) return false
if (dialogState !== DialogStates.Open) return false
if (hasParentDialog) return false
return true
})()
let scrollLockEnabled = __demoMode ? false : isClosing ? false : enabled
useScrollLock(scrollLockEnabled, ownerDocument, resolveRootContainers)

// Ensure we close the dialog as soon as the dialog itself becomes hidden
Expand Down Expand Up @@ -355,53 +321,34 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
'aria-describedby': describedby,
}

let shouldAutoFocus = !useIsTouchDevice()
let shouldMoveFocusInside = !useIsTouchDevice()
let focusTrapFeatures = FocusTrapFeatures.None

let focusTrapFeatures = enabled
? match(position, {
parent: FocusTrapFeatures.RestoreFocus,
leaf: FocusTrapFeatures.All & ~FocusTrapFeatures.FocusLock,
})
: FocusTrapFeatures.None
if (enabled && !__demoMode) {
focusTrapFeatures |= FocusTrapFeatures.RestoreFocus
focusTrapFeatures |= FocusTrapFeatures.TabLock

// Enable AutoFocus feature
if (autoFocus) {
focusTrapFeatures |= FocusTrapFeatures.AutoFocus
}

// Remove initialFocus when we should not auto focus at all
if (!shouldAutoFocus) {
focusTrapFeatures &= ~FocusTrapFeatures.InitialFocus
}
if (autoFocus) {
focusTrapFeatures |= FocusTrapFeatures.AutoFocus
}

if (__demoMode) {
focusTrapFeatures = FocusTrapFeatures.None
if (shouldMoveFocusInside) {
focusTrapFeatures |= FocusTrapFeatures.InitialFocus
}
}

return (
<StackProvider
type="Dialog"
enabled={dialogState === DialogStates.Open}
element={internalDialogRef}
onUpdate={useEvent((message, type) => {
if (type !== 'Dialog') return

match(message, {
[StackMessage.Add]: () => setNestedDialogCount((count) => count + 1),
[StackMessage.Remove]: () => setNestedDialogCount((count) => count - 1),
})
})}
>
<>
<ForcePortalRoot force={true}>
<Portal>
<DialogContext.Provider value={contextBag}>
<Portal.Group target={internalDialogRef}>
<PortalGroup target={internalDialogRef}>
<ForcePortalRoot force={false}>
<DescriptionProvider slot={slot} name="Dialog.Description">
<DescriptionProvider slot={slot}>
<PortalWrapper>
<FocusTrap
initialFocus={initialFocus}
initialFocusFallback={__demoMode ? undefined : internalDialogRef}
initialFocusFallback={internalDialogRef}
containers={resolveRootContainers}
features={focusTrapFeatures}
>
Expand All @@ -420,14 +367,14 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
</PortalWrapper>
</DescriptionProvider>
</ForcePortalRoot>
</Portal.Group>
</PortalGroup>
</DialogContext.Provider>
</Portal>
</ForcePortalRoot>
<HoistFormFields>
<MainTreeNode />
</HoistFormFields>
</StackProvider>
</>
)
}

Expand Down
Loading

0 comments on commit 6ac6930

Please sign in to comment.