@vvedantb/ui 0.29.1 → 0.30.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/ui",
3
- "version": "0.29.1",
3
+ "version": "0.30.0",
4
4
  "license": "MIT",
5
5
  "description": "Shared React 19 UI for web apps: one main JavaScript entry (primitives, shell pieces, app helpers including the Convex and Clerk provider, VITE env parsing and ensure-user, artifact viewers over the @vvedantb/server artifacts core, native tokens), a client entry without @vvedantb/server, convex or Clerk for Chrome extensions, design-token CSS, shared Tailwind v4 CSS, a Tailwind preset and packaged Instrument Sans fonts.",
6
6
  "type": "module",
@@ -72,17 +72,6 @@
72
72
  "./src/marketing/landing.css",
73
73
  "./fonts/instrument-sans.css"
74
74
  ],
75
- "scripts": {
76
- "check:tokens": "node tokens/scripts/check-component-tokens.mjs",
77
- "build": "node tokens/scripts/check-exports.mjs && node tokens/scripts/check-component-tokens.mjs && tsc -p tsconfig.json",
78
- "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.consumer.json && tsc -p tsconfig.consumer-speech.json && tsc -p tokens/tsconfig.json",
79
- "test": "node tokens/scripts/check-component-tokens.mjs && vitest run && node --test tokens/test/*.test.mjs app/tests-node/*.test.mjs",
80
- "verify": "node tokens/scripts/check-component-tokens.mjs && node tokens/verify/run.mjs",
81
- "analyze": "node tokens/verify/analyze.mjs",
82
- "brand:assets": "node scripts/build-brand-assets.mjs",
83
- "brand:sheet": "node scripts/build-brand-sheet.mjs",
84
- "prepack": "node tokens/scripts/check-exports.mjs"
85
- },
86
75
  "dependencies": {
87
76
  "@dnd-kit/core": "^6.3.1",
88
77
  "@dnd-kit/modifiers": "^9.0.0",
@@ -172,7 +161,6 @@
172
161
  "@tanstack/react-hotkeys": "^0.13.0",
173
162
  "@types/react": "^19.1.0",
174
163
  "@types/react-dom": "^19.1.0",
175
- "@vvedantb/server": "workspace:*",
176
164
  "ai": "^5.0.0",
177
165
  "class-variance-authority": "^0.7.1",
178
166
  "clsx": "^2.1.1",
@@ -196,7 +184,8 @@
196
184
  "vaul": "^1.1.0",
197
185
  "vitest": "^3.2.0",
198
186
  "zod": "^4.0.0",
199
- "zod3": "npm:zod@^3.25.76"
187
+ "zod3": "npm:zod@^3.25.76",
188
+ "@vvedantb/server": "0.10.0"
200
189
  },
201
190
  "peerDependenciesMeta": {
202
191
  "@clerk/clerk-react": {
@@ -217,5 +206,15 @@
217
206
  "@vvedantb/server": {
218
207
  "optional": true
219
208
  }
209
+ },
210
+ "scripts": {
211
+ "check:tokens": "node tokens/scripts/check-component-tokens.mjs",
212
+ "build": "node tokens/scripts/check-exports.mjs && node tokens/scripts/check-component-tokens.mjs && tsc -p tsconfig.json",
213
+ "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.consumer.json && tsc -p tsconfig.consumer-speech.json && tsc -p tokens/tsconfig.json",
214
+ "test": "node tokens/scripts/check-component-tokens.mjs && vitest run && node --test tokens/test/*.test.mjs app/tests-node/*.test.mjs",
215
+ "verify": "node tokens/scripts/check-component-tokens.mjs && node tokens/verify/run.mjs",
216
+ "analyze": "node tokens/verify/analyze.mjs",
217
+ "brand:assets": "node scripts/build-brand-assets.mjs",
218
+ "brand:sheet": "node scripts/build-brand-sheet.mjs"
220
219
  }
221
- }
220
+ }
@@ -63,6 +63,7 @@ import {
63
63
  type AgentTraceStatus,
64
64
  } from "./trace";
65
65
  import { useStreamingDisclosure } from "./use-streaming-disclosure";
66
+ import { RollingNumber } from "../ui/rolling-number";
66
67
 
67
68
  const streamdownPlugins = { cjk, code, math, mermaid };
68
69
 
@@ -94,15 +95,24 @@ const ActivityStreamingHeader = memo(function ActivityStreamingHeader({
94
95
  // Reply text is not a status: label the header from the work steps only.
95
96
  const work = steps.filter((s) => s.type !== "response");
96
97
  const activeStep = work.find((s) => s.status === "active") ?? work[0];
97
- const headerText = `${
98
- activeStep?.label ?? `${name ?? defaultName} is ${verb.toLowerCase()}...`
99
- }${startedAt ? ` (${formatActivityElapsed(elapsed)})` : ""}`;
98
+ const headerText =
99
+ activeStep?.label ?? `${name ?? defaultName} is ${verb.toLowerCase()}...`;
100
100
  return (
101
101
  <div className="flex items-center gap-2 text-muted-foreground text-sm">
102
102
  <Spinner size="sm" />
103
- <Shimmer as="span" duration={2.5} spread={1.5}>
104
- {headerText}
105
- </Shimmer>
103
+ <span>
104
+ <Shimmer as="span" duration={2.5} spread={1.5}>
105
+ {headerText}
106
+ </Shimmer>
107
+ {/* Outside the shimmer: it takes a string, and the clock rolls. */}
108
+ {startedAt ? (
109
+ <>
110
+ {" ("}
111
+ <RollingNumber value={formatActivityElapsed(elapsed)} />
112
+ {")"}
113
+ </>
114
+ ) : null}
115
+ </span>
106
116
  </div>
107
117
  );
108
118
  });
@@ -11,6 +11,9 @@ import {
11
11
  } from "../ui/hover-card";
12
12
  import { Progress } from "../ui/progress";
13
13
  import { useCn } from "../ui/ui-config";
14
+ import { m } from "motion/react";
15
+ import { springTransition } from "../motion/presets";
16
+ import { RollingNumber } from "../ui/rolling-number";
14
17
 
15
18
  const PERCENT_MAX = 100;
16
19
  const ICON_RADIUS = 10;
@@ -86,7 +89,7 @@ const ContextIcon = () => {
86
89
  const { usedTokens, maxTokens } = useContextValue();
87
90
  const circumference = 2 * Math.PI * ICON_RADIUS;
88
91
  const usedPercent = maxTokens > 0 ? usedTokens / maxTokens : 0;
89
- const dashOffset = circumference * (1 - usedPercent);
92
+ const dashOffset = circumference * (1 - Math.min(usedPercent, 1));
90
93
 
91
94
  return (
92
95
  <svg
@@ -106,7 +109,7 @@ const ContextIcon = () => {
106
109
  stroke="currentColor"
107
110
  strokeWidth={ICON_STROKE_WIDTH}
108
111
  />
109
- <circle
112
+ <m.circle
110
113
  cx={ICON_CENTER}
111
114
  cy={ICON_CENTER}
112
115
  fill="none"
@@ -114,7 +117,9 @@ const ContextIcon = () => {
114
117
  r={ICON_RADIUS}
115
118
  stroke="currentColor"
116
119
  strokeDasharray={`${circumference} ${circumference}`}
117
- strokeDashoffset={dashOffset}
120
+ initial={false}
121
+ animate={{ strokeDashoffset: dashOffset }}
122
+ transition={springTransition}
118
123
  strokeLinecap="round"
119
124
  strokeWidth={ICON_STROKE_WIDTH}
120
125
  style={{ transform: "rotate(-90deg)", transformOrigin: "center" }}
@@ -137,9 +142,10 @@ export const ContextUsageTrigger = ({ children, ...props }: ContextUsageTriggerP
137
142
  <HoverCardTrigger asChild>
138
143
  {children ?? (
139
144
  <Button type="button" variant="ghost" size="sm" {...props}>
140
- <span className="font-medium text-muted-foreground text-xs tabular-nums">
141
- {renderedPercent}
142
- </span>
145
+ <RollingNumber
146
+ value={renderedPercent}
147
+ className="font-medium text-muted-foreground text-xs"
148
+ />
143
149
  <ContextIcon />
144
150
  </Button>
145
151
  )}
@@ -188,9 +194,9 @@ export const ContextUsageContentHeader = ({
188
194
  {children ?? (
189
195
  <>
190
196
  <div className="flex items-center justify-between gap-3 text-xs tabular-nums">
191
- <p>{displayPct}</p>
197
+ <RollingNumber value={displayPct} />
192
198
  <p className="font-mono text-muted-foreground">
193
- {used} / {total}
199
+ <RollingNumber value={used} /> / {total}
194
200
  </p>
195
201
  </div>
196
202
  <div className="space-y-2">
@@ -251,9 +257,10 @@ export const ContextUsageContentFooter = ({
251
257
  {children ?? (
252
258
  <>
253
259
  <span className="text-muted-foreground">Total cost</span>
254
- <span className="font-mono tabular-nums">
255
- {totalCost === undefined ? "—" : formatUSD(totalCost)}
256
- </span>
260
+ <RollingNumber
261
+ value={totalCost === undefined ? "—" : formatUSD(totalCost)}
262
+ className="font-mono"
263
+ />
257
264
  </>
258
265
  )}
259
266
  </div>
@@ -279,12 +286,13 @@ const UsageRow = ({
279
286
  {...rest}
280
287
  >
281
288
  <span className="text-muted-foreground">{label}</span>
282
- <span className="tabular-nums">
283
- {formatTokens(tokens)}
289
+ <span className="inline-flex items-baseline">
290
+ <RollingNumber value={formatTokens(tokens)} />
284
291
  {costUSD !== undefined ? (
285
- <span className="ml-2 font-mono text-muted-foreground">
286
- {formatUSD(costUSD)}
287
- </span>
292
+ <RollingNumber
293
+ value={formatUSD(costUSD)}
294
+ className="ml-2 font-mono text-muted-foreground"
295
+ />
288
296
  ) : null}
289
297
  </span>
290
298
  </div>
@@ -48,6 +48,7 @@ import {
48
48
  SelectValue,
49
49
  } from "../ui/radix-select";
50
50
  import { CrossfadeIconSlot } from "../ui/crossfade-icon";
51
+ import { SendQueueIcon } from "../ui/send-queue-icon";
51
52
  import { Spinner } from "../ui/spinner";
52
53
  import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
53
54
  import { useCn, useUiConfig } from "../ui/ui-config";
@@ -929,6 +930,12 @@ export const PromptInputActionMenuItem = ({
929
930
  export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
930
931
  status?: ChatStatus;
931
932
  onStop?: () => void;
933
+ /**
934
+ * Set when a send would join a queue (the agent is still working). The send
935
+ * icon becomes `SendQueueIcon`: an up arrow that morphs into a stack of
936
+ * bars while `true`. Leave unset to keep `submitIcons.send`.
937
+ */
938
+ queued?: boolean;
932
939
  };
933
940
  export const PromptInputSubmit = ({
934
941
  className,
@@ -936,6 +943,7 @@ export const PromptInputSubmit = ({
936
943
  size = "icon-sm",
937
944
  status,
938
945
  onStop,
946
+ queued,
939
947
  onClick,
940
948
  children,
941
949
  ...props
@@ -948,7 +956,12 @@ export const PromptInputSubmit = ({
948
956
  status === "submitted" || status === "streaming" || status === "error"
949
957
  ? status
950
958
  : "send";
951
- let Icon = icons?.send ?? <IconCornerDownLeft className="size-4" />;
959
+ let Icon =
960
+ queued === undefined ? (
961
+ (icons?.send ?? <IconCornerDownLeft className="size-4" />)
962
+ ) : (
963
+ <SendQueueIcon queued={queued} />
964
+ );
952
965
  if (status === "submitted") {
953
966
  Icon = icons?.submitted ?? <Spinner size="sm" />;
954
967
  } else if (status === "streaming") {
package/src/client.ts CHANGED
@@ -34,6 +34,9 @@ export * from "./hooks";
34
34
  export { ErrorBoundary } from "./ui/error-boundary";
35
35
  export type { ErrorBoundaryProps } from "./ui/error-boundary";
36
36
  export { CountPop, countLabel, UnreadDot } from "./ui/count-pop";
37
+ export { RollingNumber } from "./ui/rolling-number";
38
+ export { ConfettiBurst } from "./ui/confetti-burst";
39
+ export { SendQueueIcon } from "./ui/send-queue-icon";
37
40
  export { MarqueeOnHover } from "./ui/marquee-on-hover";
38
41
  export { RelativeDateTime } from "./ui/relative-date-time";
39
42
  export type { RelativeDateTimeProps } from "./ui/relative-date-time";
package/src/index.ts CHANGED
@@ -30,6 +30,9 @@ export * from "./hooks";
30
30
  export { ErrorBoundary } from "./ui/error-boundary";
31
31
  export type { ErrorBoundaryProps } from "./ui/error-boundary";
32
32
  export { CountPop, countLabel, UnreadDot } from "./ui/count-pop";
33
+ export { RollingNumber } from "./ui/rolling-number";
34
+ export { ConfettiBurst } from "./ui/confetti-burst";
35
+ export { SendQueueIcon } from "./ui/send-queue-icon";
33
36
  export { MarqueeOnHover } from "./ui/marquee-on-hover";
34
37
  export { RelativeDateTime } from "./ui/relative-date-time";
35
38
  export type { RelativeDateTimeProps } from "./ui/relative-date-time";
@@ -23,6 +23,7 @@ import { copyMarkdownSelection } from "./markdownClipboard";
23
23
  import { rehypeGithubAlerts } from "./rehypeGithubAlerts";
24
24
  // The math plugin renders KaTeX markup but ships no stylesheet with it.
25
25
  import "katex/dist/katex.min.css";
26
+ import { useSmoothText } from "./useSmoothText";
26
27
 
27
28
  const PLUGINS = { cjk, code, math, mermaid: mermaidPlugin };
28
29
 
@@ -69,6 +70,11 @@ export type MarkdownProps = Omit<
69
70
  "plugins" | "rehypePlugins" | "parseMarkdownIntoBlocksFn"
70
71
  > & {
71
72
  children: string;
73
+ /**
74
+ * With `mode="streaming"`, ease text that arrives in bursts onto the screen
75
+ * (`useSmoothText`). Default `true`; `false` shows each chunk as it lands.
76
+ */
77
+ smooth?: boolean;
72
78
  };
73
79
 
74
80
  /**
@@ -84,9 +90,11 @@ export const Markdown = memo(function Markdown({
84
90
  components,
85
91
  remarkPlugins,
86
92
  children,
93
+ smooth = true,
87
94
  ...props
88
95
  }: MarkdownProps) {
89
96
  const merge = useCn();
97
+ const text = useSmoothText(children, smooth && props.mode === "streaming");
90
98
  return (
91
99
  <div className="contents" onCopy={copyMarkdownSelection}>
92
100
  <Streamdown
@@ -105,7 +113,7 @@ export const Markdown = memo(function Markdown({
105
113
  parseMarkdownIntoBlocksFn={parseMarkdownIntoBlocksIncremental}
106
114
  {...props}
107
115
  >
108
- {children}
116
+ {text}
109
117
  </Streamdown>
110
118
  </div>
111
119
  );
@@ -1,3 +1,4 @@
1
1
  export { Markdown } from "./Markdown";
2
2
  export type { MarkdownProps } from "./Markdown";
3
3
  export { MarkdownLink } from "./MarkdownLink";
4
+ export { useSmoothText } from "./useSmoothText";
@@ -0,0 +1,36 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+
3
+ /**
4
+ * Share of the unshown text revealed on each frame. A burst of 400 characters
5
+ * plays out over about half a second; a trickle shows as it arrives.
6
+ */
7
+ const REVEAL_PER_FRAME = 0.08;
8
+
9
+ /**
10
+ * Eases `target` onto the screen while `live` is true, so text that arrives in
11
+ * bursts reads as a steady stream. Text already present on mount shows at
12
+ * once, so a reload or a settled message never replays. When `live` turns
13
+ * false the full text shows at once.
14
+ */
15
+ export function useSmoothText(target: string, live: boolean): string {
16
+ const [length, setLength] = useState(target.length);
17
+ const lengthRef = useRef(target.length);
18
+
19
+ useEffect(() => {
20
+ if (!live) return;
21
+ // A rewrite can shorten the text; never point past its end.
22
+ lengthRef.current = Math.min(lengthRef.current, target.length);
23
+ let frame = 0;
24
+ function tick() {
25
+ const remaining = target.length - lengthRef.current;
26
+ if (remaining <= 0) return;
27
+ lengthRef.current += Math.max(1, Math.ceil(remaining * REVEAL_PER_FRAME));
28
+ setLength(lengthRef.current);
29
+ frame = requestAnimationFrame(tick);
30
+ }
31
+ frame = requestAnimationFrame(tick);
32
+ return () => cancelAnimationFrame(frame);
33
+ }, [target, live]);
34
+
35
+ return live ? target.slice(0, length) : target;
36
+ }
@@ -0,0 +1,131 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { createPortal } from "react-dom";
3
+
4
+ const PIECE_COUNT = 90;
5
+ const DURATION_MS = 1800;
6
+ /** The last share of the burst fades out instead of cutting. */
7
+ const FADE_FROM = 0.65;
8
+ const GRAVITY = 0.32;
9
+ const DRAG = 0.985;
10
+ /** Theme chart colours (RGB triplets), so the burst follows the accent. */
11
+ const DEFAULT_COLOR_VARS = ["--chart-1", "--chart-2", "--chart-3", "--chart-4", "--chart-5"];
12
+ const FALLBACK_COLOR = "rgb(120 120 120)";
13
+
14
+ interface Piece {
15
+ x: number;
16
+ y: number;
17
+ vx: number;
18
+ vy: number;
19
+ angle: number;
20
+ spin: number;
21
+ flutter: number;
22
+ width: number;
23
+ height: number;
24
+ color: string;
25
+ }
26
+
27
+ function resolveColors(colors: readonly string[] | undefined): string[] {
28
+ if (colors && colors.length > 0) return [...colors];
29
+ const style = getComputedStyle(document.documentElement);
30
+ const fromTheme = DEFAULT_COLOR_VARS.map((name) => style.getPropertyValue(name).trim())
31
+ .filter((value) => value.length > 0)
32
+ .map((value) => `rgb(${value})`);
33
+ return fromTheme.length > 0 ? fromTheme : [FALLBACK_COLOR];
34
+ }
35
+
36
+ function spawnPieces(originX: number, originY: number, colors: string[]): Piece[] {
37
+ return Array.from({ length: PIECE_COUNT }, (_, index) => {
38
+ // A fan pointing down and out of the anchor, wider than it is deep.
39
+ const angle = Math.PI / 2 + (Math.random() - 0.5) * Math.PI * 0.9;
40
+ const speed = 6 + Math.random() * 9;
41
+ return {
42
+ x: originX,
43
+ y: originY,
44
+ vx: Math.cos(angle) * speed,
45
+ vy: Math.sin(angle) * speed * 0.6 - 4,
46
+ angle: Math.random() * Math.PI * 2,
47
+ spin: (Math.random() - 0.5) * 0.4,
48
+ flutter: Math.random() * Math.PI * 2,
49
+ width: 5 + Math.random() * 5,
50
+ height: 8 + Math.random() * 6,
51
+ color: colors[index % colors.length] ?? FALLBACK_COLOR,
52
+ };
53
+ });
54
+ }
55
+
56
+ /**
57
+ * One confetti burst from the bottom centre of `anchor`, drawn on a
58
+ * full-window canvas that ignores the pointer. Calls `onDone` when the last
59
+ * piece has faded, so the caller can unmount it.
60
+ *
61
+ * Keep it for rare moments (a merge, a first success): delight should grow as
62
+ * frequency falls. Key it by a counter to replay. `colors` defaults to the
63
+ * theme's `--chart-1` … `--chart-5`.
64
+ */
65
+ export function ConfettiBurst({
66
+ anchor,
67
+ onDone,
68
+ colors,
69
+ }: {
70
+ anchor: HTMLElement;
71
+ onDone: () => void;
72
+ colors?: readonly string[];
73
+ }) {
74
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
75
+
76
+ useEffect(() => {
77
+ const canvas = canvasRef.current;
78
+ const context = canvas?.getContext("2d");
79
+ if (!canvas || !context) return;
80
+ const ratio = window.devicePixelRatio || 1;
81
+ canvas.width = window.innerWidth * ratio;
82
+ canvas.height = window.innerHeight * ratio;
83
+ context.scale(ratio, ratio);
84
+
85
+ const rect = anchor.getBoundingClientRect();
86
+ const pieces = spawnPieces(rect.left + rect.width / 2, rect.bottom, resolveColors(colors));
87
+ const start = performance.now();
88
+ let frame = 0;
89
+
90
+ function draw(now: number) {
91
+ if (!context) return;
92
+ const progress = (now - start) / DURATION_MS;
93
+ if (progress >= 1) {
94
+ onDone();
95
+ return;
96
+ }
97
+ context.clearRect(0, 0, window.innerWidth, window.innerHeight);
98
+ context.globalAlpha = progress < FADE_FROM ? 1 : 1 - (progress - FADE_FROM) / (1 - FADE_FROM);
99
+ for (const piece of pieces) {
100
+ piece.vx *= DRAG;
101
+ piece.vy = piece.vy * DRAG + GRAVITY;
102
+ piece.x += piece.vx;
103
+ piece.y += piece.vy;
104
+ piece.angle += piece.spin;
105
+ piece.flutter += 0.2;
106
+ context.save();
107
+ context.translate(piece.x, piece.y);
108
+ context.rotate(piece.angle);
109
+ // Squash on one axis so each piece seems to turn over as it falls.
110
+ context.scale(1, Math.cos(piece.flutter));
111
+ context.fillStyle = piece.color;
112
+ context.fillRect(-piece.width / 2, -piece.height / 2, piece.width, piece.height);
113
+ context.restore();
114
+ }
115
+ frame = requestAnimationFrame(draw);
116
+ }
117
+
118
+ frame = requestAnimationFrame(draw);
119
+ return () => cancelAnimationFrame(frame);
120
+ }, [anchor, onDone, colors]);
121
+
122
+ return createPortal(
123
+ <canvas
124
+ ref={canvasRef}
125
+ aria-hidden
126
+ data-testid="confetti-burst"
127
+ className="pointer-events-none fixed inset-0 z-50 size-full"
128
+ />,
129
+ document.body,
130
+ );
131
+ }
@@ -17,9 +17,25 @@ import {
17
17
  DialogTitle,
18
18
  } from "./dialog";
19
19
  import { Input } from "./input";
20
+ import { useUiConfig } from "./ui-config";
20
21
 
21
22
  export type ConfirmDialogSize = "default" | "sm" | "md";
22
23
 
24
+ /**
25
+ * App-wide classes for the confirm dialog parts, merged after each part's
26
+ * size classes (so an app can tighten `md`'s padding or widen its content).
27
+ */
28
+ export interface ConfirmDialogConfig {
29
+ classes?: {
30
+ content?: string;
31
+ header?: string;
32
+ title?: string;
33
+ description?: string;
34
+ body?: string;
35
+ footer?: string;
36
+ };
37
+ }
38
+
23
39
  type ConfirmDialogContextValue = {
24
40
  size: ConfirmDialogSize;
25
41
  alert: boolean;
@@ -133,8 +149,9 @@ export type ConfirmDialogContentProps = React.ComponentProps<typeof DialogConten
133
149
  /** Alert or plain dialog content (from Root `alert`); `sm` and `md` cap the width. */
134
150
  function Content({ className, ...props }: ConfirmDialogContentProps) {
135
151
  const { size, alert } = useConfirmDialog();
152
+ const extra = useUiConfig("ConfirmDialog")?.classes?.content;
136
153
  const Comp = alert ? AlertDialogContent : DialogContent;
137
- return <Comp {...props} className={cn(size !== "default" && "sm:max-w-96", className)} />;
154
+ return <Comp {...props} className={cn(size !== "default" && "sm:max-w-96", extra, className)} />;
138
155
  }
139
156
  Content.displayName = "ConfirmDialog.Content";
140
157
 
@@ -142,7 +159,8 @@ export type ConfirmDialogHeaderProps = React.HTMLAttributes<HTMLDivElement>;
142
159
 
143
160
  function Header({ className, ...props }: ConfirmDialogHeaderProps) {
144
161
  const { size } = useConfirmDialog();
145
- return <DialogHeader {...props} className={cn(size === "md" && "pb-4", className)} />;
162
+ const extra = useUiConfig("ConfirmDialog")?.classes?.header;
163
+ return <DialogHeader {...props} className={cn(size === "md" && "pb-4", extra, className)} />;
146
164
  }
147
165
  Header.displayName = "ConfirmDialog.Header";
148
166
 
@@ -150,7 +168,8 @@ export type ConfirmDialogTitleProps = React.ComponentProps<typeof DialogTitle>;
150
168
 
151
169
  function Title({ className, ...props }: ConfirmDialogTitleProps) {
152
170
  const { size } = useConfirmDialog();
153
- return <DialogTitle {...props} className={cn(size === "sm" && "mb-2 text-sm font-medium", className)} />;
171
+ const extra = useUiConfig("ConfirmDialog")?.classes?.title;
172
+ return <DialogTitle {...props} className={cn(size === "sm" && "mb-2 text-sm font-medium", extra, className)} />;
154
173
  }
155
174
  Title.displayName = "ConfirmDialog.Title";
156
175
 
@@ -158,12 +177,14 @@ export type ConfirmDialogDescriptionProps = React.ComponentProps<typeof DialogDe
158
177
 
159
178
  function Description({ className, ...props }: ConfirmDialogDescriptionProps) {
160
179
  const { size } = useConfirmDialog();
180
+ const extra = useUiConfig("ConfirmDialog")?.classes?.description;
161
181
  return (
162
182
  <DialogDescription
163
183
  {...props}
164
184
  className={cn(
165
185
  size === "sm" && "mb-4 text-sm text-muted-foreground",
166
186
  size === "md" && "py-6 text-muted-foreground",
187
+ extra,
167
188
  className,
168
189
  )}
169
190
  />
@@ -174,9 +195,10 @@ Description.displayName = "ConfirmDialog.Description";
174
195
 
175
196
  export type ConfirmDialogBodyProps = React.ComponentProps<"div">;
176
197
 
177
- /** Plain `<div>` for extra content. No default classes. */
178
- function Body(props: ConfirmDialogBodyProps) {
179
- return <div {...props} />;
198
+ /** Plain `<div>` for extra content. No default classes (`UiConfig.ConfirmDialog.classes.body` if set). */
199
+ function Body({ className, ...props }: ConfirmDialogBodyProps) {
200
+ const extra = useUiConfig("ConfirmDialog")?.classes?.body;
201
+ return <div {...props} className={cn(extra, className) || undefined} />;
180
202
  }
181
203
  Body.displayName = "ConfirmDialog.Body";
182
204
 
@@ -194,11 +216,12 @@ export type ConfirmDialogFooterProps = React.HTMLAttributes<HTMLDivElement> & {
194
216
 
195
217
  function Footer({ row = false, className, ...props }: ConfirmDialogFooterProps) {
196
218
  const { size } = useConfirmDialog();
197
- if (size === "sm") return <div {...props} className={cn("flex justify-end gap-2", className)} />;
219
+ const extra = useUiConfig("ConfirmDialog")?.classes?.footer;
220
+ if (size === "sm") return <div {...props} className={cn("flex justify-end gap-2", extra, className)} />;
198
221
  return (
199
222
  <DialogFooter
200
223
  {...props}
201
- className={cn(size === "md" && "pt-4", row && "justify-end flex-row!", className)}
224
+ className={cn(size === "md" && "pt-4", row && "justify-end flex-row!", extra, className)}
202
225
  />
203
226
  );
204
227
  }
@@ -6,6 +6,7 @@ export type {
6
6
  ConfirmDialogActionProps,
7
7
  ConfirmDialogBodyProps,
8
8
  ConfirmDialogCancelProps,
9
+ ConfirmDialogConfig,
9
10
  ConfirmDialogContentProps,
10
11
  ConfirmDialogDescriptionProps,
11
12
  ConfirmDialogFieldProps,
@@ -20,6 +21,13 @@ export type ConfirmDialogProps = Omit<
20
21
  ConfirmDialogRootProps,
21
22
  "size" | "alert" | "pending" | "confirmPhrase"
22
23
  > & {
24
+ /**
25
+ * While `true`, the confirm button shows a spinner beside `confirmLabel`,
26
+ * Cancel is disabled and close requests wait (see `ConfirmDialog.Root`).
27
+ */
28
+ pending?: boolean;
29
+ /** Extra content after the description, inside the dialog. `children` stays outside (for a trigger). */
30
+ body?: React.ReactNode;
23
31
  title: React.ReactNode;
24
32
  description?: React.ReactNode;
25
33
  /** Legacy (still supported): compose `ConfirmDialog.Cancel` instead. */
@@ -53,6 +61,8 @@ function LegacyConfirmDialog({
53
61
  variant = "default",
54
62
  confirmDisabled,
55
63
  size = "default",
64
+ pending,
65
+ body,
56
66
  children,
57
67
  ...props
58
68
  }: ConfirmDialogProps) {
@@ -60,7 +70,7 @@ function LegacyConfirmDialog({
60
70
  const descriptionNode =
61
71
  description != null ? <Parts.Description>{description}</Parts.Description> : null;
62
72
  return (
63
- <Parts.Root {...props} size={size}>
73
+ <Parts.Root {...props} size={size} pending={pending}>
64
74
  {children}
65
75
  <Parts.Content>
66
76
  {size === "sm" ? (
@@ -79,12 +89,18 @@ function LegacyConfirmDialog({
79
89
  {descriptionNode}
80
90
  </Parts.Header>
81
91
  )}
92
+ {body != null ? <Parts.Body>{body}</Parts.Body> : null}
82
93
  <Parts.Footer>
83
94
  {/* The alert cancel passes the click event on; sm and md call onCancel bare. */}
84
95
  <Parts.Cancel onClick={size === "default" ? onCancel : () => onCancel?.()}>
85
96
  {cancelLabel}
86
97
  </Parts.Cancel>
87
- <Parts.Action variant={variant} disabled={confirmDisabled} onClick={onConfirm}>
98
+ <Parts.Action
99
+ variant={variant}
100
+ disabled={confirmDisabled}
101
+ onClick={onConfirm}
102
+ pendingLabel={confirmLabel}
103
+ >
88
104
  {confirmLabel}
89
105
  </Parts.Action>
90
106
  </Parts.Footer>