@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.
- package/.changeset/config.json +1 -1
- package/.github/workflows/storybook.yml +9 -1
- package/CHANGELOG.md +94 -0
- package/dist/index.d.mts +643 -131
- package/dist/index.d.ts +643 -131
- package/dist/index.js +7 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +7 -6
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -3
- package/src/charts/DistributionBarChart.tsx +10 -4
- package/src/charts/DistributionHistogram.tsx +13 -5
- package/src/charts/LineGraph.stories.tsx +60 -0
- package/src/charts/LineGraph.tsx +216 -0
- package/src/charts/index.ts +2 -0
- package/src/docs/DesignSystem.mdx +241 -25
- package/src/styles/theme.css +8 -0
- package/src/ui/feedback/Alert.stories.tsx +56 -0
- package/src/ui/feedback/Alert.tsx +168 -0
- package/src/ui/{Dialog.stories.tsx → feedback/Dialog.stories.tsx} +2 -2
- package/src/ui/{Dialog.tsx → feedback/Dialog.tsx} +2 -2
- package/src/ui/feedback/EmptyState.stories.tsx +57 -0
- package/src/ui/feedback/EmptyState.tsx +123 -0
- package/src/ui/feedback/Popover.stories.tsx +130 -0
- package/src/ui/feedback/Popover.tsx +135 -0
- package/src/ui/feedback/Skeleton.stories.tsx +88 -0
- package/src/ui/{Skeleton.tsx → feedback/Skeleton.tsx} +2 -2
- package/src/ui/feedback/Spinner.stories.tsx +77 -0
- package/src/ui/feedback/Spinner.tsx +62 -0
- package/src/ui/{Tooltip.stories.tsx → feedback/Tooltip.stories.tsx} +3 -3
- package/src/ui/{Tooltip.tsx → feedback/Tooltip.tsx} +2 -2
- package/src/ui/feedback/index.ts +7 -0
- package/src/ui/forms/Button.stories.tsx +181 -0
- package/src/ui/forms/Button.tsx +323 -0
- package/src/ui/{Checkbox.stories.tsx → forms/Checkbox.stories.tsx} +2 -2
- package/src/ui/{Checkbox.tsx → forms/Checkbox.tsx} +2 -2
- package/src/ui/forms/ChipGroup.stories.tsx +118 -0
- package/src/ui/forms/ChipGroup.tsx +239 -0
- package/src/ui/{Dropdown.tsx → forms/Dropdown.tsx} +1 -1
- package/src/ui/{Field.stories.tsx → forms/Field.stories.tsx} +3 -3
- package/src/ui/{Field.tsx → forms/Field.tsx} +1 -1
- package/src/ui/{Input.stories.tsx → forms/Input.stories.tsx} +6 -6
- package/src/ui/{Input.tsx → forms/Input.tsx} +1 -1
- package/src/ui/{Label.stories.tsx → forms/Label.stories.tsx} +2 -2
- package/src/ui/{Label.tsx → forms/Label.tsx} +2 -2
- package/src/ui/{NumberField.stories.tsx → forms/NumberField.stories.tsx} +2 -2
- package/src/ui/{NumberField.tsx → forms/NumberField.tsx} +8 -9
- package/src/ui/forms/PeerbotsColorSelector.stories.tsx +49 -0
- package/src/ui/forms/PeerbotsColorSelector.tsx +100 -0
- package/src/ui/{Select.stories.tsx → forms/Select.stories.tsx} +2 -2
- package/src/ui/{Select.tsx → forms/Select.tsx} +5 -3
- package/src/ui/{Slider.stories.tsx → forms/Slider.stories.tsx} +6 -4
- package/src/ui/{Slider.tsx → forms/Slider.tsx} +7 -3
- package/src/ui/forms/SliderWithNumberField.stories.tsx +136 -0
- package/src/ui/{SliderWithNumberField.tsx → forms/SliderWithNumberField.tsx} +19 -7
- package/src/ui/{Switch.stories.tsx → forms/Switch.stories.tsx} +2 -2
- package/src/ui/{Switch.tsx → forms/Switch.tsx} +1 -1
- package/src/ui/{TabRadio.stories.tsx → forms/TabRadio.stories.tsx} +3 -3
- package/src/ui/{TabRadio.tsx → forms/TabRadio.tsx} +1 -1
- package/src/ui/{TextArea.stories.tsx → forms/TextArea.stories.tsx} +5 -3
- package/src/ui/{TextArea.tsx → forms/TextArea.tsx} +1 -1
- package/src/ui/forms/index.ts +15 -0
- package/src/ui/foundations/Anchor.stories.tsx +39 -0
- package/src/ui/foundations/Anchor.tsx +96 -0
- package/src/ui/foundations/CodeBlock.stories.tsx +86 -0
- package/src/ui/foundations/CodeBlock.tsx +148 -0
- package/src/ui/foundations/Colors.stories.tsx +80 -0
- package/src/ui/{Icon.stories.tsx → foundations/Icon.stories.tsx} +1 -1
- package/src/ui/{Icon.tsx → foundations/Icon.tsx} +1 -1
- package/src/ui/{IconRegistry.tsx → foundations/IconRegistry.tsx} +261 -57
- package/src/ui/foundations/SocialLinks.stories.tsx +38 -0
- package/src/ui/foundations/SocialLinks.tsx +117 -0
- package/src/ui/foundations/Typography.stories.tsx +58 -0
- package/src/ui/foundations/Typography.tsx +163 -0
- package/src/ui/foundations/index.ts +6 -0
- package/src/ui/index.ts +7 -27
- package/src/ui/layout/Accordion.stories.tsx +86 -0
- package/src/ui/layout/Accordion.tsx +205 -0
- package/src/ui/layout/Card.stories.tsx +151 -0
- package/src/ui/layout/Card.tsx +124 -0
- package/src/ui/layout/Collapsible.stories.tsx +54 -0
- package/src/ui/layout/Collapsible.tsx +54 -0
- package/src/ui/{Separator.stories.tsx → layout/Separator.stories.tsx} +2 -2
- package/src/ui/{Separator.tsx → layout/Separator.tsx} +1 -1
- package/src/ui/{SettingsPanel.stories.tsx → layout/SettingsPanel.stories.tsx} +5 -5
- package/src/ui/{SettingsPanel.tsx → layout/SettingsPanel.tsx} +33 -29
- package/src/ui/layout/index.ts +5 -0
- package/src/ui/media/Avatar.stories.tsx +89 -0
- package/src/ui/media/Avatar.tsx +122 -0
- package/src/ui/media/Dropzone.stories.tsx +95 -0
- package/src/ui/media/Dropzone.tsx +328 -0
- package/src/ui/media/FileUpload.stories.tsx +62 -0
- package/src/ui/media/FileUpload.tsx +175 -0
- package/src/ui/media/index.ts +3 -0
- package/src/ui/{AuthFormUI.stories.tsx → patterns/AuthFormUI.stories.tsx} +1 -1
- package/src/ui/{AuthFormUI.tsx → patterns/AuthFormUI.tsx} +24 -23
- package/src/ui/patterns/CopyButton.stories.tsx +77 -0
- package/src/ui/patterns/CopyButton.tsx +93 -0
- package/src/ui/patterns/SearchInput.stories.tsx +51 -0
- package/src/ui/patterns/SearchInput.tsx +93 -0
- package/src/ui/patterns/Stepper.stories.tsx +129 -0
- package/src/ui/patterns/Stepper.tsx +295 -0
- package/src/ui/{TabSelection.stories.tsx → patterns/TabSelection.stories.tsx} +1 -1
- package/src/ui/{TabSelection.tsx → patterns/TabSelection.tsx} +2 -2
- package/src/ui/patterns/index.ts +6 -0
- package/src/ui/patterns/useClipboard.ts +75 -0
- package/src/ui/types.ts +48 -0
- package/src/ui/Alert.stories.tsx +0 -41
- package/src/ui/Alert.tsx +0 -72
- package/src/ui/Anchor.stories.tsx +0 -25
- package/src/ui/Anchor.tsx +0 -32
- package/src/ui/Button.stories.tsx +0 -108
- package/src/ui/Button.tsx +0 -121
- package/src/ui/Collapsible.stories.tsx +0 -91
- package/src/ui/Collapsible.tsx +0 -52
- package/src/ui/Colors.stories.tsx +0 -67
- package/src/ui/Popover.tsx +0 -42
- package/src/ui/Skeleton.stories.tsx +0 -43
- package/src/ui/SliderWithNumberField.stories.tsx +0 -101
- package/src/ui/Typography.stories.tsx +0 -87
- 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
|
+
};
|