@vvedantb/ui 0.29.1 → 0.30.1

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,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>
@@ -0,0 +1,29 @@
1
+ import { IconLink } from "@tabler/icons-react";
2
+ import { DropdownMenuItem } from "./dropdown-menu";
3
+
4
+ /**
5
+ * A "Copy link" item for a More menu: copies the current page URL. Pass `path`
6
+ * when the current URL is not the link to share (a per-user route that shows
7
+ * different content to each person, say); it resolves against the origin.
8
+ */
9
+ export function CopyLinkMenuItem({
10
+ iconSize = 14,
11
+ path,
12
+ label = "Copy link",
13
+ }: {
14
+ iconSize?: number;
15
+ path?: string;
16
+ label?: string;
17
+ }) {
18
+ return (
19
+ <DropdownMenuItem
20
+ onClick={() => {
21
+ const href = path ? new URL(path, window.location.origin).href : window.location.href;
22
+ void navigator.clipboard.writeText(href);
23
+ }}
24
+ >
25
+ <IconLink size={iconSize} />
26
+ {label}
27
+ </DropdownMenuItem>
28
+ );
29
+ }
@@ -65,3 +65,21 @@ export function UnreadDot({ show, className }: { show: boolean; className?: stri
65
65
  </CountPop>
66
66
  );
67
67
  }
68
+
69
+ /**
70
+ * Shape and placement of a count badge hung off a tile's bottom-right corner.
71
+ * Put the tile in a `relative` box; add one tone class (e.g. a live colour).
72
+ */
73
+ export const CORNER_BADGE_CLASS =
74
+ "absolute -bottom-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-semibold leading-none";
75
+
76
+ /** An unread count on a tile's corner: `CountPop` in the primary tone, "99+" above 99. */
77
+ export function UnreadCornerBadge({ count, className }: { count: number | undefined; className?: string }) {
78
+ const cn = useCn();
79
+ return (
80
+ <CountPop
81
+ label={countLabel(count)}
82
+ className={cn(CORNER_BADGE_CLASS, "bg-primary text-primary-foreground", className)}
83
+ />
84
+ );
85
+ }
@@ -1,34 +1,102 @@
1
1
  import * as React from "react";
2
2
  import { Slot } from "@radix-ui/react-slot";
3
+ import { m, type Variants } from "motion/react";
4
+ import { motionEase } from "../motion/presets";
5
+ import { useUiConfig } from "./ui-config";
3
6
  import { cn } from "../utils/cn";
4
7
 
5
8
  // `default`: open centred block, large bare icon, one muted line (a page or list body).
6
9
  // `panel`: filled rounded block with the icon in a tile (a detail panel or modal section).
7
10
  export type EmptyStateVariant = "default" | "panel";
8
11
 
9
- const EmptyStateContext = React.createContext<EmptyStateVariant>("default");
12
+ /**
13
+ * App-wide look for `EmptyState`. `classes` merge after each part's built-in
14
+ * classes. `classes.iconWrap` puts the `default` icon in a wrapper (a circle,
15
+ * say) and drops the icon's own margin. `enter` turns on the staggered
16
+ * entrance for every Root that does not set `enter` itself.
17
+ */
18
+ export interface EmptyStateConfig {
19
+ classes?: {
20
+ root?: string;
21
+ iconWrap?: string;
22
+ icon?: string;
23
+ title?: string;
24
+ description?: string;
25
+ actions?: string;
26
+ };
27
+ enter?: boolean;
28
+ }
29
+
30
+ const EmptyStateContext = React.createContext<{ variant: EmptyStateVariant; enter: boolean }>({
31
+ variant: "default",
32
+ enter: false,
33
+ });
34
+
35
+ const CONTAINER_VARIANTS: Variants = {
36
+ hidden: {},
37
+ show: { transition: { staggerChildren: 0.04 } },
38
+ };
39
+
40
+ const ITEM_VARIANTS: Variants = {
41
+ hidden: { opacity: 0, y: 8 },
42
+ show: { opacity: 1, y: 0, transition: { duration: 0.22, ease: motionEase } },
43
+ };
10
44
 
11
45
  export type EmptyStateRootProps = React.ComponentProps<"div"> & {
12
46
  /** Layout for the other parts. Default `"default"`. */
13
47
  variant?: EmptyStateVariant;
48
+ /**
49
+ * Parts rise and fade in one after another. Default `UiConfig.EmptyState.enter`,
50
+ * else `false`. Pass `false` for an empty state that flashes often (a filter
51
+ * with no results). Uses `m`, so it needs `LazyMotion`.
52
+ */
53
+ enter?: boolean;
14
54
  };
15
55
 
16
- function Root({ variant = "default", className, ...props }: EmptyStateRootProps) {
56
+ function Root({ variant = "default", enter: enterProp, className, ...props }: EmptyStateRootProps) {
57
+ const config = useUiConfig("EmptyState");
58
+ const enter = enterProp ?? config?.enter ?? false;
59
+ const classes = cn(
60
+ "flex flex-col items-center justify-center text-center",
61
+ variant === "panel" ? "rounded-lg bg-surface-secondary px-4 py-10" : "px-4 py-20",
62
+ config?.classes?.root,
63
+ className,
64
+ );
17
65
  return (
18
- <EmptyStateContext value={variant}>
19
- <div
20
- {...props}
21
- className={cn(
22
- "flex flex-col items-center justify-center text-center",
23
- variant === "panel" ? "rounded-lg bg-surface-secondary px-4 py-10" : "px-4 py-20",
24
- className,
25
- )}
26
- />
66
+ <EmptyStateContext value={{ variant, enter }}>
67
+ {enter ? (
68
+ <m.div
69
+ {...stripMotionConflicts(props)}
70
+ className={classes}
71
+ variants={CONTAINER_VARIANTS}
72
+ initial="hidden"
73
+ animate="show"
74
+ />
75
+ ) : (
76
+ <div {...props} className={classes} />
77
+ )}
27
78
  </EmptyStateContext>
28
79
  );
29
80
  }
30
81
  Root.displayName = "EmptyState.Root";
31
82
 
83
+ // The DOM drag and animation handlers clash with Motion's own prop types.
84
+ function stripMotionConflicts(props: React.ComponentProps<"div">) {
85
+ const {
86
+ onDrag: _onDrag,
87
+ onDragStart: _onDragStart,
88
+ onDragEnd: _onDragEnd,
89
+ onAnimationStart: _onAnimationStart,
90
+ ...rest
91
+ } = props;
92
+ return rest;
93
+ }
94
+
95
+ /** A part's own element: `m.*` with the item variants while the Root enters. */
96
+ function useItemMotion() {
97
+ return React.use(EmptyStateContext).enter ? { variants: ITEM_VARIANTS } : null;
98
+ }
99
+
32
100
  export type EmptyStateIconProps = {
33
101
  /** Icon component, e.g. a Tabler icon. */
34
102
  icon: React.ComponentType<{ size?: number | string; stroke?: number | string; className?: string }>;
@@ -41,25 +109,43 @@ export type EmptyStateIconProps = {
41
109
  };
42
110
 
43
111
  function Icon({ icon: IconComponent, size, stroke, className }: EmptyStateIconProps) {
44
- if (React.use(EmptyStateContext) === "panel") {
45
- return (
46
- <div
47
- className={cn(
48
- "mb-3 flex h-11 w-11 items-center justify-center rounded-lg bg-surface-tertiary",
49
- className,
50
- )}
51
- >
52
- <IconComponent size={size ?? 22} className="text-muted-foreground" stroke={stroke ?? 1.5} />
53
- </div>
112
+ const { variant } = React.use(EmptyStateContext);
113
+ const config = useUiConfig("EmptyState");
114
+ const itemMotion = useItemMotion();
115
+ if (variant === "panel") {
116
+ const tileClass = cn(
117
+ "mb-3 flex h-11 w-11 items-center justify-center rounded-lg bg-surface-tertiary",
118
+ className,
119
+ );
120
+ const glyph = <IconComponent size={size ?? 22} className="text-muted-foreground" stroke={stroke ?? 1.5} />;
121
+ return itemMotion ? (
122
+ <m.div {...itemMotion} className={tileClass}>
123
+ {glyph}
124
+ </m.div>
125
+ ) : (
126
+ <div className={tileClass}>{glyph}</div>
54
127
  );
55
128
  }
56
- return (
129
+ const wrapClass = config?.classes?.iconWrap;
130
+ if (wrapClass !== undefined) {
131
+ const glyph = <IconComponent size={size ?? 40} stroke={stroke} className={config?.classes?.icon} />;
132
+ const outer = cn(wrapClass, className);
133
+ return itemMotion ? (
134
+ <m.div {...itemMotion} className={outer}>
135
+ {glyph}
136
+ </m.div>
137
+ ) : (
138
+ <div className={outer}>{glyph}</div>
139
+ );
140
+ }
141
+ const glyph = (
57
142
  <IconComponent
58
143
  size={size ?? 40}
59
144
  stroke={stroke}
60
- className={cn("mb-3 text-muted-foreground", className)}
145
+ className={cn("mb-3 text-muted-foreground", config?.classes?.icon, className)}
61
146
  />
62
147
  );
148
+ return itemMotion ? <m.div {...itemMotion}>{glyph}</m.div> : glyph;
63
149
  }
64
150
  Icon.displayName = "EmptyState.Icon";
65
151
 
@@ -69,40 +155,57 @@ export type EmptyStateTitleProps = React.ComponentProps<"p"> & {
69
155
  };
70
156
 
71
157
  function Title({ asChild = false, className, ...props }: EmptyStateTitleProps) {
72
- const Comp = asChild ? Slot : "p";
73
- return (
74
- <Comp
75
- {...props}
76
- className={cn(
77
- "text-sm font-medium text-foreground",
78
- React.use(EmptyStateContext) === "default" && "mb-1",
79
- className,
80
- )}
81
- />
158
+ const { variant } = React.use(EmptyStateContext);
159
+ const config = useUiConfig("EmptyState");
160
+ const itemMotion = useItemMotion();
161
+ const classes = cn(
162
+ "text-sm font-medium text-foreground",
163
+ variant === "default" && "mb-1",
164
+ config?.classes?.title,
165
+ className,
82
166
  );
167
+ if (asChild) return <Slot {...props} className={classes} />;
168
+ if (itemMotion) return <m.p {...stripPConflicts(props)} {...itemMotion} className={classes} />;
169
+ return <p {...props} className={classes} />;
83
170
  }
84
171
  Title.displayName = "EmptyState.Title";
85
172
 
173
+ function stripPConflicts(props: React.ComponentProps<"p">) {
174
+ const {
175
+ onDrag: _onDrag,
176
+ onDragStart: _onDragStart,
177
+ onDragEnd: _onDragEnd,
178
+ onAnimationStart: _onAnimationStart,
179
+ ...rest
180
+ } = props;
181
+ return rest;
182
+ }
183
+
86
184
  export type EmptyStateDescriptionProps = React.ComponentProps<"p">;
87
185
 
88
186
  function Description({ className, ...props }: EmptyStateDescriptionProps) {
89
- return (
90
- <p
91
- {...props}
92
- className={cn(
93
- React.use(EmptyStateContext) === "panel" && "mt-1 max-w-[240px]",
94
- "text-sm text-muted-foreground",
95
- className,
96
- )}
97
- />
187
+ const { variant } = React.use(EmptyStateContext);
188
+ const config = useUiConfig("EmptyState");
189
+ const itemMotion = useItemMotion();
190
+ const classes = cn(
191
+ variant === "panel" && "mt-1 max-w-[240px]",
192
+ "text-sm text-muted-foreground",
193
+ config?.classes?.description,
194
+ className,
98
195
  );
196
+ if (itemMotion) return <m.p {...stripPConflicts(props)} {...itemMotion} className={classes} />;
197
+ return <p {...props} className={classes} />;
99
198
  }
100
199
  Description.displayName = "EmptyState.Description";
101
200
 
102
201
  export type EmptyStateActionsProps = React.ComponentProps<"div">;
103
202
 
104
203
  function Actions({ className, ...props }: EmptyStateActionsProps) {
105
- return <div {...props} className={cn("mt-4", className)} />;
204
+ const config = useUiConfig("EmptyState");
205
+ const itemMotion = useItemMotion();
206
+ const classes = cn("mt-4", config?.classes?.actions, className);
207
+ if (itemMotion) return <m.div {...stripMotionConflicts(props)} {...itemMotion} className={classes} />;
208
+ return <div {...props} className={classes} />;
106
209
  }
107
210
  Actions.displayName = "EmptyState.Actions";
108
211
 
@@ -0,0 +1,62 @@
1
+ import { m } from "motion/react";
2
+ import { springTransition } from "../motion/presets";
3
+ import { useCn } from "./ui-config";
4
+
5
+ const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] as const;
6
+
7
+ function isDigit(char: string): boolean {
8
+ return char >= "0" && char <= "9";
9
+ }
10
+
11
+ /**
12
+ * Shows an already formatted value ("1m 05s", "42%", "$1.20") and rolls each
13
+ * digit to its new value instead of jumping. Use it for numbers that tick
14
+ * while the user watches (timers, meters, running totals). `CountPop` is for a
15
+ * badge that changes while the user looks elsewhere.
16
+ *
17
+ * Format the value first: only the characters it gets are animated. Columns
18
+ * are keyed from the right, so "9s" → "10s" rolls the units column and mounts
19
+ * only the new tens column. Uses `m`, so the app must render it inside
20
+ * `LazyMotion`.
21
+ */
22
+ export function RollingNumber({ value, className }: { value: string; className?: string }) {
23
+ const cn = useCn();
24
+ const chars = [...value];
25
+ return (
26
+ <span className={cn("inline-flex tabular-nums", className)}>
27
+ <span className="sr-only">{value}</span>
28
+ {chars.map((char, index) => {
29
+ const key = chars.length - index;
30
+ return isDigit(char) ? (
31
+ <DigitColumn key={key} digit={Number(char)} />
32
+ ) : (
33
+ <span key={key} aria-hidden className="whitespace-pre">
34
+ {char}
35
+ </span>
36
+ );
37
+ })}
38
+ </span>
39
+ );
40
+ }
41
+
42
+ function DigitColumn({ digit }: { digit: number }) {
43
+ return (
44
+ <span aria-hidden className="relative inline-block h-[1lh] overflow-hidden">
45
+ {/* Holds the width of one digit; the moving column is absolute. */}
46
+ <span className="invisible">0</span>
47
+ <m.span
48
+ className="absolute inset-x-0 top-0 flex flex-col"
49
+ initial={false}
50
+ // The column is ten rows tall, so one row is 10% of its height.
51
+ animate={{ y: `${-digit * 10}%` }}
52
+ transition={springTransition}
53
+ >
54
+ {DIGITS.map((d) => (
55
+ <span key={d} className="h-[1lh]">
56
+ {d}
57
+ </span>
58
+ ))}
59
+ </m.span>
60
+ </span>
61
+ );
62
+ }