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