@kinetixui/ui 0.1.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 (81) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +1176 -0
  3. package/dist/index.js +3939 -0
  4. package/package.json +106 -0
  5. package/src/components/accordion.tsx +52 -0
  6. package/src/components/alert-dialog.tsx +115 -0
  7. package/src/components/alert.tsx +47 -0
  8. package/src/components/aspect-ratio.tsx +7 -0
  9. package/src/components/audio-player.tsx +150 -0
  10. package/src/components/avatar.tsx +76 -0
  11. package/src/components/badge.tsx +40 -0
  12. package/src/components/breadcrumb.tsx +77 -0
  13. package/src/components/button.tsx +119 -0
  14. package/src/components/calendar.tsx +60 -0
  15. package/src/components/card.tsx +50 -0
  16. package/src/components/carousel.tsx +181 -0
  17. package/src/components/chart.tsx +177 -0
  18. package/src/components/checkbox.tsx +38 -0
  19. package/src/components/circular-progress.tsx +67 -0
  20. package/src/components/code-block.tsx +96 -0
  21. package/src/components/collapsible.tsx +9 -0
  22. package/src/components/command.tsx +123 -0
  23. package/src/components/context-menu.tsx +132 -0
  24. package/src/components/data-table.tsx +92 -0
  25. package/src/components/date-picker.tsx +68 -0
  26. package/src/components/dialog.tsx +101 -0
  27. package/src/components/drawer.tsx +82 -0
  28. package/src/components/dropdown-menu.tsx +132 -0
  29. package/src/components/fab.tsx +58 -0
  30. package/src/components/field.tsx +128 -0
  31. package/src/components/file-upload.tsx +183 -0
  32. package/src/components/footer.tsx +62 -0
  33. package/src/components/form.tsx +130 -0
  34. package/src/components/hover-card.tsx +28 -0
  35. package/src/components/image.tsx +87 -0
  36. package/src/components/inform.tsx +82 -0
  37. package/src/components/input-group.tsx +96 -0
  38. package/src/components/input-otp.tsx +63 -0
  39. package/src/components/input.tsx +72 -0
  40. package/src/components/label.tsx +20 -0
  41. package/src/components/list.tsx +69 -0
  42. package/src/components/menubar.tsx +168 -0
  43. package/src/components/metric.tsx +51 -0
  44. package/src/components/modal.tsx +126 -0
  45. package/src/components/navigation-bar.tsx +49 -0
  46. package/src/components/navigation-menu.tsx +117 -0
  47. package/src/components/number-input.tsx +86 -0
  48. package/src/components/pagination.tsx +77 -0
  49. package/src/components/password-input.tsx +36 -0
  50. package/src/components/popover.tsx +32 -0
  51. package/src/components/progress.tsx +24 -0
  52. package/src/components/quote.tsx +34 -0
  53. package/src/components/radio-group.tsx +44 -0
  54. package/src/components/rating.tsx +88 -0
  55. package/src/components/resizable.tsx +40 -0
  56. package/src/components/scroll-area.tsx +39 -0
  57. package/src/components/select.tsx +124 -0
  58. package/src/components/separator.tsx +25 -0
  59. package/src/components/sheet.tsx +104 -0
  60. package/src/components/sidebar.tsx +390 -0
  61. package/src/components/skeleton.tsx +9 -0
  62. package/src/components/slider.tsx +24 -0
  63. package/src/components/sonner.tsx +30 -0
  64. package/src/components/spinner.tsx +43 -0
  65. package/src/components/stepper.tsx +75 -0
  66. package/src/components/switch.tsx +37 -0
  67. package/src/components/tab-bar.tsx +58 -0
  68. package/src/components/table-of-contents.tsx +46 -0
  69. package/src/components/table.tsx +70 -0
  70. package/src/components/tabs.tsx +54 -0
  71. package/src/components/tag.tsx +56 -0
  72. package/src/components/textarea.tsx +56 -0
  73. package/src/components/toggle-group.tsx +41 -0
  74. package/src/components/toggle.tsx +34 -0
  75. package/src/components/tooltip.tsx +31 -0
  76. package/src/index.ts +305 -0
  77. package/src/lib/utils.ts +7 -0
  78. package/src/stories/Button.stories.tsx +155 -0
  79. package/src/stories/Input.stories.tsx +80 -0
  80. package/src/stories/Textarea.stories.tsx +69 -0
  81. package/tailwind.config.ts +107 -0
@@ -0,0 +1,101 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
5
+ import { X } from "lucide-react";
6
+ import { cn } from "../lib/utils";
7
+
8
+ const Dialog = DialogPrimitive.Root;
9
+ const DialogTrigger = DialogPrimitive.Trigger;
10
+ const DialogPortal = DialogPrimitive.Portal;
11
+ const DialogClose = DialogPrimitive.Close;
12
+
13
+ const DialogOverlay = React.forwardRef<
14
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
15
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
16
+ >(({ className, ...props }, ref) => (
17
+ <DialogPrimitive.Overlay
18
+ ref={ref}
19
+ className={cn(
20
+ "fixed inset-0 z-50 bg-foreground/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
21
+ className,
22
+ )}
23
+ {...props}
24
+ />
25
+ ));
26
+ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
27
+
28
+ const DialogContent = React.forwardRef<
29
+ React.ElementRef<typeof DialogPrimitive.Content>,
30
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
31
+ >(({ className, children, ...props }, ref) => (
32
+ <DialogPortal>
33
+ <DialogOverlay />
34
+ <DialogPrimitive.Content
35
+ ref={ref}
36
+ className={cn(
37
+ "fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg sm:rounded-xl font-sans",
38
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
39
+ className,
40
+ )}
41
+ {...props}
42
+ >
43
+ {children}
44
+ <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring disabled:pointer-events-none">
45
+ <X className="size-4" />
46
+ <span className="sr-only">Close</span>
47
+ </DialogPrimitive.Close>
48
+ </DialogPrimitive.Content>
49
+ </DialogPortal>
50
+ ));
51
+ DialogContent.displayName = DialogPrimitive.Content.displayName;
52
+
53
+ const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
54
+ <div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
55
+ );
56
+ DialogHeader.displayName = "DialogHeader";
57
+
58
+ const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
59
+ <div
60
+ className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
61
+ {...props}
62
+ />
63
+ );
64
+ DialogFooter.displayName = "DialogFooter";
65
+
66
+ const DialogTitle = React.forwardRef<
67
+ React.ElementRef<typeof DialogPrimitive.Title>,
68
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
69
+ >(({ className, ...props }, ref) => (
70
+ <DialogPrimitive.Title
71
+ ref={ref}
72
+ className={cn("text-lg font-semibold leading-none tracking-tight", className)}
73
+ {...props}
74
+ />
75
+ ));
76
+ DialogTitle.displayName = DialogPrimitive.Title.displayName;
77
+
78
+ const DialogDescription = React.forwardRef<
79
+ React.ElementRef<typeof DialogPrimitive.Description>,
80
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
81
+ >(({ className, ...props }, ref) => (
82
+ <DialogPrimitive.Description
83
+ ref={ref}
84
+ className={cn("text-sm text-muted-foreground", className)}
85
+ {...props}
86
+ />
87
+ ));
88
+ DialogDescription.displayName = DialogPrimitive.Description.displayName;
89
+
90
+ export {
91
+ Dialog,
92
+ DialogPortal,
93
+ DialogOverlay,
94
+ DialogClose,
95
+ DialogTrigger,
96
+ DialogContent,
97
+ DialogHeader,
98
+ DialogFooter,
99
+ DialogTitle,
100
+ DialogDescription,
101
+ };
@@ -0,0 +1,82 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Drawer as DrawerPrimitive } from "vaul";
5
+ import { cn } from "../lib/utils";
6
+
7
+ const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
8
+ <DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
9
+ );
10
+ Drawer.displayName = "Drawer";
11
+
12
+ const DrawerTrigger = DrawerPrimitive.Trigger;
13
+ const DrawerPortal = DrawerPrimitive.Portal;
14
+ const DrawerClose = DrawerPrimitive.Close;
15
+
16
+ const DrawerOverlay = React.forwardRef<
17
+ React.ElementRef<typeof DrawerPrimitive.Overlay>,
18
+ React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
19
+ >(({ className, ...props }, ref) => (
20
+ <DrawerPrimitive.Overlay ref={ref} className={cn("fixed inset-0 z-50 bg-foreground/40", className)} {...props} />
21
+ ));
22
+ DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
23
+
24
+ const DrawerContent = React.forwardRef<
25
+ React.ElementRef<typeof DrawerPrimitive.Content>,
26
+ React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
27
+ >(({ className, children, ...props }, ref) => (
28
+ <DrawerPortal>
29
+ <DrawerOverlay />
30
+ <DrawerPrimitive.Content
31
+ ref={ref}
32
+ className={cn(
33
+ "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background font-sans",
34
+ className,
35
+ )}
36
+ {...props}
37
+ >
38
+ <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
39
+ {children}
40
+ </DrawerPrimitive.Content>
41
+ </DrawerPortal>
42
+ ));
43
+ DrawerContent.displayName = "DrawerContent";
44
+
45
+ const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
46
+ <div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />
47
+ );
48
+ DrawerHeader.displayName = "DrawerHeader";
49
+
50
+ const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
51
+ <div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
52
+ );
53
+ DrawerFooter.displayName = "DrawerFooter";
54
+
55
+ const DrawerTitle = React.forwardRef<
56
+ React.ElementRef<typeof DrawerPrimitive.Title>,
57
+ React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
58
+ >(({ className, ...props }, ref) => (
59
+ <DrawerPrimitive.Title ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
60
+ ));
61
+ DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
62
+
63
+ const DrawerDescription = React.forwardRef<
64
+ React.ElementRef<typeof DrawerPrimitive.Description>,
65
+ React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
66
+ >(({ className, ...props }, ref) => (
67
+ <DrawerPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
68
+ ));
69
+ DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
70
+
71
+ export {
72
+ Drawer,
73
+ DrawerPortal,
74
+ DrawerOverlay,
75
+ DrawerTrigger,
76
+ DrawerClose,
77
+ DrawerContent,
78
+ DrawerHeader,
79
+ DrawerFooter,
80
+ DrawerTitle,
81
+ DrawerDescription,
82
+ };
@@ -0,0 +1,132 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
5
+ import { Check, ChevronRight, Circle } from "lucide-react";
6
+ import { cn } from "../lib/utils";
7
+
8
+ const DropdownMenu = DropdownMenuPrimitive.Root;
9
+ const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
10
+ const DropdownMenuGroup = DropdownMenuPrimitive.Group;
11
+ const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
12
+ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
13
+ const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
14
+
15
+ const contentCls =
16
+ "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md font-sans data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95";
17
+ const itemCls =
18
+ "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50";
19
+
20
+ const DropdownMenuContent = React.forwardRef<
21
+ React.ElementRef<typeof DropdownMenuPrimitive.Content>,
22
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
23
+ >(({ className, sideOffset = 4, ...props }, ref) => (
24
+ <DropdownMenuPrimitive.Portal>
25
+ <DropdownMenuPrimitive.Content ref={ref} sideOffset={sideOffset} className={cn(contentCls, className)} {...props} />
26
+ </DropdownMenuPrimitive.Portal>
27
+ ));
28
+ DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
29
+
30
+ const DropdownMenuItem = React.forwardRef<
31
+ React.ElementRef<typeof DropdownMenuPrimitive.Item>,
32
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
33
+ >(({ className, inset, ...props }, ref) => (
34
+ <DropdownMenuPrimitive.Item ref={ref} className={cn(itemCls, inset && "pl-8", className)} {...props} />
35
+ ));
36
+ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
37
+
38
+ const DropdownMenuCheckboxItem = React.forwardRef<
39
+ React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
40
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
41
+ >(({ className, children, checked, ...props }, ref) => (
42
+ <DropdownMenuPrimitive.CheckboxItem ref={ref} className={cn(itemCls, "pl-8", className)} checked={checked} {...props}>
43
+ <span className="absolute left-2 flex size-3.5 items-center justify-center">
44
+ <DropdownMenuPrimitive.ItemIndicator>
45
+ <Check className="size-4" />
46
+ </DropdownMenuPrimitive.ItemIndicator>
47
+ </span>
48
+ {children}
49
+ </DropdownMenuPrimitive.CheckboxItem>
50
+ ));
51
+ DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
52
+
53
+ const DropdownMenuRadioItem = React.forwardRef<
54
+ React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
55
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
56
+ >(({ className, children, ...props }, ref) => (
57
+ <DropdownMenuPrimitive.RadioItem ref={ref} className={cn(itemCls, "pl-8", className)} {...props}>
58
+ <span className="absolute left-2 flex size-3.5 items-center justify-center">
59
+ <DropdownMenuPrimitive.ItemIndicator>
60
+ <Circle className="size-2 fill-current" />
61
+ </DropdownMenuPrimitive.ItemIndicator>
62
+ </span>
63
+ {children}
64
+ </DropdownMenuPrimitive.RadioItem>
65
+ ));
66
+ DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
67
+
68
+ const DropdownMenuLabel = React.forwardRef<
69
+ React.ElementRef<typeof DropdownMenuPrimitive.Label>,
70
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
71
+ >(({ className, inset, ...props }, ref) => (
72
+ <DropdownMenuPrimitive.Label
73
+ ref={ref}
74
+ className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
75
+ {...props}
76
+ />
77
+ ));
78
+ DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
79
+
80
+ const DropdownMenuSeparator = React.forwardRef<
81
+ React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
82
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
83
+ >(({ className, ...props }, ref) => (
84
+ <DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
85
+ ));
86
+ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
87
+
88
+ const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
89
+ <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
90
+ );
91
+ DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
92
+
93
+ const DropdownMenuSubTrigger = React.forwardRef<
94
+ React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
95
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
96
+ >(({ className, inset, children, ...props }, ref) => (
97
+ <DropdownMenuPrimitive.SubTrigger
98
+ ref={ref}
99
+ className={cn(itemCls, "data-[state=open]:bg-accent", inset && "pl-8", className)}
100
+ {...props}
101
+ >
102
+ {children}
103
+ <ChevronRight className="ml-auto size-4" />
104
+ </DropdownMenuPrimitive.SubTrigger>
105
+ ));
106
+ DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
107
+
108
+ const DropdownMenuSubContent = React.forwardRef<
109
+ React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
110
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
111
+ >(({ className, ...props }, ref) => (
112
+ <DropdownMenuPrimitive.SubContent ref={ref} className={cn(contentCls, className)} {...props} />
113
+ ));
114
+ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
115
+
116
+ export {
117
+ DropdownMenu,
118
+ DropdownMenuTrigger,
119
+ DropdownMenuContent,
120
+ DropdownMenuItem,
121
+ DropdownMenuCheckboxItem,
122
+ DropdownMenuRadioItem,
123
+ DropdownMenuLabel,
124
+ DropdownMenuSeparator,
125
+ DropdownMenuShortcut,
126
+ DropdownMenuGroup,
127
+ DropdownMenuPortal,
128
+ DropdownMenuSub,
129
+ DropdownMenuSubContent,
130
+ DropdownMenuSubTrigger,
131
+ DropdownMenuRadioGroup,
132
+ };
@@ -0,0 +1,58 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Slot } from "@radix-ui/react-slot";
5
+ import { cva, type VariantProps } from "class-variance-authority";
6
+ import { cn } from "../lib/utils";
7
+
8
+ /**
9
+ * Fab — a floating action button. Circular by default; pass a text child
10
+ * alongside the icon to render the extended pill form (`extended`).
11
+ */
12
+ const fabVariants = cva(
13
+ [
14
+ "inline-flex shrink-0 items-center justify-center gap-2 rounded-full font-sans font-medium",
15
+ "shadow-lg outline-none transition-colors",
16
+ "focus-visible:shadow-focus disabled:pointer-events-none disabled:opacity-50",
17
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0",
18
+ ],
19
+ {
20
+ variants: {
21
+ variant: {
22
+ Primary: "bg-primary text-primary-foreground hover:bg-[--color-blue-600] active:bg-[--color-blue-700]",
23
+ Secondary:
24
+ "bg-secondary text-secondary-foreground hover:bg-[--color-green-500] hover:text-[--color-green-50] active:bg-[--color-green-600] active:text-[--color-green-50]",
25
+ },
26
+ size: {
27
+ default: "size-14 [&_svg]:size-6",
28
+ sm: "size-11 [&_svg]:size-5",
29
+ },
30
+ extended: {
31
+ true: "w-auto px-5",
32
+ false: "",
33
+ },
34
+ },
35
+ defaultVariants: { variant: "Primary", size: "default", extended: false },
36
+ },
37
+ );
38
+
39
+ export interface FabProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof fabVariants> {
40
+ asChild?: boolean;
41
+ }
42
+
43
+ const Fab = React.forwardRef<HTMLButtonElement, FabProps>(
44
+ ({ className, variant, size, extended, asChild = false, ...props }, ref) => {
45
+ const Comp: React.ElementType = asChild ? Slot : "button";
46
+ return (
47
+ <Comp
48
+ ref={ref}
49
+ data-slot="fab"
50
+ className={cn(fabVariants({ variant, size, extended }), className)}
51
+ {...props}
52
+ />
53
+ );
54
+ },
55
+ );
56
+ Fab.displayName = "Fab";
57
+
58
+ export { Fab, fabVariants };
@@ -0,0 +1,128 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { Slot } from "@radix-ui/react-slot";
5
+ import { CircleAlert, CircleCheck, Info, TriangleAlert } from "lucide-react";
6
+ import { cva, type VariantProps } from "class-variance-authority";
7
+ import { cn } from "../lib/utils";
8
+ import { Label } from "./label";
9
+
10
+ /**
11
+ * Field — the label + control + description + feedback composition, matching the
12
+ * KinetixUI design source's input anatomy (label above, helper / feedback below,
13
+ * label & message colour tracking the invalid state). Framework-agnostic: for
14
+ * React Hook Form, use `Form` / `FormField` instead.
15
+ *
16
+ * <Field invalid={!!error}>
17
+ * <FieldLabel>Email</FieldLabel>
18
+ * <FieldControl><Input type="email" /></FieldControl>
19
+ * <FieldDescription>We'll never share it.</FieldDescription>
20
+ * {error && <FieldMessage intent="error">{error}</FieldMessage>}
21
+ * </Field>
22
+ */
23
+
24
+ type FieldContextValue = { id: string; descriptionId: string; messageId: string; invalid: boolean };
25
+ const FieldContext = React.createContext<FieldContextValue | null>(null);
26
+
27
+ function useField() {
28
+ const ctx = React.useContext(FieldContext);
29
+ if (!ctx) throw new Error("Field parts must be used within <Field>");
30
+ return ctx;
31
+ }
32
+
33
+ interface FieldProps extends React.HTMLAttributes<HTMLDivElement> {
34
+ invalid?: boolean;
35
+ }
36
+
37
+ const Field = React.forwardRef<HTMLDivElement, FieldProps>(
38
+ ({ className, invalid = false, ...props }, ref) => {
39
+ const id = React.useId();
40
+ const value = React.useMemo<FieldContextValue>(
41
+ () => ({ id: `${id}-control`, descriptionId: `${id}-description`, messageId: `${id}-message`, invalid }),
42
+ [id, invalid],
43
+ );
44
+ return (
45
+ <FieldContext.Provider value={value}>
46
+ <div ref={ref} data-invalid={invalid || undefined} className={cn("grid gap-1.5", className)} {...props} />
47
+ </FieldContext.Provider>
48
+ );
49
+ },
50
+ );
51
+ Field.displayName = "Field";
52
+
53
+ const FieldLabel = React.forwardRef<
54
+ React.ElementRef<typeof Label>,
55
+ React.ComponentPropsWithoutRef<typeof Label>
56
+ >(({ className, ...props }, ref) => {
57
+ const { id, invalid } = useField();
58
+ return <Label ref={ref} htmlFor={id} className={cn(invalid && "text-destructive", className)} {...props} />;
59
+ });
60
+ FieldLabel.displayName = "FieldLabel";
61
+
62
+ /** wraps a single form control (Input, Textarea, Select…) and wires a11y attrs */
63
+ const FieldControl = React.forwardRef<HTMLElement, React.ComponentPropsWithoutRef<typeof Slot>>(
64
+ ({ ...props }, ref) => {
65
+ const { id, descriptionId, messageId, invalid } = useField();
66
+ return (
67
+ <Slot
68
+ ref={ref}
69
+ id={id}
70
+ aria-invalid={invalid || undefined}
71
+ aria-describedby={`${descriptionId} ${messageId}`}
72
+ {...props}
73
+ />
74
+ );
75
+ },
76
+ );
77
+ FieldControl.displayName = "FieldControl";
78
+
79
+ const FieldDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
80
+ ({ className, ...props }, ref) => {
81
+ const { descriptionId } = useField();
82
+ return <p ref={ref} id={descriptionId} className={cn("text-body-sm text-muted-foreground", className)} {...props} />;
83
+ },
84
+ );
85
+ FieldDescription.displayName = "FieldDescription";
86
+
87
+ const messageVariants = cva("flex items-center gap-1.5 text-body-sm [&>svg]:size-3.5 [&>svg]:shrink-0", {
88
+ variants: {
89
+ intent: {
90
+ error: "text-destructive",
91
+ warning: "text-warning",
92
+ success: "text-success",
93
+ info: "text-info",
94
+ },
95
+ },
96
+ defaultVariants: { intent: "error" },
97
+ });
98
+
99
+ const INTENT_ICON = {
100
+ error: CircleAlert,
101
+ warning: TriangleAlert,
102
+ success: CircleCheck,
103
+ info: Info,
104
+ } as const;
105
+
106
+ interface FieldMessageProps
107
+ extends React.HTMLAttributes<HTMLParagraphElement>,
108
+ VariantProps<typeof messageVariants> {
109
+ /** hide the leading intent icon */
110
+ hideIcon?: boolean;
111
+ }
112
+
113
+ const FieldMessage = React.forwardRef<HTMLParagraphElement, FieldMessageProps>(
114
+ ({ className, intent = "error", hideIcon, children, ...props }, ref) => {
115
+ const { messageId } = useField();
116
+ const Icon = INTENT_ICON[intent ?? "error"];
117
+ if (!children) return null;
118
+ return (
119
+ <p ref={ref} id={messageId} className={cn(messageVariants({ intent }), className)} {...props}>
120
+ {!hideIcon && <Icon />}
121
+ <span>{children}</span>
122
+ </p>
123
+ );
124
+ },
125
+ );
126
+ FieldMessage.displayName = "FieldMessage";
127
+
128
+ export { Field, FieldLabel, FieldControl, FieldDescription, FieldMessage, messageVariants };
@@ -0,0 +1,183 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { File, Loader2, TriangleAlert, Upload, X } from "lucide-react";
5
+ import { cn } from "../lib/utils";
6
+ import { Button } from "./button";
7
+
8
+ /**
9
+ * FileUpload — a drag-and-drop zone plus file list, single or multiple.
10
+ * Upload logic (network, storage) is the consumer's — this component is
11
+ * presentational: it reports picked files via `onFilesSelected` and renders
12
+ * whatever `files` list you hand back, each carrying its own status.
13
+ */
14
+ export interface UploadFile {
15
+ id: string;
16
+ name: string;
17
+ size?: number;
18
+ status?: "loading" | "uploaded" | "error";
19
+ progress?: number;
20
+ error?: string;
21
+ }
22
+
23
+ function formatBytes(bytes?: number) {
24
+ if (!bytes) return "";
25
+ const units = ["B", "KB", "MB", "GB"];
26
+ let v = bytes;
27
+ let i = 0;
28
+ while (v >= 1024 && i < units.length - 1) {
29
+ v /= 1024;
30
+ i++;
31
+ }
32
+ return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
33
+ }
34
+
35
+ export interface FileUploadProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onDrop"> {
36
+ multiple?: boolean;
37
+ accept?: string;
38
+ disabled?: boolean;
39
+ helperText?: React.ReactNode;
40
+ buttonLabel?: React.ReactNode;
41
+ files?: UploadFile[];
42
+ onFilesSelected?: (files: File[]) => void;
43
+ onRemove?: (id: string) => void;
44
+ onRetry?: (id: string) => void;
45
+ }
46
+
47
+ const FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(
48
+ (
49
+ { className, multiple, accept, disabled, helperText, buttonLabel = "Browse files", files = [], onFilesSelected, onRemove, onRetry, ...props },
50
+ ref,
51
+ ) => {
52
+ const inputRef = React.useRef<HTMLInputElement>(null);
53
+ const [dragging, setDragging] = React.useState(false);
54
+
55
+ const pick = (list: FileList | null) => {
56
+ if (!list || !list.length) return;
57
+ onFilesSelected?.(Array.from(list));
58
+ };
59
+
60
+ return (
61
+ <div ref={ref} className={cn("flex w-full flex-col gap-3 font-sans", className)} {...props}>
62
+ <div
63
+ role="button"
64
+ tabIndex={disabled ? -1 : 0}
65
+ aria-disabled={disabled}
66
+ onClick={() => !disabled && inputRef.current?.click()}
67
+ onKeyDown={(e) => {
68
+ if (!disabled && (e.key === "Enter" || e.key === " ")) {
69
+ e.preventDefault();
70
+ inputRef.current?.click();
71
+ }
72
+ }}
73
+ onDragOver={(e) => {
74
+ e.preventDefault();
75
+ if (!disabled) setDragging(true);
76
+ }}
77
+ onDragLeave={() => setDragging(false)}
78
+ onDrop={(e) => {
79
+ e.preventDefault();
80
+ setDragging(false);
81
+ if (!disabled) pick(e.dataTransfer.files);
82
+ }}
83
+ className={cn(
84
+ "flex flex-col items-center gap-2 rounded-md border border-dashed border-input p-6 text-center outline-none transition-colors",
85
+ !disabled && "cursor-pointer hover:bg-accent/50 focus-visible:shadow-focus",
86
+ dragging && "border-primary bg-accent",
87
+ disabled && "pointer-events-none opacity-50",
88
+ )}
89
+ >
90
+ <Upload className="size-6 text-muted-foreground" aria-hidden />
91
+ <p className="text-body-sm text-muted-foreground">Drag &amp; drop {multiple ? "files" : "a file"} here, or</p>
92
+ <Button
93
+ type="button"
94
+ variant="Outline"
95
+ size="sm"
96
+ disabled={disabled}
97
+ onClick={(e) => {
98
+ e.stopPropagation();
99
+ inputRef.current?.click();
100
+ }}
101
+ >
102
+ {buttonLabel}
103
+ </Button>
104
+ {helperText && <p className="text-body-sm text-muted-foreground">{helperText}</p>}
105
+ <input
106
+ ref={inputRef}
107
+ type="file"
108
+ multiple={multiple}
109
+ accept={accept}
110
+ disabled={disabled}
111
+ className="sr-only"
112
+ onChange={(e) => {
113
+ pick(e.target.files);
114
+ e.target.value = "";
115
+ }}
116
+ />
117
+ </div>
118
+
119
+ {files.length > 0 && (
120
+ <ul className="flex flex-col gap-2">
121
+ {files.map((f) => (
122
+ <FileUploadItem key={f.id} file={f} onRemove={onRemove} onRetry={onRetry} />
123
+ ))}
124
+ </ul>
125
+ )}
126
+ </div>
127
+ );
128
+ },
129
+ );
130
+ FileUpload.displayName = "FileUpload";
131
+
132
+ export interface FileUploadItemProps {
133
+ file: UploadFile;
134
+ onRemove?: (id: string) => void;
135
+ onRetry?: (id: string) => void;
136
+ }
137
+
138
+ function FileUploadItem({ file, onRemove, onRetry }: FileUploadItemProps) {
139
+ const isError = file.status === "error";
140
+ const isLoading = file.status === "loading";
141
+ return (
142
+ <li className={cn("flex items-center gap-2.5 rounded-md border p-2.5 text-body-sm", isError ? "border-destructive bg-destructive/5" : "border-input")}>
143
+ {isLoading ? (
144
+ <Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />
145
+ ) : isError ? (
146
+ <TriangleAlert className="size-4 shrink-0 text-destructive" aria-hidden />
147
+ ) : (
148
+ <File className="size-4 shrink-0 text-muted-foreground" aria-hidden />
149
+ )}
150
+ <div className="min-w-0 flex-1">
151
+ <p className="truncate text-foreground">{file.name}</p>
152
+ {isError && file.error ? <p className="text-destructive">{file.error}</p> : <p className="text-muted-foreground">{formatBytes(file.size)}</p>}
153
+ {isLoading && typeof file.progress === "number" && (
154
+ <div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-muted">
155
+ <div className="h-full bg-primary transition-[width]" style={{ width: `${Math.max(0, Math.min(100, file.progress))}%` }} />
156
+ </div>
157
+ )}
158
+ </div>
159
+ {isError && onRetry && (
160
+ <button
161
+ type="button"
162
+ onClick={() => onRetry(file.id)}
163
+ className="shrink-0 text-label-md font-medium text-primary underline-offset-2 outline-none hover:underline focus-visible:underline"
164
+ >
165
+ Retry
166
+ </button>
167
+ )}
168
+ {onRemove && (
169
+ <button
170
+ type="button"
171
+ onClick={() => onRemove(file.id)}
172
+ aria-label={`Remove ${file.name}`}
173
+ className="shrink-0 rounded-[2px] p-0.5 text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-1 focus-visible:ring-current"
174
+ >
175
+ <X className="size-3.5" />
176
+ </button>
177
+ )}
178
+ </li>
179
+ );
180
+ }
181
+ FileUploadItem.displayName = "FileUploadItem";
182
+
183
+ export { FileUpload, FileUploadItem, formatBytes };