@@ -78,23 +78,113 @@ export interface DialogProps
7878 VariantProps < typeof dialogVariants > {
7979 open ?: boolean
8080 onOpenChange ?: ( open : boolean ) => void
81+ /** Whether pressing Escape closes the dialog. Defaults to true; set to
82+ * false for blocking gates (e.g. ChecklistGateDialog) that must not be
83+ * dismissible via Escape. */
84+ closeOnEscape ?: boolean
85+ }
86+
87+ // Elements considered reachable via Tab, used both to seed initial focus and
88+ // to compute the wrap points for the focus trap.
89+ const FOCUSABLE_SELECTOR =
90+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
91+
92+ const DialogTitleContext = React . createContext < string | undefined > ( undefined )
93+
94+ function mergeRefs < T > (
95+ ...refs : Array < React . Ref < T > | undefined >
96+ ) : ( node : T | null ) => void {
97+ return ( node ) => {
98+ refs . forEach ( ( ref ) => {
99+ if ( ! ref ) return
100+ if ( typeof ref === "function" ) ref ( node )
101+ else ( ref as React . MutableRefObject < T | null > ) . current = node
102+ } )
103+ }
81104}
82105
83106const Dialog = React . forwardRef < HTMLDivElement , DialogProps > (
84- ( { className, variant, open, onOpenChange, children, ...props } , ref ) => {
107+ (
108+ { className, variant, open, onOpenChange, closeOnEscape = true , children, ...props } ,
109+ ref
110+ ) => {
111+ const titleId = React . useId ( )
112+ const containerRef = React . useRef < HTMLDivElement | null > ( null )
113+ const previousActiveElementRef = React . useRef < HTMLElement | null > ( null )
114+
115+ // Remember what had focus before the dialog opened, lock body scroll
116+ // while it's open, move focus into the dialog, and restore both on
117+ // close/unmount.
118+ React . useEffect ( ( ) => {
119+ if ( ! open ) return
120+
121+ previousActiveElementRef . current = document . activeElement as HTMLElement | null
122+ const previousOverflow = document . body . style . overflow
123+ document . body . style . overflow = "hidden"
124+
125+ const node = containerRef . current
126+ const focusable = node ?. querySelector < HTMLElement > ( FOCUSABLE_SELECTOR )
127+ ; ( focusable ?? node ) ?. focus ( )
128+
129+ return ( ) => {
130+ document . body . style . overflow = previousOverflow
131+ previousActiveElementRef . current ?. focus ?.( )
132+ }
133+ } , [ open ] )
134+
135+ // Escape-to-close (the one place this is handled) and a basic Tab focus
136+ // trap that keeps focus cycling within the dialog.
137+ React . useEffect ( ( ) => {
138+ if ( ! open ) return
139+
140+ function handleKeyDown ( event : KeyboardEvent ) {
141+ if ( event . key === "Escape" ) {
142+ if ( closeOnEscape ) onOpenChange ?.( false )
143+ return
144+ }
145+
146+ if ( event . key === "Tab" ) {
147+ const node = containerRef . current
148+ if ( ! node ) return
149+ const focusableEls = node . querySelectorAll < HTMLElement > ( FOCUSABLE_SELECTOR )
150+ if ( focusableEls . length === 0 ) {
151+ event . preventDefault ( )
152+ return
153+ }
154+ const first = focusableEls [ 0 ]
155+ const last = focusableEls [ focusableEls . length - 1 ]
156+ if ( event . shiftKey && document . activeElement === first ) {
157+ event . preventDefault ( )
158+ last . focus ( )
159+ } else if ( ! event . shiftKey && document . activeElement === last ) {
160+ event . preventDefault ( )
161+ first . focus ( )
162+ }
163+ }
164+ }
165+
166+ document . addEventListener ( "keydown" , handleKeyDown )
167+ return ( ) => document . removeEventListener ( "keydown" , handleKeyDown )
168+ } , [ open , closeOnEscape , onOpenChange ] )
169+
85170 if ( ! open ) return null
86171
87172 return (
88173 < div
89- ref = { ref }
174+ ref = { mergeRefs ( ref , containerRef ) }
175+ role = "dialog"
176+ aria-modal = "true"
177+ aria-labelledby = { titleId }
90178 className = { cn ( dialogVariants ( { variant, className } ) ) }
91179 { ...props }
92180 >
93181 < div
94182 className = { cn ( overlayVariants ( { variant } ) ) }
95183 onClick = { ( ) => onOpenChange ?.( false ) }
96184 />
97- { children }
185+ < DialogTitleContext . Provider value = { titleId } >
186+ { children }
187+ </ DialogTitleContext . Provider >
98188 </ div >
99189 )
100190 }
@@ -165,13 +255,17 @@ DialogFooter.displayName = "DialogFooter"
165255const DialogTitle = React . forwardRef <
166256 HTMLParagraphElement ,
167257 React . HTMLAttributes < HTMLHeadingElement >
168- > ( ( { className, ...props } , ref ) => (
169- < h2
170- ref = { ref }
171- className = { cn ( "text-lg font-semibold leading-tight tracking-tight" , className ) }
172- { ...props }
173- />
174- ) )
258+ > ( ( { className, id, ...props } , ref ) => {
259+ const contextTitleId = React . useContext ( DialogTitleContext )
260+ return (
261+ < h2
262+ ref = { ref }
263+ id = { id ?? contextTitleId }
264+ className = { cn ( "text-lg font-semibold leading-tight tracking-tight" , className ) }
265+ { ...props }
266+ />
267+ )
268+ } )
175269DialogTitle . displayName = "DialogTitle"
176270
177271const DialogDescription = React . forwardRef <
0 commit comments