diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx
index 74069637a9..3d3374c8cf 100644
--- a/packages/@headlessui-react/src/components/dialog/dialog.tsx
+++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx
@@ -1,3 +1,5 @@
+'use client'
+
// WAI-ARIA: https://www.w3.org/WAI/ARIA/apg/patterns/dialogmodal/
import React, {
createContext,
@@ -11,23 +13,23 @@ import React, {
useState,
type ContextType,
type ElementType,
- type MouseEvent as ReactMouseEvent,
type MutableRefObject,
+ type MouseEvent as ReactMouseEvent,
type Ref,
type RefObject,
} from 'react'
-import { FocusTrap } from '../../components/focus-trap/focus-trap'
-import { Portal, useNestedPortals } from '../../components/portal/portal'
import { useDocumentOverflowLockedEffect } from '../../hooks/document-overflow/use-document-overflow'
import { useEvent } from '../../hooks/use-event'
import { useEventListener } from '../../hooks/use-event-listener'
import { useId } from '../../hooks/use-id'
import { useInert } from '../../hooks/use-inert'
+import { useIsTouchDevice } from '../../hooks/use-is-touch-device'
import { useOutsideClick } from '../../hooks/use-outside-click'
import { useOwnerDocument } from '../../hooks/use-owner'
import { useRootContainers } from '../../hooks/use-root-containers'
import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complete'
import { useSyncRefs } from '../../hooks/use-sync-refs'
+import { HoistFormFields } from '../../internal/form-fields'
import { State, useOpenClosed } from '../../internal/open-closed'
import { ForcePortalRoot } from '../../internal/portal-force-root'
import { StackMessage, StackProvider } from '../../internal/stack-context'
@@ -35,7 +37,7 @@ import type { Props } from '../../types'
import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { match } from '../../utils/match'
import {
- Features,
+ RenderFeatures,
forwardRefWithAs,
render,
type HasDisplayName,
@@ -45,9 +47,11 @@ import {
import {
Description,
useDescriptions,
- _internal_ComponentDescription,
+ type _internal_ComponentDescription,
} from '../description/description'
+import { FocusTrap, FocusTrapFeatures } from '../focus-trap/focus-trap'
import { Keys } from '../keyboard'
+import { Portal, useNestedPortals } from '../portal/portal'
enum DialogStates {
Open,
@@ -84,7 +88,7 @@ let DialogContext = createContext<
close(): void
setTitleId(id: string | null): void
},
- StateDefinition
+ StateDefinition,
]
| null
>(null)
@@ -117,14 +121,14 @@ function stateReducer(state: StateDefinition, action: Actions) {
// ---
let DEFAULT_DIALOG_TAG = 'div' as const
-interface DialogRenderPropArg {
+type DialogRenderPropArg = {
open: boolean
}
type DialogPropsWeControl = 'aria-describedby' | 'aria-labelledby' | 'aria-modal'
-let DialogRenderFeatures = Features.RenderStrategy | Features.Static
+let DialogRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
-export type DialogProps
= Props<
+export type DialogProps = Props<
TTag,
DialogRenderPropArg,
DialogPropsWeControl,
@@ -133,6 +137,7 @@ export type DialogProps = Props<
onClose(value: boolean): void
initialFocus?: MutableRefObject
role?: 'dialog' | 'alertdialog'
+ autoFocus?: boolean
__demoMode?: boolean
}
>
@@ -148,6 +153,7 @@ function DialogFn(
onClose,
initialFocus,
role = 'dialog',
+ autoFocus = true,
__demoMode = false,
...theirProps
} = props
@@ -351,8 +357,8 @@ function DialogFn(
[dialogState, state, close, setTitleId]
)
- let slot = useMemo(
- () => ({ open: dialogState === DialogStates.Open }),
+ let slot = useMemo(
+ () => ({ open: dialogState === DialogStates.Open }) satisfies DialogRenderPropArg,
[dialogState]
)
@@ -360,11 +366,31 @@ function DialogFn(
ref: dialogRef,
id,
role,
+ tabIndex: -1,
'aria-modal': dialogState === DialogStates.Open ? true : undefined,
'aria-labelledby': state.titleId,
'aria-describedby': describedby,
}
+ let shouldAutoFocus = !useIsTouchDevice()
+
+ let focusTrapFeatures = enabled
+ ? match(position, {
+ parent: FocusTrapFeatures.RestoreFocus,
+ leaf: FocusTrapFeatures.All & ~FocusTrapFeatures.FocusLock,
+ })
+ : FocusTrapFeatures.None
+
+ // Enable AutoFocus feature
+ if (autoFocus) {
+ focusTrapFeatures |= FocusTrapFeatures.AutoFocus
+ }
+
+ // Remove initialFocus when we should not auto focus at all
+ if (!shouldAutoFocus) {
+ focusTrapFeatures &= ~FocusTrapFeatures.InitialFocus
+ }
+
return (
(
-
-
+
+
{render({
ourProps,
theirProps,
@@ -407,15 +427,17 @@ function DialogFn(
visible: dialogState === DialogStates.Open,
name: 'Dialog',
})}
-
-
+
+
-
+
+
+
)
}
@@ -423,12 +445,12 @@ function DialogFn(
// ---
let DEFAULT_OVERLAY_TAG = 'div' as const
-interface OverlayRenderPropArg {
+type OverlayRenderPropArg = {
open: boolean
}
type OverlayPropsWeControl = 'aria-hidden'
-export type DialogOverlayProps = Props<
+export type DialogOverlayProps = Props<
TTag,
OverlayRenderPropArg,
OverlayPropsWeControl
@@ -451,8 +473,8 @@ function OverlayFn(
close()
})
- let slot = useMemo(
- () => ({ open: dialogState === DialogStates.Open }),
+ let slot = useMemo(
+ () => ({ open: dialogState === DialogStates.Open }) satisfies OverlayRenderPropArg,
[dialogState]
)
@@ -475,12 +497,12 @@ function OverlayFn(
// ---
let DEFAULT_BACKDROP_TAG = 'div' as const
-interface BackdropRenderPropArg {
+type BackdropRenderPropArg = {
open: boolean
}
type BackdropPropsWeControl = 'aria-hidden'
-export type DialogBackdropProps = Props<
+export type DialogBackdropProps = Props<
TTag,
BackdropRenderPropArg,
BackdropPropsWeControl
@@ -503,8 +525,8 @@ function BackdropFn(
}
}, [state.panelRef])
- let slot = useMemo(
- () => ({ open: dialogState === DialogStates.Open }),
+ let slot = useMemo(
+ () => ({ open: dialogState === DialogStates.Open }) satisfies BackdropRenderPropArg,
[dialogState]
)
@@ -532,11 +554,14 @@ function BackdropFn(
// ---
let DEFAULT_PANEL_TAG = 'div' as const
-interface PanelRenderPropArg {
+type PanelRenderPropArg = {
open: boolean
}
-export type DialogPanelProps = Props
+export type DialogPanelProps = Props<
+ TTag,
+ PanelRenderPropArg
+>
function PanelFn(
props: DialogPanelProps,
@@ -547,8 +572,8 @@ function PanelFn(
let [{ dialogState }, state] = useDialogContext('Dialog.Panel')
let panelRef = useSyncRefs(ref, state.panelRef)
- let slot = useMemo(
- () => ({ open: dialogState === DialogStates.Open }),
+ let slot = useMemo(
+ () => ({ open: dialogState === DialogStates.Open }) satisfies PanelRenderPropArg,
[dialogState]
)
@@ -576,11 +601,14 @@ function PanelFn(
// ---
let DEFAULT_TITLE_TAG = 'h2' as const
-interface TitleRenderPropArg {
+type TitleRenderPropArg = {
open: boolean
}
-export type DialogTitleProps = Props
+export type DialogTitleProps = Props<
+ TTag,
+ TitleRenderPropArg
+>
function TitleFn(
props: DialogTitleProps,
@@ -597,8 +625,8 @@ function TitleFn(
return () => setTitleId(null)
}, [id, setTitleId])
- let slot = useMemo(
- () => ({ open: dialogState === DialogStates.Open }),
+ let slot = useMemo(
+ () => ({ open: dialogState === DialogStates.Open }) satisfies TitleRenderPropArg,
[dialogState]
)
@@ -648,15 +676,22 @@ export interface _internal_ComponentDialogTitle extends HasDisplayName {
export interface _internal_ComponentDialogDescription extends _internal_ComponentDescription {}
let DialogRoot = forwardRefWithAs(DialogFn) as unknown as _internal_ComponentDialog
-let Backdrop = forwardRefWithAs(BackdropFn) as unknown as _internal_ComponentDialogBackdrop
-let Panel = forwardRefWithAs(PanelFn) as unknown as _internal_ComponentDialogPanel
-let Overlay = forwardRefWithAs(OverlayFn) as unknown as _internal_ComponentDialogOverlay
-let Title = forwardRefWithAs(TitleFn) as unknown as _internal_ComponentDialogTitle
+export let DialogBackdrop = forwardRefWithAs(
+ BackdropFn
+) as unknown as _internal_ComponentDialogBackdrop
+export let DialogPanel = forwardRefWithAs(PanelFn) as unknown as _internal_ComponentDialogPanel
+export let DialogOverlay = forwardRefWithAs(
+ OverlayFn
+) as unknown as _internal_ComponentDialogOverlay
+export let DialogTitle = forwardRefWithAs(TitleFn) as unknown as _internal_ComponentDialogTitle
+/** @deprecated use `` instead of `` */
+export let DialogDescription = Description as _internal_ComponentDialogDescription
export let Dialog = Object.assign(DialogRoot, {
- Backdrop,
- Panel,
- Overlay,
- Title,
+ Backdrop: DialogBackdrop,
+ Panel: DialogPanel,
+ Overlay: DialogOverlay,
+ Title: DialogTitle,
+ /** @deprecated use `` instead of `` */
Description: Description as _internal_ComponentDialogDescription,
})
diff --git a/packages/@headlessui-react/src/components/disclosure-button/disclosure-button.tsx b/packages/@headlessui-react/src/components/disclosure-button/disclosure-button.tsx
new file mode 100644
index 0000000000..b059796889
--- /dev/null
+++ b/packages/@headlessui-react/src/components/disclosure-button/disclosure-button.tsx
@@ -0,0 +1,3 @@
+// Next.js barrel file improvements (GENERATED FILE)
+export type * from '../disclosure/disclosure'
+export { DisclosureButton } from '../disclosure/disclosure'
diff --git a/packages/@headlessui-react/src/components/disclosure-panel/disclosure-panel.tsx b/packages/@headlessui-react/src/components/disclosure-panel/disclosure-panel.tsx
new file mode 100644
index 0000000000..e45945e445
--- /dev/null
+++ b/packages/@headlessui-react/src/components/disclosure-panel/disclosure-panel.tsx
@@ -0,0 +1,3 @@
+// Next.js barrel file improvements (GENERATED FILE)
+export type * from '../disclosure/disclosure'
+export { DisclosurePanel } from '../disclosure/disclosure'
diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx
index 197c0a4e61..349c06e01d 100644
--- a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx
+++ b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx
@@ -11,7 +11,7 @@ import {
} from '../../test-utils/accessibility-assertions'
import { click, focus, Keys, MouseButton, press } from '../../test-utils/interactions'
import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs'
-import { Transition } from '../transitions/transition'
+import { Transition } from '../transition/transition'
import { Disclosure } from './disclosure'
jest.mock('../../hooks/use-id')
@@ -35,7 +35,7 @@ describe('Safe guards', () => {
])(
'should error when we are using a <%s /> without a parent ',
suppressConsoleLogs((name, Component) => {
- expect(() => render(createElement(Component))).toThrowError(
+ expect(() => render(createElement(Component as any))).toThrow(
`<${name} /> is missing a parent component.`
)
})
@@ -256,7 +256,7 @@ describe('Rendering', () => {
suppressConsoleLogs(async () => {
render(
- {JSON.stringify}
+ {(slot) => <>{JSON.stringify(slot)}>}
)
@@ -264,7 +264,13 @@ describe('Rendering', () => {
assertDisclosureButton({
state: DisclosureState.InvisibleUnmounted,
attributes: { id: 'headlessui-disclosure-button-1' },
- textContent: JSON.stringify({ open: false }),
+ textContent: JSON.stringify({
+ open: false,
+ hover: false,
+ active: false,
+ focus: false,
+ autofocus: false,
+ }),
})
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
@@ -273,7 +279,13 @@ describe('Rendering', () => {
assertDisclosureButton({
state: DisclosureState.Visible,
attributes: { id: 'headlessui-disclosure-button-1' },
- textContent: JSON.stringify({ open: true }),
+ textContent: JSON.stringify({
+ open: true,
+ hover: false,
+ active: false,
+ focus: false,
+ autofocus: false,
+ }),
})
assertDisclosurePanel({ state: DisclosureState.Visible })
})
@@ -285,7 +297,7 @@ describe('Rendering', () => {
render(
- {JSON.stringify}
+ {(slot) => <>{JSON.stringify(slot)}>}
@@ -294,7 +306,13 @@ describe('Rendering', () => {
assertDisclosureButton({
state: DisclosureState.InvisibleUnmounted,
attributes: { id: 'headlessui-disclosure-button-1' },
- textContent: JSON.stringify({ open: false }),
+ textContent: JSON.stringify({
+ open: false,
+ hover: false,
+ active: false,
+ focus: false,
+ autofocus: false,
+ }),
})
assertDisclosurePanel({ state: DisclosureState.InvisibleUnmounted })
@@ -303,7 +321,13 @@ describe('Rendering', () => {
assertDisclosureButton({
state: DisclosureState.Visible,
attributes: { id: 'headlessui-disclosure-button-1' },
- textContent: JSON.stringify({ open: true }),
+ textContent: JSON.stringify({
+ open: true,
+ hover: false,
+ active: false,
+ focus: false,
+ autofocus: false,
+ }),
})
assertDisclosurePanel({ state: DisclosureState.Visible })
})
@@ -377,7 +401,7 @@ describe('Rendering', () => {
render(
Trigger
- {JSON.stringify}
+ {(slot) => <>{JSON.stringify(slot)}>}
)
diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx
index a1945bdf59..70c01b1550 100644
--- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx
+++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx
@@ -1,7 +1,11 @@
+'use client'
+
// WAI-ARIA: https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/
+import { useFocusRing } from '@react-aria/focus'
+import { useHover } from '@react-aria/interactions'
import React, {
- createContext,
Fragment,
+ createContext,
useContext,
useEffect,
useMemo,
@@ -10,11 +14,12 @@ import React, {
type ContextType,
type Dispatch,
type ElementType,
+ type MutableRefObject,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
- type MutableRefObject,
type Ref,
} from 'react'
+import { useActivePress } from '../../hooks/use-active-press'
import { useEvent } from '../../hooks/use-event'
import { useId } from '../../hooks/use-id'
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
@@ -25,8 +30,9 @@ import { isDisabledReactIssue7711 } from '../../utils/bugs'
import { match } from '../../utils/match'
import { getOwnerDocument } from '../../utils/owner'
import {
- Features,
+ RenderFeatures,
forwardRefWithAs,
+ mergeProps,
render,
useMergeRefsFn,
type HasDisplayName,
@@ -149,14 +155,20 @@ function stateReducer(state: StateDefinition, action: Actions) {
// ---
let DEFAULT_DISCLOSURE_TAG = Fragment
-interface DisclosureRenderPropArg {
+type DisclosureRenderPropArg = {
open: boolean
close(focusableElement?: HTMLElement | MutableRefObject): void
}
+type DisclosurePropsWeControl = never
-export type DisclosureProps = Props & {
- defaultOpen?: boolean
-}
+export type DisclosureProps = Props<
+ TTag,
+ DisclosureRenderPropArg,
+ DisclosurePropsWeControl,
+ {
+ defaultOpen?: boolean
+ }
+>
function DisclosureFn(
props: DisclosureProps,
@@ -208,8 +220,12 @@ function DisclosureFn(
let api = useMemo>(() => ({ close }), [close])
- let slot = useMemo(
- () => ({ open: disclosureState === DisclosureStates.Open, close }),
+ let slot = useMemo(
+ () =>
+ ({
+ open: disclosureState === DisclosureStates.Open,
+ close,
+ }) satisfies DisclosureRenderPropArg,
[disclosureState, close]
)
@@ -242,17 +258,22 @@ function DisclosureFn(
// ---
let DEFAULT_BUTTON_TAG = 'button' as const
-interface ButtonRenderPropArg {
+type ButtonRenderPropArg = {
open: boolean
+ hover: boolean
+ active: boolean
+ focus: boolean
+ autofocus: boolean
}
type ButtonPropsWeControl = 'aria-controls' | 'aria-expanded'
-export type DisclosureButtonProps = Props<
+export type DisclosureButtonProps = Props<
TTag,
ButtonRenderPropArg,
ButtonPropsWeControl,
{
disabled?: boolean
+ autoFocus?: boolean
}
>
@@ -327,24 +348,50 @@ function ButtonFn(
}
})
- let slot = useMemo(
- () => ({ open: state.disclosureState === DisclosureStates.Open }),
- [state]
+ let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus: props.autoFocus ?? false })
+ let { isHovered: hover, hoverProps } = useHover({ isDisabled: props.disabled ?? false })
+ let { pressed: active, pressProps } = useActivePress({ disabled: props.disabled ?? false })
+
+ let slot = useMemo(
+ () =>
+ ({
+ open: state.disclosureState === DisclosureStates.Open,
+ hover,
+ active,
+ focus,
+ autofocus: props.autoFocus ?? false,
+ }) satisfies ButtonRenderPropArg,
+ [state, hover, active, focus, props.autoFocus]
)
let type = useResolveButtonType(props, internalButtonRef)
let ourProps = isWithinPanel
- ? { ref: buttonRef, type, onKeyDown: handleKeyDown, onClick: handleClick }
- : {
- ref: buttonRef,
- id,
- type,
- 'aria-expanded': state.disclosureState === DisclosureStates.Open,
- 'aria-controls': state.linkedPanel ? state.panelId : undefined,
- onKeyDown: handleKeyDown,
- onKeyUp: handleKeyUp,
- onClick: handleClick,
- }
+ ? mergeProps(
+ {
+ ref: buttonRef,
+ type,
+ onKeyDown: handleKeyDown,
+ onClick: handleClick,
+ },
+ focusProps,
+ hoverProps,
+ pressProps
+ )
+ : mergeProps(
+ {
+ ref: buttonRef,
+ id,
+ type,
+ 'aria-expanded': state.disclosureState === DisclosureStates.Open,
+ 'aria-controls': state.linkedPanel ? state.panelId : undefined,
+ onKeyDown: handleKeyDown,
+ onKeyUp: handleKeyUp,
+ onClick: handleClick,
+ },
+ focusProps,
+ hoverProps,
+ pressProps
+ )
return render({
mergeRefs,
@@ -359,15 +406,20 @@ function ButtonFn(
// ---
let DEFAULT_PANEL_TAG = 'div' as const
-interface PanelRenderPropArg {
+type PanelRenderPropArg = {
open: boolean
close: (focusableElement?: HTMLElement | MutableRefObject) => void
}
+type DisclosurePanelPropsWeControl = never
-let PanelRenderFeatures = Features.RenderStrategy | Features.Static
+let PanelRenderFeatures = RenderFeatures.RenderStrategy | RenderFeatures.Static
-export type DisclosurePanelProps = Props &
+export type DisclosurePanelProps = Props<
+ TTag,
+ PanelRenderPropArg,
+ DisclosurePanelPropsWeControl,
PropsForFeatures
+>
function PanelFn(
props: DisclosurePanelProps