Skip to content

Commit 6ac6930

Browse files
authored
Implement sibling <Dialog /> components (#3242)
* 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.
1 parent 1ee4cfd commit 6ac6930

File tree

12 files changed

+337
-169
lines changed

12 files changed

+337
-169
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
- Nothing yet!
10+
### Added
11+
12+
- Add ability to render multiple `<Dialog />` components at once (without nesting them) ([#3242](https://github.com/tailwindlabs/headlessui/pull/3242))
1113

1214
## [2.0.4] - 2024-05-25
1315

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

Lines changed: 23 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,15 @@ import React, {
99
useMemo,
1010
useReducer,
1111
useRef,
12-
useState,
1312
type ContextType,
1413
type ElementType,
1514
type MutableRefObject,
1615
type MouseEvent as ReactMouseEvent,
1716
type Ref,
1817
type RefObject,
1918
} from 'react'
19+
import { useEscape } from '../../hooks/use-escape'
2020
import { useEvent } from '../../hooks/use-event'
21-
import { useEventListener } from '../../hooks/use-event-listener'
2221
import { useId } from '../../hooks/use-id'
2322
import { useInertOthers } from '../../hooks/use-inert-others'
2423
import { useIsTouchDevice } from '../../hooks/use-is-touch-device'
@@ -33,7 +32,6 @@ import { CloseProvider } from '../../internal/close-provider'
3332
import { HoistFormFields } from '../../internal/form-fields'
3433
import { State, useOpenClosed } from '../../internal/open-closed'
3534
import { ForcePortalRoot } from '../../internal/portal-force-root'
36-
import { StackMessage, StackProvider } from '../../internal/stack-context'
3735
import type { Props } from '../../types'
3836
import { match } from '../../utils/match'
3937
import {
@@ -50,8 +48,7 @@ import {
5048
type _internal_ComponentDescription,
5149
} from '../description/description'
5250
import { FocusTrap, FocusTrapFeatures } from '../focus-trap/focus-trap'
53-
import { Keys } from '../keyboard'
54-
import { Portal, useNestedPortals } from '../portal/portal'
51+
import { Portal, PortalGroup, useNestedPortals } from '../portal/portal'
5552

5653
enum DialogStates {
5754
Open,
@@ -147,7 +144,6 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
147144
__demoMode = false,
148145
...theirProps
149146
} = props
150-
let [nestedDialogCount, setNestedDialogCount] = useState(0)
151147

152148
let didWarnOnRole = useRef(false)
153149

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

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

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

250-
// If there are multiple dialogs, then you can be the root, the leaf or one
251-
// in between. We only care about whether you are the top most one or not.
252-
let position = !hasNestedDialogs ? 'leaf' : 'parent'
253-
254244
// When the `Dialog` is wrapped in a `Transition` (or another Headless UI component that exposes
255245
// the OpenClosed state) then we get some information via context about its state. When the
256246
// `Transition` is about to close, then the `State.Closing` state will be exposed. This allows us
@@ -260,13 +250,7 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
260250
usesOpenClosedState !== null ? (usesOpenClosedState & State.Closing) === State.Closing : false
261251

262252
// Ensure other elements can't be interacted with
263-
let inertOthersEnabled = (() => {
264-
if (__demoMode) return false
265-
// Only the top-most dialog should be allowed, all others should be inert
266-
if (hasNestedDialogs) return false
267-
if (isClosing) return false
268-
return enabled
269-
})()
253+
let inertOthersEnabled = __demoMode ? false : isClosing ? false : enabled
270254
useInertOthers(inertOthersEnabled, {
271255
allowed: useEvent(() => [
272256
// Allow the headlessui-portal of the Dialog to be interactive. This
@@ -281,26 +265,13 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
281265
})
282266

283267
// Close Dialog on outside click
284-
let outsideClickEnabled = (() => {
285-
if (!enabled) return false
286-
if (hasNestedDialogs) return false
287-
return true
288-
})()
289-
useOutsideClick(outsideClickEnabled, resolveRootContainers, (event) => {
268+
useOutsideClick(enabled, resolveRootContainers, (event) => {
290269
event.preventDefault()
291270
close()
292271
})
293272

294273
// Handle `Escape` to close
295-
let escapeToCloseEnabled = (() => {
296-
if (hasNestedDialogs) return false
297-
if (dialogState !== DialogStates.Open) return false
298-
return true
299-
})()
300-
useEventListener(ownerDocument?.defaultView, 'keydown', (event) => {
301-
if (!escapeToCloseEnabled) return
302-
if (event.defaultPrevented) return
303-
if (event.key !== Keys.Escape) return
274+
useEscape(enabled, ownerDocument?.defaultView, (event) => {
304275
event.preventDefault()
305276
event.stopPropagation()
306277

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

324295
// Scroll lock
325-
let scrollLockEnabled = (() => {
326-
if (isClosing) return false
327-
if (dialogState !== DialogStates.Open) return false
328-
if (hasParentDialog) return false
329-
return true
330-
})()
296+
let scrollLockEnabled = __demoMode ? false : isClosing ? false : enabled
331297
useScrollLock(scrollLockEnabled, ownerDocument, resolveRootContainers)
332298

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

358-
let shouldAutoFocus = !useIsTouchDevice()
324+
let shouldMoveFocusInside = !useIsTouchDevice()
325+
let focusTrapFeatures = FocusTrapFeatures.None
359326

360-
let focusTrapFeatures = enabled
361-
? match(position, {
362-
parent: FocusTrapFeatures.RestoreFocus,
363-
leaf: FocusTrapFeatures.All & ~FocusTrapFeatures.FocusLock,
364-
})
365-
: FocusTrapFeatures.None
327+
if (enabled && !__demoMode) {
328+
focusTrapFeatures |= FocusTrapFeatures.RestoreFocus
329+
focusTrapFeatures |= FocusTrapFeatures.TabLock
366330

367-
// Enable AutoFocus feature
368-
if (autoFocus) {
369-
focusTrapFeatures |= FocusTrapFeatures.AutoFocus
370-
}
371-
372-
// Remove initialFocus when we should not auto focus at all
373-
if (!shouldAutoFocus) {
374-
focusTrapFeatures &= ~FocusTrapFeatures.InitialFocus
375-
}
331+
if (autoFocus) {
332+
focusTrapFeatures |= FocusTrapFeatures.AutoFocus
333+
}
376334

377-
if (__demoMode) {
378-
focusTrapFeatures = FocusTrapFeatures.None
335+
if (shouldMoveFocusInside) {
336+
focusTrapFeatures |= FocusTrapFeatures.InitialFocus
337+
}
379338
}
380339

381340
return (
382-
<StackProvider
383-
type="Dialog"
384-
enabled={dialogState === DialogStates.Open}
385-
element={internalDialogRef}
386-
onUpdate={useEvent((message, type) => {
387-
if (type !== 'Dialog') return
388-
389-
match(message, {
390-
[StackMessage.Add]: () => setNestedDialogCount((count) => count + 1),
391-
[StackMessage.Remove]: () => setNestedDialogCount((count) => count - 1),
392-
})
393-
})}
394-
>
341+
<>
395342
<ForcePortalRoot force={true}>
396343
<Portal>
397344
<DialogContext.Provider value={contextBag}>
398-
<Portal.Group target={internalDialogRef}>
345+
<PortalGroup target={internalDialogRef}>
399346
<ForcePortalRoot force={false}>
400-
<DescriptionProvider slot={slot} name="Dialog.Description">
347+
<DescriptionProvider slot={slot}>
401348
<PortalWrapper>
402349
<FocusTrap
403350
initialFocus={initialFocus}
404-
initialFocusFallback={__demoMode ? undefined : internalDialogRef}
351+
initialFocusFallback={internalDialogRef}
405352
containers={resolveRootContainers}
406353
features={focusTrapFeatures}
407354
>
@@ -420,14 +367,14 @@ function DialogFn<TTag extends ElementType = typeof DEFAULT_DIALOG_TAG>(
420367
</PortalWrapper>
421368
</DescriptionProvider>
422369
</ForcePortalRoot>
423-
</Portal.Group>
370+
</PortalGroup>
424371
</DialogContext.Provider>
425372
</Portal>
426373
</ForcePortalRoot>
427374
<HoistFormFields>
428375
<MainTreeNode />
429376
</HoistFormFields>
430-
</StackProvider>
377+
</>
431378
)
432379
}
433380

0 commit comments

Comments
 (0)