@vvedantb/ui 0.17.1 → 0.18.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.
Files changed (129) hide show
  1. package/README.md +251 -3
  2. package/package.json +13 -5
  3. package/shell/src/SidebarResize.tsx +1 -4
  4. package/src/ai-elements/__snapshots__/queue.test.tsx.snap +11 -0
  5. package/src/ai-elements/activity-question-utils.ts +26 -0
  6. package/src/ai-elements/activity-question.tsx +159 -0
  7. package/src/ai-elements/activity-shared.tsx +459 -0
  8. package/src/ai-elements/activity-step-detail.tsx +115 -0
  9. package/src/ai-elements/activity-step-label.ts +981 -0
  10. package/src/ai-elements/activity-tasks-utils.ts +199 -0
  11. package/src/ai-elements/activity-tasks.tsx +770 -0
  12. package/src/ai-elements/artifact.tsx +162 -52
  13. package/src/ai-elements/attachments.tsx +62 -32
  14. package/src/ai-elements/code-block.tsx +76 -22
  15. package/src/ai-elements/context.tsx +370 -0
  16. package/src/ai-elements/conversation.tsx +186 -62
  17. package/src/ai-elements/file-chip.tsx +53 -0
  18. package/src/ai-elements/message.tsx +161 -50
  19. package/src/ai-elements/model-picker-content.tsx +505 -0
  20. package/src/ai-elements/model-picker-types.ts +56 -0
  21. package/src/ai-elements/model-picker.tsx +301 -0
  22. package/src/ai-elements/model-selector.tsx +102 -21
  23. package/src/ai-elements/plan.tsx +130 -0
  24. package/src/ai-elements/prompt-input-settings.tsx +144 -0
  25. package/src/ai-elements/prompt-input-speech.tsx +189 -0
  26. package/src/ai-elements/prompt-input-traits.tsx +281 -0
  27. package/src/ai-elements/prompt-input.tsx +320 -134
  28. package/src/ai-elements/provider-icon.tsx +133 -0
  29. package/src/ai-elements/queue.tsx +293 -9
  30. package/src/ai-elements/shimmer.tsx +72 -1
  31. package/src/ai-elements/simple-model-ladder.tsx +108 -0
  32. package/src/ai-elements/test-results.tsx +510 -0
  33. package/src/ai-elements/trace.tsx +243 -0
  34. package/src/ai-elements/use-streaming-disclosure.ts +50 -0
  35. package/src/ai-elements/web-preview.tsx +349 -0
  36. package/src/composer/input-area/ComposerSlashMenu.tsx +1 -1
  37. package/src/index.ts +66 -12
  38. package/src/kibo/avatar-stack.tsx +40 -0
  39. package/src/kibo/contribution-graph.tsx +539 -0
  40. package/src/kibo/cursor.tsx +72 -0
  41. package/src/kibo/gantt-features.tsx +439 -0
  42. package/src/kibo/gantt-header.tsx +181 -0
  43. package/src/kibo/gantt-provider.tsx +657 -0
  44. package/src/kibo/gantt-sidebar.tsx +157 -0
  45. package/src/kibo/gantt-timeline.tsx +154 -0
  46. package/src/kibo/index.ts +151 -0
  47. package/src/kibo/kanban.tsx +338 -0
  48. package/src/kibo/kanbanDropTarget.ts +25 -0
  49. package/src/kibo/list.tsx +162 -0
  50. package/src/kibo/theme-switcher.tsx +109 -0
  51. package/src/kibo/video-player.tsx +237 -0
  52. package/src/markdown/Markdown.tsx +112 -0
  53. package/src/markdown/MarkdownBlockquote.tsx +45 -0
  54. package/src/markdown/MarkdownCodeBlock.tsx +197 -0
  55. package/src/markdown/MarkdownInlineCode.tsx +40 -0
  56. package/src/markdown/MarkdownLink.tsx +92 -0
  57. package/src/markdown/codeFence.ts +57 -0
  58. package/src/markdown/codeHighlight.ts +138 -0
  59. package/src/markdown/incrementalBlocks.ts +182 -0
  60. package/src/markdown/index.ts +3 -0
  61. package/src/markdown/markdownClipboard.ts +178 -0
  62. package/src/markdown/mermaid.ts +155 -0
  63. package/src/markdown/rehypeGithubAlerts.ts +80 -0
  64. package/src/motion/presets.ts +24 -0
  65. package/src/ui/__snapshots__/alt-wrappers.test.tsx.snap +19 -0
  66. package/src/ui/__snapshots__/sonner-dom.test.tsx.snap +5 -0
  67. package/src/ui/_menu-parts.tsx +29 -0
  68. package/src/ui/accordion.alt.tsx +46 -53
  69. package/src/ui/accordion.tsx +59 -10
  70. package/src/ui/avatar.tsx +56 -0
  71. package/src/ui/badge.tsx +45 -21
  72. package/src/ui/border-beam.tsx +97 -0
  73. package/src/ui/button-group.tsx +50 -21
  74. package/src/ui/button.tsx +67 -45
  75. package/src/ui/calendar.tsx +48 -5
  76. package/src/ui/card.tsx +77 -8
  77. package/src/ui/carousel.tsx +60 -0
  78. package/src/ui/centered-spinner.tsx +30 -0
  79. package/src/ui/checkbox.tsx +34 -16
  80. package/src/ui/clear-input.alt.tsx +33 -75
  81. package/src/ui/clear-input.tsx +140 -15
  82. package/src/ui/collapsible.tsx +40 -2
  83. package/src/ui/command.tsx +143 -70
  84. package/src/ui/context-menu.tsx +159 -93
  85. package/src/ui/crossfade-icon.tsx +123 -0
  86. package/src/ui/dialog.tsx +154 -70
  87. package/src/ui/dropdown-menu.tsx +177 -117
  88. package/src/ui/hover-card.tsx +59 -17
  89. package/src/ui/input-group.alt.tsx +83 -83
  90. package/src/ui/input-group.tsx +129 -58
  91. package/src/ui/input.tsx +13 -4
  92. package/src/ui/label.tsx +11 -8
  93. package/src/ui/list-row.tsx +232 -0
  94. package/src/ui/loading-state.tsx +215 -0
  95. package/src/ui/popover.tsx +56 -23
  96. package/src/ui/progress.tsx +28 -16
  97. package/src/ui/refresh-spin-icon.tsx +41 -0
  98. package/src/ui/search-input.tsx +66 -15
  99. package/src/ui/select.tsx +171 -100
  100. package/src/ui/separator.tsx +23 -14
  101. package/src/ui/slider.tsx +21 -7
  102. package/src/ui/sonner.tsx +68 -30
  103. package/src/ui/spinner.tsx +229 -11
  104. package/src/ui/status-dot.tsx +84 -0
  105. package/src/ui/surface.tsx +49 -0
  106. package/src/ui/switch.tsx +29 -15
  107. package/src/ui/table.tsx +38 -16
  108. package/src/ui/tabs.tsx +123 -35
  109. package/src/ui/tabsSliding.ts +33 -1
  110. package/src/ui/textarea.tsx +13 -4
  111. package/src/ui/tooltip.tsx +50 -14
  112. package/src/ui/ui-config.tsx +149 -0
  113. package/src/utils/cat-color.ts +123 -0
  114. package/src/utils/dayjs.ts +6 -0
  115. package/src/utils/gesture.ts +59 -0
  116. package/src/utils/runtimeVisibility.ts +167 -0
  117. package/src/utils/sharedClock.ts +96 -0
  118. package/src/utils/surface-radius.ts +16 -0
  119. package/src/utils/useDragSensors.ts +58 -0
  120. package/tokens/examples/tokens-moved/Fixture.tsx +283 -0
  121. package/tokens/examples/tokens-moved/README.md +20 -0
  122. package/tokens/examples/tokens-moved/index.html +12 -0
  123. package/tokens/examples/tokens-moved/main.tsx +6 -0
  124. package/tokens/examples/tokens-moved/package.json +14 -0
  125. package/tokens/examples/tokens-moved/styles.css +12 -0
  126. package/tokens/examples/tokens-moved/tsconfig.json +14 -0
  127. package/tokens/examples/tokens-moved/vite.config.ts +11 -0
  128. package/tokens/src/css/border-beam.css +116 -0
  129. package/tokens/src/css/markdown.css +318 -0
@@ -0,0 +1,232 @@
1
+ import * as React from "react";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+
4
+ import { cn } from "../utils/cn";
5
+ import { SURFACE_RADIUS_CLASS } from "../utils/surface-radius";
6
+ import { useCn, useUiConfig } from "./ui-config";
7
+
8
+ /**
9
+ * Apply to any interactive control nested inside a `<ListRow>` — a checkbox, a
10
+ * menu trigger, a dismiss button. It lifts the control above the row's stretched
11
+ * click overlay so the control receives its own clicks instead of the row's.
12
+ */
13
+ export const LIST_ROW_CONTROL_CLASS = "relative z-2";
14
+
15
+ /**
16
+ * The shell every selectable list row shares: surface, hairline, accent stripe,
17
+ * hover, selected state, press feedback, and focus ring.
18
+ *
19
+ * ## Why a stretched overlay rather than a wrapping button
20
+ *
21
+ * The obvious shape — make the whole row a `<button>` — is unusable here,
22
+ * because these rows embed their own controls: a selection checkbox, tooltip
23
+ * triggers, a dismiss action. A button inside a button is invalid HTML, and the
24
+ * inner control never receives its clicks. The usual workaround,
25
+ * `role="button" tabIndex={0}` on a div, needs a hand-written `Enter`/`Space`
26
+ * key handler and an `if (e.detail === 0) return` guard to stop that handler
27
+ * firing twice.
28
+ *
29
+ * So the row body stays a plain `<div>`, and a real `<button>`, `<a>`, or router
30
+ * `<Link>` (via {@link ListRowProps.link}) is stretched across it at `z-1`.
31
+ * The native element supplies the keyboard behaviour, the accessible role, and
32
+ * — for anchors/links — middle-click and Cmd-click. Nested controls opt above
33
+ * the overlay with {@link LIST_ROW_CONTROL_CLASS}.
34
+ *
35
+ * Prefer {@link ListRowProps.link} with a router `<Link>` for in-app paths so
36
+ * the router's `rewrite` owns the address-bar href. Use {@link ListRowProps.href}
37
+ * only for external URLs (or when no router is available).
38
+ *
39
+ * The focus ring is drawn by the row, not the overlay. Rows clip their
40
+ * decoration with `overflow-hidden` and a ring is a box-shadow, so a ring on the
41
+ * overlay would be clipped away and keyboard focus would be invisible. The
42
+ * `has-[…]` selector is scoped to the overlay's `data-slot` so a focused nested
43
+ * control does not also light up the whole row.
44
+ *
45
+ * ## Why it forwards a ref and spreads the rest
46
+ *
47
+ * Rows are the child of a `<ContextMenuTrigger asChild>`. Radix's `asChild`
48
+ * clones the child to inject its own ref and `onContextMenu`/`onPointerDown`
49
+ * handlers, so a row that neither forwards a ref nor spreads what it is handed
50
+ * drops every one of them and the right-click menu silently never opens.
51
+ */
52
+ export interface ListRowProps extends Omit<
53
+ React.ComponentPropsWithoutRef<"div">,
54
+ "onClick" | "children"
55
+ > {
56
+ children: React.ReactNode;
57
+ /**
58
+ * Background class for the 4px stripe down the leading edge — the row's
59
+ * status colour. Omit for rows that carry no status.
60
+ */
61
+ accentClassName?: string;
62
+ /** Current row. Fills the surface and recolours the hairline. */
63
+ selected?: boolean;
64
+ /**
65
+ * Router link (or any single element) used as the stretched overlay. Props
66
+ * are merged via Radix `Slot`. Prefer this over {@link href} for in-app
67
+ * navigation so rewrites apply to the rendered `href`.
68
+ */
69
+ link?: React.ReactElement;
70
+ /**
71
+ * Renders the overlay as a raw `<a>`. Use for external URLs. In-app paths
72
+ * should use {@link link} instead.
73
+ */
74
+ href?: string;
75
+ /**
76
+ * Fires on plain (non-modified) clicks. With {@link link}, call
77
+ * `event.preventDefault()` to cancel navigation (e.g. selection mode). With
78
+ * {@link href}, the row always prevents the browser navigation and expects
79
+ * this handler to navigate in-app if needed.
80
+ */
81
+ onClick?: (event: React.MouseEvent<HTMLElement>) => void;
82
+ /**
83
+ * Names the row for screen readers, since the overlay has no text of its own.
84
+ * Lands on the overlay rather than the shell, so it labels the control that
85
+ * actually carries the role.
86
+ */
87
+ "aria-label"?: string;
88
+ className?: string;
89
+ /** Padding for the row body. Defaults to the standard row inset. */
90
+ contentClassName?: string;
91
+ /** Content layered behind the row body, such as a hover glow. */
92
+ decoration?: React.ReactNode;
93
+ }
94
+
95
+ export interface ListRowConfig {
96
+ /**
97
+ * Replaces the built-in classes of a part. The row is `root`, plus
98
+ * `interactive` when it has a `link`, `href` or `onClick`, plus `selected`
99
+ * or `idle`.
100
+ */
101
+ classes?: {
102
+ /** Shell: radius, clipping, transition and the overlay focus ring. */
103
+ root?: string;
104
+ /** Press feedback, added when the row is interactive. */
105
+ interactive?: string;
106
+ /** Fill and ring of the current row. */
107
+ selected?: string;
108
+ /** Fill and hover of every other row. */
109
+ idle?: string;
110
+ /** The 4px status stripe (its colour comes from `accentClassName`). */
111
+ accent?: string;
112
+ /** The stretched `<button>` / `<a>` / `link` overlay. */
113
+ overlay?: string;
114
+ /** Row body padding (a caller `contentClassName` merges after it). */
115
+ content?: string;
116
+ };
117
+ }
118
+
119
+ const defaultClasses = {
120
+ root: cn(
121
+ "group relative overflow-hidden",
122
+ SURFACE_RADIUS_CLASS,
123
+ "transition-[background-color,scale] duration-200",
124
+ "has-[[data-slot=row-control]:focus-visible]:ring-2 has-[[data-slot=row-control]:focus-visible]:ring-ring/35",
125
+ ),
126
+ interactive: "active:scale-[0.99]",
127
+ selected: "bg-primary/5 ring-1 ring-primary/30",
128
+ idle: "bg-card hover:bg-surface-secondary",
129
+ accent: "absolute inset-y-2 left-0 w-1 rounded-r-full",
130
+ overlay: "absolute inset-0 z-1 cursor-pointer focus-visible:outline-hidden",
131
+ content: "relative p-2.5 pl-3 text-left",
132
+ };
133
+
134
+ function isModifiedClick(event: React.MouseEvent): boolean {
135
+ return event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
136
+ }
137
+
138
+ const ListRow = React.forwardRef<HTMLDivElement, ListRowProps>(function ListRow(
139
+ {
140
+ children,
141
+ accentClassName,
142
+ selected = false,
143
+ link,
144
+ href,
145
+ onClick,
146
+ className,
147
+ contentClassName,
148
+ decoration,
149
+ "aria-label": ariaLabel,
150
+ ...props
151
+ },
152
+ ref,
153
+ ) {
154
+ const classes = { ...defaultClasses, ...useUiConfig("ListRow")?.classes };
155
+ const merge = useCn();
156
+ const interactive =
157
+ link !== undefined || href !== undefined || onClick !== undefined;
158
+
159
+ const overlayClasses = classes.overlay;
160
+
161
+ return (
162
+ <div
163
+ ref={ref}
164
+ {...props}
165
+ className={merge(
166
+ classes.root,
167
+ interactive && classes.interactive,
168
+ selected ? classes.selected : classes.idle,
169
+ className,
170
+ )}
171
+ >
172
+ {decoration}
173
+ {accentClassName ? (
174
+ <div
175
+ className={merge(classes.accent, accentClassName)}
176
+ />
177
+ ) : null}
178
+ {link !== undefined ? (
179
+ <Slot
180
+ data-slot="row-control"
181
+ aria-label={ariaLabel}
182
+ className={overlayClasses}
183
+ onClick={(event: React.MouseEvent<HTMLElement>) => {
184
+ // Modified clicks are for new tab / window — leave navigation alone.
185
+ if (!isModifiedClick(event)) {
186
+ onClick?.(event);
187
+ }
188
+ // Own the click so a wrapping parent (e.g. KanbanCard) does not
189
+ // also treat it as a row activation.
190
+ event.stopPropagation();
191
+ }}
192
+ >
193
+ {link}
194
+ </Slot>
195
+ ) : href !== undefined ? (
196
+ <a
197
+ href={href}
198
+ onClick={(event) => {
199
+ if (onClick === undefined) return;
200
+ // A modified click is the entire reason this is an `<a>` — let the
201
+ // browser open the new tab or window itself. A plain click is
202
+ // handled in-app, so the navigation has to be cancelled or the
203
+ // row would also hard-reload the page out from under the router.
204
+ if (isModifiedClick(event)) return;
205
+ event.preventDefault();
206
+ onClick(event);
207
+ event.stopPropagation();
208
+ }}
209
+ data-slot="row-control"
210
+ aria-label={ariaLabel}
211
+ className={overlayClasses}
212
+ />
213
+ ) : onClick !== undefined ? (
214
+ <button
215
+ type="button"
216
+ onClick={(event) => {
217
+ onClick(event);
218
+ event.stopPropagation();
219
+ }}
220
+ data-slot="row-control"
221
+ aria-label={ariaLabel}
222
+ className={overlayClasses}
223
+ />
224
+ ) : null}
225
+ <div className={merge(classes.content, contentClassName)}>
226
+ {children}
227
+ </div>
228
+ </div>
229
+ );
230
+ });
231
+
232
+ export { ListRow };
@@ -0,0 +1,215 @@
1
+ import { useRef, useSyncExternalStore, type CSSProperties } from "react";
2
+
3
+ import { cn } from "../utils/cn";
4
+ import { bindRuntimeAnimation } from "../utils/runtimeVisibility";
5
+ import { quantizedSnapshot, subscribeQuantized } from "../utils/sharedClock";
6
+ import { useUiConfig } from "./ui-config";
7
+
8
+ /* ─────────────────────────────────────────────────────────
9
+ * LOADING STATE — pixel-grid loader for long-running work
10
+ *
11
+ * Variants:
12
+ * Drive — square cells, chevron wavefront driving right;
13
+ * the 650ms cycle is shorter than the sweep, so
14
+ * two fronts are always in flight
15
+ * Dots — same wavefront, circular cells
16
+ * Orbit — a comet lapping the grid perimeter
17
+ *
18
+ * Cells pulse on WAAPI, not a CSS `animation`: a CSS one
19
+ * fires `animationiteration` every cycle, and React's
20
+ * eagerly-attached root listeners turn each into a main-
21
+ * thread style recalc (~14×/s per mounted loader). WAAPI
22
+ * emits no animation events and runs the same keyframes.
23
+ *
24
+ * Paired with a label and a live elapsed timer in mono
25
+ * tabular figures.
26
+ *
27
+ * Source: https://21st.dev/@theshanelevine/components/loading-state
28
+ * ───────────────────────────────────────────────────────── */
29
+
30
+ export type LoadingStateVariant = "Drive" | "Dots" | "Orbit";
31
+ /** `default` = 4px cells (~15px grid). `sm` = 3px cells (~11px grid). */
32
+ export type LoadingStateSize = "default" | "sm";
33
+
34
+ /** Cell colours for the grid's left, middle and right column. */
35
+ export type LoadingStateColors = readonly [string, string, string];
36
+
37
+ export interface LoadingStateConfig {
38
+ /** Default for the `label` prop. */
39
+ label?: string;
40
+ /** Default for the `colors` prop. */
41
+ colors?: LoadingStateColors;
42
+ /** Default for the `labelStyle` prop. */
43
+ labelStyle?: CSSProperties;
44
+ /** Replaces the built-in classes of a part. */
45
+ classes?: {
46
+ /** The label text. Default `text-sm font-medium text-muted-foreground`. */
47
+ label?: string;
48
+ };
49
+ }
50
+
51
+ const SIZE_TOKENS: Record<
52
+ LoadingStateSize,
53
+ { cellPx: number; gapPx: number; radiusClass: string }
54
+ > = {
55
+ // 3×3px + 2×1px gaps = 11px.
56
+ sm: { cellPx: 3, gapPx: 1, radiusClass: "rounded-[0.5px]" },
57
+ default: { cellPx: 4, gapPx: 1.5, radiusClass: "rounded-[1px]" },
58
+ };
59
+
60
+ const chevron = Array.from({ length: 9 }, (_, i) => {
61
+ const r = Math.floor(i / 3);
62
+ const c = i % 3;
63
+ return (c + Math.abs(r - 1)) * 90;
64
+ });
65
+
66
+ const ORBIT_ORDER = [0, 1, 2, 5, 8, 7, 6, 3];
67
+ const orbit = Array.from({ length: 9 }, (_, i) => {
68
+ const k = ORBIT_ORDER.indexOf(i);
69
+ return k === -1 ? null : k * 110;
70
+ });
71
+
72
+ const PATTERNS: Record<
73
+ LoadingStateVariant,
74
+ { delays: (number | null)[]; dur: number; round: boolean }
75
+ > = {
76
+ Drive: { delays: chevron, dur: 650, round: false },
77
+ Dots: { delays: chevron, dur: 650, round: true },
78
+ Orbit: { delays: orbit, dur: 950, round: false },
79
+ };
80
+
81
+ const DEFAULT_COLORS: LoadingStateColors = ["currentColor", "currentColor", "currentColor"];
82
+
83
+ const defaultLabelClass = "text-sm font-medium text-muted-foreground";
84
+
85
+ /** Started on attach, cancelled on detach. `undefined` for a gap cell. */
86
+ type PulseRef = ((cell: HTMLSpanElement) => () => void) | undefined;
87
+
88
+ /**
89
+ * One ref callback per cell. The easing sits on the keyframes, not in the
90
+ * options, because CSS applies `animation-timing-function` per segment —
91
+ * as an option it would ease the whole cycle instead. No `fill`, so during
92
+ * its delay a cell keeps the static inline opacity, as under CSS.
93
+ */
94
+ function pulseRefs(delays: (number | null)[], dur: number): PulseRef[] {
95
+ return delays.map((d) =>
96
+ d === null
97
+ ? undefined
98
+ : (cell: HTMLSpanElement) => {
99
+ const pulse = cell.animate(
100
+ [
101
+ { opacity: 0.12, easing: "ease-in-out" },
102
+ { opacity: 1, easing: "ease-in-out" },
103
+ { opacity: 0.12 },
104
+ ],
105
+ { duration: dur, delay: d, iterations: Infinity },
106
+ );
107
+ return bindRuntimeAnimation(cell, pulse);
108
+ },
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Built once per variant, because these identities have to be stable: a ref
114
+ * callback built during render would be a new function every render, and React
115
+ * detaches and reattaches on identity change — restarting the pulse each time
116
+ * (the labelled variant re-renders 10×/s off `useElapsed`).
117
+ */
118
+ const PULSE_REFS: Record<LoadingStateVariant, PulseRef[]> = {
119
+ Drive: pulseRefs(PATTERNS.Drive.delays, PATTERNS.Drive.dur),
120
+ Dots: pulseRefs(PATTERNS.Dots.delays, PATTERNS.Dots.dur),
121
+ Orbit: pulseRefs(PATTERNS.Orbit.delays, PATTERNS.Orbit.dur),
122
+ };
123
+
124
+ const ELAPSED_MS = 100;
125
+ const noopSubscribe = () => () => {};
126
+
127
+ function useElapsed(enabled: boolean) {
128
+ const startedAt = useRef(Date.now());
129
+ const tick = useSyncExternalStore(
130
+ enabled
131
+ ? (onChange) => subscribeQuantized(ELAPSED_MS, onChange)
132
+ : noopSubscribe,
133
+ () => quantizedSnapshot(ELAPSED_MS),
134
+ () => quantizedSnapshot(ELAPSED_MS),
135
+ );
136
+ const total = enabled ? Math.max(0, (tick - startedAt.current) / 1000) : 0;
137
+ if (total < 60) return `${total.toFixed(1)}s`;
138
+ return `${Math.floor(total / 60)}m ${(total % 60).toFixed(1)}s`;
139
+ }
140
+
141
+ export interface LoadingStateProps {
142
+ /** Label text, also the `aria-label` when `iconOnly`. Default `UiConfig.LoadingState.label`, else `"Working"`. */
143
+ label?: string;
144
+ variant?: LoadingStateVariant;
145
+ size?: LoadingStateSize;
146
+ /** Pixel grid only — no label or elapsed timer. */
147
+ iconOnly?: boolean;
148
+ /** Column colours. Default `UiConfig.LoadingState.colors`, else `currentColor`. */
149
+ colors?: LoadingStateColors;
150
+ /** Inline style for the label, e.g. a shimmer gradient. Default `UiConfig.LoadingState.labelStyle`. */
151
+ labelStyle?: CSSProperties;
152
+ }
153
+
154
+ export function LoadingState({
155
+ label: labelProp,
156
+ variant = "Drive",
157
+ size = "default",
158
+ iconOnly = false,
159
+ colors: colorsProp,
160
+ labelStyle: labelStyleProp,
161
+ }: LoadingStateProps) {
162
+ const config = useUiConfig("LoadingState");
163
+ const label = labelProp ?? config?.label ?? "Working";
164
+ const colors = colorsProp ?? config?.colors ?? DEFAULT_COLORS;
165
+ const labelStyle = labelStyleProp ?? config?.labelStyle;
166
+ const elapsed = useElapsed(!iconOnly);
167
+ const { delays, round } = PATTERNS[variant];
168
+ const { cellPx, gapPx, radiusClass } = SIZE_TOKENS[size];
169
+
170
+ const grid = (
171
+ <span
172
+ aria-hidden
173
+ data-anim-chrome=""
174
+ className="grid grid-cols-3"
175
+ style={{
176
+ gap: gapPx,
177
+ gridTemplateColumns: `repeat(3, ${cellPx}px)`,
178
+ }}
179
+ >
180
+ {delays.map((d, i) => (
181
+ <span
182
+ key={i}
183
+ ref={PULSE_REFS[variant][i]}
184
+ className={round ? "rounded-full" : radiusClass}
185
+ style={{
186
+ width: cellPx,
187
+ height: cellPx,
188
+ backgroundColor: colors[i % 3],
189
+ opacity: d === null ? 0.07 : 0.15,
190
+ }}
191
+ />
192
+ ))}
193
+ </span>
194
+ );
195
+
196
+ if (iconOnly) {
197
+ return (
198
+ <div className="flex w-fit items-center" role="status" aria-label={label}>
199
+ {grid}
200
+ </div>
201
+ );
202
+ }
203
+
204
+ return (
205
+ <div className="flex w-fit items-center gap-2.5">
206
+ {grid}
207
+ <span className={cn(config?.classes?.label ?? defaultLabelClass)} style={labelStyle}>
208
+ {label}
209
+ </span>
210
+ <span className="font-mono text-[12px] text-muted-foreground tabular-nums">
211
+ {elapsed}
212
+ </span>
213
+ </div>
214
+ );
215
+ }
@@ -1,43 +1,76 @@
1
1
  import * as React from "react";
2
2
  import * as PopoverPrimitive from "@radix-ui/react-popover";
3
- import { cn } from "../utils/cn";
4
3
  import {
5
4
  floatingSurfaceAnimateClass,
6
5
  floatingSurfaceClass,
7
6
  } from "./_menu-classes";
8
7
  import { fitViewportClass } from "./_menu-parts";
8
+ import { useCn, useUiConfig } from "./ui-config";
9
+
10
+ type PopoverContentProps = React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>;
11
+
12
+ /** App defaults for `Popover*` (see `UiConfigProvider`). */
13
+ export interface PopoverConfig {
14
+ /** Replaces the built-in classes of a part. */
15
+ classes?: { trigger?: string; content?: string };
16
+ /** Default for `PopoverContent` `sideOffset`. */
17
+ sideOffset?: number;
18
+ /** Default for `PopoverContent` `collisionPadding`. */
19
+ collisionPadding?: PopoverContentProps["collisionPadding"];
20
+ }
9
21
 
10
22
  const Popover = PopoverPrimitive.Root;
11
- const PopoverTrigger = PopoverPrimitive.Trigger;
23
+ const PopoverAnchor = PopoverPrimitive.Anchor;
24
+
25
+ const PopoverTrigger = React.forwardRef<
26
+ React.ComponentRef<typeof PopoverPrimitive.Trigger>,
27
+ React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Trigger>
28
+ >((props, ref) => {
29
+ const trigger = useUiConfig("Popover")?.classes?.trigger;
30
+ const merge = useCn();
31
+ // Without config, pass props untouched: Radix's Slot turns an explicit
32
+ // `className={undefined}` into `class=""`.
33
+ if (trigger === undefined) return <PopoverPrimitive.Trigger ref={ref} {...props} />;
34
+ const { className, ...rest } = props;
35
+ return <PopoverPrimitive.Trigger ref={ref} className={merge(trigger, className)} {...rest} />;
36
+ });
37
+ PopoverTrigger.displayName = PopoverPrimitive.Trigger.displayName;
12
38
 
13
39
  const PopoverContent = React.forwardRef<
14
40
  React.ComponentRef<typeof PopoverPrimitive.Content>,
15
- React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
41
+ PopoverContentProps & {
16
42
  /**
17
43
  * Grow from the trigger, keep 8px from the viewport edge (unless
18
44
  * `collisionPadding` is set) and, on small screens, cap width and height.
19
45
  */
20
46
  fitViewport?: boolean;
21
47
  }
22
- >(({ className, align = "center", sideOffset = 6, fitViewport, collisionPadding, ...props }, ref) => (
23
- <PopoverPrimitive.Portal>
24
- <PopoverPrimitive.Content
25
- ref={ref}
26
- align={align}
27
- sideOffset={sideOffset}
28
- collisionPadding={fitViewport ? (collisionPadding ?? 8) : collisionPadding}
29
- className={cn(
30
- "z-50 w-72 max-sm:max-w-[calc(100vw-2rem)] max-sm:max-h-[85dvh] max-sm:overflow-y-auto p-4 outline-none",
31
- floatingSurfaceClass,
32
- floatingSurfaceAnimateClass,
33
- fitViewport &&
34
- `origin-[var(--radix-popper-transform-origin)] max-sm:max-w-[calc(100vw-1.5rem)] ${fitViewportClass}`,
35
- className,
36
- )}
37
- {...props}
38
- />
39
- </PopoverPrimitive.Portal>
40
- ));
48
+ >(({ className, align = "center", sideOffset, fitViewport, collisionPadding, ...props }, ref) => {
49
+ const config = useUiConfig("Popover");
50
+ const merge = useCn();
51
+ const padding = collisionPadding ?? config?.collisionPadding;
52
+ return (
53
+ <PopoverPrimitive.Portal>
54
+ <PopoverPrimitive.Content
55
+ ref={ref}
56
+ align={align}
57
+ sideOffset={sideOffset ?? config?.sideOffset ?? 6}
58
+ collisionPadding={fitViewport ? (padding ?? 8) : padding}
59
+ className={merge(
60
+ config?.classes?.content ?? [
61
+ "z-50 w-72 max-sm:max-w-[calc(100vw-2rem)] max-sm:max-h-[85dvh] max-sm:overflow-y-auto p-4 outline-none",
62
+ floatingSurfaceClass,
63
+ floatingSurfaceAnimateClass,
64
+ ],
65
+ fitViewport &&
66
+ `origin-[var(--radix-popper-transform-origin)] max-sm:max-w-[calc(100vw-1.5rem)] ${fitViewportClass}`,
67
+ className,
68
+ )}
69
+ {...props}
70
+ />
71
+ </PopoverPrimitive.Portal>
72
+ );
73
+ });
41
74
  PopoverContent.displayName = PopoverPrimitive.Content.displayName;
42
75
 
43
- export { Popover, PopoverTrigger, PopoverContent };
76
+ export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent };
@@ -1,25 +1,37 @@
1
1
  import * as React from "react";
2
2
  import * as ProgressPrimitive from "@radix-ui/react-progress";
3
- import { cn } from "../utils/cn";
3
+ import { useCn, useUiConfig } from "./ui-config";
4
+
5
+ export interface ProgressConfig {
6
+ /** Replaces the built-in classes of a part. A part left out keeps its default. */
7
+ classes?: { root?: string; indicator?: string };
8
+ }
4
9
 
5
10
  const Progress = React.forwardRef<
6
11
  React.ComponentRef<typeof ProgressPrimitive.Root>,
7
12
  React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
8
- >(({ className, value, ...props }, ref) => (
9
- <ProgressPrimitive.Root
10
- ref={ref}
11
- className={cn(
12
- "relative h-2.5 w-full overflow-hidden rounded-full bg-default/85",
13
- className,
14
- )}
15
- {...props}
16
- >
17
- <ProgressPrimitive.Indicator
18
- className="h-full w-full flex-1 rounded-full bg-primary transition-transform duration-300 ease-smooth"
19
- style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
20
- />
21
- </ProgressPrimitive.Root>
22
- ));
13
+ >(({ className, value, ...props }, ref) => {
14
+ const classes = useUiConfig("Progress")?.classes;
15
+ const merge = useCn();
16
+ return (
17
+ <ProgressPrimitive.Root
18
+ ref={ref}
19
+ className={merge(
20
+ classes?.root ?? "relative h-2.5 w-full overflow-hidden rounded-full bg-default/85",
21
+ className,
22
+ )}
23
+ {...props}
24
+ >
25
+ <ProgressPrimitive.Indicator
26
+ className={
27
+ classes?.indicator ??
28
+ "h-full w-full flex-1 rounded-full bg-primary transition-transform duration-300 ease-smooth"
29
+ }
30
+ style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
31
+ />
32
+ </ProgressPrimitive.Root>
33
+ );
34
+ });
23
35
  Progress.displayName = ProgressPrimitive.Root.displayName;
24
36
 
25
37
  export { Progress };
@@ -0,0 +1,41 @@
1
+ import { IconRefresh } from "@tabler/icons-react";
2
+ import { CrossfadeIcon } from "./crossfade-icon";
3
+ import { Spinner } from "./spinner";
4
+ import { useCn } from "./ui-config";
5
+
6
+ /**
7
+ * Refresh glyph for a control that reloads something: it crossfades to
8
+ * the `circle` {@link Spinner} while the action is in flight.
9
+ *
10
+ * One component so every toolbar swaps glyph and spinner the same way, with
11
+ * the `soft` crossfade.
12
+ *
13
+ * `className` sizes the slot and both glyphs. Inside a `Button`, the button's
14
+ * `[&_svg]:size-*` rule wins on specificity, so pass a matching size to keep
15
+ * the slot and the glyph the same box.
16
+ */
17
+ function RefreshSpinIcon({
18
+ busy,
19
+ className = "size-4",
20
+ }: {
21
+ busy: boolean;
22
+ className?: string;
23
+ }) {
24
+ const merge = useCn();
25
+ return (
26
+ <CrossfadeIcon
27
+ show={busy}
28
+ trueKey="busy"
29
+ falseKey="idle"
30
+ variant="soft"
31
+ className={merge(
32
+ "relative flex shrink-0 items-center justify-center",
33
+ className,
34
+ )}
35
+ whenTrue={<Spinner variant="circle" size="sm" className={className} />}
36
+ whenFalse={<IconRefresh className={className} />}
37
+ />
38
+ );
39
+ }
40
+
41
+ export { RefreshSpinIcon };