feat(ui): Dialog + ConfirmDialog (Pinterest chrome), pill buttons; Modal removed
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>feat/client-detail-redesign
parent
66ec742523
commit
2fbb4c6442
@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
|
||||
const WIDTH: Record<'sm' | 'md' | 'lg', number> = { sm: 420, md: 560, lg: 720 }
|
||||
const FOCUSABLE = 'input, select, textarea, button, a[href], [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
/** Pinterest-chrome modal: centered rounded card, scrim, focus trap + restore. */
|
||||
export function Dialog(props: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
footer?: ReactNode
|
||||
children: ReactNode
|
||||
}) {
|
||||
const prevFocus = useRef<HTMLElement | null>(null)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (props.open) {
|
||||
prevFocus.current = document.activeElement as HTMLElement | null
|
||||
const first = cardRef.current?.querySelector<HTMLElement>(
|
||||
'input, select, textarea, button:not(.wf-dialog-x)',
|
||||
)
|
||||
;(first ?? cardRef.current ?? undefined)?.focus()
|
||||
}
|
||||
}, [props.open])
|
||||
useEffect(() => {
|
||||
if (!props.open) prevFocus.current?.focus()
|
||||
}, [props.open])
|
||||
|
||||
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 (
|
||||
<div className="wf-dialog-back" onClick={props.onClose}>
|
||||
<div
|
||||
ref={cardRef}
|
||||
className="wf-dialog"
|
||||
style={{ maxWidth: WIDTH[props.size ?? 'md'] }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={props.title}
|
||||
tabIndex={-1}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={onKey}
|
||||
>
|
||||
<div className="wf-dialog-head">
|
||||
<h2>{props.title}</h2>
|
||||
<button type="button" className="wf-dialog-x" aria-label="Close" onClick={props.onClose}>✕</button>
|
||||
</div>
|
||||
<div className="wf-dialog-body">{props.children}</div>
|
||||
{props.footer !== undefined && <div className="wf-dialog-foot">{props.footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Small confirm variant — kills window.confirm. Busy state while onConfirm resolves. */
|
||||
export function ConfirmDialog(props: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void | Promise<void>
|
||||
title: string
|
||||
body?: ReactNode
|
||||
confirmLabel?: string
|
||||
tone?: 'danger'
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const confirm = () => {
|
||||
const out = props.onConfirm()
|
||||
if (out instanceof Promise) {
|
||||
setBusy(true)
|
||||
out.finally(() => { setBusy(false); props.onClose() })
|
||||
} else {
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onClose={props.onClose}
|
||||
title={props.title}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="wf pill" onClick={props.onClose}>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`wf pill ${props.tone === 'danger' ? 'danger' : 'primary'}`}
|
||||
onClick={confirm}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? '…' : props.confirmLabel ?? 'Confirm'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{props.body !== undefined && <div style={{ fontSize: 13.5, color: 'var(--text-dim)' }}>{props.body}</div>}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue