@peerbots/core 0.2.6 → 1.0.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 (121) hide show
  1. package/.changeset/config.json +1 -1
  2. package/.github/workflows/storybook.yml +9 -1
  3. package/CHANGELOG.md +94 -0
  4. package/dist/index.d.mts +643 -131
  5. package/dist/index.d.ts +643 -131
  6. package/dist/index.js +7 -6
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +7 -6
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +6 -3
  11. package/src/charts/DistributionBarChart.tsx +10 -4
  12. package/src/charts/DistributionHistogram.tsx +13 -5
  13. package/src/charts/LineGraph.stories.tsx +60 -0
  14. package/src/charts/LineGraph.tsx +216 -0
  15. package/src/charts/index.ts +2 -0
  16. package/src/docs/DesignSystem.mdx +241 -25
  17. package/src/styles/theme.css +8 -0
  18. package/src/ui/feedback/Alert.stories.tsx +56 -0
  19. package/src/ui/feedback/Alert.tsx +168 -0
  20. package/src/ui/{Dialog.stories.tsx → feedback/Dialog.stories.tsx} +2 -2
  21. package/src/ui/{Dialog.tsx → feedback/Dialog.tsx} +2 -2
  22. package/src/ui/feedback/EmptyState.stories.tsx +57 -0
  23. package/src/ui/feedback/EmptyState.tsx +123 -0
  24. package/src/ui/feedback/Popover.stories.tsx +130 -0
  25. package/src/ui/feedback/Popover.tsx +135 -0
  26. package/src/ui/feedback/Skeleton.stories.tsx +88 -0
  27. package/src/ui/{Skeleton.tsx → feedback/Skeleton.tsx} +2 -2
  28. package/src/ui/feedback/Spinner.stories.tsx +77 -0
  29. package/src/ui/feedback/Spinner.tsx +62 -0
  30. package/src/ui/{Tooltip.stories.tsx → feedback/Tooltip.stories.tsx} +3 -3
  31. package/src/ui/{Tooltip.tsx → feedback/Tooltip.tsx} +2 -2
  32. package/src/ui/feedback/index.ts +7 -0
  33. package/src/ui/forms/Button.stories.tsx +181 -0
  34. package/src/ui/forms/Button.tsx +323 -0
  35. package/src/ui/{Checkbox.stories.tsx → forms/Checkbox.stories.tsx} +2 -2
  36. package/src/ui/{Checkbox.tsx → forms/Checkbox.tsx} +2 -2
  37. package/src/ui/forms/ChipGroup.stories.tsx +118 -0
  38. package/src/ui/forms/ChipGroup.tsx +239 -0
  39. package/src/ui/{Dropdown.tsx → forms/Dropdown.tsx} +1 -1
  40. package/src/ui/{Field.stories.tsx → forms/Field.stories.tsx} +3 -3
  41. package/src/ui/{Field.tsx → forms/Field.tsx} +1 -1
  42. package/src/ui/{Input.stories.tsx → forms/Input.stories.tsx} +6 -6
  43. package/src/ui/{Input.tsx → forms/Input.tsx} +1 -1
  44. package/src/ui/{Label.stories.tsx → forms/Label.stories.tsx} +2 -2
  45. package/src/ui/{Label.tsx → forms/Label.tsx} +2 -2
  46. package/src/ui/{NumberField.stories.tsx → forms/NumberField.stories.tsx} +2 -2
  47. package/src/ui/{NumberField.tsx → forms/NumberField.tsx} +8 -9
  48. package/src/ui/forms/PeerbotsColorSelector.stories.tsx +49 -0
  49. package/src/ui/forms/PeerbotsColorSelector.tsx +100 -0
  50. package/src/ui/{Select.stories.tsx → forms/Select.stories.tsx} +2 -2
  51. package/src/ui/{Select.tsx → forms/Select.tsx} +5 -3
  52. package/src/ui/{Slider.stories.tsx → forms/Slider.stories.tsx} +6 -4
  53. package/src/ui/{Slider.tsx → forms/Slider.tsx} +7 -3
  54. package/src/ui/forms/SliderWithNumberField.stories.tsx +136 -0
  55. package/src/ui/{SliderWithNumberField.tsx → forms/SliderWithNumberField.tsx} +19 -7
  56. package/src/ui/{Switch.stories.tsx → forms/Switch.stories.tsx} +2 -2
  57. package/src/ui/{Switch.tsx → forms/Switch.tsx} +1 -1
  58. package/src/ui/{TabRadio.stories.tsx → forms/TabRadio.stories.tsx} +3 -3
  59. package/src/ui/{TabRadio.tsx → forms/TabRadio.tsx} +1 -1
  60. package/src/ui/{TextArea.stories.tsx → forms/TextArea.stories.tsx} +5 -3
  61. package/src/ui/{TextArea.tsx → forms/TextArea.tsx} +1 -1
  62. package/src/ui/forms/index.ts +15 -0
  63. package/src/ui/foundations/Anchor.stories.tsx +39 -0
  64. package/src/ui/foundations/Anchor.tsx +96 -0
  65. package/src/ui/foundations/CodeBlock.stories.tsx +86 -0
  66. package/src/ui/foundations/CodeBlock.tsx +148 -0
  67. package/src/ui/foundations/Colors.stories.tsx +80 -0
  68. package/src/ui/{Icon.stories.tsx → foundations/Icon.stories.tsx} +1 -1
  69. package/src/ui/{Icon.tsx → foundations/Icon.tsx} +1 -1
  70. package/src/ui/{IconRegistry.tsx → foundations/IconRegistry.tsx} +261 -57
  71. package/src/ui/foundations/SocialLinks.stories.tsx +38 -0
  72. package/src/ui/foundations/SocialLinks.tsx +117 -0
  73. package/src/ui/foundations/Typography.stories.tsx +58 -0
  74. package/src/ui/foundations/Typography.tsx +163 -0
  75. package/src/ui/foundations/index.ts +6 -0
  76. package/src/ui/index.ts +7 -27
  77. package/src/ui/layout/Accordion.stories.tsx +86 -0
  78. package/src/ui/layout/Accordion.tsx +205 -0
  79. package/src/ui/layout/Card.stories.tsx +151 -0
  80. package/src/ui/layout/Card.tsx +124 -0
  81. package/src/ui/layout/Collapsible.stories.tsx +54 -0
  82. package/src/ui/layout/Collapsible.tsx +54 -0
  83. package/src/ui/{Separator.stories.tsx → layout/Separator.stories.tsx} +2 -2
  84. package/src/ui/{Separator.tsx → layout/Separator.tsx} +1 -1
  85. package/src/ui/{SettingsPanel.stories.tsx → layout/SettingsPanel.stories.tsx} +5 -5
  86. package/src/ui/{SettingsPanel.tsx → layout/SettingsPanel.tsx} +33 -29
  87. package/src/ui/layout/index.ts +5 -0
  88. package/src/ui/media/Avatar.stories.tsx +89 -0
  89. package/src/ui/media/Avatar.tsx +122 -0
  90. package/src/ui/media/Dropzone.stories.tsx +95 -0
  91. package/src/ui/media/Dropzone.tsx +328 -0
  92. package/src/ui/media/FileUpload.stories.tsx +62 -0
  93. package/src/ui/media/FileUpload.tsx +175 -0
  94. package/src/ui/media/index.ts +3 -0
  95. package/src/ui/{AuthFormUI.stories.tsx → patterns/AuthFormUI.stories.tsx} +1 -1
  96. package/src/ui/{AuthFormUI.tsx → patterns/AuthFormUI.tsx} +24 -23
  97. package/src/ui/patterns/CopyButton.stories.tsx +77 -0
  98. package/src/ui/patterns/CopyButton.tsx +93 -0
  99. package/src/ui/patterns/SearchInput.stories.tsx +51 -0
  100. package/src/ui/patterns/SearchInput.tsx +93 -0
  101. package/src/ui/patterns/Stepper.stories.tsx +129 -0
  102. package/src/ui/patterns/Stepper.tsx +295 -0
  103. package/src/ui/{TabSelection.stories.tsx → patterns/TabSelection.stories.tsx} +1 -1
  104. package/src/ui/{TabSelection.tsx → patterns/TabSelection.tsx} +2 -2
  105. package/src/ui/patterns/index.ts +6 -0
  106. package/src/ui/patterns/useClipboard.ts +75 -0
  107. package/src/ui/types.ts +48 -0
  108. package/src/ui/Alert.stories.tsx +0 -41
  109. package/src/ui/Alert.tsx +0 -72
  110. package/src/ui/Anchor.stories.tsx +0 -25
  111. package/src/ui/Anchor.tsx +0 -32
  112. package/src/ui/Button.stories.tsx +0 -108
  113. package/src/ui/Button.tsx +0 -121
  114. package/src/ui/Collapsible.stories.tsx +0 -91
  115. package/src/ui/Collapsible.tsx +0 -52
  116. package/src/ui/Colors.stories.tsx +0 -67
  117. package/src/ui/Popover.tsx +0 -42
  118. package/src/ui/Skeleton.stories.tsx +0 -43
  119. package/src/ui/SliderWithNumberField.stories.tsx +0 -101
  120. package/src/ui/Typography.stories.tsx +0 -87
  121. package/src/ui/Typography.tsx +0 -80
@@ -0,0 +1,122 @@
1
+ import React, { useState } from "react";
2
+ import { Icon } from "../foundations/Icon";
3
+ import { cn } from "../utils";
4
+
5
+ export interface AvatarProps extends React.HTMLAttributes<HTMLDivElement> {
6
+ /** Image source URL. */
7
+ src?: string | null;
8
+ /** Full name of the user, maker, or robot. Used to generate initials and accessible label. */
9
+ name?: string;
10
+ /** Accessible alt text for the avatar image. Defaults to `name` or `"Avatar"`. */
11
+ alt?: string;
12
+ /** Size variant. Defaults to `"md"`. */
13
+ size?: "xs" | "sm" | "md" | "lg" | "xl";
14
+ /** Shape of the avatar. Defaults to `"circle"`. */
15
+ shape?: "circle" | "rounded";
16
+ /** Custom fallback icon if no image or initials are available. */
17
+ fallbackIcon?: React.ReactNode;
18
+ }
19
+
20
+ function getInitials(name?: string): string {
21
+ if (!name) return "";
22
+ const trimmed = name.trim();
23
+ if (!trimmed) return "";
24
+ const parts = trimmed.split(/\s+/);
25
+ if (parts.length === 1) {
26
+ return parts[0].substring(0, 2).toUpperCase();
27
+ }
28
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
29
+ }
30
+
31
+ const colorTints = [
32
+ "pb:bg-teal-100 pb:text-teal-900 pb:border-teal-200",
33
+ "pb:bg-blue-100 pb:text-blue-900 pb:border-blue-200",
34
+ "pb:bg-purple-100 pb:text-purple-900 pb:border-purple-200",
35
+ "pb:bg-amber-100 pb:text-amber-900 pb:border-amber-200",
36
+ "pb:bg-rose-100 pb:text-rose-900 pb:border-rose-200",
37
+ "pb:bg-emerald-100 pb:text-emerald-900 pb:border-emerald-200",
38
+ ];
39
+
40
+ function getHashColor(name?: string): string {
41
+ if (!name) return colorTints[0];
42
+ let hash = 0;
43
+ for (let i = 0; i < name.length; i++) {
44
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
45
+ }
46
+ const index = Math.abs(hash) % colorTints.length;
47
+ return colorTints[index];
48
+ }
49
+
50
+ export const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
51
+ (
52
+ {
53
+ src,
54
+ name,
55
+ alt,
56
+ size = "md",
57
+ shape = "circle",
58
+ fallbackIcon,
59
+ className,
60
+ ...props
61
+ },
62
+ ref,
63
+ ) => {
64
+ const [imgError, setImgError] = useState(false);
65
+ const initials = getInitials(name);
66
+ const accessibleAlt = alt || name || "Avatar";
67
+
68
+ const sizeStyles = {
69
+ xs: "pb:w-6 pb:h-6 pb:text-[10px]",
70
+ sm: "pb:w-8 pb:h-8 pb:text-xs",
71
+ md: "pb:w-10 pb:h-10 pb:text-sm",
72
+ lg: "pb:w-12 pb:h-12 pb:text-base",
73
+ xl: "pb:w-16 pb:h-16 pb:text-xl",
74
+ };
75
+
76
+ const iconSizeStyles = {
77
+ xs: "pb:w-3.5 pb:h-3.5",
78
+ sm: "pb:w-4 pb:h-4",
79
+ md: "pb:w-5 pb:h-5",
80
+ lg: "pb:w-6 pb:h-6",
81
+ xl: "pb:w-8 pb:h-8",
82
+ };
83
+
84
+ const shapeStyle = shape === "circle" ? "pb:rounded-full" : "pb:rounded-xl";
85
+ const colorStyle = getHashColor(name);
86
+
87
+ const hasValidImage = Boolean(src && !imgError);
88
+
89
+ return (
90
+ <div
91
+ ref={ref}
92
+ role="img"
93
+ aria-label={accessibleAlt}
94
+ className={cn(
95
+ "pb:relative pb:inline-flex pb:items-center pb:justify-center pb:shrink-0 pb:overflow-hidden pb:border pb:font-bold pb:select-none",
96
+ sizeStyles[size],
97
+ shapeStyle,
98
+ !hasValidImage && colorStyle,
99
+ className,
100
+ )}
101
+ {...props}
102
+ >
103
+ {hasValidImage ? (
104
+ <img
105
+ src={src!}
106
+ alt={accessibleAlt}
107
+ onError={() => setImgError(true)}
108
+ className="pb:w-full pb:h-full pb:object-cover"
109
+ />
110
+ ) : initials ? (
111
+ <span className="pb:tracking-wider">{initials}</span>
112
+ ) : (
113
+ <span className={cn("pb:flex pb:items-center pb:justify-center", iconSizeStyles[size])}>
114
+ {fallbackIcon || <Icon name="userCircle" className="pb:w-full pb:h-full" />}
115
+ </span>
116
+ )}
117
+ </div>
118
+ );
119
+ },
120
+ );
121
+
122
+ Avatar.displayName = "Avatar";
@@ -0,0 +1,95 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { Dropzone } from "./Dropzone";
4
+ import { Heading } from "../foundations/Typography";
5
+
6
+ const meta: Meta<typeof Dropzone> = {
7
+ title: "Media & Files/Dropzone",
8
+ component: Dropzone,
9
+ parameters: {
10
+ layout: "centered",
11
+ },
12
+ tags: ["autodocs"],
13
+ argTypes: {
14
+ accept: { control: "text" },
15
+ maxSizeMB: { control: "number" },
16
+ disabled: { control: "boolean" },
17
+ title: { control: "text" },
18
+ subtitle: { control: "text" },
19
+ showPreview: { control: "boolean" },
20
+ },
21
+ };
22
+
23
+ export default meta;
24
+ type Story = StoryObj<typeof Dropzone>;
25
+
26
+ export const Default: Story = {
27
+ args: {
28
+ title: "Click or drag file to upload",
29
+ subtitle: "PNG, JPG, WEBP, or MP4 up to 10MB",
30
+ accept: "image/*,video/*",
31
+ maxSizeMB: 10,
32
+ showPreview: true,
33
+ },
34
+ };
35
+
36
+ export const WithImagePreview: Story = {
37
+ args: {
38
+ previewUrl:
39
+ "https://images.unsplash.com/photo-1508759078412-70b92305a610?auto=format&fit=crop&w=800&q=80",
40
+ title: "Upload photo",
41
+ subtitle: "PNG, JPG up to 10MB",
42
+ },
43
+ };
44
+
45
+ export const InteractiveState: Story = {
46
+ render: () => {
47
+ return (
48
+ <div className="pb:w-96 pb:p-4 pb:space-y-4">
49
+ <Heading level={4} className="pb:text-sm pb:font-bold pb:text-gray-900">
50
+ Interactive Photo Upload (Auto-Preview)
51
+ </Heading>
52
+ <Dropzone
53
+ accept="image/*"
54
+ title="Upload creation photo"
55
+ subtitle="Drag high-res photo or click to browse"
56
+ maxSizeMB={10}
57
+ />
58
+ </div>
59
+ );
60
+ },
61
+ };
62
+
63
+ export const Variations: Story = {
64
+ render: () => (
65
+ <div className="pb:flex pb:flex-col pb:gap-6 pb:p-4 pb:w-96">
66
+ <div className="pb:space-y-2">
67
+ <Heading level={4} className="pb:text-sm pb:font-medium pb:text-black">
68
+ Empty Dropzone
69
+ </Heading>
70
+ <Dropzone
71
+ accept="image/*"
72
+ title="Upload avatar"
73
+ subtitle="PNG or JPG up to 5MB"
74
+ />
75
+ </div>
76
+
77
+ <div className="pb:space-y-2">
78
+ <Heading level={4} className="pb:text-sm pb:font-medium pb:text-black">
79
+ With Existing Image Preview
80
+ </Heading>
81
+ <Dropzone
82
+ previewUrl="https://images.unsplash.com/photo-1508759078412-70b92305a610?auto=format&fit=crop&w=800&q=80"
83
+ onFileRemove={() => {}}
84
+ />
85
+ </div>
86
+
87
+ <div className="pb:space-y-2">
88
+ <Heading level={4} className="pb:text-sm pb:font-medium pb:text-black">
89
+ Disabled State
90
+ </Heading>
91
+ <Dropzone disabled title="Upload disabled" subtitle="Submissions are closed" />
92
+ </div>
93
+ </div>
94
+ ),
95
+ };
@@ -0,0 +1,328 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { Button } from "../forms/Button";
3
+ import { Icon } from "../foundations/Icon";
4
+ import { cn } from "../utils";
5
+
6
+ export interface DropzoneProps {
7
+ /** Accepted MIME types or extensions (e.g. `"image/*"`, `"video/*"`, `".zip"`). */
8
+ accept?: string;
9
+ /** Maximum file size allowed in Megabytes (MB). */
10
+ maxSizeMB?: number;
11
+ /** Whether the dropzone is disabled. */
12
+ disabled?: boolean;
13
+ /**
14
+ * Optional controlled URL or data URL of an uploaded file to display in preview mode.
15
+ * If not provided, Dropzone will automatically generate and display a preview for selected images/videos.
16
+ */
17
+ previewUrl?: string | null;
18
+ /** Optional controlled File object. */
19
+ value?: File | null;
20
+ /** Whether to automatically show a preview when an image/video is selected. Default is `true`. */
21
+ showPreview?: boolean;
22
+ /** How to render the preview. Default is `"auto"`. */
23
+ previewType?: "image" | "video" | "auto";
24
+ /** Main callout title. Default is `"Click or drag file to upload"`. */
25
+ title?: React.ReactNode;
26
+ /** Subtitle or requirement text. Default is `"Supported formats: PNG, JPG, WEBP, MP4"`. */
27
+ subtitle?: React.ReactNode;
28
+ /** Custom icon for the dropzone idle state. */
29
+ icon?: React.ReactNode;
30
+ /** Callback fired when a valid file is dropped or selected. */
31
+ onFileSelect?: (file: File) => void;
32
+ /** Callback fired when the active file/preview is cleared. */
33
+ onFileRemove?: () => void;
34
+ /** Callback fired when a file exceeds size limit or validation fails. */
35
+ onError?: (errorMessage: string) => void;
36
+ /** Optional custom CSS classes for the root dropzone container. */
37
+ className?: string;
38
+ id?: string;
39
+ name?: string;
40
+ }
41
+
42
+ export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
43
+ (
44
+ {
45
+ accept,
46
+ maxSizeMB,
47
+ disabled = false,
48
+ previewUrl: controlledPreviewUrl,
49
+ value,
50
+ showPreview = true,
51
+ previewType = "auto",
52
+ title = "Click or drag file to upload",
53
+ subtitle,
54
+ icon,
55
+ onFileSelect,
56
+ onFileRemove,
57
+ onError,
58
+ className,
59
+ id,
60
+ name,
61
+ },
62
+ ref,
63
+ ) => {
64
+ const inputRef = useRef<HTMLInputElement>(null);
65
+ const [isDragging, setIsDragging] = useState(false);
66
+ const [internalPreviewUrl, setInternalPreviewUrl] = useState<string | null>(null);
67
+ const [internalPreviewType, setInternalPreviewType] = useState<"image" | "video">("image");
68
+ const [imageLoadError, setImageLoadError] = useState(false);
69
+ const generatedUrlRef = useRef<string | null>(null);
70
+
71
+ // Sync value file prop to preview if passed
72
+ useEffect(() => {
73
+ if (value && showPreview) {
74
+ if (generatedUrlRef.current) {
75
+ URL.revokeObjectURL(generatedUrlRef.current);
76
+ }
77
+ const isVid = value.type.startsWith("video/");
78
+ const url = URL.createObjectURL(value);
79
+ generatedUrlRef.current = url;
80
+ setInternalPreviewUrl(url);
81
+ setInternalPreviewType(isVid ? "video" : "image");
82
+ setImageLoadError(false);
83
+ }
84
+ }, [value, showPreview]);
85
+
86
+ // Clean up created object URLs on unmount
87
+ useEffect(() => {
88
+ return () => {
89
+ if (generatedUrlRef.current) {
90
+ URL.revokeObjectURL(generatedUrlRef.current);
91
+ }
92
+ };
93
+ }, []);
94
+
95
+ const activePreviewUrl =
96
+ controlledPreviewUrl !== undefined
97
+ ? controlledPreviewUrl
98
+ : internalPreviewUrl;
99
+
100
+ const handleFile = (file: File) => {
101
+ if (disabled) return;
102
+
103
+ if (maxSizeMB) {
104
+ const fileMB = file.size / (1024 * 1024);
105
+ if (fileMB > maxSizeMB) {
106
+ const msg = `File "${file.name}" (${fileMB.toFixed(1)} MB) exceeds maximum allowed size of ${maxSizeMB} MB.`;
107
+ onError?.(msg);
108
+ if (inputRef.current) inputRef.current.value = "";
109
+ return;
110
+ }
111
+ }
112
+
113
+ // Generate internal preview if enabled and not strictly controlled
114
+ if (showPreview) {
115
+ const isImg = file.type.startsWith("image/");
116
+ const isVid = file.type.startsWith("video/");
117
+ if (isImg || isVid) {
118
+ if (generatedUrlRef.current) {
119
+ URL.revokeObjectURL(generatedUrlRef.current);
120
+ }
121
+ const url = URL.createObjectURL(file);
122
+ generatedUrlRef.current = url;
123
+ setInternalPreviewUrl(url);
124
+ setInternalPreviewType(isVid ? "video" : "image");
125
+ setImageLoadError(false);
126
+ }
127
+ }
128
+
129
+ onFileSelect?.(file);
130
+ };
131
+
132
+ const handleDragEnter = (e: React.DragEvent) => {
133
+ e.preventDefault();
134
+ e.stopPropagation();
135
+ if (disabled) return;
136
+ setIsDragging(true);
137
+ };
138
+
139
+ const handleDragOver = (e: React.DragEvent) => {
140
+ e.preventDefault();
141
+ e.stopPropagation();
142
+ if (disabled) return;
143
+ setIsDragging(true);
144
+ };
145
+
146
+ const handleDragLeave = (e: React.DragEvent) => {
147
+ e.preventDefault();
148
+ e.stopPropagation();
149
+ setIsDragging(false);
150
+ };
151
+
152
+ const handleDrop = (e: React.DragEvent) => {
153
+ e.preventDefault();
154
+ e.stopPropagation();
155
+ setIsDragging(false);
156
+ if (disabled) return;
157
+
158
+ const file = e.dataTransfer.files?.[0];
159
+ if (file) {
160
+ handleFile(file);
161
+ }
162
+ };
163
+
164
+ const handleClick = () => {
165
+ if (disabled) return;
166
+ inputRef.current?.click();
167
+ };
168
+
169
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
170
+ const file = e.target.files?.[0];
171
+ if (file) {
172
+ handleFile(file);
173
+ }
174
+ };
175
+
176
+ const handleRemove = (e?: React.MouseEvent) => {
177
+ e?.stopPropagation();
178
+ if (generatedUrlRef.current) {
179
+ URL.revokeObjectURL(generatedUrlRef.current);
180
+ generatedUrlRef.current = null;
181
+ }
182
+ setInternalPreviewUrl(null);
183
+ setImageLoadError(false);
184
+ if (inputRef.current) inputRef.current.value = "";
185
+ onFileRemove?.();
186
+ };
187
+
188
+ const isVideo =
189
+ previewType === "video" ||
190
+ (previewType === "auto" &&
191
+ (internalPreviewType === "video" ||
192
+ (activePreviewUrl &&
193
+ (activePreviewUrl.includes(".mp4") ||
194
+ activePreviewUrl.includes(".mov") ||
195
+ activePreviewUrl.includes(".webm") ||
196
+ activePreviewUrl.startsWith("data:video/")))));
197
+
198
+ const hasPreview = Boolean(showPreview && activePreviewUrl);
199
+
200
+ return (
201
+ <div
202
+ ref={ref}
203
+ className={cn(
204
+ "pb:relative pb:w-full pb:rounded-2xl pb:border-2 pb:border-dashed pb:transition-all pb:overflow-hidden",
205
+ isDragging
206
+ ? "pb:border-primary pb:bg-primary/10"
207
+ : "pb:border-gray-300 pb:hover:border-primary/80 pb:bg-gray-50/70 pb:hover:bg-gray-50",
208
+ disabled && "pb:bg-gray-100/90 pb:border-gray-300 pb:cursor-not-allowed pb:hover:border-gray-300",
209
+ className,
210
+ )}
211
+ onDragEnter={handleDragEnter}
212
+ onDragOver={handleDragOver}
213
+ onDragLeave={handleDragLeave}
214
+ onDrop={handleDrop}
215
+ >
216
+ <input
217
+ ref={inputRef}
218
+ type="file"
219
+ id={id}
220
+ name={name}
221
+ accept={accept}
222
+ disabled={disabled}
223
+ onChange={handleInputChange}
224
+ className="pb:hidden"
225
+ tabIndex={-1}
226
+ />
227
+
228
+ {/* Dragging Overlay when in preview mode */}
229
+ {isDragging && hasPreview && (
230
+ <div className="pb:absolute pb:inset-0 pb:z-20 pb:bg-primary/20 pb:backdrop-blur-xs pb:flex pb:flex-col pb:items-center pb:justify-center pb:gap-2">
231
+ <div className="pb:w-10 pb:h-10 pb:rounded-full pb:bg-primary pb:text-slate-950 pb:flex pb:items-center pb:justify-center pb:shadow-md">
232
+ <Icon name="arrowDownTray" className="pb:w-5 pb:h-5" />
233
+ </div>
234
+ <p className="pb:text-xs pb:font-bold pb:text-gray-900">Drop file to replace</p>
235
+ </div>
236
+ )}
237
+
238
+ {hasPreview ? (
239
+ <div className="pb:p-4 pb:space-y-3">
240
+ <div
241
+ onClick={handleClick}
242
+ className="pb:relative pb:w-full pb:h-56 pb:overflow-hidden pb:rounded-xl pb:bg-gray-900 pb:flex pb:items-center pb:justify-center pb:cursor-pointer pb:group"
243
+ title="Click to change file"
244
+ >
245
+ {isVideo ? (
246
+ <video
247
+ src={activePreviewUrl!}
248
+ controls
249
+ className="pb:w-full pb:h-full pb:object-contain"
250
+ />
251
+ ) : imageLoadError ? (
252
+ <div className="pb:p-6 pb:text-center pb:text-gray-400 pb:space-y-2">
253
+ <Icon name="xCircle" className="pb:w-8 pb:h-8 pb:mx-auto pb:text-red-400" />
254
+ <p className="pb:text-xs pb:font-medium">Failed to load image preview</p>
255
+ </div>
256
+ ) : (
257
+ <img
258
+ src={activePreviewUrl!}
259
+ alt="File preview"
260
+ onError={() => setImageLoadError(true)}
261
+ className="pb:w-full pb:h-full pb:object-contain pb:group-hover:scale-102 pb:transition-transform"
262
+ />
263
+ )}
264
+
265
+ {/* Hover overlay hint */}
266
+ <div className="pb:absolute pb:inset-0 pb:bg-black/30 pb:opacity-0 pb:group-hover:opacity-100 pb:transition-opacity pb:flex pb:items-center pb:justify-center pb:pointer-events-none">
267
+ <span className="pb:bg-white/90 pb:text-gray-900 pb:text-xs pb:font-bold pb:px-3 pb:py-1.5 pb:rounded-lg pb:shadow">
268
+ Click or drag to replace
269
+ </span>
270
+ </div>
271
+ </div>
272
+
273
+ <div className="pb:flex pb:items-center pb:justify-center pb:gap-3">
274
+ <Button
275
+ type="button"
276
+ color="neutral"
277
+ size="sm"
278
+ onClick={handleClick}
279
+ disabled={disabled}
280
+ leftIcon={<Icon name="arrowUpTray" />}
281
+ >
282
+ Change File
283
+ </Button>
284
+
285
+ <Button
286
+ type="button"
287
+ variant="ghost"
288
+ color="danger"
289
+ size="sm"
290
+ onClick={handleRemove}
291
+ disabled={disabled}
292
+ leftIcon={<Icon name="x" />}
293
+ >
294
+ Remove
295
+ </Button>
296
+ </div>
297
+ </div>
298
+ ) : (
299
+ <div
300
+ onClick={handleClick}
301
+ role="button"
302
+ tabIndex={disabled ? -1 : 0}
303
+ onKeyDown={(e) => {
304
+ if (e.key === "Enter" || e.key === " ") {
305
+ e.preventDefault();
306
+ handleClick();
307
+ }
308
+ }}
309
+ className="pb:p-8 pb:text-center pb:cursor-pointer pb:focus:outline-none pb:focus:ring-2 pb:focus:ring-primary pb:focus:ring-offset-2 pb:rounded-xl pb:space-y-2.5 pb:flex pb:flex-col pb:items-center pb:justify-center"
310
+ >
311
+ <div className="pb:w-12 pb:h-12 pb:rounded-full pb:bg-primary/20 pb:text-dark-primary pb:flex pb:items-center pb:justify-center">
312
+ {icon || <Icon name="cloudArrowUp" className="pb:w-6 pb:h-6" />}
313
+ </div>
314
+
315
+ <div className="pb:space-y-1">
316
+ <p className="pb:text-sm pb:font-bold pb:text-gray-900">{title}</p>
317
+ {subtitle && (
318
+ <p className="pb:text-xs pb:text-gray-600">{subtitle}</p>
319
+ )}
320
+ </div>
321
+ </div>
322
+ )}
323
+ </div>
324
+ );
325
+ },
326
+ );
327
+
328
+ Dropzone.displayName = "Dropzone";
@@ -0,0 +1,62 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { FileUpload } from "./FileUpload";
3
+ import { Heading } from "../foundations/Typography";
4
+
5
+ const meta: Meta<typeof FileUpload> = {
6
+ title: "Media & Files/FileUpload",
7
+ component: FileUpload,
8
+ parameters: {
9
+ layout: "centered",
10
+ },
11
+ tags: ["autodocs"],
12
+ argTypes: {
13
+ accept: { control: "text" },
14
+ multiple: { control: "boolean" },
15
+ disabled: { control: "boolean" },
16
+ buttonText: { control: "text" },
17
+ placeholder: { control: "text" },
18
+ },
19
+ };
20
+
21
+ export default meta;
22
+ type Story = StoryObj<typeof FileUpload>;
23
+
24
+ export const Default: Story = {
25
+ args: {
26
+ buttonText: "Choose File",
27
+ placeholder: "No file chosen",
28
+ accept: "image/*",
29
+ },
30
+ };
31
+
32
+ export const Variations: Story = {
33
+ render: () => (
34
+ <div className="pb:flex pb:flex-col pb:gap-6 pb:p-4 pb:w-96">
35
+ <div className="pb:space-y-2">
36
+ <Heading level={4} className="pb:text-sm pb:font-medium pb:text-black">
37
+ Single Image Upload
38
+ </Heading>
39
+ <FileUpload accept="image/*" maxSizeBytes={5 * 1024 * 1024} />
40
+ </div>
41
+
42
+ <div className="pb:space-y-2">
43
+ <Heading level={4} className="pb:text-sm pb:font-medium pb:text-black">
44
+ Multiple Files with Custom Button
45
+ </Heading>
46
+ <FileUpload
47
+ multiple
48
+ buttonText="Browse Files"
49
+ buttonVariant="primary"
50
+ placeholder="Select one or more documents"
51
+ />
52
+ </div>
53
+
54
+ <div className="pb:space-y-2">
55
+ <Heading level={4} className="pb:text-sm pb:font-medium pb:text-black">
56
+ Disabled State
57
+ </Heading>
58
+ <FileUpload disabled />
59
+ </div>
60
+ </div>
61
+ ),
62
+ };