@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 +14 -15
- 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/ui-config.tsx +4 -0
- package/tokens/src/native.ts +13 -12
- package/app/README.md +0 -259
- package/tokens/README.md +0 -120
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/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,
|