devnonla-ui 0.1.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/LICENSE +21 -0
- package/README.md +140 -0
- package/package.json +66 -0
- package/src/alert/Alert.tsx +109 -0
- package/src/app/App.tsx +36 -0
- package/src/button/Button.tsx +278 -0
- package/src/button/ButtonCopy.tsx +60 -0
- package/src/calendar/Calendar.tsx +133 -0
- package/src/chat/AgentPanel.tsx +203 -0
- package/src/chat/common/bgTasks.ts +41 -0
- package/src/chat/common/sse.ts +156 -0
- package/src/chat/common/types.ts +43 -0
- package/src/chat/common/useAgentStream.ts +351 -0
- package/src/chat/common/utils.ts +73 -0
- package/src/chat/index.ts +56 -0
- package/src/chat/message-ui/ChatAgentMessage.tsx +29 -0
- package/src/chat/message-ui/ChatError.tsx +24 -0
- package/src/chat/message-ui/ChatInput.tsx +162 -0
- package/src/chat/message-ui/ChatMarkdown.tsx +128 -0
- package/src/chat/message-ui/ChatMarkdownTable.tsx +45 -0
- package/src/chat/message-ui/ChatSpinner.tsx +5 -0
- package/src/chat/message-ui/ChatThinking.tsx +61 -0
- package/src/chat/message-ui/ChatUserMessage.tsx +72 -0
- package/src/chat/message-ui/ChatWelcome.tsx +70 -0
- package/src/chat/message-ui/MermaidBlock.tsx +266 -0
- package/src/chat/message-ui/mermaidFence.ts +55 -0
- package/src/chat/message-ui/sanitizeMermaid.ts +135 -0
- package/src/chat/tool-ui/BackgroundTaskToolUI.tsx +36 -0
- package/src/chat/tool-ui/BackgroundTasksBar.tsx +152 -0
- package/src/chat/tool-ui/CallAgentToolUI.tsx +168 -0
- package/src/chat/tool-ui/ChatToolCall.tsx +77 -0
- package/src/chat/tool-ui/GetCurrentTimeToolUI.tsx +49 -0
- package/src/chat/tool-ui/ReadSkillToolUI.tsx +80 -0
- package/src/chat/tool-ui/RunJsToolUI.tsx +106 -0
- package/src/chat/tool-ui/ToolUiBadge.tsx +10 -0
- package/src/chat/tool-ui/ToolUiTrailing.tsx +24 -0
- package/src/chat/tool-ui/WebFetchToolUI.tsx +53 -0
- package/src/chat/tool-ui/registry.ts +29 -0
- package/src/chat/tool-ui/types.ts +22 -0
- package/src/checkbox/Checkbox.tsx +50 -0
- package/src/codeblock/CodeBlock.tsx +76 -0
- package/src/codeblock/highlight.ts +159 -0
- package/src/colorpicker/ColorPicker.tsx +45 -0
- package/src/datepicker/DatePicker.tsx +481 -0
- package/src/desktop/DesktopHeader.tsx +92 -0
- package/src/desktop/DesktopIcon.tsx +34 -0
- package/src/desktop/DesktopStage.tsx +6 -0
- package/src/desktop/DesktopWindow.tsx +504 -0
- package/src/desktop/MeadowDesktop.tsx +15 -0
- package/src/desktop/MeadowShell.tsx +12 -0
- package/src/desktop/MeadowWallpaper.tsx +5 -0
- package/src/desktop/assets/desktop-meadow.jpg +0 -0
- package/src/desktop/assets.d.ts +9 -0
- package/src/desktop/surface.ts +1 -0
- package/src/drawer/Drawer.tsx +125 -0
- package/src/dropdown/ContextMenu.tsx +97 -0
- package/src/dropdown/Dropdown.tsx +139 -0
- package/src/dropdown/menuClasses.ts +9 -0
- package/src/empty/Empty.tsx +47 -0
- package/src/form/Form.tsx +95 -0
- package/src/form/FormItem.tsx +145 -0
- package/src/form/common/context.ts +13 -0
- package/src/form/common/enum.ts +20 -0
- package/src/form/common/rules.ts +68 -0
- package/src/form/common/types.ts +169 -0
- package/src/form/index.ts +35 -0
- package/src/form/partials/FormItemHelps.tsx +31 -0
- package/src/form/variants/FieldCheckbox.tsx +20 -0
- package/src/form/variants/FieldColor.tsx +63 -0
- package/src/form/variants/FieldDateTime.tsx +25 -0
- package/src/form/variants/FieldHidden.tsx +9 -0
- package/src/form/variants/FieldInput.tsx +24 -0
- package/src/form/variants/FieldNumber.tsx +28 -0
- package/src/form/variants/FieldObject.tsx +29 -0
- package/src/form/variants/FieldRadio.tsx +40 -0
- package/src/form/variants/FieldRepeater.tsx +62 -0
- package/src/form/variants/FieldRepeaterItem.tsx +64 -0
- package/src/form/variants/FieldSelect.tsx +27 -0
- package/src/form/variants/FieldSelectMultiple.tsx +32 -0
- package/src/form/variants/FieldSelectRemote.tsx +66 -0
- package/src/form/variants/FieldSwitch.tsx +17 -0
- package/src/form/variants/FieldTextarea.tsx +25 -0
- package/src/form/variants/FieldTime.tsx +25 -0
- package/src/form-layout/FormLayout.tsx +74 -0
- package/src/icon/FluentIcon.tsx +28 -0
- package/src/icon/fluent.ts +75 -0
- package/src/index.ts +214 -0
- package/src/input/Input.tsx +371 -0
- package/src/input/SearchInput.tsx +73 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/placement.ts +44 -0
- package/src/lib/sizes.ts +117 -0
- package/src/lib/surface.ts +8 -0
- package/src/menu/Menu.tsx +98 -0
- package/src/message/message.tsx +214 -0
- package/src/modal/Modal.tsx +277 -0
- package/src/pagination/Pagination.tsx +103 -0
- package/src/popconfirm/Popconfirm.tsx +134 -0
- package/src/popover/Popover.tsx +116 -0
- package/src/scroll/OverlayScroll.tsx +141 -0
- package/src/segmented/Segmented.tsx +67 -0
- package/src/select/Select.tsx +319 -0
- package/src/skeleton/Skeleton.tsx +47 -0
- package/src/spin/Spin.tsx +160 -0
- package/src/styles.css +897 -0
- package/src/switch/Switch.tsx +58 -0
- package/src/table/Table.tsx +387 -0
- package/src/tag/Tag.tsx +109 -0
- package/src/theme.ts +131 -0
- package/src/timepicker/TimePicker.tsx +751 -0
- package/src/tooltip/Tooltip.tsx +59 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
2
|
+
import {
|
|
3
|
+
type KeyboardEvent,
|
|
4
|
+
type ReactElement,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
forwardRef,
|
|
7
|
+
isValidElement,
|
|
8
|
+
useEffect,
|
|
9
|
+
useLayoutEffect,
|
|
10
|
+
useMemo,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from "react";
|
|
14
|
+
import { cn } from "../lib/cn";
|
|
15
|
+
import { type ControlSize, controlFieldFocusBorder, controlFieldStyle, controlFieldSurface, controlFieldTransition, controlStatusClass } from "../lib/sizes";
|
|
16
|
+
import { glassOverlayClass } from "../lib/surface";
|
|
17
|
+
|
|
18
|
+
export type SelectValue = string | number;
|
|
19
|
+
|
|
20
|
+
export type SelectOptionConfig = {
|
|
21
|
+
label: ReactNode;
|
|
22
|
+
value: SelectValue;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type SelectProps = {
|
|
27
|
+
value?: SelectValue | null;
|
|
28
|
+
defaultValue?: SelectValue | null;
|
|
29
|
+
// antd interop: call sites often use (v: string) => void
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
+
onChange?: (value: any) => void;
|
|
32
|
+
// Looser antd interop — many call sites type handlers as `(v: string) => void`.
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
34
|
+
options?: SelectOptionConfig[];
|
|
35
|
+
placeholder?: string;
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
allowClear?: boolean;
|
|
38
|
+
showSearch?: boolean | { optionFilterProp?: "label" | "value" | string };
|
|
39
|
+
popupMatchSelectWidth?: boolean;
|
|
40
|
+
size?: ControlSize;
|
|
41
|
+
status?: "error" | "warning";
|
|
42
|
+
className?: string;
|
|
43
|
+
popupClassName?: string;
|
|
44
|
+
children?: ReactNode;
|
|
45
|
+
/** antd alias */
|
|
46
|
+
onSelect?: (value: SelectValue) => void;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function Chevron() {
|
|
50
|
+
return (
|
|
51
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden className="opacity-60">
|
|
52
|
+
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
53
|
+
</svg>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ClearIcon() {
|
|
58
|
+
return (
|
|
59
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden>
|
|
60
|
+
<path d="M3 3L9 9M9 3L3 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
61
|
+
</svg>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function textOf(node: ReactNode): string {
|
|
66
|
+
if (node == null || typeof node === "boolean") return "";
|
|
67
|
+
if (typeof node === "string" || typeof node === "number") return String(node);
|
|
68
|
+
if (Array.isArray(node)) return node.map(textOf).join(" ");
|
|
69
|
+
if (isValidElement(node)) return textOf((node as ReactElement<{ children?: ReactNode }>).props.children);
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function optionSearchText(opt: SelectOptionConfig, prop: string): string {
|
|
74
|
+
if (prop === "value") return String(opt.value);
|
|
75
|
+
return textOf(opt.label) || String(opt.value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select(
|
|
79
|
+
{
|
|
80
|
+
value,
|
|
81
|
+
defaultValue,
|
|
82
|
+
onChange,
|
|
83
|
+
onSelect,
|
|
84
|
+
options = [],
|
|
85
|
+
placeholder = "Select",
|
|
86
|
+
disabled,
|
|
87
|
+
allowClear,
|
|
88
|
+
showSearch,
|
|
89
|
+
popupMatchSelectWidth = true,
|
|
90
|
+
size,
|
|
91
|
+
status,
|
|
92
|
+
className,
|
|
93
|
+
popupClassName,
|
|
94
|
+
},
|
|
95
|
+
ref,
|
|
96
|
+
) {
|
|
97
|
+
const controlled = value !== undefined;
|
|
98
|
+
const [inner, setInner] = useState<SelectValue | null>(defaultValue ?? null);
|
|
99
|
+
const selected = controlled ? (value ?? null) : inner;
|
|
100
|
+
const [open, setOpen] = useState(false);
|
|
101
|
+
const [query, setQuery] = useState("");
|
|
102
|
+
const [active, setActive] = useState(0);
|
|
103
|
+
const [triggerW, setTriggerW] = useState<number>();
|
|
104
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
|
105
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
106
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
107
|
+
|
|
108
|
+
const searchable = Boolean(showSearch);
|
|
109
|
+
const filterProp =
|
|
110
|
+
typeof showSearch === "object" ? (showSearch.optionFilterProp ?? "value") : "value";
|
|
111
|
+
|
|
112
|
+
const selectedOpt = options.find((o) => o.value === selected);
|
|
113
|
+
|
|
114
|
+
const filtered = useMemo(() => {
|
|
115
|
+
if (!searchable || !query.trim()) return options;
|
|
116
|
+
const q = query.trim().toLowerCase();
|
|
117
|
+
return options.filter((o) => optionSearchText(o, filterProp).toLowerCase().includes(q));
|
|
118
|
+
}, [options, query, searchable, filterProp]);
|
|
119
|
+
|
|
120
|
+
useLayoutEffect(() => {
|
|
121
|
+
if (!open) return;
|
|
122
|
+
const w = triggerRef.current?.offsetWidth;
|
|
123
|
+
if (w) setTriggerW(w);
|
|
124
|
+
}, [open]);
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!open) {
|
|
128
|
+
setQuery("");
|
|
129
|
+
setActive(0);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const idx = options.findIndex((o) => o.value === selected);
|
|
133
|
+
setActive(idx >= 0 ? idx : 0);
|
|
134
|
+
if (searchable) {
|
|
135
|
+
const t = window.setTimeout(() => searchRef.current?.focus(), 0);
|
|
136
|
+
return () => window.clearTimeout(t);
|
|
137
|
+
}
|
|
138
|
+
}, [open, searchable]);
|
|
139
|
+
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
const el = listRef.current?.querySelector<HTMLElement>("[data-active='true']");
|
|
142
|
+
el?.scrollIntoView({ block: "nearest" });
|
|
143
|
+
}, [active, filtered]);
|
|
144
|
+
|
|
145
|
+
const commit = (next: SelectValue | null) => {
|
|
146
|
+
if (!controlled) setInner(next);
|
|
147
|
+
onChange?.(next);
|
|
148
|
+
if (next != null) onSelect?.(next);
|
|
149
|
+
setOpen(false);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const move = (dir: 1 | -1) => {
|
|
153
|
+
const enabledIdx = filtered.map((o, i) => ({ o, i })).filter((x) => !x.o.disabled);
|
|
154
|
+
if (!enabledIdx.length) return;
|
|
155
|
+
const cur = enabledIdx.findIndex((x) => x.i === active);
|
|
156
|
+
const from = cur < 0 ? (dir === 1 ? -1 : 0) : cur;
|
|
157
|
+
const next = enabledIdx[(from + dir + enabledIdx.length) % enabledIdx.length];
|
|
158
|
+
if (next) setActive(next.i);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const pickActive = () => {
|
|
162
|
+
const opt = filtered[active];
|
|
163
|
+
if (opt && !opt.disabled) commit(opt.value);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const onTriggerKey = (e: KeyboardEvent<HTMLButtonElement>) => {
|
|
167
|
+
if (disabled) return;
|
|
168
|
+
if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
|
|
169
|
+
e.preventDefault();
|
|
170
|
+
setOpen(true);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const onListKey = (e: KeyboardEvent) => {
|
|
175
|
+
if (e.key === "ArrowDown") {
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
move(1);
|
|
178
|
+
} else if (e.key === "ArrowUp") {
|
|
179
|
+
e.preventDefault();
|
|
180
|
+
move(-1);
|
|
181
|
+
} else if (e.key === "Enter") {
|
|
182
|
+
e.preventDefault();
|
|
183
|
+
pickActive();
|
|
184
|
+
} else if (e.key === "Escape") {
|
|
185
|
+
e.preventDefault();
|
|
186
|
+
setOpen(false);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const setRefs = (node: HTMLButtonElement | null) => {
|
|
191
|
+
triggerRef.current = node;
|
|
192
|
+
if (typeof ref === "function") ref(node);
|
|
193
|
+
else if (ref) (ref as { current: HTMLButtonElement | null }).current = node;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const showClear = allowClear && selected != null && selected !== "" && !disabled;
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<PopoverPrimitive.Root
|
|
200
|
+
open={open}
|
|
201
|
+
onOpenChange={(v) => {
|
|
202
|
+
if (disabled) return;
|
|
203
|
+
setOpen(v);
|
|
204
|
+
}}
|
|
205
|
+
>
|
|
206
|
+
<PopoverPrimitive.Trigger asChild>
|
|
207
|
+
<button
|
|
208
|
+
ref={setRefs}
|
|
209
|
+
type="button"
|
|
210
|
+
disabled={disabled}
|
|
211
|
+
aria-haspopup="listbox"
|
|
212
|
+
aria-expanded={open}
|
|
213
|
+
className={cn(
|
|
214
|
+
"group/select inline-flex w-full cursor-pointer items-center gap-2 text-left",
|
|
215
|
+
controlFieldSurface,
|
|
216
|
+
controlFieldTransition,
|
|
217
|
+
controlFieldFocusBorder,
|
|
218
|
+
"focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-45",
|
|
219
|
+
controlStatusClass(status),
|
|
220
|
+
className,
|
|
221
|
+
)}
|
|
222
|
+
style={controlFieldStyle(size)}
|
|
223
|
+
onKeyDown={onTriggerKey}
|
|
224
|
+
>
|
|
225
|
+
<span className={cn("min-w-0 flex-1 truncate", selected == null || selected === "" ? "text-quaternary-foreground" : "")}>
|
|
226
|
+
{selectedOpt ? selectedOpt.label : selected != null && selected !== "" ? String(selected) : placeholder}
|
|
227
|
+
</span>
|
|
228
|
+
{showClear ? (
|
|
229
|
+
<span
|
|
230
|
+
role="button"
|
|
231
|
+
tabIndex={-1}
|
|
232
|
+
aria-label="Clear"
|
|
233
|
+
className="inline-flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground opacity-0 transition-opacity hover:bg-foreground/6 hover:text-foreground group-hover/select:opacity-100"
|
|
234
|
+
onPointerDown={(e) => e.preventDefault()}
|
|
235
|
+
onClick={(e) => {
|
|
236
|
+
e.preventDefault();
|
|
237
|
+
e.stopPropagation();
|
|
238
|
+
commit(null);
|
|
239
|
+
}}
|
|
240
|
+
>
|
|
241
|
+
<ClearIcon />
|
|
242
|
+
</span>
|
|
243
|
+
) : null}
|
|
244
|
+
<Chevron />
|
|
245
|
+
</button>
|
|
246
|
+
</PopoverPrimitive.Trigger>
|
|
247
|
+
<PopoverPrimitive.Portal>
|
|
248
|
+
<PopoverPrimitive.Content
|
|
249
|
+
align="start"
|
|
250
|
+
sideOffset={4}
|
|
251
|
+
onOpenAutoFocus={(e) => {
|
|
252
|
+
if (searchable) {
|
|
253
|
+
e.preventDefault();
|
|
254
|
+
searchRef.current?.focus();
|
|
255
|
+
}
|
|
256
|
+
}}
|
|
257
|
+
onKeyDown={onListKey}
|
|
258
|
+
className={cn(
|
|
259
|
+
glassOverlayClass,
|
|
260
|
+
"overflow-hidden p-0",
|
|
261
|
+
popupClassName,
|
|
262
|
+
)}
|
|
263
|
+
style={{
|
|
264
|
+
minWidth: triggerW,
|
|
265
|
+
width: popupMatchSelectWidth ? triggerW : undefined,
|
|
266
|
+
}}
|
|
267
|
+
>
|
|
268
|
+
{searchable ? (
|
|
269
|
+
<div className="border-b border-glass-border p-1.5">
|
|
270
|
+
<input
|
|
271
|
+
ref={searchRef}
|
|
272
|
+
value={query}
|
|
273
|
+
onChange={(e) => {
|
|
274
|
+
setQuery(e.target.value);
|
|
275
|
+
setActive(0);
|
|
276
|
+
}}
|
|
277
|
+
placeholder="Search…"
|
|
278
|
+
className="h-7 w-full rounded-md border-0 bg-transparent px-2 text-sm text-foreground outline-none placeholder:text-quaternary-foreground"
|
|
279
|
+
/>
|
|
280
|
+
</div>
|
|
281
|
+
) : null}
|
|
282
|
+
<div ref={listRef} role="listbox" tabIndex={-1} className="flex max-h-72 flex-col gap-px overflow-auto p-1">
|
|
283
|
+
{filtered.length === 0 ? (
|
|
284
|
+
<div className="px-2.5 py-2 text-sm text-muted-foreground">No results</div>
|
|
285
|
+
) : (
|
|
286
|
+
filtered.map((opt, i) => (
|
|
287
|
+
<div
|
|
288
|
+
key={String(opt.value)}
|
|
289
|
+
role="option"
|
|
290
|
+
tabIndex={-1}
|
|
291
|
+
aria-selected={opt.value === selected}
|
|
292
|
+
data-active={i === active ? "true" : undefined}
|
|
293
|
+
className={cn(
|
|
294
|
+
"relative flex cursor-pointer select-none items-center rounded-md border border-solid border-transparent px-2.5 py-1.5 text-sm outline-none",
|
|
295
|
+
opt.disabled && "pointer-events-none opacity-40",
|
|
296
|
+
!opt.disabled && opt.value === selected && "nonla-glass-chip-on text-foreground",
|
|
297
|
+
!opt.disabled && i === active && opt.value !== selected && "nonla-glass-chip",
|
|
298
|
+
)}
|
|
299
|
+
onMouseEnter={() => setActive(i)}
|
|
300
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
301
|
+
onClick={() => {
|
|
302
|
+
if (!opt.disabled) commit(opt.value);
|
|
303
|
+
}}
|
|
304
|
+
>
|
|
305
|
+
{opt.label}
|
|
306
|
+
</div>
|
|
307
|
+
))
|
|
308
|
+
)}
|
|
309
|
+
</div>
|
|
310
|
+
</PopoverPrimitive.Content>
|
|
311
|
+
</PopoverPrimitive.Portal>
|
|
312
|
+
</PopoverPrimitive.Root>
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
/** API compat — options-as-children is not parsed; pass `options`. */
|
|
317
|
+
export function SelectOption({ children }: { value: SelectValue; disabled?: boolean; children?: ReactNode }) {
|
|
318
|
+
return <>{children}</>;
|
|
319
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
export type SkeletonProps = {
|
|
5
|
+
active?: boolean;
|
|
6
|
+
loading?: boolean;
|
|
7
|
+
paragraph?: boolean | { rows?: number };
|
|
8
|
+
title?: boolean;
|
|
9
|
+
avatar?: boolean;
|
|
10
|
+
className?: string;
|
|
11
|
+
children?: ReactNode;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function SkeletonRoot({ active = true, loading = true, paragraph = true, title = true, avatar, className, children }: SkeletonProps) {
|
|
15
|
+
if (!loading && children) return <>{children}</>;
|
|
16
|
+
const rows = typeof paragraph === "object" ? (paragraph.rows ?? 3) : paragraph ? 3 : 0;
|
|
17
|
+
const pulse = active ? "animate-pulse" : "";
|
|
18
|
+
return (
|
|
19
|
+
<div className={cn("flex gap-3", className)}>
|
|
20
|
+
{avatar ? <div className={cn("size-10 shrink-0 rounded-full bg-secondary", pulse)} /> : null}
|
|
21
|
+
<div className="flex-1 space-y-2">
|
|
22
|
+
{title ? <div className={cn("h-4 w-1/3 rounded bg-secondary", pulse)} /> : null}
|
|
23
|
+
{Array.from({ length: rows }).map((_, i) => (
|
|
24
|
+
<div key={i} className={cn("h-3 rounded bg-secondary", pulse, i === rows - 1 ? "w-2/3" : "w-full")} />
|
|
25
|
+
))}
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type SkeletonInputProps = {
|
|
32
|
+
active?: boolean;
|
|
33
|
+
block?: boolean;
|
|
34
|
+
className?: string;
|
|
35
|
+
style?: CSSProperties;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function SkeletonInput({ active = true, block, className, style }: SkeletonInputProps) {
|
|
39
|
+
return (
|
|
40
|
+
<div
|
|
41
|
+
className={cn("h-8 rounded-md bg-secondary", active && "animate-pulse", block ? "w-full" : "w-40", className)}
|
|
42
|
+
style={style}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const Skeleton = Object.assign(SkeletonRoot, { Input: SkeletonInput });
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useState } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
import { type ControlSize, getSizeTokens } from "../lib/sizes";
|
|
4
|
+
|
|
5
|
+
export type SpinVariant = "default" | "agent" | "subAgent";
|
|
6
|
+
|
|
7
|
+
export type SpinProps = {
|
|
8
|
+
spinning?: boolean;
|
|
9
|
+
tip?: ReactNode;
|
|
10
|
+
size?: ControlSize;
|
|
11
|
+
/** Visual style — agent / subAgent for AI run states. */
|
|
12
|
+
variant?: SpinVariant;
|
|
13
|
+
/** Replace the built-in indicator entirely. */
|
|
14
|
+
indicator?: ReactNode;
|
|
15
|
+
children?: ReactNode;
|
|
16
|
+
className?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function dimFor(size: ControlSize | undefined) {
|
|
20
|
+
return Math.round(getSizeTokens(size).icon * 1.25);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function DefaultSpinner({ size }: { size: ControlSize | undefined }) {
|
|
24
|
+
const dim = dimFor(size);
|
|
25
|
+
return (
|
|
26
|
+
<span
|
|
27
|
+
className="inline-block rounded-full border-2 border-current/25 border-t-current animate-spin text-brand"
|
|
28
|
+
style={{ width: dim, height: dim }}
|
|
29
|
+
aria-hidden
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Cell = number; // 0..8 on a 3×3 grid
|
|
35
|
+
/** Snake = [head, mid, tail] — always 3 orthogonally adjacent cells. */
|
|
36
|
+
type Snake = [Cell, Cell, Cell];
|
|
37
|
+
|
|
38
|
+
const NEIGHBORS: Record<number, number[]> = {
|
|
39
|
+
0: [1, 3],
|
|
40
|
+
1: [0, 2, 4],
|
|
41
|
+
2: [1, 5],
|
|
42
|
+
3: [0, 4, 6],
|
|
43
|
+
4: [1, 3, 5, 7],
|
|
44
|
+
5: [2, 4, 8],
|
|
45
|
+
6: [3, 7],
|
|
46
|
+
7: [4, 6, 8],
|
|
47
|
+
8: [5, 7],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function pick<T>(items: T[]): T {
|
|
51
|
+
return items[Math.floor(Math.random() * items.length)]!;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function randomSnake(): Snake {
|
|
55
|
+
const head = Math.floor(Math.random() * 9);
|
|
56
|
+
const mid = pick(NEIGHBORS[head]!);
|
|
57
|
+
const tail = pick(NEIGHBORS[mid]!.filter((c) => c !== head && c !== mid));
|
|
58
|
+
return [head, mid, tail];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Step the snake: head walks to a random free neighbor; body follows. */
|
|
62
|
+
function stepSnake(snake: Snake): Snake {
|
|
63
|
+
const [head, mid, tail] = snake;
|
|
64
|
+
let nextHeads = NEIGHBORS[head]!.filter((c) => c !== mid);
|
|
65
|
+
if (nextHeads.length === 0) {
|
|
66
|
+
// Dead-end — reverse direction from current pose.
|
|
67
|
+
return [tail, mid, head];
|
|
68
|
+
}
|
|
69
|
+
// Bias away from immediately reversing into the tail when other choices exist.
|
|
70
|
+
const forward = nextHeads.filter((c) => c !== tail);
|
|
71
|
+
if (forward.length) nextHeads = forward;
|
|
72
|
+
const next = pick(nextHeads);
|
|
73
|
+
return [next, head, mid];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 3×3 fixed dots — 3 adjacent lit cells crawl randomly (snake). */
|
|
77
|
+
function MatrixSnake({ size, tickMs = 220 }: { size: ControlSize | undefined; tickMs?: number }) {
|
|
78
|
+
const scale = getSizeTokens(size).icon / 16;
|
|
79
|
+
const cell = Math.max(4, Math.round(5 * scale));
|
|
80
|
+
const gap = Math.max(2, Math.round(2 * scale));
|
|
81
|
+
const dim = cell * 3 + gap * 2;
|
|
82
|
+
const [snake, setSnake] = useState<Snake>(() => randomSnake());
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const id = window.setInterval(() => setSnake((s) => stepSnake(s)), tickMs);
|
|
89
|
+
return () => window.clearInterval(id);
|
|
90
|
+
}, [tickMs]);
|
|
91
|
+
|
|
92
|
+
const lit = new Set(snake);
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<span
|
|
96
|
+
className="nonla-spin-matrix inline-grid"
|
|
97
|
+
style={{
|
|
98
|
+
width: dim,
|
|
99
|
+
height: dim,
|
|
100
|
+
gridTemplateColumns: `repeat(3, ${cell}px)`,
|
|
101
|
+
gridTemplateRows: `repeat(3, ${cell}px)`,
|
|
102
|
+
gap,
|
|
103
|
+
}}
|
|
104
|
+
aria-hidden
|
|
105
|
+
>
|
|
106
|
+
{Array.from({ length: 9 }, (_, i) => (
|
|
107
|
+
<span key={`c${i % 3}-r${Math.floor(i / 3)}`} className={cn("nonla-spin-matrix-cell", lit.has(i) && "is-on")} />
|
|
108
|
+
))}
|
|
109
|
+
</span>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function AgentSpinner({ size }: { size: ControlSize | undefined }) {
|
|
114
|
+
return <MatrixSnake size={size} tickMs={240} />;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function SubAgentSpinner({ size }: { size: ControlSize | undefined }) {
|
|
118
|
+
return <MatrixSnake size={size} tickMs={180} />;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function Spinner({ size, variant }: { size: ControlSize | undefined; variant: SpinVariant }) {
|
|
122
|
+
if (variant === "agent") return <AgentSpinner size={size} />;
|
|
123
|
+
if (variant === "subAgent") return <SubAgentSpinner size={size} />;
|
|
124
|
+
return <DefaultSpinner size={size} />;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function Indicator({
|
|
128
|
+
size,
|
|
129
|
+
variant,
|
|
130
|
+
indicator,
|
|
131
|
+
}: {
|
|
132
|
+
size: ControlSize | undefined;
|
|
133
|
+
variant: SpinVariant;
|
|
134
|
+
indicator?: ReactNode;
|
|
135
|
+
}) {
|
|
136
|
+
if (indicator != null) return <>{indicator}</>;
|
|
137
|
+
return <Spinner size={size} variant={variant} />;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function Spin({ spinning = true, tip, size, variant = "default", indicator, children, className }: SpinProps) {
|
|
141
|
+
if (children == null) {
|
|
142
|
+
return (
|
|
143
|
+
<div className={cn("inline-flex flex-col items-center gap-2", className)} role={spinning ? "status" : undefined} aria-live={spinning ? "polite" : undefined}>
|
|
144
|
+
{spinning ? <Indicator size={size} variant={variant} indicator={indicator} /> : null}
|
|
145
|
+
{tip ? <span className="text-xs text-muted-foreground">{tip}</span> : null}
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return (
|
|
150
|
+
<div className={cn("relative", className)}>
|
|
151
|
+
{children}
|
|
152
|
+
{spinning ? (
|
|
153
|
+
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 bg-background/50" role="status" aria-live="polite">
|
|
154
|
+
<Indicator size={size} variant={variant} indicator={indicator} />
|
|
155
|
+
{tip ? <span className="text-xs text-muted-foreground">{tip}</span> : null}
|
|
156
|
+
</div>
|
|
157
|
+
) : null}
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|