@nikala-ui/core 0.10.1 → 0.11.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 (55) hide show
  1. package/package.json +1 -1
  2. package/registry/bubble.json +18 -0
  3. package/registry/button-group.json +20 -0
  4. package/registry/create-chat-scroll.json +13 -0
  5. package/registry/create-drop-zone.json +13 -0
  6. package/registry/create-pagination.json +13 -0
  7. package/registry/dropzone.json +21 -0
  8. package/registry/footer.json +18 -0
  9. package/registry/forgot-password-01.json +28 -0
  10. package/registry/hero-01.json +22 -0
  11. package/registry/index.json +331 -0
  12. package/registry/login-01.json +28 -0
  13. package/registry/marker.json +17 -0
  14. package/registry/marquee.json +17 -0
  15. package/registry/message.json +17 -0
  16. package/registry/navbar.json +19 -0
  17. package/registry/navigation-menu.json +22 -0
  18. package/registry/otp-verification-01.json +26 -0
  19. package/registry/pagination.json +22 -0
  20. package/registry/rating.json +19 -0
  21. package/registry/register-01.json +31 -0
  22. package/registry/review-card.json +23 -0
  23. package/registry/scroll-area.json +1 -1
  24. package/registry/sidebar.json +24 -0
  25. package/registry/spinner.json +1 -1
  26. package/registry/stat.json +19 -0
  27. package/registry/table.json +17 -0
  28. package/registry/timeline.json +18 -0
  29. package/registry/toggle-group.json +21 -0
  30. package/src/registry/blocks/forgot-password-01.tsx +141 -0
  31. package/src/registry/blocks/hero-01.tsx +28 -0
  32. package/src/registry/blocks/login-01.tsx +231 -0
  33. package/src/registry/blocks/otp-verification-01.tsx +170 -0
  34. package/src/registry/blocks/register-01.tsx +318 -0
  35. package/src/registry/components/ui/bubble.tsx +142 -0
  36. package/src/registry/components/ui/button-group.tsx +42 -0
  37. package/src/registry/components/ui/dropzone.tsx +189 -0
  38. package/src/registry/components/ui/footer.tsx +247 -0
  39. package/src/registry/components/ui/marker.tsx +102 -0
  40. package/src/registry/components/ui/marquee.tsx +118 -0
  41. package/src/registry/components/ui/message.tsx +168 -0
  42. package/src/registry/components/ui/navbar.tsx +368 -0
  43. package/src/registry/components/ui/navigation-menu.tsx +358 -0
  44. package/src/registry/components/ui/pagination.tsx +258 -0
  45. package/src/registry/components/ui/rating.tsx +185 -0
  46. package/src/registry/components/ui/review-card.tsx +195 -0
  47. package/src/registry/components/ui/scroll-area.tsx +2 -2
  48. package/src/registry/components/ui/sidebar.tsx +692 -0
  49. package/src/registry/components/ui/spinner.tsx +1 -1
  50. package/src/registry/components/ui/stat.tsx +245 -0
  51. package/src/registry/components/ui/table.tsx +160 -0
  52. package/src/registry/components/ui/timeline.tsx +350 -0
  53. package/src/registry/components/ui/toggle-group.tsx +203 -0
  54. package/src/registry/index.ts +3 -3
  55. package/src/registry/metadata.ts +141 -0
@@ -0,0 +1,318 @@
1
+ import { createSignal, Show, type Component } from "solid-js";
2
+ import { createForm } from "@nikala-ui/hooks";
3
+ import {
4
+ Card,
5
+ CardHeader,
6
+ CardTitle,
7
+ CardDescription,
8
+ CardContent,
9
+ CardFooter,
10
+ } from "../components/ui/card";
11
+ import { Form } from "../components/ui/form";
12
+ import { Field, FieldLabel } from "../components/ui/field";
13
+ import { FormMessage } from "../components/ui/form-message";
14
+ import { Button } from "../components/ui/button";
15
+ import { Input } from "../components/ui/input";
16
+ import { Checkbox } from "../components/ui/checkbox";
17
+ import { Label } from "../components/ui/label";
18
+ import { Progress } from "../components/ui/progress";
19
+ import { Badge } from "../components/ui/badge";
20
+ import { Separator } from "../components/ui/separator";
21
+ import { Eye, EyeOff, Check, X, ArrowRight, ShieldCheck } from "lucide-solid";
22
+
23
+ export const Register01: Component = () => {
24
+ const [showPassword, setShowPassword] = createSignal(false);
25
+
26
+ const form = createForm({
27
+ initialValues: {
28
+ fullName: "",
29
+ email: "",
30
+ password: "",
31
+ agreeTerms: false,
32
+ },
33
+ validate: (values) => {
34
+ const errors: Record<string, string> = {};
35
+ if (!values.fullName.trim()) {
36
+ errors.fullName = "Full name is required";
37
+ }
38
+ if (!values.email.trim()) {
39
+ errors.email = "Email is required";
40
+ } else if (!values.email.includes("@")) {
41
+ errors.email = "Please enter a valid email address";
42
+ }
43
+ if (!values.password) {
44
+ errors.password = "Password is required";
45
+ } else if (values.password.length < 8) {
46
+ errors.password = "Password must be at least 8 characters";
47
+ }
48
+ if (!values.agreeTerms) {
49
+ errors.agreeTerms = "You must agree to the terms and privacy policy";
50
+ }
51
+ return errors;
52
+ },
53
+ onSubmit: async (values) => {
54
+ // Simulate API registration request
55
+ await new Promise((resolve) => setTimeout(resolve, 1200));
56
+ },
57
+ });
58
+
59
+ const password = () => form.values().password;
60
+
61
+ // Reactive password criteria calculations
62
+ const hasMinLength = () => password().length >= 8;
63
+ const hasNumber = () => /\d/.test(password());
64
+ const hasSpecial = () => /[^A-Za-z0-9]/.test(password());
65
+ const hasUpperLower = () => /[a-z]/.test(password()) && /[A-Z]/.test(password());
66
+
67
+ const strengthScore = () => {
68
+ let score = 0;
69
+ if (password().length === 0) return 0;
70
+ if (hasMinLength()) score += 25;
71
+ if (hasNumber()) score += 25;
72
+ if (hasSpecial()) score += 25;
73
+ if (hasUpperLower()) score += 25;
74
+ return score;
75
+ };
76
+
77
+ const strengthLabel = () => {
78
+ const score = strengthScore();
79
+ if (score === 0) return "None";
80
+ if (score <= 25) return "Weak";
81
+ if (score <= 50) return "Fair";
82
+ if (score <= 75) return "Good";
83
+ return "Strong";
84
+ };
85
+
86
+ const strengthColorClass = () => {
87
+ const score = strengthScore();
88
+ if (score <= 25) return "bg-destructive";
89
+ if (score <= 50) return "bg-amber-500";
90
+ if (score <= 75) return "bg-blue-500";
91
+ return "bg-emerald-500";
92
+ };
93
+
94
+ const strengthBadgeVariant = () => {
95
+ const score = strengthScore();
96
+ if (score <= 25) return "destructive";
97
+ if (score <= 50) return "outline";
98
+ return "secondary";
99
+ };
100
+
101
+ const isFormValid = () =>
102
+ form.values().fullName.trim() !== "" &&
103
+ form.values().email.trim() !== "" &&
104
+ form.values().agreeTerms &&
105
+ strengthScore() >= 50;
106
+
107
+ return (
108
+ <div class="@container w-full min-h-[600px] flex items-center justify-center p-4 sm:p-6 md:p-10">
109
+ <Card class="w-full max-w-lg border-border shadow-md bg-card">
110
+ {/* Card Header */}
111
+ <CardHeader class="space-y-2 text-center pb-6">
112
+ <div class="mx-auto size-10 rounded-lg bg-primary/10 text-primary flex items-center justify-center mb-1">
113
+ <ShieldCheck class="size-5" />
114
+ </div>
115
+ <CardTitle class="text-2xl font-bold tracking-tight">Create your account</CardTitle>
116
+ <CardDescription class="text-xs sm:text-sm">
117
+ Join thousands of developers building fast SolidJS interfaces
118
+ </CardDescription>
119
+ </CardHeader>
120
+
121
+ <CardContent class="space-y-6">
122
+ {/* Social OAuth Buttons */}
123
+ <div class="grid grid-cols-1 @xs:grid-cols-2 gap-2.5">
124
+ <Button variant="outline" class="w-full justify-center text-xs h-9">
125
+ <svg class="size-4 mr-2 shrink-0" viewBox="0 0 24 24" fill="currentColor">
126
+ <path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
127
+ </svg>
128
+ GitHub
129
+ </Button>
130
+ <Button variant="outline" class="w-full justify-center text-xs h-9">
131
+ <svg class="size-4 mr-2 shrink-0" viewBox="0 0 24 24">
132
+ <path
133
+ fill="#4285F4"
134
+ d="M23.745 12.27c0-.7-.06-1.4-.19-2.07H12v4.51h6.6c-.29 1.52-1.14 2.82-2.4 3.68v3.05h3.88c2.27-2.09 3.66-5.17 3.66-9.17z"
135
+ />
136
+ <path
137
+ fill="#34A853"
138
+ d="M12 24c3.24 0 5.95-1.08 7.93-2.91l-3.88-3.05c-1.08.72-2.45 1.16-4.05 1.16-3.12 0-5.77-2.1-6.72-4.93H1.25v3.15C3.26 21.36 7.33 24 12 24z"
139
+ />
140
+ <path
141
+ fill="#FBBC05"
142
+ d="M5.28 14.27c-.25-.72-.38-1.49-.38-2.27s.13-1.55.38-2.27V6.58H1.25C.45 8.18 0 10.03 0 12s.45 3.82 1.25 5.42l4.03-3.15z"
143
+ />
144
+ <path
145
+ fill="#EA4335"
146
+ d="M12 4.75c1.77 0 3.35.61 4.6 1.8l3.42-3.42C17.95 1.19 15.24 0 12 0 7.33 0 3.26 2.64 1.25 6.58l4.03 3.15c.95-2.83 3.6-4.98 6.72-4.98z"
147
+ />
148
+ </svg>
149
+ Google
150
+ </Button>
151
+ </div>
152
+
153
+ <div class="relative">
154
+ <div class="absolute inset-0 flex items-center">
155
+ <Separator />
156
+ </div>
157
+ <div class="relative flex justify-center text-xs uppercase">
158
+ <span class="bg-card px-3 text-muted-foreground font-medium text-[11px]">
159
+ Or register with email
160
+ </span>
161
+ </div>
162
+ </div>
163
+
164
+ {/* Registration Form with Nikala UI Form & Field ecosystem */}
165
+ <Form onSubmit={form.handleSubmit} loading={form.isSubmitting()} class="space-y-4">
166
+ <Field>
167
+ <FieldLabel for="reg-name" class="text-xs sm:text-sm">Full Name</FieldLabel>
168
+ <Input
169
+ id="reg-name"
170
+ type="text"
171
+ placeholder="Niko Pirosmani"
172
+ value={form.values().fullName}
173
+ onInput={form.handleChange("fullName")}
174
+ onBlur={form.handleBlur("fullName")}
175
+ autocomplete="name"
176
+ />
177
+ <FormMessage form={form} name="fullName" />
178
+ </Field>
179
+
180
+ <Field>
181
+ <FieldLabel for="reg-email" class="text-xs sm:text-sm">Email Address</FieldLabel>
182
+ <Input
183
+ id="reg-email"
184
+ type="email"
185
+ placeholder="niko@nikala.dev"
186
+ value={form.values().email}
187
+ onInput={form.handleChange("email")}
188
+ onBlur={form.handleBlur("email")}
189
+ autocomplete="email"
190
+ />
191
+ <FormMessage form={form} name="email" />
192
+ </Field>
193
+
194
+ {/* Password with Strength Meter */}
195
+ <Field class="space-y-1.5">
196
+ <div class="flex items-center justify-between">
197
+ <FieldLabel for="reg-password" class="text-xs sm:text-sm">Password</FieldLabel>
198
+ <Show when={password().length > 0}>
199
+ <Badge variant={strengthBadgeVariant()} class="text-[10px] px-1.5 py-0 font-mono">
200
+ {strengthLabel()}
201
+ </Badge>
202
+ </Show>
203
+ </div>
204
+
205
+ <div class="relative">
206
+ <Input
207
+ id="reg-password"
208
+ type={showPassword() ? "text" : "password"}
209
+ placeholder="Create a strong password"
210
+ value={form.values().password}
211
+ onInput={form.handleChange("password")}
212
+ onBlur={form.handleBlur("password")}
213
+ autocomplete="new-password"
214
+ class="pr-10"
215
+ />
216
+ <button
217
+ type="button"
218
+ onClick={() => setShowPassword(!showPassword())}
219
+ class="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors p-1 cursor-pointer"
220
+ aria-label={showPassword() ? "Hide password" : "Show password"}
221
+ >
222
+ <Show when={showPassword()} fallback={<Eye class="size-4" />}>
223
+ <EyeOff class="size-4" />
224
+ </Show>
225
+ </button>
226
+ </div>
227
+ <FormMessage form={form} name="password" />
228
+
229
+ {/* Reactive Password Strength Progress Bar */}
230
+ <Show when={password().length > 0}>
231
+ <Progress
232
+ value={strengthScore()}
233
+ class="h-1.5 mt-2"
234
+ indicatorClass={strengthColorClass()}
235
+ />
236
+
237
+ {/* Validation Checklist */}
238
+ <div class="grid grid-cols-2 gap-1.5 pt-1 text-[11px] text-muted-foreground">
239
+ <div class={`flex items-center gap-1.5 ${hasMinLength() ? "text-emerald-500 font-medium" : ""}`}>
240
+ <Show when={hasMinLength()} fallback={<X class="size-3 text-muted-foreground/60" />}>
241
+ <Check class="size-3" />
242
+ </Show>
243
+ <span>8+ characters</span>
244
+ </div>
245
+ <div class={`flex items-center gap-1.5 ${hasNumber() ? "text-emerald-500 font-medium" : ""}`}>
246
+ <Show when={hasNumber()} fallback={<X class="size-3 text-muted-foreground/60" />}>
247
+ <Check class="size-3" />
248
+ </Show>
249
+ <span>At least 1 number</span>
250
+ </div>
251
+ <div class={`flex items-center gap-1.5 ${hasUpperLower() ? "text-emerald-500 font-medium" : ""}`}>
252
+ <Show when={hasUpperLower()} fallback={<X class="size-3 text-muted-foreground/60" />}>
253
+ <Check class="size-3" />
254
+ </Show>
255
+ <span>Uppercase & lowercase</span>
256
+ </div>
257
+ <div class={`flex items-center gap-1.5 ${hasSpecial() ? "text-emerald-500 font-medium" : ""}`}>
258
+ <Show when={hasSpecial()} fallback={<X class="size-3 text-muted-foreground/60" />}>
259
+ <Check class="size-3" />
260
+ </Show>
261
+ <span>1 special symbol</span>
262
+ </div>
263
+ </div>
264
+ </Show>
265
+ </Field>
266
+
267
+ {/* Terms of Service Agreement */}
268
+ <div class="space-y-1 pt-1">
269
+ <div class="flex items-start space-x-2.5">
270
+ <Checkbox
271
+ id="reg-terms"
272
+ checked={form.values().agreeTerms}
273
+ onChange={(checked) => form.setFieldValue("agreeTerms", checked)}
274
+ class="mt-0.5"
275
+ />
276
+ <Label
277
+ for="reg-terms"
278
+ class="text-xs font-normal text-muted-foreground cursor-pointer select-none leading-tight"
279
+ >
280
+ I agree to the{" "}
281
+ <a href="#terms" class="text-primary font-medium underline hover:text-primary/80">
282
+ Terms of Service
283
+ </a>{" "}
284
+ and{" "}
285
+ <a href="#privacy" class="text-primary font-medium underline hover:text-primary/80">
286
+ Privacy Policy
287
+ </a>.
288
+ </Label>
289
+ </div>
290
+ <FormMessage form={form} name="agreeTerms" />
291
+ </div>
292
+
293
+ {/* Submit Button */}
294
+ <Button
295
+ type="submit"
296
+ class="w-full mt-3 font-medium"
297
+ disabled={form.isSubmitting() || !isFormValid()}
298
+ >
299
+ <Show when={form.isSubmitting()} fallback={<>Create Account <ArrowRight class="ml-2 size-4" /></>}>
300
+ Creating account...
301
+ </Show>
302
+ </Button>
303
+ </Form>
304
+ </CardContent>
305
+
306
+ {/* Card Footer */}
307
+ <CardFooter class="justify-center border-t border-border/50 py-4 text-xs text-muted-foreground">
308
+ Already have an account?{" "}
309
+ <a href="/blocks/login-01" class="text-primary font-semibold hover:underline ml-1">
310
+ Sign in
311
+ </a>
312
+ </CardFooter>
313
+ </Card>
314
+ </div>
315
+ );
316
+ };
317
+
318
+ export default Register01;
@@ -0,0 +1,142 @@
1
+ import {
2
+ splitProps,
3
+ type Component,
4
+ type JSX,
5
+ type ParentComponent,
6
+ useContext,
7
+ } from "solid-js";
8
+ import { cva, type VariantProps } from "class-variance-authority";
9
+ import { cn } from "@/lib/cn";
10
+
11
+ /* --- 1. Bubble Variants --- */
12
+ export const bubbleVariants = cva(
13
+ "relative max-w-full rounded-lg transition-colors break-words text-left select-text",
14
+ {
15
+ variants: {
16
+ variant: {
17
+ default: "bg-primary text-primary-foreground shadow-2xs",
18
+ muted: "bg-muted text-foreground border border-border/50 shadow-2xs",
19
+ outline: "border border-border bg-background text-foreground shadow-2xs",
20
+ ghost: "bg-transparent text-foreground",
21
+ },
22
+ size: {
23
+ sm: "px-3 py-1.5 text-xs",
24
+ default: "px-4 py-2.5 text-sm",
25
+ lg: "px-5 py-3 text-base",
26
+ },
27
+ },
28
+ defaultVariants: {
29
+ variant: "default",
30
+ size: "default",
31
+ },
32
+ }
33
+ );
34
+
35
+ /* --- 2. BubbleGroup --- */
36
+ export interface BubbleGroupProps extends JSX.HTMLAttributes<HTMLDivElement> {
37
+ class?: string;
38
+ }
39
+
40
+ export const BubbleGroup: ParentComponent<BubbleGroupProps> = (props) => {
41
+ const [local, rest] = splitProps(props, ["class", "children"]);
42
+
43
+ return (
44
+ <div
45
+ class={cn("flex flex-col gap-1 w-full", local.class)}
46
+ {...rest}
47
+ >
48
+ {local.children}
49
+ </div>
50
+ );
51
+ };
52
+
53
+ /* --- 3. Bubble --- */
54
+ export interface BubbleProps
55
+ extends JSX.HTMLAttributes<HTMLDivElement>,
56
+ VariantProps<typeof bubbleVariants> {
57
+ class?: string;
58
+ }
59
+
60
+ export const Bubble: ParentComponent<BubbleProps> = (props) => {
61
+ const [local, rest] = splitProps(props, ["variant", "size", "class", "children"]);
62
+
63
+ return (
64
+ <div
65
+ class={cn(
66
+ bubbleVariants({
67
+ variant: local.variant,
68
+ size: local.size,
69
+ }),
70
+ local.class
71
+ )}
72
+ {...rest}
73
+ >
74
+ {local.children}
75
+ </div>
76
+ );
77
+ };
78
+
79
+ /* --- 4. BubbleContent --- */
80
+ export interface BubbleContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
81
+ class?: string;
82
+ }
83
+
84
+ export const BubbleContent: ParentComponent<BubbleContentProps> = (props) => {
85
+ const [local, rest] = splitProps(props, ["class", "children"]);
86
+
87
+ return (
88
+ <div
89
+ class={cn("leading-relaxed", local.class)}
90
+ {...rest}
91
+ >
92
+ {local.children}
93
+ </div>
94
+ );
95
+ };
96
+
97
+ /* --- 5. BubbleReactions --- */
98
+ export interface BubbleReactionsProps extends JSX.HTMLAttributes<HTMLDivElement> {
99
+ class?: string;
100
+ }
101
+
102
+ export const BubbleReactions: ParentComponent<BubbleReactionsProps> = (props) => {
103
+ const [local, rest] = splitProps(props, ["class", "children"]);
104
+
105
+ return (
106
+ <div
107
+ class={cn("flex items-center gap-1.5 pt-2 mt-2 border-t border-border/30 z-10 flex-wrap", local.class)}
108
+ {...rest}
109
+ >
110
+ {local.children}
111
+ </div>
112
+ );
113
+ };
114
+
115
+ /* --- 6. BubbleReaction --- */
116
+ export interface BubbleReactionProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
117
+ active?: boolean;
118
+ count?: number;
119
+ class?: string;
120
+ }
121
+
122
+ export const BubbleReaction: ParentComponent<BubbleReactionProps> = (props) => {
123
+ const [local, rest] = splitProps(props, ["active", "count", "class", "children"]);
124
+
125
+ return (
126
+ <button
127
+ type="button"
128
+ data-active={local.active ? "true" : "false"}
129
+ class={cn(
130
+ "inline-flex items-center gap-1.5 rounded-md border border-border/70 bg-background/80 px-2 py-1 text-xs text-foreground shadow-2xs transition-all hover:bg-accent hover:border-primary/40 cursor-pointer select-none",
131
+ local.active && "border-primary/40 bg-primary/10 text-primary font-medium",
132
+ local.class
133
+ )}
134
+ {...rest}
135
+ >
136
+ <span class="text-xs leading-none">{local.children}</span>
137
+ {local.count !== undefined && (
138
+ <span class="text-[11px] font-mono leading-none text-muted-foreground">{local.count}</span>
139
+ )}
140
+ </button>
141
+ );
142
+ };
@@ -0,0 +1,42 @@
1
+ import { splitProps, type Component, type JSX } from "solid-js";
2
+ import { cn } from "@/lib/cn";
3
+
4
+ export interface ButtonGroupProps extends JSX.HTMLAttributes<HTMLDivElement> {
5
+ /** Controls whether grouped buttons are arranged in a row or column. */
6
+ orientation?: "horizontal" | "vertical";
7
+ class?: string;
8
+ }
9
+
10
+ /**
11
+ * Groups adjacent buttons into a connected control with shared borders and radii.
12
+ *
13
+ * ButtonGroup is intentionally presentational. Use Button for individual actions
14
+ * and compose the group with the same reactive state as the surrounding feature.
15
+ */
16
+ export const ButtonGroup: Component<ButtonGroupProps> = (props) => {
17
+ const [local, rest] = splitProps(props, [
18
+ "orientation",
19
+ "class",
20
+ "children",
21
+ ]);
22
+
23
+ const orientation = () => local.orientation ?? "horizontal";
24
+
25
+ return (
26
+ <div
27
+ role="group"
28
+ data-orientation={orientation()}
29
+ class={cn(
30
+ "isolate inline-flex",
31
+ orientation() === "horizontal"
32
+ ? "flex-row [&>button:not(:first-child)]:-ml-px [&>button:not(:first-child)]:rounded-l-none [&>button:not(:last-child)]:rounded-r-none"
33
+ : "flex-col [&>button:not(:first-child)]:-mt-px [&>button:not(:first-child)]:rounded-t-none [&>button:not(:last-child)]:rounded-b-none",
34
+ "[&>button:focus-visible]:z-10",
35
+ local.class
36
+ )}
37
+ {...rest}
38
+ >
39
+ {local.children}
40
+ </div>
41
+ );
42
+ };
@@ -0,0 +1,189 @@
1
+ import { splitProps, type Component, type JSX } from "solid-js";
2
+ import { cn } from "@/lib/cn";
3
+ import { CloudUpload, FileText, X, AlertCircle } from "lucide-solid";
4
+
5
+ export interface DropzoneProps extends JSX.HTMLAttributes<HTMLDivElement> {
6
+ class?: string;
7
+ isOver?: boolean;
8
+ disabled?: boolean;
9
+ }
10
+
11
+ /**
12
+ * Root container for the Dropzone file upload component.
13
+ */
14
+ export const Dropzone: Component<DropzoneProps> = (props) => {
15
+ const [local, rest] = splitProps(props, ["class", "isOver", "disabled"]);
16
+
17
+ return (
18
+ <div
19
+ class={cn(
20
+ "group relative flex w-full flex-col items-center justify-center rounded-lg border-2 border-dashed border-border bg-card/50 p-8 text-center transition-all",
21
+ "hover:border-primary/50 hover:bg-card/80",
22
+ local.isOver && "border-primary bg-primary/5 ring-2 ring-primary/20",
23
+ local.disabled && "pointer-events-none opacity-50",
24
+ local.class
25
+ )}
26
+ {...rest}
27
+ />
28
+ );
29
+ };
30
+
31
+ export interface DropzoneIconProps extends JSX.HTMLAttributes<HTMLDivElement> {
32
+ class?: string;
33
+ }
34
+
35
+ /**
36
+ * Centered icon placeholder for Dropzone.
37
+ */
38
+ export const DropzoneIcon: Component<DropzoneIconProps> = (props) => {
39
+ const [local, rest] = splitProps(props, ["class", "children"]);
40
+
41
+ return (
42
+ <div
43
+ class={cn(
44
+ "mb-3 flex size-12 items-center justify-center rounded-lg bg-primary/10 text-primary transition-transform group-hover:scale-105",
45
+ local.class
46
+ )}
47
+ {...rest}
48
+ >
49
+ {local.children || <CloudUpload class="size-6" />}
50
+ </div>
51
+ );
52
+ };
53
+
54
+ export interface DropzoneTitleProps extends JSX.HTMLAttributes<HTMLHeadingElement> {
55
+ class?: string;
56
+ }
57
+
58
+ /**
59
+ * Primary title text for the dropzone prompt.
60
+ */
61
+ export const DropzoneTitle: Component<DropzoneTitleProps> = (props) => {
62
+ const [local, rest] = splitProps(props, ["class"]);
63
+
64
+ return (
65
+ <h4
66
+ class={cn("text-sm font-semibold tracking-tight text-foreground", local.class)}
67
+ {...rest}
68
+ />
69
+ );
70
+ };
71
+
72
+ export interface DropzoneDescriptionProps extends JSX.HTMLAttributes<HTMLParagraphElement> {
73
+ class?: string;
74
+ }
75
+
76
+ /**
77
+ * Subtitle description text for dropzone file specifications.
78
+ */
79
+ export const DropzoneDescription: Component<DropzoneDescriptionProps> = (props) => {
80
+ const [local, rest] = splitProps(props, ["class"]);
81
+
82
+ return (
83
+ <p
84
+ class={cn("mt-1 text-xs text-muted-foreground", local.class)}
85
+ {...rest}
86
+ />
87
+ );
88
+ };
89
+
90
+ export interface DropzoneFileListProps extends JSX.HTMLAttributes<HTMLDivElement> {
91
+ class?: string;
92
+ }
93
+
94
+ /**
95
+ * Container list for uploaded files.
96
+ */
97
+ export const DropzoneFileList: Component<DropzoneFileListProps> = (props) => {
98
+ const [local, rest] = splitProps(props, ["class"]);
99
+
100
+ return (
101
+ <div
102
+ class={cn("mt-4 flex w-full flex-col gap-2", local.class)}
103
+ {...rest}
104
+ />
105
+ );
106
+ };
107
+
108
+ export interface DropzoneFileItemProps extends JSX.HTMLAttributes<HTMLDivElement> {
109
+ class?: string;
110
+ name: string;
111
+ size?: string;
112
+ onRemove?: () => void;
113
+ }
114
+
115
+ /**
116
+ * Individual uploaded file card with name, formatted size, and remove button.
117
+ */
118
+ export const DropzoneFileItem: Component<DropzoneFileItemProps> = (props) => {
119
+ const [local, rest] = splitProps(props, ["class", "name", "size", "onRemove", "children"]);
120
+
121
+ return (
122
+ <div
123
+ class={cn(
124
+ "flex items-center justify-between gap-3 rounded-md border border-border bg-card p-2.5 text-xs transition-colors",
125
+ local.class
126
+ )}
127
+ {...rest}
128
+ >
129
+ <div class="flex min-w-0 items-center gap-2.5">
130
+ <div class="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
131
+ {local.children || <FileText class="size-4" />}
132
+ </div>
133
+ <div class="flex min-w-0 flex-col text-left">
134
+ <span class="truncate font-medium text-foreground">{local.name}</span>
135
+ {local.size && (
136
+ <span class="font-mono text-[11px] text-muted-foreground">{local.size}</span>
137
+ )}
138
+ </div>
139
+ </div>
140
+
141
+ {local.onRemove && (
142
+ <button
143
+ type="button"
144
+ onClick={(e) => {
145
+ e.stopPropagation();
146
+ local.onRemove?.();
147
+ }}
148
+ class="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
149
+ aria-label="Remove file"
150
+ >
151
+ <X class="size-3.5" />
152
+ </button>
153
+ )}
154
+ </div>
155
+ );
156
+ };
157
+
158
+ export interface DropzoneRejectedItemProps extends JSX.HTMLAttributes<HTMLDivElement> {
159
+ class?: string;
160
+ name: string;
161
+ error?: string;
162
+ size?: string;
163
+ }
164
+
165
+ /**
166
+ * Card for displaying rejected files and validation errors.
167
+ */
168
+ export const DropzoneRejectedItem: Component<DropzoneRejectedItemProps> = (props) => {
169
+ const [local, rest] = splitProps(props, ["class", "name", "error", "size"]);
170
+
171
+ return (
172
+ <div
173
+ class={cn(
174
+ "flex items-center justify-between gap-3 rounded-md border border-destructive/30 bg-destructive/10 p-2.5 text-xs text-destructive",
175
+ local.class
176
+ )}
177
+ {...rest}
178
+ >
179
+ <div class="flex min-w-0 items-center gap-2">
180
+ <AlertCircle class="size-4 shrink-0" />
181
+ <span class="truncate font-medium">{local.name}</span>
182
+ {local.size && <span class="font-mono text-[11px] opacity-80">({local.size})</span>}
183
+ </div>
184
+ {local.error && (
185
+ <span class="shrink-0 text-[11px] font-medium">{local.error}</span>
186
+ )}
187
+ </div>
188
+ );
189
+ };