Add Dialog.Backdrop and Dialog.Panel components (#1333)
* implement `Dialog.Backdrop` and `Dialog.Panel` * cleanup TypeScript warnings * update changelog
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
assertDialogTitle,
|
||||
getDialog,
|
||||
getDialogOverlay,
|
||||
getDialogBackdrop,
|
||||
getByText,
|
||||
assertActiveElement,
|
||||
getDialogs,
|
||||
@@ -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) => {
|
||||
@@ -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',
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import React, {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
MutableRefObject,
|
||||
Ref,
|
||||
createRef,
|
||||
} from 'react'
|
||||
|
||||
import { Props } from '../../types'
|
||||
@@ -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'
|
||||
@@ -44,6 +45,7 @@ enum DialogStates {
|
||||
|
||||
interface StateDefinition {
|
||||
titleId: string | null
|
||||
panelRef: MutableRefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
enum ActionTypes {
|
||||
@@ -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])
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MutableRefObject, useMemo, useRef } from 'react'
|
||||
import { MutableRefObject, useRef } from 'react'
|
||||
import { microTask } from '../utils/micro-task'
|
||||
import { useLatestValue } from './use-latest-value'
|
||||
import { useWindowEvent } from './use-window-event'
|
||||
@@ -7,9 +7,15 @@ type Container = MutableRefObject<HTMLElement | null> | HTMLElement | null
|
||||
type ContainerCollection = Container[] | Set<Container>
|
||||
type ContainerInput = Container | ContainerCollection
|
||||
|
||||
export enum Features {
|
||||
None = 1 << 0,
|
||||
IgnoreScrollbars = 1 << 1,
|
||||
}
|
||||
|
||||
export function useOutsideClick(
|
||||
containers: ContainerInput | (() => ContainerInput),
|
||||
cb: (event: MouseEvent | PointerEvent, target: HTMLElement) => void
|
||||
cb: (event: MouseEvent | PointerEvent, target: HTMLElement) => void,
|
||||
features: Features = Features.None
|
||||
) {
|
||||
let called = useRef(false)
|
||||
let handler = useLatestValue((event: MouseEvent | PointerEvent) => {
|
||||
@@ -40,6 +46,25 @@ export function useOutsideClick(
|
||||
// Ignore if the target doesn't exist in the DOM anymore
|
||||
if (!target.ownerDocument.documentElement.contains(target)) return
|
||||
|
||||
// Ignore scrollbars:
|
||||
// This is a bit hacky, and is only necessary because we are checking for `pointerdown` and
|
||||
// `mousedown` events. They _are_ being called if you click on a scrollbar. The `click` event
|
||||
// is not called when clicking on a scrollbar, but we can't use that otherwise it won't work
|
||||
// on mobile devices where only pointer events are being used.
|
||||
if ((features & Features.IgnoreScrollbars) === Features.IgnoreScrollbars) {
|
||||
// TODO: We can calculate this dynamically~is. On macOS if you have the "Automatically based
|
||||
// on mouse or trackpad" setting enabled, then the scrollbar will float on top and therefore
|
||||
// you can't calculate its with by checking the clientWidth and scrollWidth of the element.
|
||||
// Therefore we are currently hardcoding this to be 20px.
|
||||
let scrollbarWidth = 20
|
||||
|
||||
let viewport = target.ownerDocument.documentElement
|
||||
if (event.clientX > viewport.clientWidth - scrollbarWidth) return
|
||||
if (event.clientX < scrollbarWidth) return
|
||||
if (event.clientY > viewport.clientHeight - scrollbarWidth) return
|
||||
if (event.clientY < scrollbarWidth) return
|
||||
}
|
||||
|
||||
// Ignore if the target exists in one of the containers
|
||||
for (let container of _containers) {
|
||||
if (container === null) continue
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MutableRefObject, useRef } from 'react'
|
||||
import { MutableRefObject } from 'react'
|
||||
|
||||
import { Reason, transition } from '../components/transitions/utils/transition'
|
||||
import { disposables } from '../utils/disposables'
|
||||
|
||||
@@ -1334,6 +1334,10 @@ export function getDialogOverlay(): HTMLElement | null {
|
||||
return document.querySelector('[id^="headlessui-dialog-overlay-"]')
|
||||
}
|
||||
|
||||
export function getDialogBackdrop(): HTMLElement | null {
|
||||
return document.querySelector('[id^="headlessui-dialog-backdrop-"]')
|
||||
}
|
||||
|
||||
export function getDialogOverlays(): HTMLElement[] {
|
||||
return Array.from(document.querySelectorAll('[id^="headlessui-dialog-overlay-"]'))
|
||||
}
|
||||
|
||||
@@ -8,7 +8,14 @@ import {
|
||||
} from 'vue'
|
||||
import { render } from '../../test-utils/vue-testing-library'
|
||||
|
||||
import { Dialog, DialogOverlay, DialogTitle, DialogDescription } from './dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogOverlay,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from './dialog'
|
||||
import { TransitionRoot } from '../transitions/transition'
|
||||
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
|
||||
import {
|
||||
@@ -19,6 +26,7 @@ import {
|
||||
assertDialogTitle,
|
||||
getDialog,
|
||||
getDialogOverlay,
|
||||
getDialogBackdrop,
|
||||
getByText,
|
||||
assertActiveElement,
|
||||
getDialogs,
|
||||
@@ -50,7 +58,15 @@ beforeAll(() => {
|
||||
afterAll(() => jest.restoreAllMocks())
|
||||
|
||||
function renderTemplate(input: string | ComponentOptionsWithoutProps) {
|
||||
let defaultComponents = { Dialog, DialogOverlay, DialogTitle, DialogDescription, TabSentinel }
|
||||
let defaultComponents = {
|
||||
Dialog,
|
||||
DialogOverlay,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
TabSentinel,
|
||||
}
|
||||
|
||||
if (typeof input === 'string') {
|
||||
return render(defineComponent({ template: input, components: defaultComponents }))
|
||||
@@ -69,6 +85,8 @@ describe('Safe guards', () => {
|
||||
it.each([
|
||||
['DialogOverlay', DialogOverlay],
|
||||
['DialogTitle', DialogTitle],
|
||||
['DialogBackdrop', DialogBackdrop],
|
||||
['DialogPanel', DialogPanel],
|
||||
])(
|
||||
'should error when we are using a <%s /> without a parent <Dialog />',
|
||||
suppressConsoleLogs((name, Component) => {
|
||||
@@ -381,6 +399,140 @@ describe('Rendering', () => {
|
||||
)
|
||||
})
|
||||
|
||||
describe('DialogBackdrop', () => {
|
||||
it(
|
||||
'should throw an error if a DialogBackdrop is used without a DialogPanel',
|
||||
suppressConsoleLogs(async () => {
|
||||
expect.hasAssertions()
|
||||
|
||||
renderTemplate({
|
||||
template: `
|
||||
<div>
|
||||
<button id="trigger" @click="toggleOpen">
|
||||
Trigger
|
||||
</button>
|
||||
<Dialog :open="isOpen" @close="setIsOpen">
|
||||
<DialogBackdrop />
|
||||
<TabSentinel />
|
||||
</Dialog>
|
||||
</div>
|
||||
`,
|
||||
setup() {
|
||||
let isOpen = ref(false)
|
||||
return {
|
||||
isOpen,
|
||||
setIsOpen(value: boolean) {
|
||||
isOpen.value = value
|
||||
},
|
||||
toggleOpen() {
|
||||
isOpen.value = !isOpen.value
|
||||
},
|
||||
}
|
||||
},
|
||||
errorCaptured(err) {
|
||||
expect(err as Error).toEqual(
|
||||
new Error(
|
||||
'A <DialogBackdrop /> component is being used, but a <DialogPanel /> component is missing.'
|
||||
)
|
||||
)
|
||||
|
||||
return false
|
||||
},
|
||||
})
|
||||
|
||||
await click(document.getElementById('trigger'))
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'should not throw an error if a DialogBackdrop is used with a DialogPanel',
|
||||
suppressConsoleLogs(async () => {
|
||||
renderTemplate({
|
||||
template: `
|
||||
<div>
|
||||
<button id="trigger" @click="toggleOpen">
|
||||
Trigger
|
||||
</button>
|
||||
<Dialog :open="isOpen" @close="setIsOpen">
|
||||
<DialogBackdrop />
|
||||
<DialogPanel>
|
||||
<TabSentinel />
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
</div>
|
||||
`,
|
||||
setup() {
|
||||
let isOpen = ref(false)
|
||||
return {
|
||||
isOpen,
|
||||
setIsOpen(value: boolean) {
|
||||
isOpen.value = value
|
||||
},
|
||||
toggleOpen() {
|
||||
isOpen.value = !isOpen.value
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
await click(document.getElementById('trigger'))
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'should portal the DialogBackdrop',
|
||||
suppressConsoleLogs(async () => {
|
||||
renderTemplate({
|
||||
template: `
|
||||
<div>
|
||||
<button id="trigger" @click="toggleOpen">
|
||||
Trigger
|
||||
</button>
|
||||
<Dialog :open="isOpen" @close="setIsOpen">
|
||||
<DialogBackdrop />
|
||||
<DialogPanel>
|
||||
<TabSentinel />
|
||||
<DialogPanel>
|
||||
</Dialog>
|
||||
</div>
|
||||
`,
|
||||
setup() {
|
||||
let isOpen = ref(false)
|
||||
return {
|
||||
isOpen,
|
||||
setIsOpen(value: boolean) {
|
||||
isOpen.value = value
|
||||
},
|
||||
toggleOpen() {
|
||||
isOpen.value = !isOpen.value
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
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('DialogTitle', () => {
|
||||
it(
|
||||
'should be possible to render DialogTitle using a render prop',
|
||||
@@ -1158,6 +1310,90 @@ describe('Mouse interactions', () => {
|
||||
assertDialog({ state: DialogState.Visible })
|
||||
})
|
||||
)
|
||||
|
||||
it(
|
||||
'should close the Dialog if we click outside the DialogPanel',
|
||||
suppressConsoleLogs(async () => {
|
||||
renderTemplate({
|
||||
template: `
|
||||
<div>
|
||||
<button id="trigger" @click="toggleOpen">
|
||||
Trigger
|
||||
</button>
|
||||
<Dialog :open="isOpen" @close="setIsOpen">
|
||||
<DialogBackdrop />
|
||||
<DialogPanel>
|
||||
<TabSentinel />
|
||||
</DialogPanel>
|
||||
<button id="outside">Outside, technically</button>
|
||||
</Dialog>
|
||||
</div>
|
||||
`,
|
||||
setup() {
|
||||
let isOpen = ref(false)
|
||||
return {
|
||||
isOpen,
|
||||
setIsOpen(value: boolean) {
|
||||
isOpen.value = value
|
||||
},
|
||||
toggleOpen() {
|
||||
isOpen.value = !isOpen.value
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
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 DialogPanel',
|
||||
suppressConsoleLogs(async () => {
|
||||
renderTemplate({
|
||||
template: `
|
||||
<div>
|
||||
<button id="trigger" @click="toggleOpen">
|
||||
Trigger
|
||||
</button>
|
||||
<Dialog :open="isOpen" @close="setIsOpen">
|
||||
<DialogBackdrop />
|
||||
<DialogPanel>
|
||||
<button id="inside">Inside</button>
|
||||
<TabSentinel />
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
</div>
|
||||
`,
|
||||
setup() {
|
||||
let isOpen = ref(false)
|
||||
return {
|
||||
isOpen,
|
||||
setIsOpen(value: boolean) {
|
||||
isOpen.value = value
|
||||
},
|
||||
toggleOpen() {
|
||||
isOpen.value = !isOpen.value
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
await click(document.getElementById('trigger'))
|
||||
|
||||
assertDialog({ state: DialogState.Visible })
|
||||
|
||||
await click(document.getElementById('inside'))
|
||||
|
||||
assertDialog({ state: DialogState.Visible })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe('Nesting', () => {
|
||||
|
||||
@@ -29,7 +29,7 @@ import { ForcePortalRoot } from '../../internal/portal-force-root'
|
||||
import { Description, useDescriptions } from '../description/description'
|
||||
import { dom } from '../../utils/dom'
|
||||
import { useOpenClosed, State } from '../../internal/open-closed'
|
||||
import { useOutsideClick } from '../../hooks/use-outside-click'
|
||||
import { useOutsideClick, Features as OutsideClickFeatures } from '../../hooks/use-outside-click'
|
||||
import { getOwnerDocument } from '../../utils/owner'
|
||||
import { useEventListener } from '../../hooks/use-event-listener'
|
||||
|
||||
@@ -42,6 +42,7 @@ interface StateDefinition {
|
||||
dialogState: Ref<DialogStates>
|
||||
|
||||
titleId: Ref<string | null>
|
||||
panelRef: Ref<HTMLDivElement | null>
|
||||
|
||||
setTitleId(id: string | null): void
|
||||
|
||||
@@ -178,6 +179,7 @@ export let Dialog = defineComponent({
|
||||
|
||||
let api = {
|
||||
titleId,
|
||||
panelRef: ref(null),
|
||||
dialogState,
|
||||
setTitleId(id: string | null) {
|
||||
if (titleId.value === id) return
|
||||
@@ -199,10 +201,11 @@ export let Dialog = defineComponent({
|
||||
).filter((container) => {
|
||||
if (!(container instanceof HTMLElement)) return false // Skip non-HTMLElements
|
||||
if (container.contains(previousElement.value)) return false // Skip if it is the main app
|
||||
if (api.panelRef.value && container.contains(api.panelRef.value)) return false
|
||||
return true // Keep
|
||||
})
|
||||
|
||||
return [...rootContainers, internalDialogRef.value] as HTMLElement[]
|
||||
return [...rootContainers, api.panelRef.value ?? internalDialogRef.value] as HTMLElement[]
|
||||
},
|
||||
|
||||
(_event, target) => {
|
||||
@@ -211,7 +214,8 @@ export let Dialog = defineComponent({
|
||||
|
||||
api.close()
|
||||
nextTick(() => target?.focus())
|
||||
}
|
||||
},
|
||||
OutsideClickFeatures.IgnoreScrollbars
|
||||
)
|
||||
|
||||
// Handle `Escape` to close
|
||||
@@ -354,6 +358,81 @@ export let DialogOverlay = defineComponent({
|
||||
|
||||
// ---
|
||||
|
||||
export let DialogBackdrop = defineComponent({
|
||||
name: 'DialogBackdrop',
|
||||
props: {
|
||||
as: { type: [Object, String], default: 'div' },
|
||||
},
|
||||
inheritAttrs: false,
|
||||
setup(props, { attrs, slots, expose }) {
|
||||
let api = useDialogContext('DialogBackdrop')
|
||||
let id = `headlessui-dialog-backdrop-${useId()}`
|
||||
let internalBackdropRef = ref(null)
|
||||
|
||||
expose({ el: internalBackdropRef, $el: internalBackdropRef })
|
||||
|
||||
onMounted(() => {
|
||||
if (api.panelRef.value === null) {
|
||||
throw new Error(
|
||||
`A <DialogBackdrop /> component is being used, but a <DialogPanel /> component is missing.`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
let incomingProps = props
|
||||
let ourProps = {
|
||||
id,
|
||||
ref: internalBackdropRef,
|
||||
'aria-hidden': true,
|
||||
}
|
||||
|
||||
return h(ForcePortalRoot, { force: true }, () =>
|
||||
h(Portal, () =>
|
||||
render({
|
||||
props: { ...attrs, ...incomingProps, ...ourProps },
|
||||
slot: { open: api.dialogState.value === DialogStates.Open },
|
||||
attrs,
|
||||
slots,
|
||||
name: 'DialogBackdrop',
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// ---
|
||||
|
||||
export let DialogPanel = defineComponent({
|
||||
name: 'DialogPanel',
|
||||
props: {
|
||||
as: { type: [Object, String], default: 'div' },
|
||||
},
|
||||
setup(props, { attrs, slots }) {
|
||||
let api = useDialogContext('DialogPanel')
|
||||
let id = `headlessui-dialog-panel-${useId()}`
|
||||
|
||||
return () => {
|
||||
let ourProps = {
|
||||
id,
|
||||
ref: api.panelRef,
|
||||
}
|
||||
let incomingProps = props
|
||||
|
||||
return render({
|
||||
props: { ...incomingProps, ...ourProps },
|
||||
slot: { open: api.dialogState.value === DialogStates.Open },
|
||||
attrs,
|
||||
slots,
|
||||
name: 'DialogPanel',
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// ---
|
||||
|
||||
export let DialogTitle = defineComponent({
|
||||
name: 'DialogTitle',
|
||||
props: {
|
||||
|
||||
@@ -98,7 +98,7 @@ export let Switch = defineComponent({
|
||||
event.preventDefault()
|
||||
toggle()
|
||||
} else if (event.key === Keys.Enter) {
|
||||
attemptSubmit(event.currentTarget)
|
||||
attemptSubmit(event.currentTarget as HTMLElement)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,9 +21,15 @@ type Container = Ref<HTMLElement | null> | HTMLElement | null
|
||||
type ContainerCollection = Container[] | Set<Container>
|
||||
type ContainerInput = Container | ContainerCollection
|
||||
|
||||
export enum Features {
|
||||
None = 1 << 0,
|
||||
IgnoreScrollbars = 1 << 1,
|
||||
}
|
||||
|
||||
export function useOutsideClick(
|
||||
containers: ContainerInput | (() => ContainerInput),
|
||||
cb: (event: MouseEvent | PointerEvent, target: HTMLElement) => void
|
||||
cb: (event: MouseEvent | PointerEvent, target: HTMLElement) => void,
|
||||
features: Features = Features.None
|
||||
) {
|
||||
let called = false
|
||||
function handle(event: MouseEvent | PointerEvent) {
|
||||
@@ -54,6 +60,25 @@ export function useOutsideClick(
|
||||
return [containers]
|
||||
})(containers)
|
||||
|
||||
// Ignore scrollbars:
|
||||
// This is a bit hacky, and is only necessary because we are checking for `pointerdown` and
|
||||
// `mousedown` events. They _are_ being called if you click on a scrollbar. The `click` event
|
||||
// is not called when clicking on a scrollbar, but we can't use that otherwise it won't work
|
||||
// on mobile devices where only pointer events are being used.
|
||||
if ((features & Features.IgnoreScrollbars) === Features.IgnoreScrollbars) {
|
||||
// TODO: We can calculate this dynamically~is. On macOS if you have the "Automatically based
|
||||
// on mouse or trackpad" setting enabled, then the scrollbar will float on top and therefore
|
||||
// you can't calculate its with by checking the clientWidth and scrollWidth of the element.
|
||||
// Therefore we are currently hardcoding this to be 20px.
|
||||
let scrollbarWidth = 20
|
||||
|
||||
let viewport = target.ownerDocument.documentElement
|
||||
if (event.clientX > viewport.clientWidth - scrollbarWidth) return
|
||||
if (event.clientX < scrollbarWidth) return
|
||||
if (event.clientY > viewport.clientHeight - scrollbarWidth) return
|
||||
if (event.clientY < scrollbarWidth) return
|
||||
}
|
||||
|
||||
// Ignore if the target exists in one of the containers
|
||||
for (let container of _containers) {
|
||||
if (container === null) continue
|
||||
|
||||
@@ -17,6 +17,8 @@ it('should expose the correct components', () => {
|
||||
// Dialog
|
||||
'Dialog',
|
||||
'DialogOverlay',
|
||||
'DialogBackdrop',
|
||||
'DialogPanel',
|
||||
'DialogTitle',
|
||||
'DialogDescription',
|
||||
|
||||
|
||||
@@ -1334,6 +1334,10 @@ export function getDialogOverlay(): HTMLElement | null {
|
||||
return document.querySelector('[id^="headlessui-dialog-overlay-"]')
|
||||
}
|
||||
|
||||
export function getDialogBackdrop(): HTMLElement | null {
|
||||
return document.querySelector('[id^="headlessui-dialog-backdrop-"]')
|
||||
}
|
||||
|
||||
export function getDialogOverlays(): HTMLElement[] {
|
||||
return Array.from(document.querySelectorAll('[id^="headlessui-dialog-overlay-"]'))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user