Add Dialog.Backdrop and Dialog.Panel components (#1333)

* implement `Dialog.Backdrop` and `Dialog.Panel`

* cleanup TypeScript warnings

* update changelog
This commit is contained in:
Robin Malfait
2022-04-14 17:15:43 +02:00
committed by GitHub
parent 0162c57d88
commit b4a4e0b307
14 changed files with 663 additions and 15 deletions
+2
View File
@@ -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-"]'))
}