Skip to content

Commit b4a4e0b

Browse files
authored
Add Dialog.Backdrop and Dialog.Panel components (#1333)
* implement `Dialog.Backdrop` and `Dialog.Panel` * cleanup TypeScript warnings * update changelog
1 parent 0162c57 commit b4a4e0b

File tree

14 files changed

+663
-15
lines changed

14 files changed

+663
-15
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4242
- Add `<form>` compatibility ([#1214](https://github.com/tailwindlabs/headlessui/pull/1214))
4343
- Add `multi` value support for Listbox & Combobox ([#1243](https://github.com/tailwindlabs/headlessui/pull/1243))
4444
- Implement `nullable` mode on `Combobox` in single value mode ([#1295](https://github.com/tailwindlabs/headlessui/pull/1295))
45+
- Add `Dialog.Backdrop` and `Dialog.Panel` components ([#1333](https://github.com/tailwindlabs/headlessui/pull/1333))
4546

4647
## [Unreleased - @headlessui/vue]
4748

@@ -80,6 +81,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8081
- Add `<form>` compatibility ([#1214](https://github.com/tailwindlabs/headlessui/pull/1214))
8182
- Add `multi` value support for Listbox & Combobox ([#1243](https://github.com/tailwindlabs/headlessui/pull/1243))
8283
- Implement `nullable` mode on `Combobox` in single value mode ([#1295](https://github.com/tailwindlabs/headlessui/pull/1295))
84+
- Add `Dialog.Backdrop` and `Dialog.Panel` components ([#1333](https://github.com/tailwindlabs/headlessui/pull/1333))
8385

8486
## [@headlessui/react@v1.5.0] - 2022-02-17
8587

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1731,7 +1731,7 @@ describe('Keyboard interactions', () => {
17311731
let handleChange = jest.fn()
17321732
function Example() {
17331733
let [value, setValue] = useState<string>('bob')
1734-
let [query, setQuery] = useState<string>('')
1734+
let [, setQuery] = useState<string>('')
17351735

17361736
return (
17371737
<Combobox

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -865,6 +865,9 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
865865
}
866866
actions.closeCombobox()
867867
return d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true }))
868+
869+
default:
870+
return
868871
}
869872
},
870873
[d, state, actions, data]

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

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
assertDialogTitle,
1212
getDialog,
1313
getDialogOverlay,
14+
getDialogBackdrop,
1415
getByText,
1516
assertActiveElement,
1617
getDialogs,
@@ -39,6 +40,8 @@ describe('Safe guards', () => {
3940
it.each([
4041
['Dialog.Overlay', Dialog.Overlay],
4142
['Dialog.Title', Dialog.Title],
43+
['Dialog.Backdrop', Dialog.Backdrop],
44+
['Dialog.Panel', Dialog.Panel],
4245
])(
4346
'should error when we are using a <%s /> without a parent <Dialog />',
4447
suppressConsoleLogs((name, Component) => {
@@ -307,6 +310,110 @@ describe('Rendering', () => {
307310
)
308311
})
309312

313+
describe('Dialog.Backdrop', () => {
314+
it(
315+
'should throw an error if a Dialog.Backdrop is used without a Dialog.Panel',
316+
suppressConsoleLogs(async () => {
317+
function Example() {
318+
let [isOpen, setIsOpen] = useState(false)
319+
return (
320+
<>
321+
<button id="trigger" onClick={() => setIsOpen((v) => !v)}>
322+
Trigger
323+
</button>
324+
<Dialog open={isOpen} onClose={setIsOpen}>
325+
<Dialog.Backdrop />
326+
<TabSentinel />
327+
</Dialog>
328+
</>
329+
)
330+
}
331+
332+
render(<Example />)
333+
334+
try {
335+
await click(document.getElementById('trigger'))
336+
337+
expect(true).toBe(false)
338+
} catch (e: unknown) {
339+
expect((e as Error).message).toBe(
340+
'A <Dialog.Backdrop /> component is being used, but a <Dialog.Panel /> component is missing.'
341+
)
342+
}
343+
})
344+
)
345+
346+
it(
347+
'should not throw an error if a Dialog.Backdrop is used with a Dialog.Panel',
348+
suppressConsoleLogs(async () => {
349+
function Example() {
350+
let [isOpen, setIsOpen] = useState(false)
351+
return (
352+
<>
353+
<button id="trigger" onClick={() => setIsOpen((v) => !v)}>
354+
Trigger
355+
</button>
356+
<Dialog open={isOpen} onClose={setIsOpen}>
357+
<Dialog.Backdrop />
358+
<Dialog.Panel>
359+
<TabSentinel />
360+
</Dialog.Panel>
361+
</Dialog>
362+
</>
363+
)
364+
}
365+
366+
render(<Example />)
367+
368+
await click(document.getElementById('trigger'))
369+
})
370+
)
371+
372+
it(
373+
'should portal the Dialog.Backdrop',
374+
suppressConsoleLogs(async () => {
375+
function Example() {
376+
let [isOpen, setIsOpen] = useState(false)
377+
return (
378+
<>
379+
<button id="trigger" onClick={() => setIsOpen((v) => !v)}>
380+
Trigger
381+
</button>
382+
<Dialog open={isOpen} onClose={setIsOpen}>
383+
<Dialog.Backdrop />
384+
<Dialog.Panel>
385+
<TabSentinel />
386+
</Dialog.Panel>
387+
</Dialog>
388+
</>
389+
)
390+
}
391+
392+
render(<Example />)
393+
394+
await click(document.getElementById('trigger'))
395+
396+
let dialog = getDialog()
397+
let backdrop = getDialogBackdrop()
398+
399+
expect(dialog).not.toBe(null)
400+
dialog = dialog as HTMLElement
401+
402+
expect(backdrop).not.toBe(null)
403+
backdrop = backdrop as HTMLElement
404+
405+
// It should not be nested
406+
let position = dialog.compareDocumentPosition(backdrop)
407+
expect(position & Node.DOCUMENT_POSITION_CONTAINED_BY).not.toBe(
408+
Node.DOCUMENT_POSITION_CONTAINED_BY
409+
)
410+
411+
// It should be a sibling
412+
expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
413+
})
414+
)
415+
})
416+
310417
describe('Dialog.Title', () => {
311418
it(
312419
'should be possible to render Dialog.Title using a render prop',
@@ -891,6 +998,72 @@ describe('Mouse interactions', () => {
891998
assertDialog({ state: DialogState.Visible })
892999
})
8931000
)
1001+
1002+
it(
1003+
'should close the Dialog if we click outside the Dialog.Panel',
1004+
suppressConsoleLogs(async () => {
1005+
function Example() {
1006+
let [isOpen, setIsOpen] = useState(false)
1007+
return (
1008+
<>
1009+
<button id="trigger" onClick={() => setIsOpen((v) => !v)}>
1010+
Trigger
1011+
</button>
1012+
<Dialog open={isOpen} onClose={setIsOpen}>
1013+
<Dialog.Backdrop />
1014+
<Dialog.Panel>
1015+
<TabSentinel />
1016+
</Dialog.Panel>
1017+
<button id="outside">Outside, technically</button>
1018+
</Dialog>
1019+
</>
1020+
)
1021+
}
1022+
1023+
render(<Example />)
1024+
1025+
await click(document.getElementById('trigger'))
1026+
1027+
assertDialog({ state: DialogState.Visible })
1028+
1029+
await click(document.getElementById('outside'))
1030+
1031+
assertDialog({ state: DialogState.InvisibleUnmounted })
1032+
})
1033+
)
1034+
1035+
it(
1036+
'should not close the Dialog if we click inside the Dialog.Panel',
1037+
suppressConsoleLogs(async () => {
1038+
function Example() {
1039+
let [isOpen, setIsOpen] = useState(false)
1040+
return (
1041+
<>
1042+
<button id="trigger" onClick={() => setIsOpen((v) => !v)}>
1043+
Trigger
1044+
</button>
1045+
<Dialog open={isOpen} onClose={setIsOpen}>
1046+
<Dialog.Backdrop />
1047+
<Dialog.Panel>
1048+
<button id="inside">Inside</button>
1049+
<TabSentinel />
1050+
</Dialog.Panel>
1051+
</Dialog>
1052+
</>
1053+
)
1054+
}
1055+
1056+
render(<Example />)
1057+
1058+
await click(document.getElementById('trigger'))
1059+
1060+
assertDialog({ state: DialogState.Visible })
1061+
1062+
await click(document.getElementById('inside'))
1063+
1064+
assertDialog({ state: DialogState.Visible })
1065+
})
1066+
)
8941067
})
8951068

8961069
describe('Nesting', () => {

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

Lines changed: 99 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import React, {
1515
MouseEvent as ReactMouseEvent,
1616
MutableRefObject,
1717
Ref,
18+
createRef,
1819
} from 'react'
1920

2021
import { Props } from '../../types'
@@ -32,7 +33,7 @@ import { Description, useDescriptions } from '../description/description'
3233
import { useOpenClosed, State } from '../../internal/open-closed'
3334
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
3435
import { StackProvider, StackMessage } from '../../internal/stack-context'
35-
import { useOutsideClick } from '../../hooks/use-outside-click'
36+
import { useOutsideClick, Features as OutsideClickFeatures } from '../../hooks/use-outside-click'
3637
import { getOwnerDocument } from '../../utils/owner'
3738
import { useOwnerDocument } from '../../hooks/use-owner'
3839
import { useEventListener } from '../../hooks/use-event-listener'
@@ -44,6 +45,7 @@ enum DialogStates {
4445

4546
interface StateDefinition {
4647
titleId: string | null
48+
panelRef: MutableRefObject<HTMLDivElement | null>
4749
}
4850

4951
enum ActionTypes {
@@ -182,6 +184,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
182184
let [state, dispatch] = useReducer(stateReducer, {
183185
titleId: null,
184186
descriptionId: null,
187+
panelRef: createRef(),
185188
} as StateDefinition)
186189

187190
let close = useCallback(() => onClose(false), [onClose])
@@ -220,18 +223,23 @@ let DialogRoot = forwardRefWithAs(function Dialog<
220223
(container) => {
221224
if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements
222225
if (container.contains(previousElement.current)) return false // Skip if it is the main app
226+
if (state.panelRef.current && container.contains(state.panelRef.current)) return false
223227
return true // Keep
224228
}
225229
)
226230

227-
return [...rootContainers, internalDialogRef.current] as HTMLElement[]
231+
return [
232+
...rootContainers,
233+
state.panelRef.current ?? internalDialogRef.current,
234+
] as HTMLElement[]
228235
},
229236
() => {
230237
if (dialogState !== DialogStates.Open) return
231238
if (hasNestedDialogs) return
232239

233240
close()
234-
}
241+
},
242+
OutsideClickFeatures.IgnoreScrollbars
235243
)
236244

237245
// Handle `Escape` to close
@@ -413,6 +421,93 @@ let Overlay = forwardRefWithAs(function Overlay<
413421

414422
// ---
415423

424+
let DEFAULT_BACKDROP_TAG = 'div' as const
425+
interface BackdropRenderPropArg {
426+
open: boolean
427+
}
428+
type BackdropPropsWeControl = 'id' | 'aria-hidden' | 'onClick'
429+
430+
let Backdrop = forwardRefWithAs(function Backdrop<
431+
TTag extends ElementType = typeof DEFAULT_BACKDROP_TAG
432+
>(props: Props<TTag, BackdropRenderPropArg, BackdropPropsWeControl>, ref: Ref<HTMLDivElement>) {
433+
let [{ dialogState }, state] = useDialogContext('Dialog.Backdrop')
434+
let backdropRef = useSyncRefs(ref)
435+
436+
let id = `headlessui-dialog-backdrop-${useId()}`
437+
438+
useEffect(() => {
439+
if (state.panelRef.current === null) {
440+
throw new Error(
441+
`A <Dialog.Backdrop /> component is being used, but a <Dialog.Panel /> component is missing.`
442+
)
443+
}
444+
}, [state.panelRef])
445+
446+
let slot = useMemo<BackdropRenderPropArg>(
447+
() => ({ open: dialogState === DialogStates.Open }),
448+
[dialogState]
449+
)
450+
451+
let theirProps = props
452+
let ourProps = {
453+
ref: backdropRef,
454+
id,
455+
'aria-hidden': true,
456+
}
457+
458+
return (
459+
<ForcePortalRoot force>
460+
<Portal>
461+
{render({
462+
ourProps,
463+
theirProps,
464+
slot,
465+
defaultTag: DEFAULT_BACKDROP_TAG,
466+
name: 'Dialog.Backdrop',
467+
})}
468+
</Portal>
469+
</ForcePortalRoot>
470+
)
471+
})
472+
473+
// ---
474+
475+
let DEFAULT_PANEL_TAG = 'div' as const
476+
interface PanelRenderPropArg {
477+
open: boolean
478+
}
479+
480+
let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
481+
props: Props<TTag, PanelRenderPropArg>,
482+
ref: Ref<HTMLDivElement>
483+
) {
484+
let [{ dialogState }, state] = useDialogContext('Dialog.Panel')
485+
let panelRef = useSyncRefs(ref, state.panelRef)
486+
487+
let id = `headlessui-dialog-panel-${useId()}`
488+
489+
let slot = useMemo<PanelRenderPropArg>(
490+
() => ({ open: dialogState === DialogStates.Open }),
491+
[dialogState]
492+
)
493+
494+
let theirProps = props
495+
let ourProps = {
496+
ref: panelRef,
497+
id,
498+
}
499+
500+
return render({
501+
ourProps,
502+
theirProps,
503+
slot,
504+
defaultTag: DEFAULT_PANEL_TAG,
505+
name: 'Dialog.Panel',
506+
})
507+
})
508+
509+
// ---
510+
416511
let DEFAULT_TITLE_TAG = 'h2' as const
417512
interface TitleRenderPropArg {
418513
open: boolean
@@ -452,4 +547,4 @@ let Title = forwardRefWithAs(function Title<TTag extends ElementType = typeof DE
452547

453548
// ---
454549

455-
export let Dialog = Object.assign(DialogRoot, { Overlay, Title, Description })
550+
export let Dialog = Object.assign(DialogRoot, { Backdrop, Panel, Overlay, Title, Description })

0 commit comments

Comments
 (0)