forked from jordanlambrecht/tracker-tracker
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathDialog.tsx
More file actions
192 lines (177 loc) · 5.53 KB
/
Copy pathDialog.tsx
File metadata and controls
192 lines (177 loc) · 5.53 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
// src/components/ui/Dialog.tsx
"use client"
import { H2 } from "@typography"
import clsx from "clsx"
import { type ReactNode, useCallback, useEffect, useRef } from "react"
import { Button } from "@/components/ui/Button"
import { XIcon } from "@/components/ui/Icons"
import { useAnimatedPresence } from "@/hooks/useAnimatedPresence"
type DialogSize = "sm" | "md" | "lg" | "xl" | "full"
const SIZE_PRESETS: Record<DialogSize, { maxWidth: string; maxHeight: string }> = {
sm: { maxWidth: "max-w-md", maxHeight: "70vh" },
md: { maxWidth: "max-w-2xl", maxHeight: "85vh" },
lg: { maxWidth: "max-w-4xl", maxHeight: "90vh" },
xl: { maxWidth: "max-w-6xl", maxHeight: "90vh" },
full: { maxWidth: "max-w-[95vw]", maxHeight: "95vh" },
}
interface DialogProps {
open: boolean
onClose: () => void
title?: ReactNode
ariaLabel?: string
/** Preset size. Defaults to "md". Overridden by explicit maxWidth/maxHeight. */
size?: DialogSize
/** Tailwind max-width class (i.e "max-w-3xl"). Overrides size preset. */
maxWidth?: string
/** CSS max-height value (i.e "85vh"). Overrides size preset. */
maxHeight?: string
/** Sticky footer content (action buttons, etc.) */
footer?: ReactNode
/** When true, disables escape, backdrop click, and close button */
busy?: boolean
/** If provided, the panel renders as a form with this submit handler */
onSubmit?: () => void
/** Extra attributes spread onto the form element when onSubmit is used */
formProps?: React.HTMLAttributes<HTMLFormElement>
children: ReactNode
}
function Dialog({
open,
onClose,
title,
ariaLabel,
size = "md",
maxWidth,
maxHeight,
footer,
busy,
onSubmit,
formProps,
children,
}: DialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null)
const preset = SIZE_PRESETS[size]
const resolvedMaxWidth = maxWidth ?? preset.maxWidth
const resolvedMaxHeight = maxHeight ?? preset.maxHeight
const {
mounted,
visible,
onTransitionEnd: baseOnTransitionEnd,
} = useAnimatedPresence(open, "opacity")
// Wrap transition end to close native dialog before unmount (restores focus to trigger)
const onTransitionEnd = useCallback(
(e: { propertyName: string }) => {
if (!visible) dialogRef.current?.close()
baseOnTransitionEnd(e)
},
[visible, baseOnTransitionEnd]
)
// Sync native dialog with mount lifecycle
useEffect(() => {
const dialog = dialogRef.current
if (dialog && mounted && !dialog.open) {
dialog.showModal()
}
}, [mounted])
// Body scroll lock — native dialog does not prevent background scrolling
useEffect(() => {
if (!mounted) return
document.body.style.overflow = "hidden"
return () => {
document.body.style.overflow = ""
}
}, [mounted])
const handleClose = useCallback(() => {
if (!busy) onClose()
}, [busy, onClose])
if (!mounted) return null
const titleIsString = typeof title === "string"
const panelCls = clsx(
"relative w-full flex flex-col bg-elevated nm-raised rounded-nm-xl overflow-hidden",
resolvedMaxWidth
)
const panelStyle = {
opacity: visible ? 1 : 0,
transform: visible ? "scale(1)" : "scale(0.95)",
transition: visible
? "opacity 200ms ease-out, transform 200ms ease-out"
: "opacity 150ms ease-in, transform 150ms ease-in",
maxHeight: resolvedMaxHeight,
}
const header =
title !== undefined ? (
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
{titleIsString ? <H2 className="uppercase tracking-wider">{title}</H2> : title}
<Button
variant="ghost"
size="sm"
onClick={handleClose}
disabled={busy}
aria-label="Close dialog"
className="px-2 py-1.5"
>
<XIcon width="16" height="16" />
</Button>
</div>
) : (
<div className="absolute top-3 right-3 z-10">
<Button
variant="ghost"
size="sm"
onClick={handleClose}
disabled={busy}
aria-label="Close dialog"
className="px-2 py-1.5"
>
<XIcon width="16" height="16" />
</Button>
</div>
)
const body = <div className="overflow-y-auto flex-1 p-6">{children}</div>
const footerEl = footer ? (
<div className="shrink-0 px-6 py-4 border-t border-border">{footer}</div>
) : null
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: native dialog onCancel handles keyboard (Escape); onClick is backdrop-only dismiss
<dialog
ref={dialogRef}
data-overlay
data-visible={visible || undefined}
className="fixed inset-0 m-0 p-4 w-screen h-screen bg-transparent flex items-center justify-center outline-none"
aria-modal="true"
aria-label={ariaLabel ?? (titleIsString ? (title as string) : undefined)}
onCancel={(e) => {
e.preventDefault()
handleClose()
}}
onClick={(e) => {
if (e.target === dialogRef.current) handleClose()
}}
>
{onSubmit ? (
<form
{...formProps}
onTransitionEnd={onTransitionEnd}
className={panelCls}
style={panelStyle}
onSubmit={(e) => {
e.preventDefault()
onSubmit()
}}
>
{header}
{body}
{footerEl}
</form>
) : (
<div onTransitionEnd={onTransitionEnd} className={panelCls} style={panelStyle}>
{header}
{body}
{footerEl}
</div>
)}
</dialog>
)
}
export type { DialogProps, DialogSize }
export { Dialog }