diff --git a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx index a6b380f71d..26d47950e2 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx @@ -11,6 +11,8 @@ import { assertDialogTitle, getDialog, getDialogOverlay, + getByText, + assertActiveElement, } from '../../test-utils/accessibility-assertions' import { click, press, Keys } from '../../test-utils/interactions' import { Props } from '../../types' @@ -67,6 +69,17 @@ describe('Safe guards', () => { describe('Rendering', () => { describe('Dialog', () => { + it( + 'should complain when the `open` and `onClose` prop are missing', + suppressConsoleLogs(async () => { + // @ts-expect-error + expect(() => render()).toThrowErrorMatchingInlineSnapshot( + `"You have to provide an \`open\` and an \`onClose\` prop to the \`Dialog\` component."` + ) + expect.hasAssertions() + }) + ) + it( 'should complain when an `open` prop is provided without an `onClose` prop', suppressConsoleLogs(async () => { @@ -392,4 +405,73 @@ describe('Mouse interactions', () => { assertDialog({ state: DialogState.InvisibleUnmounted }) }) ) + + it( + 'should be possible to close the dialog, and re-focus the button when we click outside on the body element', + suppressConsoleLogs(async () => { + function Example() { + let [isOpen, setIsOpen] = useState(false) + return ( + <> + + + Contents + + + + ) + } + render() + + // Open dialog + await click(getByText('Trigger')) + + // Verify it is open + assertDialog({ state: DialogState.Visible }) + + // Click the body to close + await click(document.body) + + // Verify it is closed + assertDialog({ state: DialogState.InvisibleUnmounted }) + + // Verify the button is focused + assertActiveElement(getByText('Trigger')) + }) + ) + + it( + 'should be possible to close the dialog, and keep focus on the focusable element', + suppressConsoleLogs(async () => { + function Example() { + let [isOpen, setIsOpen] = useState(false) + return ( + <> + + + + Contents + + + + ) + } + render() + + // Open dialog + await click(getByText('Trigger')) + + // Verify it is open + assertDialog({ state: DialogState.Visible }) + + // Click the button to close (outside click) + await click(getByText('Hello')) + + // Verify it is closed + assertDialog({ state: DialogState.InvisibleUnmounted }) + + // Verify the button is focused + assertActiveElement(getByText('Hello')) + }) + ) }) diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index 642fd38a50..2c24c7fd39 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -119,6 +119,12 @@ let DialogRoot = forwardRefWithAs(function Dialog< // Validations let hasOpen = props.hasOwnProperty('open') let hasOnClose = props.hasOwnProperty('onClose') + if (!hasOpen && !hasOnClose) { + throw new Error( + `You have to provide an \`open\` and an \`onClose\` prop to the \`Dialog\` component.` + ) + } + if (!hasOpen) { throw new Error( `You provided an \`onClose\` prop to the \`Dialog\`, but forgot an \`open\` prop.` @@ -161,6 +167,21 @@ let DialogRoot = forwardRefWithAs(function Dialog< [dispatch] ) + // Handle outside click + useEffect(() => { + function handler(event: MouseEvent) { + let target = event.target as HTMLElement + + if (dialogState !== DialogStates.Open) return + if (internalDialogRef.current?.contains(target)) return + + close() + } + + window.addEventListener('mousedown', handler) + return () => window.removeEventListener('mousedown', handler) + }, [dialogState, internalDialogRef, close]) + // Handle `Escape` to close useEffect(() => { function handler(event: KeyboardEvent) {