Skip to content

Commit

Permalink
Add Dialog.Backdrop and Dialog.Panel components (#1333)
Browse files Browse the repository at this point in the history
* implement `Dialog.Backdrop` and `Dialog.Panel`

* cleanup TypeScript warnings

* update changelog
  • Loading branch information
RobinMalfait authored Apr 14, 2022
1 parent 0162c57 commit b4a4e0b
Show file tree
Hide file tree
Showing 14 changed files with 663 additions and 15 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add `<form>` compatibility ([#1214](https://github.com/tailwindlabs/headlessui/pull/1214))
- Add `multi` value support for Listbox & Combobox ([#1243](https://github.com/tailwindlabs/headlessui/pull/1243))
- Implement `nullable` mode on `Combobox` in single value mode ([#1295](https://github.com/tailwindlabs/headlessui/pull/1295))
- Add `Dialog.Backdrop` and `Dialog.Panel` components ([#1333](https://github.com/tailwindlabs/headlessui/pull/1333))

## [Unreleased - @headlessui/vue]

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

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1731,7 +1731,7 @@ describe('Keyboard interactions', () => {
let handleChange = jest.fn()
function Example() {
let [value, setValue] = useState<string>('bob')
let [query, setQuery] = useState<string>('')
let [, setQuery] = useState<string>('')

return (
<Combobox
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,9 @@ let Button = forwardRefWithAs(function Button<TTag extends ElementType = typeof
}
actions.closeCombobox()
return d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true }))

default:
return
}
},
[d, state, actions, data]
Expand Down
173 changes: 173 additions & 0 deletions packages/@headlessui-react/src/components/dialog/dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
assertDialogTitle,
getDialog,
getDialogOverlay,
getDialogBackdrop,
getByText,
assertActiveElement,
getDialogs,
Expand Down Expand Up @@ -39,6 +40,8 @@ describe('Safe guards', () => {
it.each([
['Dialog.Overlay', Dialog.Overlay],
['Dialog.Title', Dialog.Title],
['Dialog.Backdrop', Dialog.Backdrop],
['Dialog.Panel', Dialog.Panel],
])(
'should error when we are using a <%s /> without a parent <Dialog />',
suppressConsoleLogs((name, Component) => {
Expand Down Expand Up @@ -307,6 +310,110 @@ describe('Rendering', () => {
)
})

describe('Dialog.Backdrop', () => {
it(
'should throw an error if a Dialog.Backdrop is used without a Dialog.Panel',
suppressConsoleLogs(async () => {
function Example() {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<button id="trigger" onClick={() => setIsOpen((v) => !v)}>
Trigger
</button>
<Dialog open={isOpen} onClose={setIsOpen}>
<Dialog.Backdrop />
<TabSentinel />
</Dialog>
</>
)
}

render(<Example />)

try {
await click(document.getElementById('trigger'))

expect(true).toBe(false)
} catch (e: unknown) {
expect((e as Error).message).toBe(
'A <Dialog.Backdrop /> component is being used, but a <Dialog.Panel /> component is missing.'
)
}
})
)

it(
'should not throw an error if a Dialog.Backdrop is used with a Dialog.Panel',
suppressConsoleLogs(async () => {
function Example() {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<button id="trigger" onClick={() => setIsOpen((v) => !v)}>
Trigger
</button>
<Dialog open={isOpen} onClose={setIsOpen}>
<Dialog.Backdrop />
<Dialog.Panel>
<TabSentinel />
</Dialog.Panel>
</Dialog>
</>
)
}

render(<Example />)

await click(document.getElementById('trigger'))
})
)

it(
'should portal the Dialog.Backdrop',
suppressConsoleLogs(async () => {
function Example() {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<button id="trigger" onClick={() => setIsOpen((v) => !v)}>
Trigger
</button>
<Dialog open={isOpen} onClose={setIsOpen}>
<Dialog.Backdrop />
<Dialog.Panel>
<TabSentinel />
</Dialog.Panel>
</Dialog>
</>
)
}

render(<Example />)

await click(document.getElementById('trigger'))

let dialog = getDialog()
let backdrop = getDialogBackdrop()

expect(dialog).not.toBe(null)
dialog = dialog as HTMLElement

expect(backdrop).not.toBe(null)
backdrop = backdrop as HTMLElement

// It should not be nested
let position = dialog.compareDocumentPosition(backdrop)
expect(position & Node.DOCUMENT_POSITION_CONTAINED_BY).not.toBe(
Node.DOCUMENT_POSITION_CONTAINED_BY
)

// It should be a sibling
expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
})
)
})

describe('Dialog.Title', () => {
it(
'should be possible to render Dialog.Title using a render prop',
Expand Down Expand Up @@ -891,6 +998,72 @@ describe('Mouse interactions', () => {
assertDialog({ state: DialogState.Visible })
})
)

it(
'should close the Dialog if we click outside the Dialog.Panel',
suppressConsoleLogs(async () => {
function Example() {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<button id="trigger" onClick={() => setIsOpen((v) => !v)}>
Trigger
</button>
<Dialog open={isOpen} onClose={setIsOpen}>
<Dialog.Backdrop />
<Dialog.Panel>
<TabSentinel />
</Dialog.Panel>
<button id="outside">Outside, technically</button>
</Dialog>
</>
)
}

render(<Example />)

await click(document.getElementById('trigger'))

assertDialog({ state: DialogState.Visible })

await click(document.getElementById('outside'))

assertDialog({ state: DialogState.InvisibleUnmounted })
})
)

it(
'should not close the Dialog if we click inside the Dialog.Panel',
suppressConsoleLogs(async () => {
function Example() {
let [isOpen, setIsOpen] = useState(false)
return (
<>
<button id="trigger" onClick={() => setIsOpen((v) => !v)}>
Trigger
</button>
<Dialog open={isOpen} onClose={setIsOpen}>
<Dialog.Backdrop />
<Dialog.Panel>
<button id="inside">Inside</button>
<TabSentinel />
</Dialog.Panel>
</Dialog>
</>
)
}

render(<Example />)

await click(document.getElementById('trigger'))

assertDialog({ state: DialogState.Visible })

await click(document.getElementById('inside'))

assertDialog({ state: DialogState.Visible })
})
)
})

describe('Nesting', () => {
Expand Down
103 changes: 99 additions & 4 deletions packages/@headlessui-react/src/components/dialog/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import React, {
MouseEvent as ReactMouseEvent,
MutableRefObject,
Ref,
createRef,
} from 'react'

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

interface StateDefinition {
titleId: string | null
panelRef: MutableRefObject<HTMLDivElement | null>
}

enum ActionTypes {
Expand Down Expand Up @@ -182,6 +184,7 @@ let DialogRoot = forwardRefWithAs(function Dialog<
let [state, dispatch] = useReducer(stateReducer, {
titleId: null,
descriptionId: null,
panelRef: createRef(),
} as StateDefinition)

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

return [...rootContainers, internalDialogRef.current] as HTMLElement[]
return [
...rootContainers,
state.panelRef.current ?? internalDialogRef.current,
] as HTMLElement[]
},
() => {
if (dialogState !== DialogStates.Open) return
if (hasNestedDialogs) return

close()
}
},
OutsideClickFeatures.IgnoreScrollbars
)

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

// ---

let DEFAULT_BACKDROP_TAG = 'div' as const
interface BackdropRenderPropArg {
open: boolean
}
type BackdropPropsWeControl = 'id' | 'aria-hidden' | 'onClick'

let Backdrop = forwardRefWithAs(function Backdrop<
TTag extends ElementType = typeof DEFAULT_BACKDROP_TAG
>(props: Props<TTag, BackdropRenderPropArg, BackdropPropsWeControl>, ref: Ref<HTMLDivElement>) {
let [{ dialogState }, state] = useDialogContext('Dialog.Backdrop')
let backdropRef = useSyncRefs(ref)

let id = `headlessui-dialog-backdrop-${useId()}`

useEffect(() => {
if (state.panelRef.current === null) {
throw new Error(
`A <Dialog.Backdrop /> component is being used, but a <Dialog.Panel /> component is missing.`
)
}
}, [state.panelRef])

let slot = useMemo<BackdropRenderPropArg>(
() => ({ open: dialogState === DialogStates.Open }),
[dialogState]
)

let theirProps = props
let ourProps = {
ref: backdropRef,
id,
'aria-hidden': true,
}

return (
<ForcePortalRoot force>
<Portal>
{render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_BACKDROP_TAG,
name: 'Dialog.Backdrop',
})}
</Portal>
</ForcePortalRoot>
)
})

// ---

let DEFAULT_PANEL_TAG = 'div' as const
interface PanelRenderPropArg {
open: boolean
}

let Panel = forwardRefWithAs(function Panel<TTag extends ElementType = typeof DEFAULT_PANEL_TAG>(
props: Props<TTag, PanelRenderPropArg>,
ref: Ref<HTMLDivElement>
) {
let [{ dialogState }, state] = useDialogContext('Dialog.Panel')
let panelRef = useSyncRefs(ref, state.panelRef)

let id = `headlessui-dialog-panel-${useId()}`

let slot = useMemo<PanelRenderPropArg>(
() => ({ open: dialogState === DialogStates.Open }),
[dialogState]
)

let theirProps = props
let ourProps = {
ref: panelRef,
id,
}

return render({
ourProps,
theirProps,
slot,
defaultTag: DEFAULT_PANEL_TAG,
name: 'Dialog.Panel',
})
})

// ---

let DEFAULT_TITLE_TAG = 'h2' as const
interface TitleRenderPropArg {
open: boolean
Expand Down Expand Up @@ -452,4 +547,4 @@ let Title = forwardRefWithAs(function Title<TTag extends ElementType = typeof DE

// ---

export let Dialog = Object.assign(DialogRoot, { Overlay, Title, Description })
export let Dialog = Object.assign(DialogRoot, { Backdrop, Panel, Overlay, Title, Description })
Loading

0 comments on commit b4a4e0b

Please sign in to comment.