@ -12,36 +12,45 @@ export function Dialog(props: {
footer? : ReactNode
footer? : ReactNode
children : ReactNode
children : ReactNode
} ) {
} ) {
const prevFocus = useRef < HTMLElement | null > ( null )
const cardRef = useRef < HTMLDivElement > ( null )
const cardRef = useRef < HTMLDivElement > ( null )
const onCloseRef = useRef ( props . onClose )
onCloseRef . current = props . onClose
useEffect ( ( ) = > {
useEffect ( ( ) = > {
if ( props . open ) {
if ( ! props . open ) return
prevFocus . current = document . activeElement as HTMLElement | null
const prev = document . activeElement as HTMLElement | null
const first = cardRef . current ? . querySelector < HTMLElement > (
const nodes = cardRef . current ? . querySelectorAll < HTMLElement > (
'input, select, textarea, button:not(.wf-dialog-x)' ,
'input, select, textarea, button:not(.wf-dialog-x)' ,
)
)
; ( first ? ? cardRef . current ? ? undefined ) ? . focus ( )
const first = nodes === undefined ? undefined : [ . . . nodes ] . find ( ( n ) = > ! n . hasAttribute ( 'disabled' ) )
}
; ( first ? ? cardRef . current ? ? undefined ) ? . focus ( )
// Restore focus on close AND on unmount-while-open ({show && <Dialog/>} pattern).
return ( ) = > prev ? . focus ( )
} , [ props . open ] )
} , [ props . open ] )
// Escape + Tab trap on document, so they work even when focus escapes the card
// (e.g. after a click on a disabled control lands focus on <body>).
useEffect ( ( ) = > {
useEffect ( ( ) = > {
if ( ! props . open ) prevFocus . current ? . focus ( )
if ( ! props . open ) return
const onKey = ( e : KeyboardEvent ) = > {
if ( e . key === 'Escape' ) { e . preventDefault ( ) ; onCloseRef . current ( ) ; return }
if ( e . key !== 'Tab' ) return
const nodes = cardRef . current ? . querySelectorAll < HTMLElement > ( FOCUSABLE )
const list = nodes === undefined ? [ ] : [ . . . nodes ] . filter ( ( n ) = > ! n . hasAttribute ( 'disabled' ) )
const first = list [ 0 ]
const last = list [ list . length - 1 ]
if ( first === undefined || last === undefined ) { e . preventDefault ( ) ; return }
const active = document . activeElement
if ( ! ( cardRef . current ? . contains ( active ) ? ? false ) ) { e . preventDefault ( ) ; first . focus ( ) ; return }
if ( e . shiftKey && active === first ) { e . preventDefault ( ) ; last . focus ( ) }
else if ( ! e . shiftKey && active === last ) { e . preventDefault ( ) ; first . focus ( ) }
}
document . addEventListener ( 'keydown' , onKey )
return ( ) = > document . removeEventListener ( 'keydown' , onKey )
} , [ props . open ] )
} , [ props . open ] )
if ( ! props . open ) return null
if ( ! props . open ) return null
const onKey = ( e : React.KeyboardEvent ) = > {
if ( e . key === 'Escape' ) { e . preventDefault ( ) ; props . onClose ( ) ; return }
if ( e . key !== 'Tab' ) return
const nodes = cardRef . current ? . querySelectorAll < HTMLElement > ( FOCUSABLE )
const list = nodes === undefined ? [ ] : [ . . . nodes ] . filter ( ( n ) = > ! n . hasAttribute ( 'disabled' ) )
const first = list [ 0 ]
const last = list [ list . length - 1 ]
if ( first === undefined || last === undefined ) { e . preventDefault ( ) ; return }
if ( e . shiftKey && document . activeElement === first ) { e . preventDefault ( ) ; last . focus ( ) }
else if ( ! e . shiftKey && document . activeElement === last ) { e . preventDefault ( ) ; first . focus ( ) }
}
return (
return (
< div className = "wf-dialog-back" onClick = { props . onClose } >
< div className = "wf-dialog-back" onClick = { props . onClose } >
< div
< div
@ -53,7 +62,6 @@ export function Dialog(props: {
aria - label = { props . title }
aria - label = { props . title }
tabIndex = { - 1 }
tabIndex = { - 1 }
onClick = { ( e ) = > e . stopPropagation ( ) }
onClick = { ( e ) = > e . stopPropagation ( ) }
onKeyDown = { onKey }
>
>
< div className = "wf-dialog-head" >
< div className = "wf-dialog-head" >
< h2 > { props . title } < / h2 >
< h2 > { props . title } < / h2 >
@ -66,7 +74,9 @@ export function Dialog(props: {
)
)
}
}
/** Small confirm variant — kills window.confirm. Busy state while onConfirm resolves. */
/ * * S m a l l c o n f i r m v a r i a n t — k i l l s w i n d o w . c o n f i r m . B u s y s t a t e w h i l e o n C o n f i r m r e s o l v e s .
* A rejected onConfirm keeps the dialog open and shows the error ; close paths are
* disabled while the action is in flight . * /
export function ConfirmDialog ( props : {
export function ConfirmDialog ( props : {
open : boolean
open : boolean
onClose : ( ) = > void
onClose : ( ) = > void
@ -77,11 +87,21 @@ export function ConfirmDialog(props: {
tone ? : 'danger'
tone ? : 'danger'
} ) {
} ) {
const [ busy , setBusy ] = useState ( false )
const [ busy , setBusy ] = useState ( false )
const [ error , setError ] = useState < string | null > ( null )
useEffect ( ( ) = > { if ( props . open ) setError ( null ) } , [ props . open ] )
const close = ( ) = > { if ( ! busy ) props . onClose ( ) }
const confirm = ( ) = > {
const confirm = ( ) = > {
setError ( null )
const out = props . onConfirm ( )
const out = props . onConfirm ( )
if ( out instanceof Promise ) {
if ( out instanceof Promise ) {
setBusy ( true )
setBusy ( true )
out . finally ( ( ) = > { setBusy ( false ) ; props . onClose ( ) } )
out . then (
( ) = > { setBusy ( false ) ; props . onClose ( ) } ,
( e : unknown ) = > {
setBusy ( false )
setError ( e instanceof Error ? e.message : String ( e ) )
} ,
)
} else {
} else {
props . onClose ( )
props . onClose ( )
}
}
@ -89,12 +109,12 @@ export function ConfirmDialog(props: {
return (
return (
< Dialog
< Dialog
open = { props . open }
open = { props . open }
onClose = { props. onC lose}
onClose = { c lose}
title = { props . title }
title = { props . title }
size = "sm"
size = "sm"
footer = {
footer = {
< >
< >
< button type = "button" className = "wf pill" onClick = { props. onClose } > Cancel < / button >
< button type = "button" className = "wf pill" onClick = { close} disabled = { busy } > Cancel < / button >
< button
< button
type = "button"
type = "button"
className = { ` wf pill ${ props . tone === 'danger' ? 'danger' : 'primary' } ` }
className = { ` wf pill ${ props . tone === 'danger' ? 'danger' : 'primary' } ` }
@ -107,6 +127,9 @@ export function ConfirmDialog(props: {
}
}
>
>
{ props . body !== undefined && < div style = { { fontSize : 13.5 , color : 'var(--text-dim)' } } > { props . body } < / div > }
{ props . body !== undefined && < div style = { { fontSize : 13.5 , color : 'var(--text-dim)' } } > { props . body } < / div > }
{ error !== null && (
< div role = "alert" style = { { marginTop : 8 , fontSize : 13 , color : 'var(--err)' } } > { error } < / div >
) }
< / Dialog >
< / Dialog >
)
)
}
}