@hilum/ui 3.3.4 → 3.5.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 (66) hide show
  1. package/README.md +4 -0
  2. package/dist/{chunk-MAHPAP65.js → chunk-RI6QJR2L.js} +6 -3
  3. package/dist/chunk-RI6QJR2L.js.map +1 -0
  4. package/dist/index.d.ts +800 -122
  5. package/dist/index.js +10458 -1119
  6. package/dist/index.js.map +1 -1
  7. package/dist/tokens.css +17 -0
  8. package/dist/tokens.d.ts +3 -0
  9. package/dist/tokens.js +1 -1
  10. package/llms.txt +18 -4
  11. package/package.json +19 -1
  12. package/src/components/accordion.tsx +746 -58
  13. package/src/components/alert-dialog.tsx +11 -5
  14. package/src/components/ask-user-questions.tsx +1366 -0
  15. package/src/components/badge.tsx +105 -24
  16. package/src/components/button.tsx +207 -38
  17. package/src/components/card.tsx +3 -2
  18. package/src/components/chat-message.tsx +116 -0
  19. package/src/components/checkbox-group.tsx +403 -0
  20. package/src/components/checkbox.tsx +6 -3
  21. package/src/components/color-input.tsx +3 -1
  22. package/src/components/color-picker.tsx +1992 -80
  23. package/src/components/dialog.tsx +141 -81
  24. package/src/components/dropdown-menu.tsx +21 -13
  25. package/src/components/dropdown.tsx +245 -0
  26. package/src/components/file-thumbnail.tsx +156 -0
  27. package/src/components/hover-card.tsx +2 -0
  28. package/src/components/input-copy.tsx +307 -0
  29. package/src/components/input-group.tsx +303 -108
  30. package/src/components/input-message.tsx +883 -0
  31. package/src/components/input-number.tsx +62 -21
  32. package/src/components/input.tsx +6 -3
  33. package/src/components/menu-item.tsx +156 -0
  34. package/src/components/mobile-drawer.tsx +50 -0
  35. package/src/components/nav-item.tsx +43 -0
  36. package/src/components/nav-menu.tsx +47 -0
  37. package/src/components/popover.tsx +3 -1
  38. package/src/components/radio-group.tsx +381 -35
  39. package/src/components/scroll-area.tsx +9 -2
  40. package/src/components/select.tsx +708 -121
  41. package/src/components/sheet.tsx +15 -6
  42. package/src/components/slider.tsx +1583 -20
  43. package/src/components/status-badge.tsx +2 -0
  44. package/src/components/switch.tsx +245 -28
  45. package/src/components/table.tsx +191 -77
  46. package/src/components/tabs-subtle.tsx +411 -0
  47. package/src/components/tabs.tsx +459 -49
  48. package/src/components/textarea.tsx +7 -3
  49. package/src/components/thinking-indicator.tsx +106 -0
  50. package/src/components/thinking-steps.tsx +382 -0
  51. package/src/components/tooltip.tsx +158 -33
  52. package/src/hooks/use-merge-split.tsx +359 -0
  53. package/src/hooks/use-proximity-hover.ts +208 -0
  54. package/src/index.ts +14 -0
  55. package/src/lib/elevated.tsx +52 -0
  56. package/src/lib/font-weight.ts +18 -0
  57. package/src/lib/icon-context.tsx +98 -0
  58. package/src/lib/icon-map.tsx +669 -0
  59. package/src/lib/interaction.ts +102 -0
  60. package/src/lib/scroll-fade.tsx +241 -0
  61. package/src/lib/shape-context.tsx +121 -0
  62. package/src/lib/springs.ts +30 -0
  63. package/src/lib/surface-classes.ts +43 -0
  64. package/src/lib/surface-context.tsx +17 -0
  65. package/src/tokens/tokens.ts +3 -0
  66. package/dist/chunk-MAHPAP65.js.map +0 -1
@@ -1,55 +1,465 @@
1
1
  "use client";
2
2
 
3
- import * as React from "react";
4
- import { Tabs } from "radix-ui";
3
+ import {
4
+ useRef,
5
+ useState,
6
+ useCallback,
7
+ useEffect,
8
+ useLayoutEffect,
9
+ createContext,
10
+ useContext,
11
+ forwardRef,
12
+ Children,
13
+ cloneElement,
14
+ isValidElement,
15
+ type ComponentPropsWithoutRef,
16
+ } from "react";
17
+ import * as TabsPrimitive from "@radix-ui/react-tabs";
18
+ import { motion, AnimatePresence } from "framer-motion";
19
+ import type { IconComponent } from "../lib/icon-context";
5
20
  import { cn } from "../lib/utils";
21
+ import { spring } from "../lib/springs";
22
+ import { fontWeights } from "../lib/font-weight";
23
+ import { useShape } from "../lib/shape-context";
24
+ import { useSurface } from "../lib/surface-context";
25
+ import { surfaceClasses } from "../lib/surface-classes";
26
+ import { useProximityHover } from "../hooks/use-proximity-hover";
27
+
28
+ /* ─────────────────────── Contexts ─────────────────────── */
29
+
30
+ interface TabsValueOrderContextValue {
31
+ valueOrder: string[];
32
+ setValueOrder: (order: string[]) => void;
33
+ selectedValue: string | undefined;
34
+ }
35
+
36
+ const TabsValueOrderContext = createContext<TabsValueOrderContextValue | null>(null);
37
+
38
+ interface TabsListContextValue {
39
+ registerTab: (index: number, value: string, el: HTMLElement | null) => void;
40
+ hoveredIndex: number | null;
41
+ selectedValue: string | undefined;
42
+ /** Optimistically set selectedIdx so the indicator moves immediately on click. */
43
+ setOptimisticIdx: (index: number) => void;
44
+ }
45
+
46
+ const TabsListContext = createContext<TabsListContextValue | null>(null);
47
+
48
+ function useTabsList() {
49
+ const ctx = useContext(TabsListContext);
50
+ if (!ctx) throw new Error("TabItem must be used within a TabsList");
51
+ return ctx;
52
+ }
53
+
54
+ /* ─────────────────────── Tabs (Root) ─────────────────────── */
55
+
56
+ interface TabsProps extends Omit<
57
+ ComponentPropsWithoutRef<typeof TabsPrimitive.Root>,
58
+ "onValueChange" | "onSelect"
59
+ > {
60
+ /** Controlled value (takes precedence over selectedIndex). */
61
+ value?: string;
62
+ /** Called when the active tab changes. */
63
+ onValueChange?: (value: string) => void;
64
+ /** Index-based controlled alternative. */
65
+ selectedIndex?: number;
66
+ /** Called with the new index when the active tab changes. */
67
+ onSelect?: (index: number) => void;
68
+ }
69
+
70
+ const Tabs = forwardRef<HTMLDivElement, TabsProps>(
71
+ ({ value, onValueChange, selectedIndex, onSelect, defaultValue, children, ...props }, ref) => {
72
+ const [valueOrder, setValueOrder] = useState<string[]>([]);
73
+ const [uncontrolledValue, setUncontrolledValue] = useState<string | undefined>(defaultValue);
74
+ const updateValueOrder = useCallback((order: string[]) => {
75
+ setValueOrder((current) => {
76
+ if (
77
+ current.length === order.length &&
78
+ current.every((value, index) => value === order[index])
79
+ ) {
80
+ return current;
81
+ }
82
+ return order;
83
+ });
84
+ }, []);
85
+
86
+ // Resolve value: explicit value > selectedIndex lookup > defaultValue
87
+ const resolvedValue =
88
+ value ?? (selectedIndex != null ? valueOrder[selectedIndex] : uncontrolledValue);
89
+
90
+ const handleValueChange = useCallback(
91
+ (newValue: string) => {
92
+ if (value === undefined && selectedIndex == null) {
93
+ setUncontrolledValue(newValue);
94
+ }
95
+ onValueChange?.(newValue);
96
+ if (onSelect) {
97
+ const idx = valueOrder.indexOf(newValue);
98
+ if (idx !== -1) onSelect(idx);
99
+ }
100
+ },
101
+ [onValueChange, onSelect, valueOrder],
102
+ );
103
+
104
+ return (
105
+ <TabsValueOrderContext.Provider
106
+ value={{
107
+ valueOrder,
108
+ setValueOrder: updateValueOrder,
109
+ selectedValue: resolvedValue,
110
+ }}
111
+ >
112
+ <TabsPrimitive.Root
113
+ ref={ref}
114
+ onValueChange={handleValueChange}
115
+ {...(resolvedValue !== undefined ? { value: resolvedValue } : {})}
116
+ {...(resolvedValue == null && defaultValue !== undefined ? { defaultValue } : {})}
117
+ activationMode="automatic"
118
+ {...props}
119
+ >
120
+ {children}
121
+ </TabsPrimitive.Root>
122
+ </TabsValueOrderContext.Provider>
123
+ );
124
+ },
125
+ );
126
+
127
+ Tabs.displayName = "Tabs";
128
+
129
+ /* ─────────────────────── TabsList ─────────────────────── */
130
+
131
+ type TabsListProps = ComponentPropsWithoutRef<typeof TabsPrimitive.List>;
132
+
133
+ const TabsList = forwardRef<HTMLDivElement, TabsListProps>(
134
+ ({ children, className, ...props }, ref) => {
135
+ const containerRef = useRef<HTMLDivElement>(null);
136
+ const isMouseInside = useRef(false);
137
+ const shape = useShape();
138
+ const substrate = useSurface();
139
+ // Active pill lifts 3 levels above substrate (1 above the muted track + 2 for pop).
140
+ // On the page (substrate 1) this lands on surface 4 — matches the original design.
141
+ // Inside a dialog (substrate 5) it lifts to surface 8 instead of staying at 4.
142
+ const indicatorLevel = Math.min(substrate + 3, 8);
143
+ const valueOrderCtx = useContext(TabsValueOrderContext);
144
+ const [optimisticIdx, setOptimisticIdx] = useState<number | null>(null);
145
+
146
+ // Derive value order from children synchronously
147
+ const values = Children.toArray(children)
148
+ .filter(isValidElement)
149
+ .map((child) => (child.props as { value?: string }).value)
150
+ .filter((v): v is string => typeof v === "string");
151
+ const valueOrderKey = values.join(",");
152
+ const setValueOrder = valueOrderCtx?.setValueOrder;
153
+
154
+ // Report value order up to Tabs root
155
+ useLayoutEffect(() => {
156
+ setValueOrder?.(values);
157
+ }, [setValueOrder, valueOrderKey]);
158
+
159
+ // Proximity hover
160
+ const {
161
+ activeIndex: hoveredIndex,
162
+ setActiveIndex: setHoveredIndex,
163
+ itemRects,
164
+ handlers,
165
+ registerItem,
166
+ measureItems,
167
+ } = useProximityHover(containerRef, { axis: "x" });
168
+
169
+ // Register items: bridge from (index, value, el) → registerItem(index, el)
170
+ const registerTab = useCallback(
171
+ (index: number, _value: string, el: HTMLElement | null) => {
172
+ registerItem(index, el);
173
+ },
174
+ [registerItem],
175
+ );
176
+
177
+ // Measure on children change
178
+ useEffect(() => {
179
+ measureItems();
180
+ }, [measureItems, children]);
181
+
182
+ // Remeasure on resize
183
+ useEffect(() => {
184
+ const el = containerRef.current;
185
+ if (!el) return;
186
+ const ro = new ResizeObserver(() => measureItems());
187
+ ro.observe(el);
188
+ return () => ro.disconnect();
189
+ }, [measureItems]);
190
+
191
+ // Track mouse inside
192
+ const handleMouseMove = useCallback(
193
+ (e: React.MouseEvent) => {
194
+ isMouseInside.current = true;
195
+ handlers.onMouseMove(e);
196
+ },
197
+ [handlers],
198
+ );
199
+
200
+ const handleMouseLeave = useCallback(() => {
201
+ isMouseInside.current = false;
202
+ handlers.onMouseLeave();
203
+ }, [handlers]);
204
+
205
+ // Focus ring
206
+ const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
207
+ const selectedValue = valueOrderCtx?.selectedValue;
208
+ const selectedIdx = selectedValue !== undefined ? values.indexOf(selectedValue) : -1;
209
+
210
+ useEffect(() => {
211
+ setOptimisticIdx(selectedIdx >= 0 ? selectedIdx : null);
212
+ }, [selectedIdx]);
213
+
214
+ const activeSelectedIdx = optimisticIdx;
215
+ const selectedRect = activeSelectedIdx !== null ? itemRects[activeSelectedIdx] : null;
216
+ const hoverRect = hoveredIndex !== null ? itemRects[hoveredIndex] : null;
217
+ const focusRect = focusedIndex !== null ? itemRects[focusedIndex] : null;
218
+ const isHoveringSelected = hoveredIndex === activeSelectedIdx;
219
+ const isHovering = hoveredIndex !== null && !isHoveringSelected;
220
+
221
+ // Auto-assign _index to children
222
+ const indexedChildren = Children.map(children, (child, i) => {
223
+ if (isValidElement(child)) {
224
+ return cloneElement(child, { _index: i } as Record<string, unknown>);
225
+ }
226
+ return child;
227
+ });
228
+
229
+ return (
230
+ <TabsListContext.Provider
231
+ value={{
232
+ registerTab,
233
+ hoveredIndex,
234
+ selectedValue,
235
+ setOptimisticIdx,
236
+ }}
237
+ >
238
+ <TabsPrimitive.List
239
+ ref={(node) => {
240
+ (containerRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
241
+ if (typeof ref === "function") ref(node);
242
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
243
+ }}
244
+ onMouseMove={handleMouseMove}
245
+ onMouseLeave={handleMouseLeave}
246
+ onFocus={(e) => {
247
+ const trigger = (e.target as HTMLElement).closest('[role="tab"]');
248
+ if (!trigger) return;
249
+ const indexAttr = trigger.getAttribute("data-proximity-index");
250
+ if (indexAttr != null) {
251
+ const idx = Number(indexAttr);
252
+ setHoveredIndex(idx);
253
+ setFocusedIndex((e.target as HTMLElement).matches(":focus-visible") ? idx : null);
254
+ }
255
+ }}
256
+ onBlur={(e) => {
257
+ if (containerRef.current?.contains(e.relatedTarget as Node)) return;
258
+ setFocusedIndex(null);
259
+ if (isMouseInside.current) return;
260
+ setHoveredIndex(null);
261
+ }}
262
+ className={cn(
263
+ "relative inline-flex items-center gap-0.5 p-1 select-none bg-muted",
264
+ shape.container,
265
+ className,
266
+ )}
267
+ {...props}
268
+ >
269
+ {/* Active segment indicator */}
270
+ {selectedRect && (
271
+ <motion.div
272
+ className={cn(
273
+ "absolute pointer-events-none",
274
+ surfaceClasses(indicatorLevel),
275
+ shape.bg,
276
+ )}
277
+ initial={false}
278
+ animate={{
279
+ left: selectedRect.left,
280
+ width: selectedRect.width,
281
+ top: selectedRect.top,
282
+ height: selectedRect.height,
283
+ opacity: isHovering ? 0.85 : 1,
284
+ }}
285
+ transition={{
286
+ ...spring.moderate,
287
+ opacity: { duration: 0.08 },
288
+ }}
289
+ />
290
+ )}
291
+
292
+ {/* Hover indicator */}
293
+ <AnimatePresence>
294
+ {hoverRect && !isHoveringSelected && selectedRect && (
295
+ <motion.div
296
+ className={cn("absolute pointer-events-none bg-hover", shape.bg)}
297
+ initial={{
298
+ left: selectedRect.left,
299
+ width: selectedRect.width,
300
+ top: selectedRect.top,
301
+ height: selectedRect.height,
302
+ opacity: 0,
303
+ }}
304
+ animate={{
305
+ left: hoverRect.left,
306
+ width: hoverRect.width,
307
+ top: hoverRect.top,
308
+ height: hoverRect.height,
309
+ opacity: 0.4,
310
+ }}
311
+ exit={
312
+ !isMouseInside.current && selectedRect
313
+ ? {
314
+ left: selectedRect.left,
315
+ width: selectedRect.width,
316
+ top: selectedRect.top,
317
+ height: selectedRect.height,
318
+ opacity: 0,
319
+ transition: {
320
+ ...spring.moderate,
321
+ opacity: { duration: 0.06 },
322
+ },
323
+ }
324
+ : { opacity: 0, transition: spring.fast.exit }
325
+ }
326
+ transition={{
327
+ ...spring.fast,
328
+ opacity: { duration: 0.08 },
329
+ }}
330
+ />
331
+ )}
332
+ </AnimatePresence>
333
+
334
+ {/* Focus ring */}
335
+ <AnimatePresence>
336
+ {focusRect && (
337
+ <motion.div
338
+ className={cn(
339
+ "absolute pointer-events-none z-20 border border-[#6B97FF]",
340
+ shape.focusRing,
341
+ )}
342
+ initial={false}
343
+ animate={{
344
+ left: focusRect.left - 2,
345
+ top: focusRect.top - 2,
346
+ width: focusRect.width + 4,
347
+ height: focusRect.height + 4,
348
+ }}
349
+ exit={{ opacity: 0, transition: spring.fast.exit }}
350
+ transition={{
351
+ ...spring.fast,
352
+ opacity: { duration: 0.08 },
353
+ }}
354
+ />
355
+ )}
356
+ </AnimatePresence>
357
+
358
+ {indexedChildren}
359
+ </TabsPrimitive.List>
360
+ </TabsListContext.Provider>
361
+ );
362
+ },
363
+ );
6
364
 
7
- const TabsRoot = React.forwardRef<
8
- React.ComponentRef<typeof Tabs.Root>,
9
- React.ComponentPropsWithoutRef<typeof Tabs.Root>
10
- >(({ className, ...props }, ref) => (
11
- <Tabs.Root ref={ref} className={cn("w-full", className)} {...props} />
12
- ));
13
- TabsRoot.displayName = "Tabs";
14
-
15
- const TabsList = React.forwardRef<
16
- React.ComponentRef<typeof Tabs.List>,
17
- React.ComponentPropsWithoutRef<typeof Tabs.List>
18
- >(({ className, ...props }, ref) => (
19
- <Tabs.List
20
- ref={ref}
21
- className={cn("inline-flex items-center gap-1 border-b border-border w-full", className)}
22
- {...props}
23
- />
24
- ));
25
365
  TabsList.displayName = "TabsList";
26
366
 
27
- const TabsTrigger = React.forwardRef<
28
- React.ComponentRef<typeof Tabs.Trigger>,
29
- React.ComponentPropsWithoutRef<typeof Tabs.Trigger>
30
- >(({ className, ...props }, ref) => (
31
- <Tabs.Trigger
32
- ref={ref}
33
- className={cn(
34
- "px-0 pb-2.5 body font-medium text-muted-foreground",
35
- "border-b-2 border-transparent -mb-px",
36
- "transition-colors cursor-pointer",
37
- "data-[state=active]:text-brand-primary data-[state=active]:border-brand-primary",
38
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary/20",
39
- "disabled:pointer-events-none disabled:opacity-50",
40
- className,
41
- )}
42
- {...props}
43
- />
44
- ));
45
- TabsTrigger.displayName = "TabsTrigger";
46
-
47
- const TabsContent = React.forwardRef<
48
- React.ComponentRef<typeof Tabs.Content>,
49
- React.ComponentPropsWithoutRef<typeof Tabs.Content>
50
- >(({ className, ...props }, ref) => (
51
- <Tabs.Content ref={ref} className={cn("mt-4", className)} {...props} />
52
- ));
53
- TabsContent.displayName = "TabsContent";
54
-
55
- export { TabsRoot as Tabs, TabsList, TabsTrigger, TabsContent };
367
+ /* ─────────────────────── TabItem ─────────────────────── */
368
+
369
+ interface TabItemProps extends ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> {
370
+ /** Unique value for this tab. */
371
+ value: string;
372
+ /** Optional leading icon. */
373
+ icon?: IconComponent;
374
+ /** Text label. */
375
+ label?: string;
376
+ /** @internal Auto-assigned by TabsList. */
377
+ _index?: number;
378
+ }
379
+
380
+ const TabItem = forwardRef<HTMLButtonElement, TabItemProps>(
381
+ ({ value, icon: Icon, label, children, _index = 0, className, ...props }, ref) => {
382
+ const internalRef = useRef<HTMLButtonElement>(null);
383
+ const { registerTab, hoveredIndex, selectedValue, setOptimisticIdx } = useTabsList();
384
+
385
+ useEffect(() => {
386
+ registerTab(_index, value, internalRef.current);
387
+ return () => registerTab(_index, value, null);
388
+ }, [_index, value, registerTab]);
389
+
390
+ const isSelected = selectedValue === value;
391
+ const isActive = hoveredIndex === _index || isSelected;
392
+
393
+ return (
394
+ <TabsPrimitive.Trigger
395
+ onClick={() => setOptimisticIdx(_index)}
396
+ ref={(node) => {
397
+ (internalRef as React.MutableRefObject<HTMLButtonElement | null>).current = node;
398
+ if (typeof ref === "function") ref(node);
399
+ else if (ref) (ref as React.MutableRefObject<HTMLButtonElement | null>).current = node;
400
+ }}
401
+ value={value}
402
+ data-proximity-index={_index}
403
+ className={cn(
404
+ "relative z-10 flex items-center gap-2 px-3 py-1.5 cursor-pointer bg-transparent border-none outline-none",
405
+ className,
406
+ )}
407
+ {...props}
408
+ >
409
+ {Icon && (
410
+ <Icon
411
+ size={16}
412
+ strokeWidth={isActive ? 2 : 1.5}
413
+ className={cn(
414
+ "transition-[color,stroke-width] duration-80",
415
+ isActive ? "text-foreground" : "text-muted-foreground",
416
+ )}
417
+ />
418
+ )}
419
+ <span className="inline-grid text-[13px] whitespace-nowrap">
420
+ <span
421
+ className="col-start-1 row-start-1 invisible"
422
+ style={{ fontVariationSettings: fontWeights.semibold }}
423
+ aria-hidden="true"
424
+ >
425
+ {label ?? children}
426
+ </span>
427
+ <span
428
+ className={cn(
429
+ "col-start-1 row-start-1 transition-[color,font-variation-settings] duration-80",
430
+ isActive ? "text-foreground" : "text-muted-foreground",
431
+ )}
432
+ style={{
433
+ fontVariationSettings: isSelected ? fontWeights.semibold : fontWeights.normal,
434
+ }}
435
+ >
436
+ {label ?? children}
437
+ </span>
438
+ </span>
439
+ </TabsPrimitive.Trigger>
440
+ );
441
+ },
442
+ );
443
+
444
+ TabItem.displayName = "TabItem";
445
+
446
+ /* ─────────────────────── TabPanel ─────────────────────── */
447
+
448
+ interface TabPanelProps extends ComponentPropsWithoutRef<typeof TabsPrimitive.Content> {
449
+ /** Must match a TabItem value. */
450
+ value: string;
451
+ }
452
+
453
+ const TabPanel = forwardRef<HTMLDivElement, TabPanelProps>(({ className, ...props }, ref) => {
454
+ return <TabsPrimitive.Content ref={ref} className={cn("outline-none", className)} {...props} />;
455
+ });
456
+
457
+ TabPanel.displayName = "TabPanel";
458
+
459
+ /* ─────────────────────── Exports ─────────────────────── */
460
+
461
+ const TabsTrigger = TabItem;
462
+ const TabsContent = TabPanel;
463
+
464
+ export { Tabs, TabsList, TabItem, TabPanel, TabsTrigger, TabsContent };
465
+ export type { TabsProps, TabsListProps, TabItemProps, TabPanelProps };
@@ -1,15 +1,19 @@
1
1
  import * as React from "react";
2
2
  import { cn } from "../lib/utils";
3
+ import { controlSurfaceClasses, focusRingClasses, motionClasses } from "../lib/interaction";
3
4
 
4
5
  function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
5
6
  return (
6
7
  <textarea
7
8
  data-slot="textarea"
8
9
  className={cn(
9
- "flex min-h-[80px] w-full rounded-md border border-border bg-card px-3 py-2",
10
+ "flex min-h-[80px] w-full rounded-md px-3 py-2",
10
11
  "body text-foreground placeholder:text-muted-foreground",
11
- "resize-none transition-colors duration-150",
12
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/20 focus-visible:border-border",
12
+ "resize-none",
13
+ controlSurfaceClasses,
14
+ motionClasses,
15
+ focusRingClasses,
16
+ "focus-visible:border-brand-primary",
13
17
  "disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-muted",
14
18
  className,
15
19
  )}
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ import { forwardRef, useState, useEffect, type HTMLAttributes } from "react";
4
+ import { motion, AnimatePresence } from "framer-motion";
5
+ import { cn } from "../lib/utils";
6
+ import { fontWeights } from "../lib/font-weight";
7
+
8
+ const circleA =
9
+ "M 12 8 C 14.21 8 16 9.79 16 12 C 16 14.21 14.21 16 12 16 C 9.79 16 8 14.21 8 12 C 8 9.79 9.79 8 12 8 Z";
10
+
11
+ const infinity =
12
+ "M 12 12 C 14 8.5 19 8.5 19 12 C 19 15.5 14 15.5 12 12 C 10 8.5 5 8.5 5 12 C 5 15.5 10 15.5 12 12 Z";
13
+
14
+ const circleB =
15
+ "M 12 16 C 14.21 16 16 14.21 16 12 C 16 9.79 14.21 8 12 8 C 9.79 8 8 9.79 8 12 C 8 14.21 9.79 16 12 16 Z";
16
+
17
+ const words = ["Thinking", "Moonwalking", "Planning", "Refining"];
18
+
19
+ interface ThinkingIndicatorProps extends HTMLAttributes<HTMLDivElement> {
20
+ label?: string;
21
+ /** Show the morphing circle⇄infinity glyph before the label. Set to `false`
22
+ * for a text-only indicator (e.g. inline before a streamed reply). */
23
+ showIcon?: boolean;
24
+ }
25
+
26
+ const ThinkingIndicator = forwardRef<HTMLDivElement, ThinkingIndicatorProps>(
27
+ ({ className, label, showIcon = true, ...props }, ref) => {
28
+ const [index, setIndex] = useState(0);
29
+ const currentLabel = label ?? words[index];
30
+
31
+ useEffect(() => {
32
+ const interval = setInterval(() => {
33
+ setIndex((i) => (i + 1) % words.length);
34
+ }, 4000);
35
+ return () => clearInterval(interval);
36
+ }, []);
37
+
38
+ return (
39
+ <div
40
+ ref={ref}
41
+ role="status"
42
+ aria-label={currentLabel}
43
+ className={cn("flex items-center gap-2 px-3 py-2", className)}
44
+ {...props}
45
+ >
46
+ {showIcon && (
47
+ <motion.svg
48
+ aria-hidden
49
+ width={20}
50
+ height={20}
51
+ viewBox="0 0 24 24"
52
+ fill="none"
53
+ stroke="currentColor"
54
+ strokeWidth={1.5}
55
+ strokeLinecap="round"
56
+ strokeLinejoin="round"
57
+ className="text-muted-foreground shrink-0"
58
+ >
59
+ <motion.path
60
+ animate={{
61
+ d: [circleA, infinity, circleB, infinity, circleA],
62
+ }}
63
+ transition={{
64
+ d: {
65
+ duration: 6,
66
+ ease: "easeInOut",
67
+ repeat: Infinity,
68
+ times: [0, 0.25, 0.5, 0.75, 1.0],
69
+ },
70
+ }}
71
+ />
72
+ </motion.svg>
73
+ )}
74
+ <span
75
+ className="inline-grid text-[13px] overflow-hidden"
76
+ style={{ fontVariationSettings: fontWeights.medium }}
77
+ >
78
+ <span className="col-start-1 row-start-1 invisible shimmer-text" aria-hidden="true">
79
+ {"\u00a0"}
80
+ </span>
81
+ <AnimatePresence mode="popLayout" initial={false}>
82
+ <motion.span
83
+ key={currentLabel}
84
+ className="col-start-1 row-start-1 shimmer-text"
85
+ initial={{ y: "80%", opacity: 0 }}
86
+ animate={{ y: 0, opacity: 1, transition: { duration: 0.24, ease: [0.4, 0, 0.2, 1] } }}
87
+ exit={{
88
+ y: "-80%",
89
+ opacity: 0,
90
+ transition: { duration: 0.16, ease: [0.4, 0, 0.2, 1] },
91
+ }}
92
+ >
93
+ {currentLabel}
94
+ </motion.span>
95
+ </AnimatePresence>
96
+ </span>
97
+ </div>
98
+ );
99
+ },
100
+ );
101
+
102
+ ThinkingIndicator.displayName = "ThinkingIndicator";
103
+
104
+ export { ThinkingIndicator };
105
+ export type { ThinkingIndicatorProps };
106
+ export default ThinkingIndicator;