@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.
- package/README.md +4 -0
- package/dist/{chunk-MAHPAP65.js → chunk-RI6QJR2L.js} +6 -3
- package/dist/chunk-RI6QJR2L.js.map +1 -0
- package/dist/index.d.ts +800 -122
- package/dist/index.js +10458 -1119
- package/dist/index.js.map +1 -1
- package/dist/tokens.css +17 -0
- package/dist/tokens.d.ts +3 -0
- package/dist/tokens.js +1 -1
- package/llms.txt +18 -4
- package/package.json +19 -1
- package/src/components/accordion.tsx +746 -58
- package/src/components/alert-dialog.tsx +11 -5
- package/src/components/ask-user-questions.tsx +1366 -0
- package/src/components/badge.tsx +105 -24
- package/src/components/button.tsx +207 -38
- package/src/components/card.tsx +3 -2
- package/src/components/chat-message.tsx +116 -0
- package/src/components/checkbox-group.tsx +403 -0
- package/src/components/checkbox.tsx +6 -3
- package/src/components/color-input.tsx +3 -1
- package/src/components/color-picker.tsx +1992 -80
- package/src/components/dialog.tsx +141 -81
- package/src/components/dropdown-menu.tsx +21 -13
- package/src/components/dropdown.tsx +245 -0
- package/src/components/file-thumbnail.tsx +156 -0
- package/src/components/hover-card.tsx +2 -0
- package/src/components/input-copy.tsx +307 -0
- package/src/components/input-group.tsx +303 -108
- package/src/components/input-message.tsx +883 -0
- package/src/components/input-number.tsx +62 -21
- package/src/components/input.tsx +6 -3
- package/src/components/menu-item.tsx +156 -0
- package/src/components/mobile-drawer.tsx +50 -0
- package/src/components/nav-item.tsx +43 -0
- package/src/components/nav-menu.tsx +47 -0
- package/src/components/popover.tsx +3 -1
- package/src/components/radio-group.tsx +381 -35
- package/src/components/scroll-area.tsx +9 -2
- package/src/components/select.tsx +708 -121
- package/src/components/sheet.tsx +15 -6
- package/src/components/slider.tsx +1583 -20
- package/src/components/status-badge.tsx +2 -0
- package/src/components/switch.tsx +245 -28
- package/src/components/table.tsx +191 -77
- package/src/components/tabs-subtle.tsx +411 -0
- package/src/components/tabs.tsx +459 -49
- package/src/components/textarea.tsx +7 -3
- package/src/components/thinking-indicator.tsx +106 -0
- package/src/components/thinking-steps.tsx +382 -0
- package/src/components/tooltip.tsx +158 -33
- package/src/hooks/use-merge-split.tsx +359 -0
- package/src/hooks/use-proximity-hover.ts +208 -0
- package/src/index.ts +14 -0
- package/src/lib/elevated.tsx +52 -0
- package/src/lib/font-weight.ts +18 -0
- package/src/lib/icon-context.tsx +98 -0
- package/src/lib/icon-map.tsx +669 -0
- package/src/lib/interaction.ts +102 -0
- package/src/lib/scroll-fade.tsx +241 -0
- package/src/lib/shape-context.tsx +121 -0
- package/src/lib/springs.ts +30 -0
- package/src/lib/surface-classes.ts +43 -0
- package/src/lib/surface-context.tsx +17 -0
- package/src/tokens/tokens.ts +3 -0
- package/dist/chunk-MAHPAP65.js.map +0 -1
package/src/components/badge.tsx
CHANGED
|
@@ -1,34 +1,115 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type HTMLAttributes } from "react";
|
|
2
4
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
5
|
import { cn } from "../lib/utils";
|
|
6
|
+
import { useShape } from "../lib/shape-context";
|
|
7
|
+
|
|
8
|
+
const badgeColors = {
|
|
9
|
+
gray: "#a3a3a3",
|
|
10
|
+
red: "#ef4444",
|
|
11
|
+
orange: "#f97316",
|
|
12
|
+
amber: "#f59e0b",
|
|
13
|
+
yellow: "#eab308",
|
|
14
|
+
lime: "#84cc16",
|
|
15
|
+
green: "#22c55e",
|
|
16
|
+
emerald: "#10b981",
|
|
17
|
+
teal: "#14b8a6",
|
|
18
|
+
cyan: "#06b6d4",
|
|
19
|
+
blue: "#3b82f6",
|
|
20
|
+
indigo: "#6366f1",
|
|
21
|
+
violet: "#8b5cf6",
|
|
22
|
+
purple: "#a855f7",
|
|
23
|
+
fuchsia: "#d946ef",
|
|
24
|
+
pink: "#ec4899",
|
|
25
|
+
rose: "#f43f5e",
|
|
26
|
+
} as const;
|
|
4
27
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
28
|
+
type BadgeColor = keyof typeof badgeColors;
|
|
29
|
+
|
|
30
|
+
const badgeVariants = cva("inline-flex items-center font-medium whitespace-nowrap", {
|
|
31
|
+
variants: {
|
|
32
|
+
variant: {
|
|
33
|
+
default: "",
|
|
34
|
+
secondary: "",
|
|
35
|
+
outline: "border border-border text-foreground",
|
|
36
|
+
brand: "",
|
|
37
|
+
success: "",
|
|
38
|
+
warning: "",
|
|
39
|
+
destructive: "",
|
|
40
|
+
solid: "",
|
|
41
|
+
dot: "border border-border text-foreground",
|
|
18
42
|
},
|
|
19
|
-
|
|
20
|
-
|
|
43
|
+
size: {
|
|
44
|
+
sm: "h-5 px-2 text-[11px] gap-1",
|
|
45
|
+
md: "h-6 px-2.5 text-[12px] gap-1.5",
|
|
46
|
+
lg: "h-7 px-3 text-[13px] gap-1.5",
|
|
21
47
|
},
|
|
22
48
|
},
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
49
|
+
defaultVariants: {
|
|
50
|
+
variant: "solid",
|
|
51
|
+
size: "md",
|
|
52
|
+
},
|
|
53
|
+
});
|
|
26
54
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
);
|
|
55
|
+
interface BadgeProps
|
|
56
|
+
extends Omit<HTMLAttributes<HTMLSpanElement>, "color">, VariantProps<typeof badgeVariants> {
|
|
57
|
+
color?: BadgeColor;
|
|
31
58
|
}
|
|
59
|
+
|
|
60
|
+
const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
|
|
61
|
+
({ className, variant = "solid", size = "md", color, children, style, ...props }, ref) => {
|
|
62
|
+
const shape = useShape();
|
|
63
|
+
const resolvedColor =
|
|
64
|
+
color ??
|
|
65
|
+
(variant === "brand"
|
|
66
|
+
? "blue"
|
|
67
|
+
: variant === "success"
|
|
68
|
+
? "green"
|
|
69
|
+
: variant === "warning"
|
|
70
|
+
? "amber"
|
|
71
|
+
: variant === "destructive"
|
|
72
|
+
? "red"
|
|
73
|
+
: "gray");
|
|
74
|
+
const colorValue = badgeColors[resolvedColor];
|
|
75
|
+
const isSolid = variant !== "dot" && variant !== "outline";
|
|
76
|
+
const dotSize = size === "sm" ? 6 : size === "lg" ? 8 : 7;
|
|
77
|
+
|
|
78
|
+
const colorStyle = isSolid
|
|
79
|
+
? resolvedColor === "gray"
|
|
80
|
+
? { backgroundColor: "var(--accent)", color: "var(--foreground)" }
|
|
81
|
+
: {
|
|
82
|
+
color: "var(--foreground)",
|
|
83
|
+
backgroundColor: `color-mix(in srgb, ${colorValue} 15%, var(--background))`,
|
|
84
|
+
}
|
|
85
|
+
: {};
|
|
86
|
+
|
|
87
|
+
const dotColor = resolvedColor === "gray" ? "var(--muted-foreground)" : colorValue;
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<span
|
|
91
|
+
ref={ref}
|
|
92
|
+
className={cn(badgeVariants({ variant, size }), shape.item, className)}
|
|
93
|
+
style={{ ...colorStyle, ...style }}
|
|
94
|
+
{...props}
|
|
95
|
+
>
|
|
96
|
+
{!isSolid && (
|
|
97
|
+
<span
|
|
98
|
+
className="shrink-0 rounded-full"
|
|
99
|
+
style={{
|
|
100
|
+
width: dotSize,
|
|
101
|
+
height: dotSize,
|
|
102
|
+
backgroundColor: dotColor,
|
|
103
|
+
}}
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
{children}
|
|
107
|
+
</span>
|
|
108
|
+
);
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
32
112
|
Badge.displayName = "Badge";
|
|
33
113
|
|
|
34
|
-
export { Badge, badgeVariants };
|
|
114
|
+
export { Badge, badgeVariants, badgeColors };
|
|
115
|
+
export type { BadgeProps, BadgeColor };
|
|
@@ -1,63 +1,232 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
cloneElement,
|
|
5
|
+
forwardRef,
|
|
6
|
+
isValidElement,
|
|
7
|
+
type ButtonHTMLAttributes,
|
|
8
|
+
type CSSProperties,
|
|
9
|
+
type ReactElement,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
2
12
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
-
import {
|
|
13
|
+
import type { IconComponent } from "../lib/icon-context";
|
|
4
14
|
import { cn } from "../lib/utils";
|
|
15
|
+
import { useShape } from "../lib/shape-context";
|
|
5
16
|
|
|
6
17
|
const buttonVariants = cva(
|
|
7
18
|
[
|
|
8
|
-
"inline-flex
|
|
9
|
-
"
|
|
10
|
-
"transition-
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
19
|
+
"group relative isolate inline-flex items-center justify-center outline-none cursor-pointer",
|
|
20
|
+
"text-box-trim-both text-box-edge-cap-alphabetic",
|
|
21
|
+
"transition-colors duration-80",
|
|
22
|
+
"disabled:opacity-50 disabled:pointer-events-none",
|
|
23
|
+
"focus-visible:ring-1 focus-visible:ring-[#6B97FF]",
|
|
14
24
|
],
|
|
15
25
|
{
|
|
16
26
|
variants: {
|
|
17
27
|
variant: {
|
|
18
|
-
default:
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
outline: "
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
ghost: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
28
|
+
default: "text-background",
|
|
29
|
+
primary: "text-background",
|
|
30
|
+
brand: "text-background",
|
|
31
|
+
secondary: "text-foreground",
|
|
32
|
+
outline: "border border-border text-foreground",
|
|
33
|
+
tertiary: "border border-border text-foreground",
|
|
34
|
+
destructive: "text-destructive border border-destructive/30",
|
|
35
|
+
ghost: "text-muted-foreground hover:text-foreground",
|
|
27
36
|
link: "text-foreground underline-offset-4 hover:underline",
|
|
28
37
|
},
|
|
29
38
|
size: {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
"icon-xs": "
|
|
36
|
-
"icon-sm": "
|
|
37
|
-
|
|
39
|
+
xs: "h-6 px-2.5 text-[11px] gap-1",
|
|
40
|
+
sm: "h-7 px-3 text-[12px] gap-1",
|
|
41
|
+
default: "h-8 px-4 text-[13px] gap-1.5",
|
|
42
|
+
md: "h-8 px-4 text-[13px] gap-1.5",
|
|
43
|
+
lg: "h-9 px-5 text-[14px] gap-1.5",
|
|
44
|
+
"icon-xs": "h-7 w-7 p-0 [&_svg]:h-3 [&_svg]:w-3",
|
|
45
|
+
"icon-sm": "h-8 w-8 p-0 [&_svg]:h-3.5 [&_svg]:w-3.5",
|
|
46
|
+
icon: "h-9 w-9 p-0 [&_svg]:h-4 [&_svg]:w-4",
|
|
47
|
+
"icon-lg": "h-10 w-10 p-0 [&_svg]:h-5 [&_svg]:w-5",
|
|
38
48
|
},
|
|
49
|
+
iconLeft: { true: "" },
|
|
50
|
+
iconRight: { true: "" },
|
|
39
51
|
},
|
|
52
|
+
compoundVariants: [
|
|
53
|
+
{ size: "sm", iconLeft: true, className: "pl-[6px]" },
|
|
54
|
+
{ size: "md", iconLeft: true, className: "pl-[10px]" },
|
|
55
|
+
{ size: "lg", iconLeft: true, className: "pl-[14px]" },
|
|
56
|
+
{ size: "sm", iconRight: true, className: "pr-[6px]" },
|
|
57
|
+
{ size: "md", iconRight: true, className: "pr-[10px]" },
|
|
58
|
+
{ size: "lg", iconRight: true, className: "pr-[14px]" },
|
|
59
|
+
],
|
|
40
60
|
defaultVariants: {
|
|
41
|
-
variant: "
|
|
42
|
-
size: "
|
|
61
|
+
variant: "primary",
|
|
62
|
+
size: "md",
|
|
43
63
|
},
|
|
44
64
|
},
|
|
45
65
|
);
|
|
46
66
|
|
|
47
|
-
interface ButtonProps
|
|
67
|
+
interface ButtonProps
|
|
68
|
+
extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
|
|
48
69
|
asChild?: boolean;
|
|
70
|
+
loading?: boolean;
|
|
71
|
+
leadingIcon?: IconComponent;
|
|
72
|
+
trailingIcon?: IconComponent;
|
|
73
|
+
/** Force the visual pressed/held state. Useful when the button drives an
|
|
74
|
+
* external open piece of UI (a popover, dropdown, etc.) so it reads as
|
|
75
|
+
* engaged while the menu is showing. */
|
|
76
|
+
active?: boolean;
|
|
49
77
|
}
|
|
50
78
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
79
|
+
const bgVariants: Record<string, string> = {
|
|
80
|
+
default: "bg-foreground group-hover:bg-foreground/90 group-active:bg-foreground/80",
|
|
81
|
+
primary: "bg-foreground group-hover:bg-foreground/90 group-active:bg-foreground/80",
|
|
82
|
+
brand: "bg-brand group-hover:bg-brand/90 group-active:bg-brand/80",
|
|
83
|
+
secondary: "bg-accent group-hover:bg-accent/80 group-active:bg-accent",
|
|
84
|
+
outline: "bg-transparent group-hover:bg-hover group-active:bg-active",
|
|
85
|
+
tertiary: "bg-transparent group-hover:bg-hover group-active:bg-active",
|
|
86
|
+
destructive: "bg-destructive/10 group-hover:bg-destructive/15 group-active:bg-destructive/20",
|
|
87
|
+
ghost: "bg-transparent group-hover:bg-hover group-active:bg-active",
|
|
88
|
+
link: "bg-transparent",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const activeBgVariants: Record<string, string> = {
|
|
92
|
+
default: "bg-foreground/80",
|
|
93
|
+
primary: "bg-foreground/80",
|
|
94
|
+
brand: "bg-brand/80",
|
|
95
|
+
secondary: "bg-accent",
|
|
96
|
+
outline: "bg-active",
|
|
97
|
+
tertiary: "bg-active",
|
|
98
|
+
destructive: "bg-destructive/20",
|
|
99
|
+
ghost: "bg-active",
|
|
100
|
+
link: "bg-transparent",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
104
|
+
(
|
|
105
|
+
{
|
|
106
|
+
className,
|
|
107
|
+
variant,
|
|
108
|
+
size,
|
|
109
|
+
asChild = false,
|
|
110
|
+
loading = false,
|
|
111
|
+
leadingIcon: LeadingIcon,
|
|
112
|
+
trailingIcon: TrailingIcon,
|
|
113
|
+
active = false,
|
|
114
|
+
disabled,
|
|
115
|
+
children,
|
|
116
|
+
style,
|
|
117
|
+
...props
|
|
118
|
+
},
|
|
119
|
+
ref,
|
|
120
|
+
) => {
|
|
121
|
+
const Comp = asChild ? Slot : "button";
|
|
122
|
+
const isIconOnly =
|
|
123
|
+
size === "icon" || size === "icon-xs" || size === "icon-sm" || size === "icon-lg";
|
|
124
|
+
const iconSize = size === "xs" || size === "sm" ? 14 : size === "lg" ? 20 : 16;
|
|
125
|
+
const shape = useShape();
|
|
126
|
+
const bgClass = active
|
|
127
|
+
? activeBgVariants[variant ?? "primary"]
|
|
128
|
+
: bgVariants[variant ?? "primary"];
|
|
129
|
+
|
|
130
|
+
if (asChild && isValidElement(children)) {
|
|
131
|
+
const child = children as ReactElement<{ className?: string; style?: CSSProperties }>;
|
|
132
|
+
return (cloneElement as any)(child, {
|
|
133
|
+
...props,
|
|
134
|
+
"data-slot": "button",
|
|
135
|
+
className: cn(
|
|
136
|
+
buttonVariants({
|
|
137
|
+
variant,
|
|
138
|
+
size,
|
|
139
|
+
iconLeft: !isIconOnly && !!LeadingIcon,
|
|
140
|
+
iconRight: !isIconOnly && !!TrailingIcon,
|
|
141
|
+
}),
|
|
142
|
+
shape.button,
|
|
143
|
+
child.props.className,
|
|
144
|
+
className,
|
|
145
|
+
),
|
|
146
|
+
style: { ...child.props.style, ...style },
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<Comp
|
|
152
|
+
ref={ref}
|
|
153
|
+
data-slot="button"
|
|
154
|
+
className={cn(
|
|
155
|
+
buttonVariants({
|
|
156
|
+
variant,
|
|
157
|
+
size,
|
|
158
|
+
iconLeft: !isIconOnly && !!LeadingIcon,
|
|
159
|
+
iconRight: !isIconOnly && !!TrailingIcon,
|
|
160
|
+
}),
|
|
161
|
+
shape.button,
|
|
162
|
+
className,
|
|
163
|
+
)}
|
|
164
|
+
disabled={disabled || loading}
|
|
165
|
+
style={style}
|
|
166
|
+
{...props}
|
|
167
|
+
>
|
|
168
|
+
<span
|
|
169
|
+
aria-hidden
|
|
170
|
+
className={cn(
|
|
171
|
+
"absolute inset-0 rounded-[inherit] transition-[background-color,transform] duration-80 group-active:scale-[0.98]",
|
|
172
|
+
bgClass,
|
|
173
|
+
)}
|
|
174
|
+
/>
|
|
175
|
+
<span className="relative inline-flex items-center justify-center gap-[inherit]">
|
|
176
|
+
{loading ? (
|
|
177
|
+
<>
|
|
178
|
+
<span className="flex items-center justify-center gap-[inherit] opacity-0">
|
|
179
|
+
{LeadingIcon && !isIconOnly && <LeadingIcon size={iconSize} strokeWidth={2} />}
|
|
180
|
+
{children}
|
|
181
|
+
{TrailingIcon && !isIconOnly && <TrailingIcon size={iconSize} strokeWidth={2} />}
|
|
182
|
+
</span>
|
|
183
|
+
<span className="absolute inset-0 flex items-center justify-center">
|
|
184
|
+
<svg className="h-8 w-8" viewBox="0 0 24 24" fill="none">
|
|
185
|
+
<path
|
|
186
|
+
d="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"
|
|
187
|
+
stroke="currentColor"
|
|
188
|
+
strokeWidth="1.125"
|
|
189
|
+
strokeLinecap="round"
|
|
190
|
+
pathLength="100"
|
|
191
|
+
style={{
|
|
192
|
+
strokeDasharray: "15 85",
|
|
193
|
+
animation:
|
|
194
|
+
"spinner-move 2s linear infinite, spinner-dash 4s ease-in-out infinite",
|
|
195
|
+
}}
|
|
196
|
+
/>
|
|
197
|
+
</svg>
|
|
198
|
+
</span>
|
|
199
|
+
</>
|
|
200
|
+
) : isIconOnly ? (
|
|
201
|
+
<span className="[&_svg]:stroke-[1.5] [&_svg]:transition-[stroke-width] [&_svg]:duration-80 group-hover:[&_svg]:stroke-[2]">
|
|
202
|
+
{children}
|
|
203
|
+
</span>
|
|
204
|
+
) : (
|
|
205
|
+
<>
|
|
206
|
+
{LeadingIcon && (
|
|
207
|
+
<LeadingIcon
|
|
208
|
+
size={iconSize}
|
|
209
|
+
strokeWidth={1.5}
|
|
210
|
+
className="transition-[stroke-width] duration-80 group-hover:stroke-[2]"
|
|
211
|
+
/>
|
|
212
|
+
)}
|
|
213
|
+
<span>{children}</span>
|
|
214
|
+
{TrailingIcon && (
|
|
215
|
+
<TrailingIcon
|
|
216
|
+
size={iconSize}
|
|
217
|
+
strokeWidth={1.5}
|
|
218
|
+
className="transition-[stroke-width] duration-80 group-hover:stroke-[2]"
|
|
219
|
+
/>
|
|
220
|
+
)}
|
|
221
|
+
</>
|
|
222
|
+
)}
|
|
223
|
+
</span>
|
|
224
|
+
</Comp>
|
|
225
|
+
);
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
|
|
61
229
|
Button.displayName = "Button";
|
|
62
230
|
|
|
63
231
|
export { Button, buttonVariants };
|
|
232
|
+
export type { ButtonProps };
|
package/src/components/card.tsx
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
3
|
import { cn } from "../lib/utils";
|
|
4
|
+
import { motionClasses } from "../lib/interaction";
|
|
4
5
|
|
|
5
6
|
/* ------------------------------------------------------------------ */
|
|
6
7
|
/* Card root */
|
|
7
8
|
/* ------------------------------------------------------------------ */
|
|
8
9
|
|
|
9
|
-
const cardVariants = cva("rounded-xl", {
|
|
10
|
+
const cardVariants = cva(["rounded-xl", motionClasses], {
|
|
10
11
|
variants: {
|
|
11
12
|
variant: {
|
|
12
13
|
default: "bg-card shadow-natural",
|
|
13
|
-
muted: "bg-muted",
|
|
14
|
+
muted: "bg-muted shadow-natural",
|
|
14
15
|
ghost: "bg-transparent",
|
|
15
16
|
},
|
|
16
17
|
},
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, type ReactNode } from "react";
|
|
4
|
+
import { motion, type HTMLMotionProps } from "framer-motion";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
import { spring } from "../lib/springs";
|
|
7
|
+
import { useShape } from "../lib/shape-context";
|
|
8
|
+
import { FileThumbnail } from "./file-thumbnail";
|
|
9
|
+
|
|
10
|
+
interface ChatMessageProps extends Omit<HTMLMotionProps<"div">, "children"> {
|
|
11
|
+
/** Who sent the message. Drives alignment and bubble colour:
|
|
12
|
+
* `user` → right-aligned accent bubble, `assistant` → left-aligned plain text. */
|
|
13
|
+
from: "user" | "assistant";
|
|
14
|
+
/** Optional attachments rendered as square thumbnails above the bubble. */
|
|
15
|
+
files?: File[];
|
|
16
|
+
/** Side length of each attachment thumbnail in pixels. Defaults to 64. */
|
|
17
|
+
thumbnailSize?: number;
|
|
18
|
+
/** Timestamp shown in the hover-revealed meta row, before the actions.
|
|
19
|
+
* User-message only — ignored on assistant replies. Caller pre-formats it
|
|
20
|
+
* (e.g. `"Wednesday 6:08 PM"`). */
|
|
21
|
+
time?: ReactNode;
|
|
22
|
+
/** Icon-only action buttons shown in the hover-revealed meta row (e.g. copy,
|
|
23
|
+
* edit, regenerate). Rendered next to the timestamp. */
|
|
24
|
+
actions?: ReactNode;
|
|
25
|
+
/** Message body. When omitted the text bubble is dropped (attachment-only message). */
|
|
26
|
+
children?: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── ChatMessage ──────────────────────────────────────────────────────────
|
|
30
|
+
// A single transcript entry with baked-in entrance + layout motion. Pairs with
|
|
31
|
+
// InputMessage's onSend: render one per sent/received message. `layout="position"`
|
|
32
|
+
// lets earlier messages slide up smoothly when a new one is appended.
|
|
33
|
+
const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
|
|
34
|
+
({ from, files, thumbnailSize = 64, time, actions, children, className, ...props }, ref) => {
|
|
35
|
+
const shape = useShape();
|
|
36
|
+
const isUser = from === "user";
|
|
37
|
+
// Timestamps are a user-message affordance; assistant replies show actions only.
|
|
38
|
+
const showTime = isUser && time != null;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<motion.div
|
|
42
|
+
ref={ref}
|
|
43
|
+
layout="position"
|
|
44
|
+
initial={{ opacity: 0, y: 8, scale: 0.96 }}
|
|
45
|
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
46
|
+
transition={spring.moderate}
|
|
47
|
+
style={{ transformOrigin: isUser ? "bottom right" : "bottom left" }}
|
|
48
|
+
className={cn(
|
|
49
|
+
"group flex max-w-[80%] flex-col gap-1.5",
|
|
50
|
+
isUser ? "items-end self-end" : "items-start self-start",
|
|
51
|
+
className,
|
|
52
|
+
)}
|
|
53
|
+
{...props}
|
|
54
|
+
>
|
|
55
|
+
{files && files.length > 0 && (
|
|
56
|
+
<div className={cn("flex flex-wrap gap-1.5", isUser ? "justify-end" : "justify-start")}>
|
|
57
|
+
{files.map((file, i) => (
|
|
58
|
+
<FileThumbnail
|
|
59
|
+
key={`${file.name}-${file.size}-${file.lastModified}-${i}`}
|
|
60
|
+
file={file}
|
|
61
|
+
size={thumbnailSize}
|
|
62
|
+
/>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
{children != null && children !== "" && (
|
|
67
|
+
<div
|
|
68
|
+
className={cn(
|
|
69
|
+
"py-2 text-[14px] whitespace-pre-wrap break-words",
|
|
70
|
+
// User keeps the bubble chrome (rounded fill + horizontal padding);
|
|
71
|
+
// the assistant reply is flush-left plain text with no background.
|
|
72
|
+
isUser
|
|
73
|
+
? cn(
|
|
74
|
+
shape.bg,
|
|
75
|
+
// `text-pretty` is reserved for settled user bubbles. On the
|
|
76
|
+
// assistant reply it's left off on purpose: `text-wrap: pretty`
|
|
77
|
+
// re-balances the last lines on every content change, so a
|
|
78
|
+
// word-by-word stream visibly reflows earlier words to new
|
|
79
|
+
// lines. Default (normal) wrapping appends left-to-right and
|
|
80
|
+
// stays put as the text grows.
|
|
81
|
+
"px-3.5 text-pretty bg-[color-mix(in_oklab,var(--accent),var(--background)_45%)] text-accent-foreground",
|
|
82
|
+
)
|
|
83
|
+
: "text-foreground",
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
{children}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
{(showTime || actions != null) && (
|
|
90
|
+
// Meta row: timestamp + icon-only actions. Always rendered (so it
|
|
91
|
+
// reserves its height and the gap between bubbles never shifts) but
|
|
92
|
+
// hidden until the message is hovered or an action is focused.
|
|
93
|
+
// The timestamp is a user-message affordance only — assistant replies
|
|
94
|
+
// show their actions alone. User rows read date → icons left-to-right.
|
|
95
|
+
<div
|
|
96
|
+
className={cn(
|
|
97
|
+
"flex items-center gap-2 px-1 text-[12px] leading-none text-muted-foreground select-none",
|
|
98
|
+
"opacity-0 pointer-events-none transition-opacity duration-150",
|
|
99
|
+
"group-hover:opacity-100 group-hover:pointer-events-auto",
|
|
100
|
+
"group-focus-within:opacity-100 group-focus-within:pointer-events-auto",
|
|
101
|
+
)}
|
|
102
|
+
>
|
|
103
|
+
{showTime && <span className="tabular-nums">{time}</span>}
|
|
104
|
+
{actions != null && <span className="flex items-center gap-0.5">{actions}</span>}
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
</motion.div>
|
|
108
|
+
);
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
ChatMessage.displayName = "ChatMessage";
|
|
113
|
+
|
|
114
|
+
export { ChatMessage };
|
|
115
|
+
export type { ChatMessageProps };
|
|
116
|
+
export default ChatMessage;
|