@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.
- package/README.md +251 -3
- package/package.json +13 -5
- package/shell/src/SidebarResize.tsx +1 -4
- package/src/ai-elements/__snapshots__/queue.test.tsx.snap +11 -0
- package/src/ai-elements/activity-question-utils.ts +26 -0
- package/src/ai-elements/activity-question.tsx +159 -0
- package/src/ai-elements/activity-shared.tsx +459 -0
- package/src/ai-elements/activity-step-detail.tsx +115 -0
- package/src/ai-elements/activity-step-label.ts +981 -0
- package/src/ai-elements/activity-tasks-utils.ts +199 -0
- package/src/ai-elements/activity-tasks.tsx +770 -0
- package/src/ai-elements/artifact.tsx +162 -52
- package/src/ai-elements/attachments.tsx +62 -32
- package/src/ai-elements/code-block.tsx +76 -22
- package/src/ai-elements/context.tsx +370 -0
- package/src/ai-elements/conversation.tsx +186 -62
- package/src/ai-elements/file-chip.tsx +53 -0
- package/src/ai-elements/message.tsx +161 -50
- package/src/ai-elements/model-picker-content.tsx +505 -0
- package/src/ai-elements/model-picker-types.ts +56 -0
- package/src/ai-elements/model-picker.tsx +301 -0
- package/src/ai-elements/model-selector.tsx +102 -21
- package/src/ai-elements/plan.tsx +130 -0
- package/src/ai-elements/prompt-input-settings.tsx +144 -0
- package/src/ai-elements/prompt-input-speech.tsx +189 -0
- package/src/ai-elements/prompt-input-traits.tsx +281 -0
- package/src/ai-elements/prompt-input.tsx +320 -134
- package/src/ai-elements/provider-icon.tsx +133 -0
- package/src/ai-elements/queue.tsx +293 -9
- package/src/ai-elements/shimmer.tsx +72 -1
- package/src/ai-elements/simple-model-ladder.tsx +108 -0
- package/src/ai-elements/test-results.tsx +510 -0
- package/src/ai-elements/trace.tsx +243 -0
- package/src/ai-elements/use-streaming-disclosure.ts +50 -0
- package/src/ai-elements/web-preview.tsx +349 -0
- package/src/composer/input-area/ComposerSlashMenu.tsx +1 -1
- package/src/index.ts +66 -12
- package/src/kibo/avatar-stack.tsx +40 -0
- package/src/kibo/contribution-graph.tsx +539 -0
- package/src/kibo/cursor.tsx +72 -0
- package/src/kibo/gantt-features.tsx +439 -0
- package/src/kibo/gantt-header.tsx +181 -0
- package/src/kibo/gantt-provider.tsx +657 -0
- package/src/kibo/gantt-sidebar.tsx +157 -0
- package/src/kibo/gantt-timeline.tsx +154 -0
- package/src/kibo/index.ts +151 -0
- package/src/kibo/kanban.tsx +338 -0
- package/src/kibo/kanbanDropTarget.ts +25 -0
- package/src/kibo/list.tsx +162 -0
- package/src/kibo/theme-switcher.tsx +109 -0
- package/src/kibo/video-player.tsx +237 -0
- package/src/markdown/Markdown.tsx +112 -0
- package/src/markdown/MarkdownBlockquote.tsx +45 -0
- package/src/markdown/MarkdownCodeBlock.tsx +197 -0
- package/src/markdown/MarkdownInlineCode.tsx +40 -0
- package/src/markdown/MarkdownLink.tsx +92 -0
- package/src/markdown/codeFence.ts +57 -0
- package/src/markdown/codeHighlight.ts +138 -0
- package/src/markdown/incrementalBlocks.ts +182 -0
- package/src/markdown/index.ts +3 -0
- package/src/markdown/markdownClipboard.ts +178 -0
- package/src/markdown/mermaid.ts +155 -0
- package/src/markdown/rehypeGithubAlerts.ts +80 -0
- package/src/motion/presets.ts +24 -0
- package/src/ui/__snapshots__/alt-wrappers.test.tsx.snap +19 -0
- package/src/ui/__snapshots__/sonner-dom.test.tsx.snap +5 -0
- package/src/ui/_menu-parts.tsx +29 -0
- package/src/ui/accordion.alt.tsx +46 -53
- package/src/ui/accordion.tsx +59 -10
- package/src/ui/avatar.tsx +56 -0
- package/src/ui/badge.tsx +45 -21
- package/src/ui/border-beam.tsx +97 -0
- package/src/ui/button-group.tsx +50 -21
- package/src/ui/button.tsx +67 -45
- package/src/ui/calendar.tsx +48 -5
- package/src/ui/card.tsx +77 -8
- package/src/ui/carousel.tsx +60 -0
- package/src/ui/centered-spinner.tsx +30 -0
- package/src/ui/checkbox.tsx +34 -16
- package/src/ui/clear-input.alt.tsx +33 -75
- package/src/ui/clear-input.tsx +140 -15
- package/src/ui/collapsible.tsx +40 -2
- package/src/ui/command.tsx +143 -70
- package/src/ui/context-menu.tsx +159 -93
- package/src/ui/crossfade-icon.tsx +123 -0
- package/src/ui/dialog.tsx +154 -70
- package/src/ui/dropdown-menu.tsx +177 -117
- package/src/ui/hover-card.tsx +59 -17
- package/src/ui/input-group.alt.tsx +83 -83
- package/src/ui/input-group.tsx +129 -58
- package/src/ui/input.tsx +13 -4
- package/src/ui/label.tsx +11 -8
- package/src/ui/list-row.tsx +232 -0
- package/src/ui/loading-state.tsx +215 -0
- package/src/ui/popover.tsx +56 -23
- package/src/ui/progress.tsx +28 -16
- package/src/ui/refresh-spin-icon.tsx +41 -0
- package/src/ui/search-input.tsx +66 -15
- package/src/ui/select.tsx +171 -100
- package/src/ui/separator.tsx +23 -14
- package/src/ui/slider.tsx +21 -7
- package/src/ui/sonner.tsx +68 -30
- package/src/ui/spinner.tsx +229 -11
- package/src/ui/status-dot.tsx +84 -0
- package/src/ui/surface.tsx +49 -0
- package/src/ui/switch.tsx +29 -15
- package/src/ui/table.tsx +38 -16
- package/src/ui/tabs.tsx +123 -35
- package/src/ui/tabsSliding.ts +33 -1
- package/src/ui/textarea.tsx +13 -4
- package/src/ui/tooltip.tsx +50 -14
- package/src/ui/ui-config.tsx +149 -0
- package/src/utils/cat-color.ts +123 -0
- package/src/utils/dayjs.ts +6 -0
- package/src/utils/gesture.ts +59 -0
- package/src/utils/runtimeVisibility.ts +167 -0
- package/src/utils/sharedClock.ts +96 -0
- package/src/utils/surface-radius.ts +16 -0
- package/src/utils/useDragSensors.ts +58 -0
- package/tokens/examples/tokens-moved/Fixture.tsx +283 -0
- package/tokens/examples/tokens-moved/README.md +20 -0
- package/tokens/examples/tokens-moved/index.html +12 -0
- package/tokens/examples/tokens-moved/main.tsx +6 -0
- package/tokens/examples/tokens-moved/package.json +14 -0
- package/tokens/examples/tokens-moved/styles.css +12 -0
- package/tokens/examples/tokens-moved/tsconfig.json +14 -0
- package/tokens/examples/tokens-moved/vite.config.ts +11 -0
- package/tokens/src/css/border-beam.css +116 -0
- 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
|
+
}
|
package/src/ui/popover.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 };
|
package/src/ui/progress.tsx
CHANGED
|
@@ -1,25 +1,37 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
|
3
|
-
import {
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 };
|