@vvedantb/ui 0.29.0 → 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 +1 -1
- package/src/ai-elements/activity-tasks.tsx +16 -6
- package/src/ai-elements/context.tsx +24 -16
- package/src/ai-elements/prompt-input.tsx +14 -1
- package/src/client.ts +3 -0
- package/src/index.ts +3 -0
- package/src/markdown/Markdown.tsx +9 -1
- package/src/markdown/index.ts +1 -0
- package/src/markdown/useSmoothText.ts +36 -0
- package/src/ui/confetti-burst.tsx +131 -0
- package/src/ui/confirm-dialog-parts.tsx +31 -8
- package/src/ui/confirm-dialog.tsx +18 -2
- package/src/ui/empty-state.tsx +146 -43
- package/src/ui/rolling-number.tsx +62 -0
- package/src/ui/send-queue-icon.tsx +55 -0
- package/src/ui/sonner-icons.tsx +4 -4
- package/src/ui/ui-config.tsx +4 -0
- package/tokens/src/native.ts +13 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -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
|
-
<
|
|
104
|
-
{
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
141
|
-
{renderedPercent}
|
|
142
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
255
|
-
{totalCost === undefined ? "—" : formatUSD(totalCost)}
|
|
256
|
-
|
|
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="
|
|
283
|
-
{formatTokens(tokens)}
|
|
289
|
+
<span className="inline-flex items-baseline">
|
|
290
|
+
<RollingNumber value={formatTokens(tokens)} />
|
|
284
291
|
{costUSD !== undefined ? (
|
|
285
|
-
<
|
|
286
|
-
{formatUSD(costUSD)}
|
|
287
|
-
|
|
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 =
|
|
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
|
-
{
|
|
116
|
+
{text}
|
|
109
117
|
</Streamdown>
|
|
110
118
|
</div>
|
|
111
119
|
);
|
package/src/markdown/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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>
|
package/src/ui/empty-state.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { m } from "motion/react";
|
|
2
|
+
import { springTransition } from "../motion/presets";
|
|
3
|
+
|
|
4
|
+
interface Segment {
|
|
5
|
+
x1: number;
|
|
6
|
+
y1: number;
|
|
7
|
+
x2: number;
|
|
8
|
+
y2: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type Shape = readonly [Segment, Segment, Segment];
|
|
12
|
+
|
|
13
|
+
// Both shapes use the same three lines, so the arrow's own strokes move into
|
|
14
|
+
// the queue bars instead of one icon fading out and another fading in.
|
|
15
|
+
// Arrow points match Tabler's `IconArrowUp` on its 24px grid.
|
|
16
|
+
const ARROW: Shape = [
|
|
17
|
+
{ x1: 6, y1: 11, x2: 12, y2: 5 },
|
|
18
|
+
{ x1: 12, y1: 5, x2: 12, y2: 19 },
|
|
19
|
+
{ x1: 18, y1: 11, x2: 12, y2: 5 },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const QUEUE: Shape = [
|
|
23
|
+
{ x1: 5, y1: 7, x2: 19, y2: 7 },
|
|
24
|
+
{ x1: 5, y1: 12, x2: 19, y2: 12 },
|
|
25
|
+
{ x1: 19, y1: 17, x2: 5, y2: 17 },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A send arrow that turns into a stack of bars when `queued`: while the agent
|
|
30
|
+
* works, a send joins the queue. Line N of the arrow becomes line N of the
|
|
31
|
+
* stack. Uses `m`, so the app must render it inside `LazyMotion`.
|
|
32
|
+
*/
|
|
33
|
+
export function SendQueueIcon({ queued, className = "size-4" }: { queued: boolean; className?: string }) {
|
|
34
|
+
const shape = queued ? QUEUE : ARROW;
|
|
35
|
+
return (
|
|
36
|
+
<svg
|
|
37
|
+
viewBox="0 0 24 24"
|
|
38
|
+
fill="none"
|
|
39
|
+
stroke="currentColor"
|
|
40
|
+
strokeWidth={2}
|
|
41
|
+
strokeLinecap="round"
|
|
42
|
+
className={className}
|
|
43
|
+
aria-hidden
|
|
44
|
+
>
|
|
45
|
+
{shape.map((segment, index) => (
|
|
46
|
+
<m.line
|
|
47
|
+
key={index}
|
|
48
|
+
initial={false}
|
|
49
|
+
animate={{ x1: segment.x1, y1: segment.y1, x2: segment.x2, y2: segment.y2 }}
|
|
50
|
+
transition={springTransition}
|
|
51
|
+
/>
|
|
52
|
+
))}
|
|
53
|
+
</svg>
|
|
54
|
+
);
|
|
55
|
+
}
|
package/src/ui/sonner-icons.tsx
CHANGED
|
@@ -8,7 +8,7 @@ function InfoIcon() {
|
|
|
8
8
|
<svg
|
|
9
9
|
aria-hidden="true"
|
|
10
10
|
className="h-6 w-6 fill-current text-primary"
|
|
11
|
-
fill="
|
|
11
|
+
fill="currentColor"
|
|
12
12
|
viewBox="0 0 24 24"
|
|
13
13
|
xmlns="http://www.w3.org/2000/svg"
|
|
14
14
|
>
|
|
@@ -22,7 +22,7 @@ function SuccessIcon() {
|
|
|
22
22
|
<svg
|
|
23
23
|
aria-hidden="true"
|
|
24
24
|
className="h-6 w-6 fill-current text-success"
|
|
25
|
-
fill="
|
|
25
|
+
fill="currentColor"
|
|
26
26
|
viewBox="0 0 24 24"
|
|
27
27
|
xmlns="http://www.w3.org/2000/svg"
|
|
28
28
|
>
|
|
@@ -36,7 +36,7 @@ function WarningIcon() {
|
|
|
36
36
|
<svg
|
|
37
37
|
aria-hidden="true"
|
|
38
38
|
className="h-6 w-6 fill-current text-warning"
|
|
39
|
-
fill="
|
|
39
|
+
fill="currentColor"
|
|
40
40
|
viewBox="0 0 24 24"
|
|
41
41
|
xmlns="http://www.w3.org/2000/svg"
|
|
42
42
|
>
|
|
@@ -54,7 +54,7 @@ function ErrorIcon() {
|
|
|
54
54
|
<svg
|
|
55
55
|
aria-hidden="true"
|
|
56
56
|
className="h-6 w-6 fill-current text-destructive"
|
|
57
|
-
fill="
|
|
57
|
+
fill="currentColor"
|
|
58
58
|
viewBox="0 0 20 20"
|
|
59
59
|
xmlns="http://www.w3.org/2000/svg"
|
|
60
60
|
>
|
package/src/ui/ui-config.tsx
CHANGED
|
@@ -45,6 +45,8 @@ import type { SettingsSectionConfig } from "../../app/src/settings/SettingsSecti
|
|
|
45
45
|
import type { ShimmerConfig } from "../ai-elements/shimmer";
|
|
46
46
|
import type { SliderConfig } from "./slider";
|
|
47
47
|
import type { SonnerToasterConfig } from "./sonner";
|
|
48
|
+
import type { ConfirmDialogConfig } from "./confirm-dialog-parts";
|
|
49
|
+
import type { EmptyStateConfig } from "./empty-state";
|
|
48
50
|
import type { StatusDotConfig } from "./status-dot";
|
|
49
51
|
import type { SwitchConfig } from "./switch";
|
|
50
52
|
import type { TableConfig } from "./table";
|
|
@@ -93,6 +95,8 @@ export interface UiConfig {
|
|
|
93
95
|
ContextMenu?: ContextMenuConfig;
|
|
94
96
|
Conversation?: ConversationConfig;
|
|
95
97
|
Dialog?: DialogConfig;
|
|
98
|
+
ConfirmDialog?: ConfirmDialogConfig;
|
|
99
|
+
EmptyState?: EmptyStateConfig;
|
|
96
100
|
DropdownMenu?: DropdownMenuConfig;
|
|
97
101
|
Gantt?: GanttConfig;
|
|
98
102
|
HoverCard?: HoverCardConfig;
|
package/tokens/src/native.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* JS tokens for React Native.
|
|
3
|
-
* (oklch
|
|
2
|
+
* JS tokens for React Native. The neutrals and radii match `tokens.css`
|
|
3
|
+
* (oklch → sRGB hex, 1rem = 16). `fill`, `hairline` and `radius.card` are
|
|
4
|
+
* native chrome with no web token.
|
|
4
5
|
*/
|
|
5
6
|
export const tokens = {
|
|
6
7
|
color: {
|
|
7
|
-
background: { light: "#
|
|
8
|
-
foreground: { light: "#
|
|
9
|
-
muted: { light: "#
|
|
10
|
-
border: { light: "#
|
|
11
|
-
surface: { light: "#ffffff", dark: "#
|
|
12
|
-
surfaceSecondary: { light: "#
|
|
13
|
-
surfaceCard: { light: "#
|
|
14
|
-
separator: { light: "#
|
|
8
|
+
background: { light: "#f5f5f5", dark: "#060606" },
|
|
9
|
+
foreground: { light: "#181818", dark: "#fcfcfc" },
|
|
10
|
+
muted: { light: "#727272", dark: "#a0a0a0" },
|
|
11
|
+
border: { light: "#dedede", dark: "#292929" },
|
|
12
|
+
surface: { light: "#ffffff", dark: "#181818" },
|
|
13
|
+
surfaceSecondary: { light: "#efefef", dark: "#242323" },
|
|
14
|
+
surfaceCard: { light: "#f8f8f8", dark: "#242323" },
|
|
15
|
+
separator: { light: "#e4e4e4", dark: "#222222" },
|
|
15
16
|
white: "#ffffff",
|
|
16
17
|
black: "#000000",
|
|
17
18
|
hairline: {
|
|
@@ -20,12 +21,12 @@ export const tokens = {
|
|
|
20
21
|
},
|
|
21
22
|
fill: {
|
|
22
23
|
light: "rgba(255, 255, 255, 0.86)",
|
|
23
|
-
dark: "rgba(
|
|
24
|
+
dark: "rgba(24, 24, 24, 0.88)",
|
|
24
25
|
},
|
|
25
26
|
},
|
|
26
27
|
radius: {
|
|
27
28
|
lg: 16,
|
|
28
|
-
field:
|
|
29
|
+
field: 10,
|
|
29
30
|
md: 14,
|
|
30
31
|
sm: 12,
|
|
31
32
|
card: 24,
|