@hilum/ui 0.1.1
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 +42 -0
- package/dist/icons.d.ts +1 -0
- package/dist/icons.js +3 -0
- package/dist/icons.js.map +1 -0
- package/dist/index.d.ts +992 -0
- package/dist/index.js +4433 -0
- package/dist/index.js.map +1 -0
- package/dist/tokens.css +265 -0
- package/dist/tokens.d.ts +189 -0
- package/dist/tokens.js +193 -0
- package/dist/tokens.js.map +1 -0
- package/package.json +80 -0
- package/src/components/__tests__/data.test.tsx +180 -0
- package/src/components/__tests__/disclosure.test.tsx +198 -0
- package/src/components/__tests__/display.test.tsx +262 -0
- package/src/components/__tests__/feedback.test.tsx +138 -0
- package/src/components/__tests__/form.test.tsx +297 -0
- package/src/components/__tests__/layout.test.tsx +388 -0
- package/src/components/__tests__/overlay.test.tsx +90 -0
- package/src/components/__tests__/primitives.test.tsx +248 -0
- package/src/components/accordion.tsx +68 -0
- package/src/components/action-panel.tsx +112 -0
- package/src/components/activity-feed.tsx +69 -0
- package/src/components/alert-dialog.tsx +132 -0
- package/src/components/alert.tsx +44 -0
- package/src/components/aspect-ratio.tsx +19 -0
- package/src/components/avatar-stack.tsx +70 -0
- package/src/components/avatar.tsx +103 -0
- package/src/components/badge.tsx +33 -0
- package/src/components/breadcrumb.tsx +76 -0
- package/src/components/button-group.tsx +45 -0
- package/src/components/button.tsx +60 -0
- package/src/components/calendar.tsx +61 -0
- package/src/components/card-heading.tsx +60 -0
- package/src/components/card.tsx +120 -0
- package/src/components/carousel.tsx +222 -0
- package/src/components/chart.tsx +77 -0
- package/src/components/checkbox.tsx +35 -0
- package/src/components/collapsible.tsx +9 -0
- package/src/components/color-input.tsx +90 -0
- package/src/components/color-picker.tsx +111 -0
- package/src/components/combobox.tsx +207 -0
- package/src/components/command-palette.tsx +137 -0
- package/src/components/command.tsx +248 -0
- package/src/components/context-menu.tsx +201 -0
- package/src/components/data-table.tsx +151 -0
- package/src/components/date-picker.tsx +56 -0
- package/src/components/description-list.tsx +70 -0
- package/src/components/dialog.tsx +97 -0
- package/src/components/drawer.tsx +90 -0
- package/src/components/dropdown-menu.tsx +202 -0
- package/src/components/empty-state.tsx +43 -0
- package/src/components/field.tsx +45 -0
- package/src/components/grid-list.tsx +159 -0
- package/src/components/hover-card.tsx +34 -0
- package/src/components/input-group.tsx +134 -0
- package/src/components/input-number.tsx +142 -0
- package/src/components/input-otp.tsx +68 -0
- package/src/components/input.tsx +23 -0
- package/src/components/kbd.tsx +18 -0
- package/src/components/label.tsx +23 -0
- package/src/components/media-object.tsx +49 -0
- package/src/components/menubar.tsx +235 -0
- package/src/components/native-select.tsx +44 -0
- package/src/components/navigation-menu.tsx +158 -0
- package/src/components/notification.tsx +109 -0
- package/src/components/page-heading.tsx +125 -0
- package/src/components/pagination.tsx +89 -0
- package/src/components/popover.tsx +35 -0
- package/src/components/progress.tsx +23 -0
- package/src/components/property-row.tsx +56 -0
- package/src/components/radio-card.tsx +105 -0
- package/src/components/radio-group.tsx +38 -0
- package/src/components/resizable.tsx +74 -0
- package/src/components/scroll-area.tsx +42 -0
- package/src/components/section-heading.tsx +64 -0
- package/src/components/select.tsx +130 -0
- package/src/components/separator.tsx +25 -0
- package/src/components/sheet.tsx +117 -0
- package/src/components/sidebar.tsx +636 -0
- package/src/components/skeleton.tsx +14 -0
- package/src/components/slider.tsx +24 -0
- package/src/components/sonner.tsx +34 -0
- package/src/components/spinner.tsx +37 -0
- package/src/components/stacked-list.tsx +64 -0
- package/src/components/stat-card.tsx +54 -0
- package/src/components/steps.tsx +179 -0
- package/src/components/switch.tsx +33 -0
- package/src/components/table.tsx +101 -0
- package/src/components/tabs.tsx +55 -0
- package/src/components/textarea.tsx +21 -0
- package/src/components/toggle-group.tsx +49 -0
- package/src/components/toggle.tsx +66 -0
- package/src/components/tooltip.tsx +43 -0
- package/src/icons.ts +158 -0
- package/src/index.ts +84 -0
- package/src/lib/utils.ts +6 -0
- package/src/tokens/tokens.ts +194 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
|
|
5
|
+
import { ArrowLeft, ArrowRight } from "lucide-react";
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
import { Button } from "./button";
|
|
8
|
+
|
|
9
|
+
type CarouselApi = UseEmblaCarouselType[1];
|
|
10
|
+
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
|
11
|
+
type CarouselOptions = UseCarouselParameters[0];
|
|
12
|
+
type CarouselPlugin = UseCarouselParameters[1];
|
|
13
|
+
|
|
14
|
+
interface CarouselProps {
|
|
15
|
+
opts?: CarouselOptions;
|
|
16
|
+
plugins?: CarouselPlugin;
|
|
17
|
+
orientation?: "horizontal" | "vertical";
|
|
18
|
+
setApi?: (api: CarouselApi) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type CarouselContextProps = {
|
|
22
|
+
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
|
23
|
+
api: ReturnType<typeof useEmblaCarousel>[1];
|
|
24
|
+
scrollPrev: () => void;
|
|
25
|
+
scrollNext: () => void;
|
|
26
|
+
canScrollPrev: boolean;
|
|
27
|
+
canScrollNext: boolean;
|
|
28
|
+
} & CarouselProps;
|
|
29
|
+
|
|
30
|
+
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
|
31
|
+
|
|
32
|
+
function useCarousel() {
|
|
33
|
+
const context = React.useContext(CarouselContext);
|
|
34
|
+
if (!context) throw new Error("useCarousel must be used within a <Carousel />");
|
|
35
|
+
return context;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function Carousel({
|
|
39
|
+
opts,
|
|
40
|
+
plugins,
|
|
41
|
+
orientation = "horizontal",
|
|
42
|
+
setApi,
|
|
43
|
+
className,
|
|
44
|
+
children,
|
|
45
|
+
...props
|
|
46
|
+
}: CarouselProps & React.ComponentProps<"div">) {
|
|
47
|
+
const [carouselRef, emblaApi] = useEmblaCarousel(
|
|
48
|
+
{
|
|
49
|
+
...opts,
|
|
50
|
+
axis: orientation === "vertical" ? "y" : "x",
|
|
51
|
+
},
|
|
52
|
+
plugins,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
|
56
|
+
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
|
57
|
+
|
|
58
|
+
const onSelect = React.useCallback((api: CarouselApi) => {
|
|
59
|
+
if (!api) return;
|
|
60
|
+
setCanScrollPrev(api.canScrollPrev());
|
|
61
|
+
setCanScrollNext(api.canScrollNext());
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const scrollPrev = React.useCallback(() => {
|
|
65
|
+
emblaApi?.scrollPrev();
|
|
66
|
+
}, [emblaApi]);
|
|
67
|
+
|
|
68
|
+
const scrollNext = React.useCallback(() => {
|
|
69
|
+
emblaApi?.scrollNext();
|
|
70
|
+
}, [emblaApi]);
|
|
71
|
+
|
|
72
|
+
const handleKeyDown = React.useCallback(
|
|
73
|
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
74
|
+
if (event.key === "ArrowLeft") {
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
scrollPrev();
|
|
77
|
+
} else if (event.key === "ArrowRight") {
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
scrollNext();
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
[scrollPrev, scrollNext],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
React.useEffect(() => {
|
|
86
|
+
if (!emblaApi) return;
|
|
87
|
+
onSelect(emblaApi);
|
|
88
|
+
emblaApi.on("reInit", onSelect);
|
|
89
|
+
emblaApi.on("select", onSelect);
|
|
90
|
+
return () => {
|
|
91
|
+
emblaApi.off("reInit", onSelect);
|
|
92
|
+
emblaApi.off("select", onSelect);
|
|
93
|
+
};
|
|
94
|
+
}, [emblaApi, onSelect]);
|
|
95
|
+
|
|
96
|
+
React.useEffect(() => {
|
|
97
|
+
if (!emblaApi || !setApi) return;
|
|
98
|
+
setApi(emblaApi);
|
|
99
|
+
}, [emblaApi, setApi]);
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<CarouselContext.Provider
|
|
103
|
+
value={{
|
|
104
|
+
carouselRef,
|
|
105
|
+
api: emblaApi,
|
|
106
|
+
opts,
|
|
107
|
+
orientation,
|
|
108
|
+
scrollPrev,
|
|
109
|
+
scrollNext,
|
|
110
|
+
canScrollPrev,
|
|
111
|
+
canScrollNext,
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
<div
|
|
115
|
+
onKeyDownCapture={handleKeyDown}
|
|
116
|
+
className={cn("relative", className)}
|
|
117
|
+
role="region"
|
|
118
|
+
aria-roledescription="carousel"
|
|
119
|
+
{...props}
|
|
120
|
+
>
|
|
121
|
+
{children}
|
|
122
|
+
</div>
|
|
123
|
+
</CarouselContext.Provider>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
128
|
+
const { carouselRef, orientation } = useCarousel();
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div ref={carouselRef} className="overflow-hidden">
|
|
132
|
+
<div
|
|
133
|
+
className={cn("flex", orientation === "vertical" ? "-mt-4 flex-col" : "-ml-4", className)}
|
|
134
|
+
{...props}
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
|
141
|
+
const { orientation } = useCarousel();
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div
|
|
145
|
+
role="group"
|
|
146
|
+
aria-roledescription="slide"
|
|
147
|
+
className={cn(
|
|
148
|
+
"min-w-0 shrink-0 grow-0 basis-full",
|
|
149
|
+
orientation === "vertical" ? "pt-4" : "pl-4",
|
|
150
|
+
className,
|
|
151
|
+
)}
|
|
152
|
+
{...props}
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function CarouselPrevious({
|
|
158
|
+
className,
|
|
159
|
+
variant = "outline",
|
|
160
|
+
size = "icon",
|
|
161
|
+
...props
|
|
162
|
+
}: React.ComponentProps<typeof Button>) {
|
|
163
|
+
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<Button
|
|
167
|
+
variant={variant}
|
|
168
|
+
size={size}
|
|
169
|
+
className={cn(
|
|
170
|
+
"absolute h-9 w-9 rounded-full",
|
|
171
|
+
orientation === "vertical"
|
|
172
|
+
? "-top-12 left-1/2 -translate-x-1/2 rotate-90"
|
|
173
|
+
: "-left-12 top-1/2 -translate-y-1/2",
|
|
174
|
+
className,
|
|
175
|
+
)}
|
|
176
|
+
disabled={!canScrollPrev}
|
|
177
|
+
onClick={scrollPrev}
|
|
178
|
+
{...props}
|
|
179
|
+
>
|
|
180
|
+
<ArrowLeft className="size-4" />
|
|
181
|
+
<span className="sr-only">Previous slide</span>
|
|
182
|
+
</Button>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function CarouselNext({
|
|
187
|
+
className,
|
|
188
|
+
variant = "outline",
|
|
189
|
+
size = "icon",
|
|
190
|
+
...props
|
|
191
|
+
}: React.ComponentProps<typeof Button>) {
|
|
192
|
+
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<Button
|
|
196
|
+
variant={variant}
|
|
197
|
+
size={size}
|
|
198
|
+
className={cn(
|
|
199
|
+
"absolute h-9 w-9 rounded-full",
|
|
200
|
+
orientation === "vertical"
|
|
201
|
+
? "-bottom-12 left-1/2 -translate-x-1/2 rotate-90"
|
|
202
|
+
: "-right-12 top-1/2 -translate-y-1/2",
|
|
203
|
+
className,
|
|
204
|
+
)}
|
|
205
|
+
disabled={!canScrollNext}
|
|
206
|
+
onClick={scrollNext}
|
|
207
|
+
{...props}
|
|
208
|
+
>
|
|
209
|
+
<ArrowRight className="size-4" />
|
|
210
|
+
<span className="sr-only">Next slide</span>
|
|
211
|
+
</Button>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export {
|
|
216
|
+
type CarouselApi,
|
|
217
|
+
Carousel,
|
|
218
|
+
CarouselContent,
|
|
219
|
+
CarouselItem,
|
|
220
|
+
CarouselPrevious,
|
|
221
|
+
CarouselNext,
|
|
222
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { ResponsiveContainer, type TooltipContentProps } from "recharts";
|
|
5
|
+
import { cn } from "../lib/utils";
|
|
6
|
+
|
|
7
|
+
// Color palette for charts
|
|
8
|
+
export const CHART_COLORS = {
|
|
9
|
+
primary: "#FF4D01", // brand-primary
|
|
10
|
+
secondary: "#CDEA19", // brand-secondary
|
|
11
|
+
tertiary: "#FDE086", // brand-secondary
|
|
12
|
+
muted: "#a8978a", // taupe-400
|
|
13
|
+
subtle: "#e3dcd4", // taupe-200
|
|
14
|
+
dark: "#26181a", // taupe-900
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
interface ChartContainerProps {
|
|
18
|
+
children: React.ReactNode;
|
|
19
|
+
className?: string;
|
|
20
|
+
height?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ChartContainer({ children, className, height = 300 }: ChartContainerProps) {
|
|
24
|
+
return (
|
|
25
|
+
<div className={cn("w-full", className)}>
|
|
26
|
+
<ResponsiveContainer width="100%" height={height}>
|
|
27
|
+
{children as React.ReactElement}
|
|
28
|
+
</ResponsiveContainer>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
ChartContainer.displayName = "ChartContainer";
|
|
33
|
+
|
|
34
|
+
// Custom tooltip component styled to match the design system
|
|
35
|
+
interface ChartTooltipProps extends Partial<TooltipContentProps<number, string>> {
|
|
36
|
+
labelClassName?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ChartTooltip({ active, payload, label, labelClassName }: ChartTooltipProps) {
|
|
40
|
+
if (!active || !payload?.length) return null;
|
|
41
|
+
return (
|
|
42
|
+
<div className="rounded-xl border border-taupe-100 bg-white px-3 py-2 shadow-elevated">
|
|
43
|
+
{label && <p className={cn("mb-1 label text-taupe-400", labelClassName)}>{label}</p>}
|
|
44
|
+
{payload.map((entry, i) => (
|
|
45
|
+
<div key={i} className="flex items-center gap-2">
|
|
46
|
+
<span
|
|
47
|
+
className="inline-block h-2 w-2 rounded-full"
|
|
48
|
+
style={{ backgroundColor: entry.color }}
|
|
49
|
+
/>
|
|
50
|
+
<span className="caption text-taupe-500">{entry.name}:</span>
|
|
51
|
+
<span className="caption font-medium text-taupe-900">{entry.value}</span>
|
|
52
|
+
</div>
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
ChartTooltip.displayName = "ChartTooltip";
|
|
58
|
+
|
|
59
|
+
export { ChartContainer, ChartTooltip };
|
|
60
|
+
|
|
61
|
+
export {
|
|
62
|
+
BarChart,
|
|
63
|
+
Bar,
|
|
64
|
+
LineChart,
|
|
65
|
+
Line,
|
|
66
|
+
AreaChart,
|
|
67
|
+
Area,
|
|
68
|
+
PieChart,
|
|
69
|
+
Pie,
|
|
70
|
+
Cell,
|
|
71
|
+
CartesianGrid,
|
|
72
|
+
XAxis,
|
|
73
|
+
YAxis,
|
|
74
|
+
Tooltip as RechartsTooltip,
|
|
75
|
+
Legend,
|
|
76
|
+
type TooltipProps as RechartsTooltipProps,
|
|
77
|
+
} from "recharts";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Checkbox } from "radix-ui";
|
|
5
|
+
import { Check, Minus } from "lucide-react";
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
|
|
8
|
+
const CheckboxRoot = React.forwardRef<
|
|
9
|
+
React.ComponentRef<typeof Checkbox.Root>,
|
|
10
|
+
React.ComponentPropsWithoutRef<typeof Checkbox.Root>
|
|
11
|
+
>(({ className, ...props }, ref) => (
|
|
12
|
+
<Checkbox.Root
|
|
13
|
+
ref={ref}
|
|
14
|
+
className={cn(
|
|
15
|
+
"peer size-4 shrink-0 rounded border border-taupe-300 bg-white transition-colors",
|
|
16
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary/30",
|
|
17
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
18
|
+
"data-[state=checked]:bg-brand-primary data-[state=checked]:border-brand-primary",
|
|
19
|
+
"data-[state=indeterminate]:bg-brand-primary data-[state=indeterminate]:border-brand-primary",
|
|
20
|
+
className,
|
|
21
|
+
)}
|
|
22
|
+
{...props}
|
|
23
|
+
>
|
|
24
|
+
<Checkbox.Indicator className="flex items-center justify-center text-white">
|
|
25
|
+
{props.checked === "indeterminate" ? (
|
|
26
|
+
<Minus size={11} strokeWidth={3} />
|
|
27
|
+
) : (
|
|
28
|
+
<Check size={11} strokeWidth={3} />
|
|
29
|
+
)}
|
|
30
|
+
</Checkbox.Indicator>
|
|
31
|
+
</Checkbox.Root>
|
|
32
|
+
));
|
|
33
|
+
CheckboxRoot.displayName = "Checkbox";
|
|
34
|
+
|
|
35
|
+
export { CheckboxRoot as Checkbox };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Collapsible } from "radix-ui";
|
|
4
|
+
|
|
5
|
+
const CollapsibleRoot = Collapsible.Root;
|
|
6
|
+
const CollapsibleTrigger = Collapsible.Trigger;
|
|
7
|
+
const CollapsibleContent = Collapsible.Content;
|
|
8
|
+
|
|
9
|
+
export { CollapsibleRoot as Collapsible, CollapsibleTrigger, CollapsibleContent };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import { ColorPicker } from "./color-picker";
|
|
6
|
+
|
|
7
|
+
interface ColorInputProps {
|
|
8
|
+
value: string;
|
|
9
|
+
onChange: (next: string) => void;
|
|
10
|
+
/** Show an opacity slider (0–100). Pass `opacity` and `onOpacityChange`. */
|
|
11
|
+
opacity?: number;
|
|
12
|
+
onOpacityChange?: (next: number) => void;
|
|
13
|
+
className?: string;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
presets?: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compact, inline color input designed for designer property panels.
|
|
20
|
+
* Combines a swatch trigger (ColorPicker), a hex input, and an optional opacity field.
|
|
21
|
+
*/
|
|
22
|
+
function ColorInput({
|
|
23
|
+
value,
|
|
24
|
+
onChange,
|
|
25
|
+
opacity,
|
|
26
|
+
onOpacityChange,
|
|
27
|
+
className,
|
|
28
|
+
disabled,
|
|
29
|
+
presets,
|
|
30
|
+
}: ColorInputProps) {
|
|
31
|
+
const [hex, setHex] = React.useState(value);
|
|
32
|
+
|
|
33
|
+
React.useEffect(() => {
|
|
34
|
+
setHex(value);
|
|
35
|
+
}, [value]);
|
|
36
|
+
|
|
37
|
+
const commitHex = (next: string) => {
|
|
38
|
+
if (/^#([0-9a-fA-F]{3}){1,2}$/.test(next)) onChange(next);
|
|
39
|
+
else setHex(value);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
className={cn(
|
|
45
|
+
"inline-flex h-8 items-stretch gap-0 rounded-md border border-taupe-200 bg-white overflow-hidden",
|
|
46
|
+
"focus-within:ring-2 focus-within:ring-brand-primary/20 focus-within:border-brand-primary",
|
|
47
|
+
disabled && "opacity-50 pointer-events-none",
|
|
48
|
+
className,
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
<ColorPicker
|
|
52
|
+
value={value}
|
|
53
|
+
onChange={onChange}
|
|
54
|
+
presets={presets}
|
|
55
|
+
disabled={disabled}
|
|
56
|
+
className="h-full w-8 border-0 rounded-none focus-visible:ring-0"
|
|
57
|
+
/>
|
|
58
|
+
<input
|
|
59
|
+
type="text"
|
|
60
|
+
value={hex.replace(/^#/, "")}
|
|
61
|
+
onChange={(e) => setHex("#" + e.target.value.replace(/^#/, ""))}
|
|
62
|
+
onBlur={(e) => commitHex("#" + e.target.value.replace(/^#/, ""))}
|
|
63
|
+
onKeyDown={(e) => {
|
|
64
|
+
if (e.key === "Enter")
|
|
65
|
+
commitHex("#" + (e.target as HTMLInputElement).value.replace(/^#/, ""));
|
|
66
|
+
}}
|
|
67
|
+
spellCheck={false}
|
|
68
|
+
className="w-[5.5rem] caption text-taupe-900 px-2 bg-transparent border-l border-taupe-100 focus:outline-none uppercase"
|
|
69
|
+
/>
|
|
70
|
+
{typeof opacity === "number" && onOpacityChange && (
|
|
71
|
+
<div className="relative flex items-center border-l border-taupe-100">
|
|
72
|
+
<input
|
|
73
|
+
type="number"
|
|
74
|
+
min={0}
|
|
75
|
+
max={100}
|
|
76
|
+
value={Math.round(opacity)}
|
|
77
|
+
onChange={(e) => onOpacityChange(Number(e.target.value))}
|
|
78
|
+
className="w-12 caption text-taupe-900 px-2 bg-transparent focus:outline-none text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
79
|
+
/>
|
|
80
|
+
<span aria-hidden className="caption-xs text-taupe-400 pr-2">
|
|
81
|
+
%
|
|
82
|
+
</span>
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export { ColorInput };
|
|
90
|
+
export type { ColorInputProps };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import { Popover } from "radix-ui";
|
|
6
|
+
|
|
7
|
+
interface ColorPickerProps {
|
|
8
|
+
value: string;
|
|
9
|
+
onChange: (next: string) => void;
|
|
10
|
+
className?: string;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
/** Optional preset palette shown above the picker. */
|
|
13
|
+
presets?: string[];
|
|
14
|
+
/** Accessible label for the trigger. */
|
|
15
|
+
ariaLabel?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isValidHex(s: string): boolean {
|
|
19
|
+
return /^#([0-9a-fA-F]{3}){1,2}$/.test(s);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function ColorPicker({
|
|
23
|
+
value,
|
|
24
|
+
onChange,
|
|
25
|
+
className,
|
|
26
|
+
disabled,
|
|
27
|
+
presets,
|
|
28
|
+
ariaLabel = "Color",
|
|
29
|
+
}: ColorPickerProps) {
|
|
30
|
+
const [hex, setHex] = React.useState(value);
|
|
31
|
+
|
|
32
|
+
React.useEffect(() => {
|
|
33
|
+
setHex(value);
|
|
34
|
+
}, [value]);
|
|
35
|
+
|
|
36
|
+
const commit = (next: string) => {
|
|
37
|
+
if (isValidHex(next)) onChange(next);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<Popover.Root>
|
|
42
|
+
<Popover.Trigger asChild>
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
disabled={disabled}
|
|
46
|
+
aria-label={ariaLabel}
|
|
47
|
+
className={cn(
|
|
48
|
+
"inline-flex h-8 w-8 items-center justify-center rounded-md border border-taupe-200 bg-white",
|
|
49
|
+
"transition-colors hover:border-taupe-300",
|
|
50
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary/20 focus-visible:border-brand-primary",
|
|
51
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
52
|
+
className,
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
<span
|
|
56
|
+
aria-hidden
|
|
57
|
+
className="h-5 w-5 rounded-sm border border-black/5"
|
|
58
|
+
style={{ backgroundColor: value }}
|
|
59
|
+
/>
|
|
60
|
+
</button>
|
|
61
|
+
</Popover.Trigger>
|
|
62
|
+
<Popover.Portal>
|
|
63
|
+
<Popover.Content
|
|
64
|
+
align="start"
|
|
65
|
+
sideOffset={6}
|
|
66
|
+
className="z-50 w-56 rounded-xl bg-white p-3 shadow-natural outline-none"
|
|
67
|
+
>
|
|
68
|
+
<input
|
|
69
|
+
type="color"
|
|
70
|
+
value={isValidHex(hex) ? hex : "#000000"}
|
|
71
|
+
onChange={(e) => {
|
|
72
|
+
setHex(e.target.value);
|
|
73
|
+
onChange(e.target.value);
|
|
74
|
+
}}
|
|
75
|
+
className="h-10 w-full cursor-pointer rounded-md border border-taupe-200 bg-white p-0.5"
|
|
76
|
+
/>
|
|
77
|
+
<div className="mt-2 flex items-center gap-2">
|
|
78
|
+
<span className="caption text-taupe-500">Hex</span>
|
|
79
|
+
<input
|
|
80
|
+
type="text"
|
|
81
|
+
value={hex}
|
|
82
|
+
onChange={(e) => setHex(e.target.value)}
|
|
83
|
+
onBlur={(e) => commit(e.target.value)}
|
|
84
|
+
onKeyDown={(e) => {
|
|
85
|
+
if (e.key === "Enter") commit((e.target as HTMLInputElement).value);
|
|
86
|
+
}}
|
|
87
|
+
className="flex-1 h-7 rounded-md border border-taupe-200 bg-white px-2 caption text-taupe-900 focus:outline-none focus:border-brand-primary"
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
{presets && presets.length > 0 && (
|
|
91
|
+
<div className="mt-3 grid grid-cols-8 gap-1.5">
|
|
92
|
+
{presets.map((p) => (
|
|
93
|
+
<button
|
|
94
|
+
key={p}
|
|
95
|
+
type="button"
|
|
96
|
+
onClick={() => onChange(p)}
|
|
97
|
+
aria-label={p}
|
|
98
|
+
className="h-5 w-5 rounded-sm border border-black/10 transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-primary/30"
|
|
99
|
+
style={{ backgroundColor: p }}
|
|
100
|
+
/>
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
</Popover.Content>
|
|
105
|
+
</Popover.Portal>
|
|
106
|
+
</Popover.Root>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export { ColorPicker };
|
|
111
|
+
export type { ColorPickerProps };
|