@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
@@ -0,0 +1,156 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { cn } from "../lib/utils";
5
+ import { useShape } from "../lib/shape-context";
6
+
7
+ // ─── Lazy pdfjs loader ────────────────────────────────────────────────────
8
+ // Imports pdfjs-dist on first PDF, caches the module, and points the worker
9
+ // at the matching CDN build. Consumers don't need bundler-side worker config.
10
+ type PdfjsModule = typeof import("pdfjs-dist");
11
+ let pdfjsPromise: Promise<PdfjsModule> | null = null;
12
+
13
+ async function loadPdfjs(): Promise<PdfjsModule> {
14
+ if (!pdfjsPromise) {
15
+ pdfjsPromise = import("pdfjs-dist").then((mod) => {
16
+ if (!mod.GlobalWorkerOptions.workerSrc) {
17
+ mod.GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${mod.version}/build/pdf.worker.min.mjs`;
18
+ }
19
+ return mod;
20
+ });
21
+ }
22
+ return pdfjsPromise;
23
+ }
24
+
25
+ async function renderPdfFirstPage(file: File, targetWidth: number): Promise<string> {
26
+ const pdfjs = await loadPdfjs();
27
+ const buffer = await file.arrayBuffer();
28
+ const pdf = await pdfjs.getDocument({ data: buffer }).promise;
29
+ const page = await pdf.getPage(1);
30
+ const baseViewport = page.getViewport({ scale: 1 });
31
+ const scale = (targetWidth * 2) / baseViewport.width; // 2× for retina
32
+ const viewport = page.getViewport({ scale });
33
+ const canvas = document.createElement("canvas");
34
+ canvas.width = viewport.width;
35
+ canvas.height = viewport.height;
36
+ await page.render({ canvas, viewport }).promise;
37
+ return canvas.toDataURL("image/png");
38
+ }
39
+
40
+ // ─── File thumbnail ───────────────────────────────────────────────────────
41
+ // Read-only square preview of a File. Images use object-cover via
42
+ // `URL.createObjectURL`; PDFs render the first page via pdfjs; while either is
43
+ // resolving a spinner is shown. Self-contained (border + surface + sizing) so
44
+ // it can be reused both inside the composer's preview row and to render
45
+ // already-sent attachments in a chat transcript.
46
+ interface FileThumbnailProps {
47
+ file?: File;
48
+ name?: string;
49
+ type?: string;
50
+ /** Side length of the square thumbnail in pixels. */
51
+ size?: number | string;
52
+ className?: string;
53
+ }
54
+
55
+ function FileThumbnail({ file, name, type, size = 48, className }: FileThumbnailProps) {
56
+ const shape = useShape();
57
+ const numericSize = typeof size === "number" ? size : 48;
58
+
59
+ if (!file) {
60
+ return (
61
+ <div className={cn("flex items-center gap-3 rounded-lg border border-border p-2", className)}>
62
+ <div
63
+ className={cn(
64
+ "flex shrink-0 items-center justify-center bg-accent text-[11px] text-muted-foreground",
65
+ shape.item,
66
+ )}
67
+ style={{ width: numericSize, height: numericSize }}
68
+ >
69
+ {type ?? "File"}
70
+ </div>
71
+ <div className="min-w-0">
72
+ <div className="truncate text-[13px] text-foreground">{name ?? "Untitled file"}</div>
73
+ <div className="text-[12px] text-muted-foreground">
74
+ {typeof size === "string" ? size : (type ?? "File")}
75
+ </div>
76
+ </div>
77
+ </div>
78
+ );
79
+ }
80
+
81
+ const isImage = file.type.startsWith("image/");
82
+ const isPdf = file.type === "application/pdf";
83
+
84
+ // Create blob URL inside an effect (NOT useMemo) so the cleanup-revoke
85
+ // and the URL-creation stay in sync. In React 18 StrictMode dev, a
86
+ // useMemo-created URL gets revoked by the simulated effect-cleanup but
87
+ // useMemo doesn't re-run on the simulated re-mount (no re-render happens),
88
+ // leaving the DOM with a stale, revoked `blob:` URL — broken image.
89
+ // Putting both in the same effect means the simulated re-mount creates a
90
+ // fresh URL and updates state. The one-frame "before URL" state is
91
+ // covered by the bg-accent (no fallback icon shown for images), so the
92
+ // transition is visually clean.
93
+ const [imageUrl, setImageUrl] = useState<string | null>(null);
94
+ useEffect(() => {
95
+ if (!isImage) return;
96
+ const url = URL.createObjectURL(file);
97
+ setImageUrl(url);
98
+ return () => URL.revokeObjectURL(url);
99
+ }, [isImage, file]);
100
+
101
+ // PDFs need async rendering — loading flash is unavoidable for the first
102
+ // ~100–300ms while pdfjs loads. Falls back to the generic icon on error.
103
+ const [pdfUrl, setPdfUrl] = useState<string | null>(null);
104
+ useEffect(() => {
105
+ if (!isPdf) return;
106
+ let cancelled = false;
107
+ renderPdfFirstPage(file, numericSize)
108
+ .then((url) => {
109
+ if (!cancelled) setPdfUrl(url);
110
+ })
111
+ .catch(() => {
112
+ /* fall through to spinner */
113
+ });
114
+ return () => {
115
+ cancelled = true;
116
+ };
117
+ }, [file, isPdf, numericSize]);
118
+
119
+ const previewUrl = imageUrl ?? pdfUrl;
120
+
121
+ return (
122
+ <div
123
+ className={cn(
124
+ "relative shrink-0 overflow-hidden bg-accent border border-border",
125
+ shape.bg,
126
+ className,
127
+ )}
128
+ style={{ width: numericSize, height: numericSize }}
129
+ >
130
+ {previewUrl ? (
131
+ // eslint-disable-next-line @next/next/no-img-element
132
+ <img
133
+ src={previewUrl}
134
+ alt={file.name}
135
+ className="absolute inset-0 w-full h-full object-cover"
136
+ />
137
+ ) : (
138
+ // Circular spinner while we wait for the preview to be ready.
139
+ // Used for both images (brief URL-creation gap) and PDFs (longer
140
+ // pdfjs render). The thin ring is mostly subtle (border-border)
141
+ // with one quadrant accented (border-t-muted-foreground) so the
142
+ // `animate-spin` rotation reads as a moving arc.
143
+ <div className="absolute inset-0 flex items-center justify-center">
144
+ <div
145
+ className="w-6 h-6 rounded-full border-2 border-border border-t-muted-foreground animate-spin"
146
+ aria-label="Loading preview"
147
+ role="status"
148
+ />
149
+ </div>
150
+ )}
151
+ </div>
152
+ );
153
+ }
154
+
155
+ export { FileThumbnail, loadPdfjs, renderPdfFirstPage };
156
+ export type { FileThumbnailProps };
@@ -3,6 +3,7 @@
3
3
  import * as React from "react";
4
4
  import { HoverCard } from "radix-ui";
5
5
  import { cn } from "../lib/utils";
6
+ import { motionClasses } from "../lib/interaction";
6
7
 
7
8
  const HoverCardRoot = HoverCard.Root;
8
9
  const HoverCardTrigger = HoverCard.Trigger;
@@ -18,6 +19,7 @@ const HoverCardContent = React.forwardRef<
18
19
  sideOffset={sideOffset}
19
20
  className={cn(
20
21
  "z-50 w-64 rounded-xl border border-border bg-card p-4 shadow-elevated outline-none",
22
+ motionClasses,
21
23
  "data-[state=open]:animate-in data-[state=closed]:animate-out",
22
24
  "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
23
25
  "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
@@ -0,0 +1,307 @@
1
+ "use client";
2
+
3
+ import { forwardRef, useState, useCallback, useRef, useEffect, type HTMLAttributes } from "react";
4
+ import { motion, AnimatePresence } from "framer-motion";
5
+ import { cn } from "../lib/utils";
6
+ import { useIcon } from "../lib/icon-context";
7
+ import { fontWeights } from "../lib/font-weight";
8
+ import { useShape } from "../lib/shape-context";
9
+ import { spring } from "../lib/springs";
10
+ import { Tooltip } from "./tooltip";
11
+
12
+ type InputCopyVariant = "icon" | "button";
13
+ type InputCopyAlign = "right" | "left";
14
+
15
+ interface InputCopyProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
16
+ /** The value to display and copy to clipboard. */
17
+ value: string;
18
+ /** Optional label displayed above the input. */
19
+ label?: string;
20
+ /** Callback fired after the value is copied. */
21
+ onCopy?: () => void;
22
+ /** Whether the component is disabled. */
23
+ disabled?: boolean;
24
+ /** Display variant: icon-only with tooltip, or button with label. */
25
+ variant?: InputCopyVariant;
26
+ /** Position of the copy action relative to the value. */
27
+ align?: InputCopyAlign;
28
+ }
29
+
30
+ const InputCopy = forwardRef<HTMLDivElement, InputCopyProps>(
31
+ (
32
+ { value, label, onCopy, disabled, variant = "icon", align = "right", className, ...props },
33
+ ref,
34
+ ) => {
35
+ const CopyIcon = useIcon("copy");
36
+ const [copied, setCopied] = useState(false);
37
+ const [copyCount, setCopyCount] = useState(0);
38
+ // "idle" = normal tooltip behavior, "copied" = force open, "suppressed" = force closed
39
+ const [tooltipState, setTooltipState] = useState<"idle" | "copied" | "suppressed">("idle");
40
+ const timeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
41
+ const tooltipVisibleRef = useRef(false);
42
+ const tooltipWasVisibleRef = useRef(false);
43
+ const shape = useShape();
44
+
45
+ const handlePointerDown = useCallback(() => {
46
+ // Capture tooltip visibility before Radix closes it on pointer down
47
+ tooltipWasVisibleRef.current = tooltipVisibleRef.current;
48
+ }, []);
49
+
50
+ const handleCopy = useCallback(async () => {
51
+ if (disabled) return;
52
+ try {
53
+ await navigator.clipboard.writeText(value);
54
+ setCopied(true);
55
+ setCopyCount((c) => c + 1);
56
+ setTooltipState(tooltipWasVisibleRef.current ? "copied" : "suppressed");
57
+ onCopy?.();
58
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
59
+ timeoutRef.current = setTimeout(() => {
60
+ setCopied(false);
61
+ setTooltipState("suppressed");
62
+ }, 2000);
63
+ } catch {
64
+ // Clipboard API not available — silently fail
65
+ }
66
+ }, [value, disabled, onCopy]);
67
+
68
+ const handleTooltipOpenChange = useCallback((open: boolean) => {
69
+ tooltipVisibleRef.current = open;
70
+ }, []);
71
+
72
+ useEffect(() => {
73
+ return () => {
74
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
75
+ };
76
+ }, []);
77
+
78
+ const handleMouseEnter = useCallback(() => {
79
+ setTooltipState((prev) => (prev === "suppressed" ? "idle" : prev));
80
+ }, []);
81
+
82
+ const handleMouseLeave = useCallback(() => {
83
+ setTooltipState((prev) => (prev === "copied" ? "suppressed" : prev));
84
+ }, []);
85
+
86
+ const iconSwitch = (
87
+ <AnimatePresence mode="wait" initial={false}>
88
+ {copied ? (
89
+ <motion.span
90
+ key={`check-${copyCount}`}
91
+ initial={{ opacity: 0, scale: 0.6 }}
92
+ animate={{ opacity: 1, scale: 1 }}
93
+ exit={{ opacity: 0, scale: 0.8 }}
94
+ transition={spring.fast}
95
+ className="flex items-center justify-center [&_svg]:stroke-[1.5] [&_svg]:transition-[stroke-width] [&_svg]:duration-80 group-hover:[&_svg]:stroke-[2]"
96
+ >
97
+ <svg
98
+ width={14}
99
+ height={14}
100
+ viewBox="2 4 20 16"
101
+ fill="none"
102
+ stroke="currentColor"
103
+ strokeLinecap="round"
104
+ strokeLinejoin="round"
105
+ >
106
+ <motion.path
107
+ d="M6 12L10 16L18 8"
108
+ initial={{ pathLength: 0 }}
109
+ animate={{
110
+ pathLength: 1,
111
+ transition: { duration: 0.08, ease: "easeOut" },
112
+ }}
113
+ />
114
+ </svg>
115
+ </motion.span>
116
+ ) : (
117
+ <motion.span
118
+ key="copy"
119
+ initial={{ opacity: 0, scale: 0.8 }}
120
+ animate={{ opacity: 1, scale: 1 }}
121
+ exit={{ opacity: 0, scale: 0.8 }}
122
+ transition={spring.fast}
123
+ className="flex items-center justify-center"
124
+ >
125
+ <CopyIcon
126
+ size={14}
127
+ strokeWidth={1.5}
128
+ className="transition-[stroke-width] duration-80 group-hover:stroke-[2]"
129
+ />
130
+ </motion.span>
131
+ )}
132
+ </AnimatePresence>
133
+ );
134
+
135
+ const actionElement =
136
+ variant === "button" ? (
137
+ <span
138
+ className={cn(
139
+ "shrink-0 flex items-center gap-1.5 px-1.5 py-2 text-[13px] transition-colors duration-80",
140
+ "text-muted-foreground group-hover:text-foreground",
141
+ )}
142
+ style={{ fontVariationSettings: fontWeights.normal }}
143
+ >
144
+ <AnimatePresence mode="wait" initial={false}>
145
+ {copied ? (
146
+ <motion.span
147
+ key={`check-label-${copyCount}`}
148
+ className="flex items-center gap-1.5"
149
+ initial={{ opacity: 0, scale: 0.6 }}
150
+ animate={{ opacity: 1, scale: 1 }}
151
+ exit={{ opacity: 0, scale: 0.8 }}
152
+ transition={spring.fast}
153
+ >
154
+ <span className="flex items-center justify-center">
155
+ <svg
156
+ width={14}
157
+ height={14}
158
+ viewBox="2 4 20 16"
159
+ fill="none"
160
+ stroke="currentColor"
161
+ strokeWidth={2}
162
+ strokeLinecap="round"
163
+ strokeLinejoin="round"
164
+ >
165
+ <motion.path
166
+ d="M6 12L10 16L18 8"
167
+ initial={{ pathLength: 0 }}
168
+ animate={{
169
+ pathLength: 1,
170
+ transition: { duration: 0.08, ease: "easeOut" },
171
+ }}
172
+ />
173
+ </svg>
174
+ </span>
175
+ <span className="select-none inline-grid text-left">
176
+ <span className="col-start-1 row-start-1 invisible" aria-hidden="true">
177
+ Copied
178
+ </span>
179
+ <span className="col-start-1 row-start-1">Copied</span>
180
+ </span>
181
+ </motion.span>
182
+ ) : (
183
+ <motion.span
184
+ key="copy-label"
185
+ className="flex items-center gap-1.5"
186
+ initial={{ opacity: 0, scale: 0.8 }}
187
+ animate={{ opacity: 1, scale: 1 }}
188
+ exit={{ opacity: 0, scale: 0.8 }}
189
+ transition={spring.fast}
190
+ >
191
+ <span className="flex items-center justify-center">
192
+ <CopyIcon
193
+ size={14}
194
+ strokeWidth={1.5}
195
+ className="transition-[stroke-width] duration-80 group-hover:stroke-[2]"
196
+ />
197
+ </span>
198
+ <span className="select-none inline-grid text-left">
199
+ <span className="col-start-1 row-start-1 invisible" aria-hidden="true">
200
+ Copied
201
+ </span>
202
+ <span className="col-start-1 row-start-1">Copy</span>
203
+ </span>
204
+ </motion.span>
205
+ )}
206
+ </AnimatePresence>
207
+ </span>
208
+ ) : (
209
+ <span
210
+ className={cn(
211
+ "shrink-0 px-1.5 py-2 transition-colors duration-80",
212
+ "text-muted-foreground group-hover:text-foreground",
213
+ )}
214
+ >
215
+ {iconSwitch}
216
+ </span>
217
+ );
218
+
219
+ const valueElement = (
220
+ <span
221
+ className={cn(
222
+ "flex-1 min-w-0 text-left text-[13px] text-foreground font-mono py-2 select-none truncate",
223
+ align === "left" ? "pl-1" : "pl-0",
224
+ )}
225
+ style={{ fontVariationSettings: fontWeights.normal }}
226
+ >
227
+ <mark className="bg-transparent text-foreground transition-colors duration-80 group-hover:bg-[#6B97FF]/20 group-hover:text-foreground">
228
+ {value}
229
+ </mark>
230
+ </span>
231
+ );
232
+
233
+ const buttonContent =
234
+ align === "left" ? (
235
+ <>
236
+ {actionElement}
237
+ {valueElement}
238
+ </>
239
+ ) : (
240
+ <>
241
+ {valueElement}
242
+ {actionElement}
243
+ </>
244
+ );
245
+
246
+ const button = (
247
+ <button
248
+ type="button"
249
+ onPointerDown={handlePointerDown}
250
+ onClick={handleCopy}
251
+ disabled={disabled}
252
+ aria-label={copied ? "Copied" : "Copy to clipboard"}
253
+ className={cn(
254
+ "group flex items-center w-full cursor-pointer outline-none transition-all duration-80",
255
+ "focus-visible:ring-1 focus-visible:ring-[#6B97FF]",
256
+ shape.input,
257
+ )}
258
+ >
259
+ {buttonContent}
260
+ </button>
261
+ );
262
+
263
+ return (
264
+ <div
265
+ ref={ref}
266
+ className={cn(
267
+ "flex flex-col gap-0.5",
268
+ disabled && "opacity-50 pointer-events-none",
269
+ className,
270
+ )}
271
+ onMouseEnter={handleMouseEnter}
272
+ onMouseLeave={handleMouseLeave}
273
+ {...props}
274
+ >
275
+ {label && (
276
+ <span
277
+ className={cn("text-[13px] text-muted-foreground", align === "left" ? "pl-1" : "pl-0")}
278
+ style={{ fontVariationSettings: fontWeights.normal }}
279
+ >
280
+ {label}
281
+ </span>
282
+ )}
283
+ {variant === "icon" ? (
284
+ <Tooltip
285
+ content={tooltipState === "idle" ? "Copy to clipboard" : "Copied"}
286
+ delayDuration={500}
287
+ sideOffset={2}
288
+ forceOpen={
289
+ tooltipState === "copied" ? true : tooltipState === "suppressed" ? false : undefined
290
+ }
291
+ onOpenChange={handleTooltipOpenChange}
292
+ >
293
+ {button}
294
+ </Tooltip>
295
+ ) : (
296
+ button
297
+ )}
298
+ </div>
299
+ );
300
+ },
301
+ );
302
+
303
+ InputCopy.displayName = "InputCopy";
304
+
305
+ export { InputCopy };
306
+ export type { InputCopyProps };
307
+ export default InputCopy;