@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.
- 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 +10 -1
- package/src/index.ts +10 -1
- package/src/markdown/Markdown.tsx +9 -1
- package/src/markdown/index.ts +1 -0
- package/src/markdown/useSmoothText.ts +36 -0
- package/src/motion/motion-provider.tsx +29 -0
- package/src/motion/panel-transition.tsx +39 -0
- package/src/ui/bulk-action-bar.tsx +229 -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/copy-link-menu-item.tsx +29 -0
- package/src/ui/count-pop.tsx +18 -0
- 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/toggle-search.tsx +138 -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.1",
|
|
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
|
-
<
|
|
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
|
@@ -33,7 +33,16 @@ export { restoreModalScrollLock } from "./ui/overlay-a11y";
|
|
|
33
33
|
export * from "./hooks";
|
|
34
34
|
export { ErrorBoundary } from "./ui/error-boundary";
|
|
35
35
|
export type { ErrorBoundaryProps } from "./ui/error-boundary";
|
|
36
|
-
export { CountPop, countLabel, UnreadDot } from "./ui/count-pop";
|
|
36
|
+
export { CountPop, countLabel, UnreadDot, CORNER_BADGE_CLASS, UnreadCornerBadge } 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";
|
|
40
|
+
export { BulkActionBar } from "./ui/bulk-action-bar";
|
|
41
|
+
export type { BulkActionBarProps, BulkBarAction } from "./ui/bulk-action-bar";
|
|
42
|
+
export { CopyLinkMenuItem } from "./ui/copy-link-menu-item";
|
|
43
|
+
export { ToggleSearch } from "./ui/toggle-search";
|
|
44
|
+
export type { ToggleSearchProps, ToggleSearchVariant } from "./ui/toggle-search";
|
|
45
|
+
export { MotionProvider } from "./motion/motion-provider";
|
|
37
46
|
export { MarqueeOnHover } from "./ui/marquee-on-hover";
|
|
38
47
|
export { RelativeDateTime } from "./ui/relative-date-time";
|
|
39
48
|
export type { RelativeDateTimeProps } from "./ui/relative-date-time";
|
package/src/index.ts
CHANGED
|
@@ -29,7 +29,16 @@ export { restoreModalScrollLock } from "./ui/overlay-a11y";
|
|
|
29
29
|
export * from "./hooks";
|
|
30
30
|
export { ErrorBoundary } from "./ui/error-boundary";
|
|
31
31
|
export type { ErrorBoundaryProps } from "./ui/error-boundary";
|
|
32
|
-
export { CountPop, countLabel, UnreadDot } from "./ui/count-pop";
|
|
32
|
+
export { CountPop, countLabel, UnreadDot, CORNER_BADGE_CLASS, UnreadCornerBadge } 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";
|
|
36
|
+
export { BulkActionBar } from "./ui/bulk-action-bar";
|
|
37
|
+
export type { BulkActionBarProps, BulkBarAction } from "./ui/bulk-action-bar";
|
|
38
|
+
export { CopyLinkMenuItem } from "./ui/copy-link-menu-item";
|
|
39
|
+
export { ToggleSearch } from "./ui/toggle-search";
|
|
40
|
+
export type { ToggleSearchProps, ToggleSearchVariant } from "./ui/toggle-search";
|
|
41
|
+
export { MotionProvider } from "./motion/motion-provider";
|
|
33
42
|
export { MarqueeOnHover } from "./ui/marquee-on-hover";
|
|
34
43
|
export { RelativeDateTime } from "./ui/relative-date-time";
|
|
35
44
|
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,29 @@
|
|
|
1
|
+
import { LazyMotion, MotionConfig, domMax, type FeatureBundle } from "motion/react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The app-wide Motion setup that `m.*` components (in this package and the
|
|
6
|
+
* app) need: one lazy feature bundle under `strict`, so a stray `motion.*`
|
|
7
|
+
* import throws instead of pulling the full bundle into the entry chunk.
|
|
8
|
+
*
|
|
9
|
+
* `features` defaults to `domMax`, which includes layout animations
|
|
10
|
+
* (`layoutId` shared highlights); `domAnimation` is smaller if the app has
|
|
11
|
+
* none. Under `strict` a missing feature silently skips the animation.
|
|
12
|
+
* `reducedMotion` defaults to `"user"` (follow the OS setting); pass `"never"`
|
|
13
|
+
* to always animate.
|
|
14
|
+
*/
|
|
15
|
+
export function MotionProvider({
|
|
16
|
+
children,
|
|
17
|
+
features = domMax,
|
|
18
|
+
reducedMotion = "user",
|
|
19
|
+
}: {
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
features?: FeatureBundle;
|
|
22
|
+
reducedMotion?: "user" | "always" | "never";
|
|
23
|
+
}) {
|
|
24
|
+
return (
|
|
25
|
+
<LazyMotion features={features} strict>
|
|
26
|
+
<MotionConfig reducedMotion={reducedMotion}>{children}</MotionConfig>
|
|
27
|
+
</LazyMotion>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -36,3 +36,42 @@ export function PanelTransition({
|
|
|
36
36
|
</ViewTransition>
|
|
37
37
|
);
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
export interface ViewTransitionSuspenseProps {
|
|
41
|
+
fallback: ReactNode;
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
/** View transition class on the content as it arrives. Default `vt-enter`. */
|
|
44
|
+
enter?: string;
|
|
45
|
+
/** View transition class on the fallback as it leaves. Default `vt-exit`. */
|
|
46
|
+
exit?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `<Suspense>` whose fallback fades out as the content comes in, through React
|
|
51
|
+
* `<ViewTransition>`. Suspense reveals commit on retry lanes, which React
|
|
52
|
+
* animates even outside `startTransition`. Needs a React build that exports
|
|
53
|
+
* `ViewTransition`; the app supplies the `::view-transition-old(.vt-exit)` and
|
|
54
|
+
* `::view-transition-new(.vt-enter)` CSS. Routers that commit route changes
|
|
55
|
+
* synchronously (through `useSyncExternalStore`) do not animate here; use the
|
|
56
|
+
* router's own view transition for pages.
|
|
57
|
+
*/
|
|
58
|
+
export function ViewTransitionSuspense({
|
|
59
|
+
fallback,
|
|
60
|
+
children,
|
|
61
|
+
enter = "vt-enter",
|
|
62
|
+
exit = "vt-exit",
|
|
63
|
+
}: ViewTransitionSuspenseProps) {
|
|
64
|
+
return (
|
|
65
|
+
<React.Suspense
|
|
66
|
+
fallback={
|
|
67
|
+
<ViewTransition default="none" exit={exit}>
|
|
68
|
+
{fallback}
|
|
69
|
+
</ViewTransition>
|
|
70
|
+
}
|
|
71
|
+
>
|
|
72
|
+
<ViewTransition default="none" enter={enter}>
|
|
73
|
+
{children}
|
|
74
|
+
</ViewTransition>
|
|
75
|
+
</React.Suspense>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { m, AnimatePresence } from "motion/react";
|
|
2
|
+
import { ConfirmSkipHint, skipConfirmTitle } from "../../app/src/confirm/ConfirmSkipHint";
|
|
3
|
+
import { tokenTransition } from "../motion/presets";
|
|
4
|
+
import { CountPop } from "./count-pop";
|
|
5
|
+
import {
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuItem,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
} from "./dropdown-menu";
|
|
11
|
+
import { Separator } from "./separator";
|
|
12
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip";
|
|
13
|
+
import { IconDots, IconX } from "@tabler/icons-react";
|
|
14
|
+
import type { ComponentType } from "react";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* One control in the bar. The bar is deliberately dumb: each action carries the
|
|
18
|
+
* handler it should run, so confirm dialogs, Alt-to-skip and any per-surface
|
|
19
|
+
* bookkeeping stay with the caller that owns that state.
|
|
20
|
+
*/
|
|
21
|
+
export interface BulkBarAction {
|
|
22
|
+
key: string;
|
|
23
|
+
/** Full label used in the More menu and for accessibility. */
|
|
24
|
+
label: string;
|
|
25
|
+
/** Shorter label shown inline in the bar (defaults to `label`). */
|
|
26
|
+
shortLabel?: string;
|
|
27
|
+
icon: ComponentType<{ size: number; className?: string }>;
|
|
28
|
+
destructive?: boolean;
|
|
29
|
+
/** Shows the Alt-to-skip hint; the caller still owns the skip itself. */
|
|
30
|
+
showSkipHint?: boolean;
|
|
31
|
+
onClick: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface BulkActionBarProps {
|
|
35
|
+
isSelecting: boolean;
|
|
36
|
+
selectedCount: number;
|
|
37
|
+
totalCount: number;
|
|
38
|
+
onExitSelect: () => void;
|
|
39
|
+
onSelectAll: () => void;
|
|
40
|
+
onClearSelection: () => void;
|
|
41
|
+
primaryActions: ReadonlyArray<BulkBarAction>;
|
|
42
|
+
moreActions?: ReadonlyArray<BulkBarAction>;
|
|
43
|
+
destructiveAction?: BulkBarAction;
|
|
44
|
+
/** Motion key for the presence animation, e.g. "quick-tasks-bulk-bar". */
|
|
45
|
+
barKey: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A single labelled button inside the action bar. The label collapses to
|
|
50
|
+
* icon-only below the `sm` breakpoint (HeroUI "responsive labels" pattern).
|
|
51
|
+
*/
|
|
52
|
+
function BarButton({
|
|
53
|
+
action,
|
|
54
|
+
disabled,
|
|
55
|
+
}: {
|
|
56
|
+
action: BulkBarAction;
|
|
57
|
+
disabled: boolean;
|
|
58
|
+
}) {
|
|
59
|
+
const Icon = action.icon;
|
|
60
|
+
return (
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
aria-label={action.label}
|
|
64
|
+
title={
|
|
65
|
+
action.showSkipHint ? skipConfirmTitle(action.label) : action.label
|
|
66
|
+
}
|
|
67
|
+
onClick={action.onClick}
|
|
68
|
+
disabled={disabled}
|
|
69
|
+
// `motion-press`, not `transition-colors`: the destructive Delete takes
|
|
70
|
+
// this same branch, so the highest-consequence control in the bar was the
|
|
71
|
+
// one with no acknowledgement at all before its confirm dialog appeared.
|
|
72
|
+
className={`motion-press inline-flex h-9 shrink-0 items-center gap-1.5 rounded-lg px-2.5 text-sm font-medium active:scale-[0.96] disabled:pointer-events-none disabled:opacity-30 ${
|
|
73
|
+
action.destructive
|
|
74
|
+
? "text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
75
|
+
: "text-muted-foreground hover:bg-surface-secondary hover:text-foreground"
|
|
76
|
+
}`}
|
|
77
|
+
>
|
|
78
|
+
<Icon size={17} />
|
|
79
|
+
<span className="hidden sm:inline">
|
|
80
|
+
{action.shortLabel ?? action.label}
|
|
81
|
+
</span>
|
|
82
|
+
{action.showSkipHint ? <ConfirmSkipHint /> : null}
|
|
83
|
+
</button>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The floating selection bar for any list with bulk actions. Render it inside
|
|
89
|
+
* a `relative` list container: it sits absolute at the bottom. It owns the shell only: presence
|
|
90
|
+
* motion, the count, select-all/clear, the primary buttons, the More menu and
|
|
91
|
+
* the destructive slot on the right. Behaviour arrives as `onClick` per action.
|
|
92
|
+
*/
|
|
93
|
+
export function BulkActionBar({
|
|
94
|
+
isSelecting,
|
|
95
|
+
selectedCount,
|
|
96
|
+
totalCount,
|
|
97
|
+
onExitSelect,
|
|
98
|
+
onSelectAll,
|
|
99
|
+
onClearSelection,
|
|
100
|
+
primaryActions,
|
|
101
|
+
moreActions,
|
|
102
|
+
destructiveAction,
|
|
103
|
+
barKey,
|
|
104
|
+
}: BulkActionBarProps) {
|
|
105
|
+
const hasSelection = selectedCount > 0;
|
|
106
|
+
const allSelected = totalCount > 0 && selectedCount >= totalCount;
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<AnimatePresence initial={false}>
|
|
110
|
+
{isSelecting && (
|
|
111
|
+
<m.div
|
|
112
|
+
key={barKey}
|
|
113
|
+
className="absolute inset-x-0 bottom-3 z-20 flex justify-center px-4 pb-[env(safe-area-inset-bottom)]"
|
|
114
|
+
initial={{ opacity: 0, y: 20 }}
|
|
115
|
+
animate={{ opacity: 1, y: 0 }}
|
|
116
|
+
exit={{ opacity: 0, y: 20 }}
|
|
117
|
+
transition={tokenTransition.fast}
|
|
118
|
+
>
|
|
119
|
+
<TooltipProvider>
|
|
120
|
+
<div className="flex max-w-[calc(100vw-2rem)] items-center gap-1 overflow-x-auto rounded-surface bg-popover/95 px-2.5 py-2 backdrop-blur-md smooth-shadow-ring-lg scrollbar-none">
|
|
121
|
+
{/* Prefix: selection count */}
|
|
122
|
+
<div className="flex shrink-0 items-center gap-2 pl-1 pr-0.5">
|
|
123
|
+
<CountPop
|
|
124
|
+
label={String(selectedCount)}
|
|
125
|
+
className="rounded-md bg-surface-secondary px-2 py-0.5 text-xs font-semibold text-foreground tabular-nums"
|
|
126
|
+
/>
|
|
127
|
+
<span className="hidden text-sm font-medium text-muted-foreground sm:inline">
|
|
128
|
+
selected
|
|
129
|
+
</span>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
{/* Reaching the whole list is the one action a selection bar can
|
|
133
|
+
offer that the rows cannot: shift-clicking to the end of a
|
|
134
|
+
long list is the alternative. */}
|
|
135
|
+
{totalCount > 0 && (
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
onClick={allSelected ? onClearSelection : onSelectAll}
|
|
139
|
+
className="motion-press inline-flex h-9 shrink-0 items-center rounded-lg px-2 text-sm font-medium text-muted-foreground hover:bg-surface-secondary hover:text-foreground active:scale-[0.96]"
|
|
140
|
+
>
|
|
141
|
+
{allSelected ? "Clear" : `Select all (${totalCount})`}
|
|
142
|
+
</button>
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
<Separator
|
|
146
|
+
orientation="vertical"
|
|
147
|
+
className="mx-1.5 h-5 shrink-0 bg-border"
|
|
148
|
+
/>
|
|
149
|
+
|
|
150
|
+
{/* Content: primary actions + More dropdown */}
|
|
151
|
+
{primaryActions.map((action) => (
|
|
152
|
+
<BarButton
|
|
153
|
+
key={action.key}
|
|
154
|
+
action={action}
|
|
155
|
+
disabled={!hasSelection}
|
|
156
|
+
/>
|
|
157
|
+
))}
|
|
158
|
+
|
|
159
|
+
{moreActions && moreActions.length > 0 && (
|
|
160
|
+
<DropdownMenu>
|
|
161
|
+
<DropdownMenuTrigger asChild>
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
aria-label="More actions"
|
|
165
|
+
disabled={!hasSelection}
|
|
166
|
+
className="inline-flex size-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-surface-secondary hover:text-foreground disabled:pointer-events-none disabled:opacity-30 data-[state=open]:bg-surface-secondary data-[state=open]:text-foreground"
|
|
167
|
+
>
|
|
168
|
+
<IconDots size={17} />
|
|
169
|
+
</button>
|
|
170
|
+
</DropdownMenuTrigger>
|
|
171
|
+
<DropdownMenuContent align="center" side="top" sideOffset={8}>
|
|
172
|
+
{moreActions.map((action) => {
|
|
173
|
+
const Icon = action.icon;
|
|
174
|
+
return (
|
|
175
|
+
<DropdownMenuItem
|
|
176
|
+
key={action.key}
|
|
177
|
+
disabled={!hasSelection}
|
|
178
|
+
onClick={action.onClick}
|
|
179
|
+
>
|
|
180
|
+
<Icon size={16} />
|
|
181
|
+
{action.label}
|
|
182
|
+
</DropdownMenuItem>
|
|
183
|
+
);
|
|
184
|
+
})}
|
|
185
|
+
</DropdownMenuContent>
|
|
186
|
+
</DropdownMenu>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
{destructiveAction && (
|
|
190
|
+
<>
|
|
191
|
+
<Separator
|
|
192
|
+
orientation="vertical"
|
|
193
|
+
className="mx-1.5 h-5 shrink-0 bg-border"
|
|
194
|
+
/>
|
|
195
|
+
<BarButton
|
|
196
|
+
action={destructiveAction}
|
|
197
|
+
disabled={!hasSelection}
|
|
198
|
+
/>
|
|
199
|
+
</>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
<Separator
|
|
203
|
+
orientation="vertical"
|
|
204
|
+
className="mx-1.5 h-5 shrink-0 bg-border"
|
|
205
|
+
/>
|
|
206
|
+
|
|
207
|
+
{/* Suffix: dismiss selection */}
|
|
208
|
+
<Tooltip>
|
|
209
|
+
<TooltipTrigger asChild>
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
aria-label="Cancel selection"
|
|
213
|
+
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-surface-secondary hover:text-foreground"
|
|
214
|
+
onClick={onExitSelect}
|
|
215
|
+
>
|
|
216
|
+
<IconX size={17} />
|
|
217
|
+
</button>
|
|
218
|
+
</TooltipTrigger>
|
|
219
|
+
<TooltipContent side="top" className="text-xs" sideOffset={8}>
|
|
220
|
+
Cancel selection
|
|
221
|
+
</TooltipContent>
|
|
222
|
+
</Tooltip>
|
|
223
|
+
</div>
|
|
224
|
+
</TooltipProvider>
|
|
225
|
+
</m.div>
|
|
226
|
+
)}
|
|
227
|
+
</AnimatePresence>
|
|
228
|
+
);
|
|
229
|
+
}
|