@vvedantb/ui 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,318 @@
1
+ import * as React from "react";
2
+ import { IconLoader2 } from "@tabler/icons-react";
3
+ import { confirmFieldLabelClass } from "../../shell/src/app-shell-classes";
4
+ import { cn } from "../utils/cn";
5
+ import {
6
+ AlertDialogAction,
7
+ AlertDialogCancel,
8
+ AlertDialogContent,
9
+ } from "./alert-dialog";
10
+ import { Button, type ButtonProps } from "./button";
11
+ import {
12
+ Dialog,
13
+ DialogContent,
14
+ DialogDescription,
15
+ DialogFooter,
16
+ DialogHeader,
17
+ DialogTitle,
18
+ } from "./dialog";
19
+ import { Input } from "./input";
20
+
21
+ export type ConfirmDialogSize = "default" | "sm" | "md";
22
+
23
+ type ConfirmDialogContextValue = {
24
+ size: ConfirmDialogSize;
25
+ alert: boolean;
26
+ pending: boolean;
27
+ confirmPhrase: string | undefined;
28
+ typed: string;
29
+ setTyped: (value: string) => void;
30
+ phraseMatches: boolean;
31
+ /** Calls the Root's `onOpenChange(false)` unless `pending`. */
32
+ requestClose: () => void;
33
+ };
34
+
35
+ const ConfirmDialogContext = React.createContext<ConfirmDialogContextValue>({
36
+ size: "default",
37
+ alert: true,
38
+ pending: false,
39
+ confirmPhrase: undefined,
40
+ typed: "",
41
+ setTyped: () => {},
42
+ phraseMatches: true,
43
+ requestClose: () => {},
44
+ });
45
+
46
+ /** State of the nearest `ConfirmDialog.Root`, for custom parts. */
47
+ export function useConfirmDialog(): ConfirmDialogContextValue {
48
+ return React.useContext(ConfirmDialogContext);
49
+ }
50
+
51
+ export type ConfirmDialogRootProps = React.ComponentProps<typeof Dialog> & {
52
+ /** Layout slot for the other parts. Default `"default"`. */
53
+ size?: ConfirmDialogSize;
54
+ /** Blocking alert dialog: no outside dismiss, no close button, Cancel focused. Default `size === "default"`. */
55
+ alert?: boolean;
56
+ /**
57
+ * While `true`, close requests (Escape, outside click, close button, Cancel)
58
+ * are swallowed, Cancel is disabled and Action shows its pending state.
59
+ * Needs a controlled `open`.
60
+ */
61
+ pending?: boolean;
62
+ /** Type-to-confirm phrase for `ConfirmDialog.Field`. Action stays disabled until it matches (trimmed, case-insensitive). */
63
+ confirmPhrase?: string;
64
+ };
65
+
66
+ function Root({
67
+ size = "default",
68
+ alert = size === "default",
69
+ pending = false,
70
+ confirmPhrase,
71
+ open,
72
+ onOpenChange,
73
+ children,
74
+ ...props
75
+ }: ConfirmDialogRootProps) {
76
+ const [typed, setTyped] = React.useState("");
77
+ const [prevOpen, setPrevOpen] = React.useState(open);
78
+ if (open !== prevOpen) {
79
+ setPrevOpen(open);
80
+ if (open) setTyped("");
81
+ }
82
+
83
+ const handleOpenChange = (next: boolean) => {
84
+ if (!next && pending) return;
85
+ if (next) setTyped("");
86
+ onOpenChange?.(next);
87
+ };
88
+
89
+ const phraseMatches =
90
+ confirmPhrase === undefined ||
91
+ typed.trim().toLowerCase() === confirmPhrase.trim().toLowerCase();
92
+
93
+ return (
94
+ <ConfirmDialogContext.Provider
95
+ value={{
96
+ size,
97
+ alert,
98
+ pending,
99
+ confirmPhrase,
100
+ typed,
101
+ setTyped,
102
+ phraseMatches,
103
+ requestClose: () => handleOpenChange(false),
104
+ }}
105
+ >
106
+ <Dialog {...props} open={open} onOpenChange={handleOpenChange}>
107
+ {children}
108
+ </Dialog>
109
+ </ConfirmDialogContext.Provider>
110
+ );
111
+ }
112
+ Root.displayName = "ConfirmDialog.Root";
113
+
114
+ export type ConfirmDialogContentProps = React.ComponentProps<typeof DialogContent>;
115
+
116
+ /** Alert or plain dialog content (from Root `alert`); `sm` and `md` cap the width. */
117
+ function Content({ className, ...props }: ConfirmDialogContentProps) {
118
+ const { size, alert } = useConfirmDialog();
119
+ const Comp = alert ? AlertDialogContent : DialogContent;
120
+ return <Comp {...props} className={cn(size !== "default" && "sm:max-w-96", className)} />;
121
+ }
122
+ Content.displayName = "ConfirmDialog.Content";
123
+
124
+ export type ConfirmDialogHeaderProps = React.HTMLAttributes<HTMLDivElement>;
125
+
126
+ function Header({ className, ...props }: ConfirmDialogHeaderProps) {
127
+ const { size } = useConfirmDialog();
128
+ return <DialogHeader {...props} className={cn(size === "md" && "pb-4", className)} />;
129
+ }
130
+ Header.displayName = "ConfirmDialog.Header";
131
+
132
+ export type ConfirmDialogTitleProps = React.ComponentProps<typeof DialogTitle>;
133
+
134
+ function Title({ className, ...props }: ConfirmDialogTitleProps) {
135
+ const { size } = useConfirmDialog();
136
+ return <DialogTitle {...props} className={cn(size === "sm" && "mb-2 text-sm font-medium", className)} />;
137
+ }
138
+ Title.displayName = "ConfirmDialog.Title";
139
+
140
+ export type ConfirmDialogDescriptionProps = React.ComponentProps<typeof DialogDescription>;
141
+
142
+ function Description({ className, ...props }: ConfirmDialogDescriptionProps) {
143
+ const { size } = useConfirmDialog();
144
+ return (
145
+ <DialogDescription
146
+ {...props}
147
+ className={cn(
148
+ size === "sm" && "mb-4 text-sm text-muted-foreground",
149
+ size === "md" && "py-6 text-muted-foreground",
150
+ className,
151
+ )}
152
+ />
153
+ );
154
+ }
155
+ // Matched by `DialogContent` when it checks an alert dialog for a description.
156
+ Description.displayName = "ConfirmDialog.Description";
157
+
158
+ export type ConfirmDialogBodyProps = React.ComponentProps<"div">;
159
+
160
+ /** Plain `<div>` for extra content. No default classes. */
161
+ function Body(props: ConfirmDialogBodyProps) {
162
+ return <div {...props} />;
163
+ }
164
+ Body.displayName = "ConfirmDialog.Body";
165
+
166
+ export type ConfirmDialogFooterProps = React.HTMLAttributes<HTMLDivElement>;
167
+
168
+ function Footer({ className, ...props }: ConfirmDialogFooterProps) {
169
+ const { size } = useConfirmDialog();
170
+ if (size === "sm") return <div {...props} className={cn("flex justify-end gap-2", className)} />;
171
+ return <DialogFooter {...props} className={cn(size === "md" && "pt-4", className)} />;
172
+ }
173
+ Footer.displayName = "ConfirmDialog.Footer";
174
+
175
+ export type ConfirmDialogCancelProps = ButtonProps;
176
+
177
+ /**
178
+ * Alert: a `DialogClose` outline button. Otherwise a ghost button that runs
179
+ * `onClick` and then, unless it called `preventDefault()`, the Root's
180
+ * `onOpenChange(false)`. Disabled while pending.
181
+ */
182
+ function Cancel({ children = "Cancel", variant, size, disabled, onClick, ...props }: ConfirmDialogCancelProps) {
183
+ const dialog = useConfirmDialog();
184
+ if (dialog.alert) {
185
+ return (
186
+ <AlertDialogCancel
187
+ variant={variant}
188
+ size={size}
189
+ {...props}
190
+ onClick={onClick}
191
+ disabled={disabled || dialog.pending}
192
+ >
193
+ {children}
194
+ </AlertDialogCancel>
195
+ );
196
+ }
197
+ return (
198
+ <Button
199
+ variant={variant ?? "ghost"}
200
+ size={size ?? (dialog.size === "sm" ? "sm" : undefined)}
201
+ {...props}
202
+ onClick={(event) => {
203
+ onClick?.(event);
204
+ if (!event.defaultPrevented) dialog.requestClose();
205
+ }}
206
+ disabled={disabled || dialog.pending}
207
+ >
208
+ {children}
209
+ </Button>
210
+ );
211
+ }
212
+ Cancel.displayName = "ConfirmDialog.Cancel";
213
+
214
+ export type ConfirmDialogActionProps = ButtonProps & {
215
+ /** Default from Root `pending`. Disables the button. */
216
+ pending?: boolean;
217
+ /** Shown with a spinner while pending. Without it the children stay. */
218
+ pendingLabel?: React.ReactNode;
219
+ /** Spinner size in px. Default `14`. */
220
+ spinnerSize?: number;
221
+ };
222
+
223
+ /** Confirm button. Does not close the dialog. Disabled while pending or until the confirm phrase matches. */
224
+ function Action({
225
+ pending: pendingProp,
226
+ pendingLabel,
227
+ spinnerSize = 14,
228
+ size,
229
+ disabled,
230
+ children,
231
+ ...props
232
+ }: ConfirmDialogActionProps) {
233
+ const dialog = useConfirmDialog();
234
+ const pending = pendingProp ?? dialog.pending;
235
+ const content =
236
+ pending && pendingLabel !== undefined ? (
237
+ <>
238
+ <IconLoader2 size={spinnerSize} className="animate-spin" />
239
+ {pendingLabel}
240
+ </>
241
+ ) : (
242
+ children
243
+ );
244
+ const isDisabled = disabled || pending || !dialog.phraseMatches;
245
+ if (dialog.alert) {
246
+ return (
247
+ <AlertDialogAction size={size} {...props} disabled={isDisabled}>
248
+ {content}
249
+ </AlertDialogAction>
250
+ );
251
+ }
252
+ return (
253
+ <Button size={size ?? (dialog.size === "sm" ? "sm" : undefined)} {...props} disabled={isDisabled}>
254
+ {content}
255
+ </Button>
256
+ );
257
+ }
258
+ Action.displayName = "ConfirmDialog.Action";
259
+
260
+ export type ConfirmDialogFieldProps = {
261
+ /** Replaces the whole label content. Default "Type <phrase> to confirm". */
262
+ label?: React.ReactNode;
263
+ /** Enter in the input, only while the phrase matches and not pending. */
264
+ onEnter?: () => void;
265
+ className?: string;
266
+ /** Spread onto the `Input`; `id` also sets the label `htmlFor`. */
267
+ inputProps?: Omit<React.ComponentProps<typeof Input>, "value" | "defaultValue" | "onChange">;
268
+ };
269
+
270
+ /** Type-to-confirm input bound to the Root `confirmPhrase`. */
271
+ function Field({ label, onEnter, className, inputProps }: ConfirmDialogFieldProps) {
272
+ const { confirmPhrase = "", typed, setTyped, phraseMatches, pending } = useConfirmDialog();
273
+ const autoId = React.useId();
274
+ const { id = autoId, onKeyDown, ...rest } = inputProps ?? {};
275
+ return (
276
+ <div className={cn("space-y-2", className)}>
277
+ <label htmlFor={id} className={confirmFieldLabelClass}>
278
+ {label ?? (
279
+ <>
280
+ Type{" "}
281
+ <span className="font-mono text-foreground">{confirmPhrase}</span>{" "}
282
+ to confirm
283
+ </>
284
+ )}
285
+ </label>
286
+ <Input
287
+ id={id}
288
+ autoComplete="off"
289
+ autoFocus
290
+ value={typed}
291
+ onChange={(event) => setTyped(event.target.value)}
292
+ disabled={pending}
293
+ placeholder={confirmPhrase}
294
+ {...rest}
295
+ onKeyDown={(event) => {
296
+ onKeyDown?.(event);
297
+ if (event.key !== "Enter" || !phraseMatches || pending) return;
298
+ event.preventDefault();
299
+ onEnter?.();
300
+ }}
301
+ />
302
+ </div>
303
+ );
304
+ }
305
+ Field.displayName = "ConfirmDialog.Field";
306
+
307
+ export const ConfirmDialogParts = {
308
+ Root,
309
+ Content,
310
+ Header,
311
+ Title,
312
+ Description,
313
+ Body,
314
+ Footer,
315
+ Cancel,
316
+ Action,
317
+ Field,
318
+ };
@@ -1,34 +1,39 @@
1
1
  import * as React from "react";
2
- import {
3
- AlertDialog,
4
- AlertDialogAction,
5
- AlertDialogCancel,
6
- AlertDialogContent,
7
- AlertDialogDescription,
8
- AlertDialogFooter,
9
- AlertDialogHeader,
10
- AlertDialogTitle,
11
- } from "./alert-dialog";
12
- import { Button } from "./button";
13
- import {
14
- Dialog,
15
- DialogContent,
16
- DialogDescription,
17
- DialogFooter,
18
- DialogHeader,
19
- DialogTitle,
20
- } from "./dialog";
2
+ import { ConfirmDialogParts as Parts, type ConfirmDialogRootProps } from "./confirm-dialog-parts";
21
3
 
22
- export type ConfirmDialogProps = React.ComponentProps<typeof AlertDialog> & {
4
+ export { useConfirmDialog } from "./confirm-dialog-parts";
5
+ export type {
6
+ ConfirmDialogActionProps,
7
+ ConfirmDialogBodyProps,
8
+ ConfirmDialogCancelProps,
9
+ ConfirmDialogContentProps,
10
+ ConfirmDialogDescriptionProps,
11
+ ConfirmDialogFieldProps,
12
+ ConfirmDialogFooterProps,
13
+ ConfirmDialogHeaderProps,
14
+ ConfirmDialogRootProps,
15
+ ConfirmDialogSize,
16
+ ConfirmDialogTitleProps,
17
+ } from "./confirm-dialog-parts";
18
+
19
+ export type ConfirmDialogProps = Omit<
20
+ ConfirmDialogRootProps,
21
+ "size" | "alert" | "pending" | "confirmPhrase"
22
+ > & {
23
23
  title: React.ReactNode;
24
24
  description?: React.ReactNode;
25
+ /** Legacy (still supported): compose `ConfirmDialog.Cancel` instead. */
25
26
  cancelLabel?: React.ReactNode;
27
+ /** Legacy (still supported): compose `ConfirmDialog.Action` instead. */
26
28
  confirmLabel?: React.ReactNode;
27
29
  onCancel?: () => void;
28
30
  onConfirm?: () => void;
31
+ /** Legacy (still supported): pass `variant` to `ConfirmDialog.Action` instead. */
29
32
  variant?: "default" | "destructive";
30
33
  confirmDisabled?: boolean;
31
34
  /**
35
+ * Legacy (still supported): use `ConfirmDialog.Root size` with the parts.
36
+ *
32
37
  * `"default"`: blocking alert dialog that closes on either button.
33
38
  * `"sm"`: compact dismissible dialog (outside click, Escape and the close
34
39
  * button close it). Confirm does not close it; the caller controls `open`.
@@ -38,7 +43,7 @@ export type ConfirmDialogProps = React.ComponentProps<typeof AlertDialog> & {
38
43
  size?: "default" | "sm" | "md";
39
44
  };
40
45
 
41
- function ConfirmDialog({
46
+ function LegacyConfirmDialog({
42
47
  title,
43
48
  description,
44
49
  cancelLabel = "Cancel",
@@ -51,95 +56,49 @@ function ConfirmDialog({
51
56
  children,
52
57
  ...props
53
58
  }: ConfirmDialogProps) {
54
- if (size === "sm") {
55
- return (
56
- <Dialog {...props}>
57
- {children}
58
- <DialogContent className="sm:max-w-96">
59
- <DialogTitle className="mb-2 text-sm font-medium">{title}</DialogTitle>
60
- {description != null ? (
61
- <DialogDescription className="mb-4 text-sm text-muted-foreground">
62
- {description}
63
- </DialogDescription>
64
- ) : null}
65
- <div className="flex justify-end gap-2">
66
- <Button
67
- variant="ghost"
68
- size="sm"
69
- onClick={() => {
70
- onCancel?.();
71
- props.onOpenChange?.(false);
72
- }}
73
- >
74
- {cancelLabel}
75
- </Button>
76
- <Button
77
- variant={variant}
78
- size="sm"
79
- disabled={confirmDisabled}
80
- onClick={onConfirm}
81
- >
82
- {confirmLabel}
83
- </Button>
84
- </div>
85
- </DialogContent>
86
- </Dialog>
87
- );
88
- }
89
-
90
- if (size === "md") {
91
- return (
92
- <Dialog {...props}>
93
- {children}
94
- <DialogContent className="sm:max-w-96">
95
- <DialogHeader className="pb-4">
96
- <DialogTitle>{title}</DialogTitle>
97
- </DialogHeader>
98
- {description != null ? (
99
- <DialogDescription className="py-6 text-muted-foreground">{description}</DialogDescription>
100
- ) : null}
101
- <DialogFooter className="pt-4">
102
- <Button
103
- variant="ghost"
104
- onClick={() => {
105
- onCancel?.();
106
- props.onOpenChange?.(false);
107
- }}
108
- >
109
- {cancelLabel}
110
- </Button>
111
- <Button variant={variant} disabled={confirmDisabled} onClick={onConfirm}>
112
- {confirmLabel}
113
- </Button>
114
- </DialogFooter>
115
- </DialogContent>
116
- </Dialog>
117
- );
118
- }
119
-
59
+ const titleNode = <Parts.Title>{title}</Parts.Title>;
60
+ const descriptionNode =
61
+ description != null ? <Parts.Description>{description}</Parts.Description> : null;
120
62
  return (
121
- <AlertDialog {...props}>
63
+ <Parts.Root {...props} size={size}>
122
64
  {children}
123
- <AlertDialogContent>
124
- <AlertDialogHeader>
125
- <AlertDialogTitle>{title}</AlertDialogTitle>
126
- {description != null ? (
127
- <AlertDialogDescription>{description}</AlertDialogDescription>
128
- ) : null}
129
- </AlertDialogHeader>
130
- <AlertDialogFooter>
131
- <AlertDialogCancel onClick={onCancel}>{cancelLabel}</AlertDialogCancel>
132
- <AlertDialogAction
133
- variant={variant}
134
- disabled={confirmDisabled}
135
- onClick={onConfirm}
136
- >
65
+ <Parts.Content>
66
+ {size === "sm" ? (
67
+ <>
68
+ {titleNode}
69
+ {descriptionNode}
70
+ </>
71
+ ) : size === "md" ? (
72
+ <>
73
+ <Parts.Header>{titleNode}</Parts.Header>
74
+ {descriptionNode}
75
+ </>
76
+ ) : (
77
+ <Parts.Header>
78
+ {titleNode}
79
+ {descriptionNode}
80
+ </Parts.Header>
81
+ )}
82
+ <Parts.Footer>
83
+ {/* The alert cancel passes the click event on; sm and md call onCancel bare. */}
84
+ <Parts.Cancel onClick={size === "default" ? onCancel : () => onCancel?.()}>
85
+ {cancelLabel}
86
+ </Parts.Cancel>
87
+ <Parts.Action variant={variant} disabled={confirmDisabled} onClick={onConfirm}>
137
88
  {confirmLabel}
138
- </AlertDialogAction>
139
- </AlertDialogFooter>
140
- </AlertDialogContent>
141
- </AlertDialog>
89
+ </Parts.Action>
90
+ </Parts.Footer>
91
+ </Parts.Content>
92
+ </Parts.Root>
142
93
  );
143
94
  }
95
+ LegacyConfirmDialog.displayName = "ConfirmDialog";
96
+
97
+ /**
98
+ * Confirm dialog. Call it with props (the legacy prop-driven API), or compose
99
+ * the parts: `ConfirmDialog.Root`, `.Content`, `.Header`, `.Title`,
100
+ * `.Description`, `.Body`, `.Footer`, `.Cancel`, `.Action` and `.Field`.
101
+ */
102
+ const ConfirmDialog = Object.assign(LegacyConfirmDialog, Parts);
144
103
 
145
104
  export { ConfirmDialog };
@@ -143,11 +143,13 @@ export function treeHasDialogDescription(
143
143
  if (!React.isValidElement(child)) return false;
144
144
  const type = child.type;
145
145
  if (type === descriptionType) return true;
146
+ // Wrappers named like `ConfirmDialog.Description` count too.
146
147
  const displayName =
147
- typeof type === "object" && type !== null
148
- ? (type as { displayName?: string }).displayName
148
+ (typeof type === "function" || (typeof type === "object" && type !== null)) &&
149
+ "displayName" in type
150
+ ? type.displayName
149
151
  : undefined;
150
- if (displayName === "DialogDescription") return true;
152
+ if (typeof displayName === "string" && /Dialog\.?Description$/.test(displayName)) return true;
151
153
  const props = child.props as { children?: React.ReactNode };
152
154
  return treeHasDialogDescription(props.children, descriptionType);
153
155
  });