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 (
+ <>
+
+
+ >
+ )
+ }
+ 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 (
+ <>
+
+
+
+ >
+ )
+ }
+ 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) {