diff --git a/CHANGELOG.md b/CHANGELOG.md index dc92b06..0f693d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `
` 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 `` 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 diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx index f376666..1143142 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -1731,7 +1731,7 @@ describe('Keyboard interactions', () => { let handleChange = jest.fn() function Example() { let [value, setValue] = useState('bob') - let [query, setQuery] = useState('') + let [, setQuery] = useState('') return ( state.inputRef.current?.focus({ preventScroll: true })) + + default: + return } }, [d, state, actions, data] diff --git a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx index 9cc70ae..c59e304 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx @@ -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 ', 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 ( + <> + + + + + + + ) + } + + render() + + try { + await click(document.getElementById('trigger')) + + expect(true).toBe(false) + } catch (e: unknown) { + expect((e as Error).message).toBe( + 'A component is being used, but a 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 ( + <> + + + + + + + + + ) + } + + render() + + await click(document.getElementById('trigger')) + }) + ) + + it( + 'should portal the Dialog.Backdrop', + suppressConsoleLogs(async () => { + function Example() { + let [isOpen, setIsOpen] = useState(false) + return ( + <> + + + + + + + + + ) + } + + render() + + 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 ( + <> + + + + + + + + + + ) + } + + render() + + 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 ( + <> + + + + + + + + + + ) + } + + render() + + await click(document.getElementById('trigger')) + + assertDialog({ state: DialogState.Visible }) + + await click(document.getElementById('inside')) + + assertDialog({ state: DialogState.Visible }) + }) + ) }) describe('Nesting', () => { diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index fa773c0..de5c079 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -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 } 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, ref: Ref) { + 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 component is being used, but a component is missing.` + ) + } + }, [state.panelRef]) + + let slot = useMemo( + () => ({ open: dialogState === DialogStates.Open }), + [dialogState] + ) + + let theirProps = props + let ourProps = { + ref: backdropRef, + id, + 'aria-hidden': true, + } + + return ( + + + {render({ + ourProps, + theirProps, + slot, + defaultTag: DEFAULT_BACKDROP_TAG, + name: 'Dialog.Backdrop', + })} + + + ) +}) + +// --- + +let DEFAULT_PANEL_TAG = 'div' as const +interface PanelRenderPropArg { + open: boolean +} + +let Panel = forwardRefWithAs(function Panel( + props: Props, + ref: Ref +) { + let [{ dialogState }, state] = useDialogContext('Dialog.Panel') + let panelRef = useSyncRefs(ref, state.panelRef) + + let id = `headlessui-dialog-panel-${useId()}` + + let slot = useMemo( + () => ({ 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 | HTMLElement | null type ContainerCollection = Container[] | Set 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 diff --git a/packages/@headlessui-react/src/hooks/use-transition.ts b/packages/@headlessui-react/src/hooks/use-transition.ts index 424d605..e066bbd 100644 --- a/packages/@headlessui-react/src/hooks/use-transition.ts +++ b/packages/@headlessui-react/src/hooks/use-transition.ts @@ -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' diff --git a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts index 37c435f..6eba1d4 100644 --- a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts +++ b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts @@ -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-"]')) } diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts index 8c727fd..fc42bfe 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts @@ -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 ', 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: ` +
+ + + + + +
+ `, + 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 component is being used, but a 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: ` +
+ + + + + + + +
+ `, + 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: ` +
+ + + + + + + +
+ `, + 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: ` +
+ + + + + + + + +
+ `, + 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: ` +
+ + + + + + + + +
+ `, + 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', () => { diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.ts b/packages/@headlessui-vue/src/components/dialog/dialog.ts index 79be470..954e222 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.ts @@ -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 titleId: Ref + panelRef: Ref 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 component is being used, but a 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: { diff --git a/packages/@headlessui-vue/src/components/switch/switch.ts b/packages/@headlessui-vue/src/components/switch/switch.ts index d64c0b2..a6c69d1 100644 --- a/packages/@headlessui-vue/src/components/switch/switch.ts +++ b/packages/@headlessui-vue/src/components/switch/switch.ts @@ -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) } } diff --git a/packages/@headlessui-vue/src/hooks/use-outside-click.ts b/packages/@headlessui-vue/src/hooks/use-outside-click.ts index fb68c9c..6af891e 100644 --- a/packages/@headlessui-vue/src/hooks/use-outside-click.ts +++ b/packages/@headlessui-vue/src/hooks/use-outside-click.ts @@ -21,9 +21,15 @@ type Container = Ref | HTMLElement | null type ContainerCollection = Container[] | Set 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 diff --git a/packages/@headlessui-vue/src/index.test.ts b/packages/@headlessui-vue/src/index.test.ts index d8d8fac..8352a47 100644 --- a/packages/@headlessui-vue/src/index.test.ts +++ b/packages/@headlessui-vue/src/index.test.ts @@ -17,6 +17,8 @@ it('should expose the correct components', () => { // Dialog 'Dialog', 'DialogOverlay', + 'DialogBackdrop', + 'DialogPanel', 'DialogTitle', 'DialogDescription', diff --git a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts index 37c435f..6eba1d4 100644 --- a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts +++ b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts @@ -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-"]')) }