@boxcustodia/library 2.0.0-alpha.13 → 2.0.0-alpha.14
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/dist/index.cjs.js +1 -138
- package/dist/index.d.ts +1083 -715
- package/dist/index.es.js +7077 -56175
- package/dist/theme.css +1 -1
- package/package.json +34 -26
- package/src/__doc__/Examples.tsx +1 -1
- package/src/__doc__/Intro.mdx +3 -3
- package/src/__doc__/Tabs.mdx +112 -0
- package/src/__doc__/V2.mdx +1246 -0
- package/src/components/accordion/accordion.stories.tsx +143 -0
- package/src/components/accordion/accordion.tsx +135 -0
- package/src/components/accordion/index.ts +1 -0
- package/src/components/alert/alert.stories.tsx +24 -4
- package/src/components/alert/alert.tsx +17 -9
- package/src/components/alert-dialog/alert-dialog.stories.tsx +24 -0
- package/src/components/alert-dialog/alert-dialog.test.tsx +1 -1
- package/src/components/alert-dialog/alert-dialog.tsx +58 -10
- package/src/components/auto-complete/auto-complete.stories.tsx +616 -200
- package/src/components/auto-complete/auto-complete.tsx +420 -68
- package/src/components/auto-complete/index.ts +0 -1
- package/src/components/avatar/avatar.stories.tsx +162 -21
- package/src/components/avatar/avatar.tsx +79 -20
- package/src/components/button/button.stories.tsx +219 -294
- package/src/components/button/button.test.tsx +10 -17
- package/src/components/button/button.tsx +78 -19
- package/src/components/button/components/base-button.tsx +30 -53
- package/src/components/button/index.ts +0 -1
- package/src/components/calendar/calendar.stories.tsx +1 -1
- package/src/components/calendar/calendar.tsx +4 -4
- package/src/components/card/card.stories.tsx +141 -69
- package/src/components/card/card.tsx +155 -54
- package/src/components/center/center.stories.tsx +22 -39
- package/src/components/checkbox/checkbox.stories.tsx +25 -5
- package/src/components/checkbox/checkbox.tsx +76 -15
- package/src/components/checkbox-group/checkbox-group.stories.tsx +116 -28
- package/src/components/checkbox-group/checkbox-group.tsx +84 -3
- package/src/components/combobox/combobox.stories.tsx +33 -23
- package/src/components/combobox/combobox.tsx +119 -103
- package/src/components/date-picker/date-input.stories.tsx +14 -6
- package/src/components/date-picker/date-input.tsx +2 -2
- package/src/components/date-picker/date-picker.model.ts +13 -4
- package/src/components/date-picker/date-picker.stories.tsx +38 -12
- package/src/components/date-picker/date-picker.tsx +28 -14
- package/src/components/dialog/dialog.stories.tsx +18 -0
- package/src/components/dialog/dialog.test.tsx +1 -1
- package/src/components/dialog/dialog.tsx +51 -20
- package/src/components/divider/divider.stories.tsx +6 -0
- package/src/components/dropzone/dropzone.stories.tsx +71 -90
- package/src/components/dropzone/dropzone.tsx +383 -105
- package/src/components/dropzone/index.ts +0 -1
- package/src/components/empty/empty.stories.tsx +165 -0
- package/src/components/empty/empty.tsx +156 -0
- package/src/components/empty/index.ts +1 -0
- package/src/components/field/field.stories.tsx +226 -3
- package/src/components/field/field.tsx +77 -42
- package/src/components/form/form.stories.tsx +320 -197
- package/src/components/form/form.tsx +3 -23
- package/src/components/index.ts +2 -6
- package/src/components/input/input.stories.tsx +5 -5
- package/src/components/input/input.tsx +4 -4
- package/src/components/kbd/kbd.stories.tsx +1 -0
- package/src/components/label/label.stories.tsx +16 -0
- package/src/components/label/label.tsx +13 -2
- package/src/components/loader/loader.stories.tsx +7 -5
- package/src/components/loader/loader.tsx +8 -3
- package/src/components/menu/menu-primitives.tsx +207 -196
- package/src/components/menu/menu.stories.tsx +276 -146
- package/src/components/menu/menu.tsx +146 -54
- package/src/components/number-input/number-input.stories.tsx +27 -4
- package/src/components/number-input/number-input.test.tsx +2 -2
- package/src/components/number-input/number-input.tsx +25 -29
- package/src/components/otp/index.ts +1 -0
- package/src/components/otp/otp.stories.tsx +209 -0
- package/src/components/otp/otp.tsx +100 -0
- package/src/components/pagination/index.ts +1 -0
- package/src/components/pagination/pagination.model.ts +2 -0
- package/src/components/pagination/pagination.stories.tsx +154 -59
- package/src/components/pagination/pagination.test.tsx +122 -57
- package/src/components/pagination/pagination.tsx +575 -77
- package/src/components/password/password.stories.tsx +18 -3
- package/src/components/password/password.tsx +26 -10
- package/src/components/popover/popover.stories.tsx +26 -5
- package/src/components/popover/popover.tsx +15 -23
- package/src/components/progress/progress.stories.tsx +1 -0
- package/src/components/radio-group/index.ts +1 -0
- package/src/components/radio-group/radio-group.stories.tsx +251 -0
- package/src/components/radio-group/radio-group.tsx +212 -0
- package/src/components/scroll-area/scroll-area.stories.tsx +1 -0
- package/src/components/select/select.stories.tsx +118 -19
- package/src/components/select/select.tsx +67 -62
- package/src/components/skeleton/skeleton.stories.tsx +1 -0
- package/src/components/stack/stack.stories.tsx +179 -89
- package/src/components/stack/stack.tsx +2 -2
- package/src/components/stepper/index.ts +1 -1
- package/src/components/stepper/stepper.stories.tsx +767 -83
- package/src/components/stepper/stepper.test.tsx +18 -18
- package/src/components/stepper/stepper.tsx +554 -0
- package/src/components/switch/switch.stories.tsx +15 -1
- package/src/components/switch/switch.tsx +17 -4
- package/src/components/table/index.ts +0 -2
- package/src/components/table/table.stories.tsx +131 -18
- package/src/components/table/table.test.tsx +1 -1
- package/src/components/table/table.tsx +183 -77
- package/src/components/tabs/tabs.stories.tsx +373 -155
- package/src/components/tabs/tabs.test.tsx +12 -12
- package/src/components/tabs/tabs.tsx +72 -149
- package/src/components/tag/index.ts +0 -1
- package/src/components/tag/tag.stories.tsx +155 -120
- package/src/components/tag/tag.tsx +47 -95
- package/src/components/textarea/textarea.stories.tsx +8 -22
- package/src/components/textarea/textarea.tsx +17 -79
- package/src/components/timeline/timeline.stories.tsx +323 -42
- package/src/components/timeline/timeline.tsx +359 -132
- package/src/components/toast/toast.stories.tsx +1 -0
- package/src/components/tooltip/tooltip.tsx +11 -9
- package/src/components/tree/index.ts +0 -1
- package/src/components/tree/tree.stories.tsx +365 -408
- package/src/components/tree/tree.test.tsx +163 -0
- package/src/components/tree/tree.tsx +212 -36
- package/src/hooks/useAsync/__doc__/useAsync.stories.tsx +5 -5
- package/src/hooks/useClipboard/__doc__/useClipboard.stories.tsx +1 -3
- package/src/hooks/useDebounceCallback/__doc__/useDebouncedCallback.stories.tsx +6 -6
- package/src/hooks/useDocumentTitle/__doc__/useDocumentTitle.stories.tsx +1 -1
- package/src/hooks/useEventListener/__test__/useEventListener.test.tsx +1 -1
- package/src/hooks/useLocalStorage/__doc__/useLocalStorage.stories.tsx +1 -1
- package/src/hooks/usePagination/usePagination.tsx +36 -24
- package/src/styles/theme.css +1 -1
- package/src/utils/form.tsx +67 -37
- package/src/utils/index.ts +1 -1
- package/src/__doc__/Migration.mdx +0 -451
- package/src/components/auto-complete/auto-complete-primitives.tsx +0 -155
- package/src/components/background-image/background-image.stories.tsx +0 -21
- package/src/components/background-image/background-image.test.tsx +0 -29
- package/src/components/background-image/background-image.tsx +0 -23
- package/src/components/background-image/index.ts +0 -1
- package/src/components/button/button.variants.ts +0 -44
- package/src/components/button/components/loader-overlay.tsx +0 -21
- package/src/components/button/components/loading-icon.tsx +0 -47
- package/src/components/dropzone/upload-primitives.tsx +0 -310
- package/src/components/dropzone/use-dropzone.ts +0 -122
- package/src/components/empty-state/empty-state.stories.tsx +0 -56
- package/src/components/empty-state/empty-state.tsx +0 -39
- package/src/components/empty-state/index.ts +0 -1
- package/src/components/heading/heading.stories.tsx +0 -74
- package/src/components/heading/heading.tsx +0 -28
- package/src/components/heading/heading.variants.ts +0 -27
- package/src/components/heading/index.ts +0 -1
- package/src/components/kbd/kbd.variants.ts +0 -26
- package/src/components/menu/util/render-menu-item.tsx +0 -54
- package/src/components/multi-select/hooks/use-multi-select.ts +0 -66
- package/src/components/multi-select/index.ts +0 -1
- package/src/components/multi-select/multi-select.stories.tsx +0 -294
- package/src/components/multi-select/multi-select.tsx +0 -300
- package/src/components/multi-select/multi-select.variants.ts +0 -22
- package/src/components/pagination/components/pagination-option.tsx +0 -27
- package/src/components/show/index.ts +0 -1
- package/src/components/show/show.stories.tsx +0 -197
- package/src/components/show/show.test.tsx +0 -41
- package/src/components/show/show.tsx +0 -16
- package/src/components/stepper/Stepper.tsx +0 -190
- package/src/components/stepper/context/stepper-context.tsx +0 -11
- package/src/components/table/table-primitives.tsx +0 -122
- package/src/components/table/table.model.ts +0 -20
- package/src/components/table-pagination/index.ts +0 -2
- package/src/components/table-pagination/table-pagination.model.ts +0 -2
- package/src/components/table-pagination/table-pagination.stories.tsx +0 -23
- package/src/components/table-pagination/table-pagination.test.tsx +0 -32
- package/src/components/table-pagination/table-pagination.tsx +0 -108
- package/src/components/tabs/context/tabs-context.tsx +0 -14
- package/src/components/tag/tag.variants.ts +0 -31
- package/src/components/timeline/timeline-status.ts +0 -5
- package/src/components/tree/hooks/use-controllable-tree-state.ts +0 -80
- package/src/components/tree/tree-primitives.tsx +0 -126
|
@@ -1,91 +1,363 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { cn } from "../../lib";
|
|
4
|
-
import { FileTypeValue } from "./file-types";
|
|
1
|
+
import { useRender } from "@base-ui/react/use-render";
|
|
2
|
+
import { LucideIcon, UploadCloud, X } from "lucide-react";
|
|
5
3
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} from "
|
|
12
|
-
import {
|
|
4
|
+
type ComponentPropsWithoutRef,
|
|
5
|
+
createContext,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
useContext,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import type { FileRejection } from "react-dropzone";
|
|
11
|
+
import { useDropzone as useRDZ } from "react-dropzone";
|
|
12
|
+
import { cn } from "../../lib";
|
|
13
|
+
import { Button } from "../button";
|
|
14
|
+
import type { FileTypeValue } from "./file-types";
|
|
15
|
+
|
|
16
|
+
// --- Types ---
|
|
17
|
+
|
|
18
|
+
export type FileErrorCode =
|
|
19
|
+
| "INVALID_EXTENSION"
|
|
20
|
+
| "FILE_TOO_LARGE"
|
|
21
|
+
| "MAX_FILES_EXCEEDED";
|
|
22
|
+
|
|
23
|
+
export type FileError = {
|
|
24
|
+
file: File;
|
|
25
|
+
errorMessage: string;
|
|
26
|
+
errorCode: FileErrorCode;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// --- Helpers ---
|
|
30
|
+
|
|
31
|
+
const RDZ_CODE_MAP: Record<string, FileErrorCode> = {
|
|
32
|
+
"file-too-large": "FILE_TOO_LARGE",
|
|
33
|
+
"too-many-files": "MAX_FILES_EXCEEDED",
|
|
34
|
+
"file-invalid-type": "INVALID_EXTENSION",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function mapRejections(rejections: FileRejection[]): [File[], FileError[]] {
|
|
38
|
+
const files: File[] = [];
|
|
39
|
+
const errors: FileError[] = [];
|
|
40
|
+
for (const { file, errors: rdzErrors } of rejections) {
|
|
41
|
+
files.push(file);
|
|
42
|
+
for (const err of rdzErrors) {
|
|
43
|
+
errors.push({
|
|
44
|
+
file,
|
|
45
|
+
errorMessage: err.message,
|
|
46
|
+
errorCode: RDZ_CODE_MAP[err.code] ?? "INVALID_EXTENSION",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return [files, errors];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function mimeToLabel(mime: string): string {
|
|
54
|
+
const sub = mime.split("/")[1] ?? mime;
|
|
55
|
+
const withoutPlus = sub.split("+")[0];
|
|
56
|
+
const parts = withoutPlus.split(".");
|
|
57
|
+
return parts[parts.length - 1] ?? withoutPlus;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- Context ---
|
|
61
|
+
|
|
62
|
+
type DropzoneCtx = {
|
|
63
|
+
acceptedFiles: File[];
|
|
64
|
+
rejectedFiles: File[];
|
|
65
|
+
removeAccepted: (index: number) => void;
|
|
66
|
+
removeRejected: (index: number) => void;
|
|
67
|
+
isDragActive: boolean;
|
|
68
|
+
getRootProps: ReturnType<typeof useRDZ>["getRootProps"];
|
|
69
|
+
getInputProps: ReturnType<typeof useRDZ>["getInputProps"];
|
|
70
|
+
open: () => void;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const DropzoneContext = createContext<DropzoneCtx | null>(null);
|
|
74
|
+
|
|
75
|
+
function useDropzoneCtx(): DropzoneCtx {
|
|
76
|
+
const ctx = useContext(DropzoneContext);
|
|
77
|
+
if (!ctx) throw new Error("useDropzoneCtx must be used within DropzoneRoot");
|
|
78
|
+
return ctx;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- DropzoneRoot ---
|
|
82
|
+
|
|
83
|
+
export type DropzoneRootProps = {
|
|
84
|
+
onFileAdd?: (files: File[]) => void;
|
|
85
|
+
onFileRemove?: (index: number, type: "accepted" | "rejected") => void;
|
|
86
|
+
onFilesChange?: (files: File[]) => void;
|
|
87
|
+
files?: File[];
|
|
88
|
+
maxFiles?: number;
|
|
89
|
+
allowedExtensions?: FileTypeValue[];
|
|
90
|
+
maxFileSize?: number;
|
|
91
|
+
multiple?: boolean;
|
|
92
|
+
onError?: (errors: FileError[]) => void;
|
|
93
|
+
children?: ReactNode;
|
|
94
|
+
} & Omit<ComponentPropsWithoutRef<"div">, "onDrop" | "onError">;
|
|
95
|
+
|
|
96
|
+
export function DropzoneRoot({
|
|
97
|
+
onFileAdd,
|
|
98
|
+
onFileRemove,
|
|
99
|
+
onFilesChange,
|
|
100
|
+
files: controlledFiles,
|
|
101
|
+
maxFiles = 1,
|
|
102
|
+
allowedExtensions = [],
|
|
103
|
+
maxFileSize = 5 * 1024 * 1024,
|
|
104
|
+
multiple,
|
|
105
|
+
onError,
|
|
106
|
+
children,
|
|
107
|
+
className,
|
|
108
|
+
...props
|
|
109
|
+
}: DropzoneRootProps) {
|
|
110
|
+
const isControlled = controlledFiles !== undefined;
|
|
111
|
+
const [internalFiles, setInternalFiles] = useState<File[]>([]);
|
|
112
|
+
const [rejectedFiles, setRejectedFiles] = useState<File[]>([]);
|
|
113
|
+
const acceptedFiles = isControlled ? controlledFiles : internalFiles;
|
|
114
|
+
|
|
115
|
+
const updateFiles = (next: File[]) => {
|
|
116
|
+
if (!isControlled) setInternalFiles(next);
|
|
117
|
+
onFilesChange?.(next);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const accept =
|
|
121
|
+
allowedExtensions.length > 0
|
|
122
|
+
? Object.fromEntries(allowedExtensions.map((mime) => [mime, []]))
|
|
123
|
+
: undefined;
|
|
124
|
+
|
|
125
|
+
const { getRootProps, getInputProps, isDragActive, open } = useRDZ({
|
|
126
|
+
onDrop(newAccepted, newRejections) {
|
|
127
|
+
const [newRejected, fileErrors] = mapRejections(newRejections);
|
|
128
|
+
updateFiles([...acceptedFiles, ...newAccepted]);
|
|
129
|
+
setRejectedFiles((prev) => [...prev, ...newRejected]);
|
|
130
|
+
onFileAdd?.(newAccepted);
|
|
131
|
+
if (fileErrors.length > 0) onError?.(fileErrors);
|
|
132
|
+
},
|
|
133
|
+
accept,
|
|
134
|
+
maxFiles,
|
|
135
|
+
maxSize: maxFileSize,
|
|
136
|
+
multiple: multiple ?? maxFiles > 1,
|
|
137
|
+
noClick: true,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const removeAccepted = (index: number) => {
|
|
141
|
+
updateFiles(acceptedFiles.filter((_, i) => i !== index));
|
|
142
|
+
onFileRemove?.(index, "accepted");
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const removeRejected = (index: number) => {
|
|
146
|
+
setRejectedFiles((prev) => prev.filter((_, i) => i !== index));
|
|
147
|
+
onFileRemove?.(index, "rejected");
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<DropzoneContext.Provider
|
|
152
|
+
value={{
|
|
153
|
+
acceptedFiles,
|
|
154
|
+
rejectedFiles,
|
|
155
|
+
removeAccepted,
|
|
156
|
+
removeRejected,
|
|
157
|
+
isDragActive,
|
|
158
|
+
getRootProps,
|
|
159
|
+
getInputProps,
|
|
160
|
+
open,
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
<div className={cn("w-full", className)} data-slot="dropzone" {...props}>
|
|
164
|
+
{children}
|
|
165
|
+
</div>
|
|
166
|
+
</DropzoneContext.Provider>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- DropzoneTrigger ---
|
|
171
|
+
|
|
172
|
+
export type DropzoneTriggerProps = useRender.ComponentProps<"div">;
|
|
173
|
+
|
|
174
|
+
export function DropzoneTrigger({
|
|
175
|
+
render,
|
|
176
|
+
className,
|
|
177
|
+
onClick,
|
|
178
|
+
children,
|
|
179
|
+
...props
|
|
180
|
+
}: DropzoneTriggerProps) {
|
|
181
|
+
const { getRootProps, getInputProps, isDragActive, open } = useDropzoneCtx();
|
|
182
|
+
|
|
183
|
+
const element = useRender({
|
|
184
|
+
defaultTagName: "div",
|
|
185
|
+
render,
|
|
186
|
+
props: {
|
|
187
|
+
...(getRootProps() as ComponentPropsWithoutRef<"div">),
|
|
188
|
+
...props,
|
|
189
|
+
className,
|
|
190
|
+
"data-slot": "dropzone-trigger",
|
|
191
|
+
"data-drag-active": isDragActive || undefined,
|
|
192
|
+
onClick: (event: React.MouseEvent<HTMLDivElement>) => {
|
|
193
|
+
open();
|
|
194
|
+
onClick?.(event);
|
|
195
|
+
},
|
|
196
|
+
children: render ? undefined : children,
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<>
|
|
202
|
+
{element}
|
|
203
|
+
<input
|
|
204
|
+
{...getInputProps()}
|
|
205
|
+
className="hidden"
|
|
206
|
+
data-slot="dropzone-input"
|
|
207
|
+
/>
|
|
208
|
+
</>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- DropzoneContent ---
|
|
213
|
+
|
|
214
|
+
export function DropzoneContent({
|
|
215
|
+
className,
|
|
216
|
+
...props
|
|
217
|
+
}: ComponentPropsWithoutRef<"div">) {
|
|
218
|
+
return (
|
|
219
|
+
<div
|
|
220
|
+
className={cn("mt-2 space-y-2", className)}
|
|
221
|
+
data-slot="dropzone-content"
|
|
222
|
+
{...props}
|
|
223
|
+
/>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- DropzoneItem ---
|
|
228
|
+
|
|
229
|
+
export type DropzoneItemProps = ComponentPropsWithoutRef<"div"> & {
|
|
230
|
+
file: File;
|
|
231
|
+
onRemove: () => void;
|
|
232
|
+
renderIcon?: (file: File) => ReactNode;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export function DropzoneItem({
|
|
236
|
+
file,
|
|
237
|
+
onRemove,
|
|
238
|
+
className,
|
|
239
|
+
renderIcon,
|
|
240
|
+
...props
|
|
241
|
+
}: DropzoneItemProps) {
|
|
242
|
+
return (
|
|
243
|
+
<div
|
|
244
|
+
className={cn("flex items-center gap-2 mb-2", className)}
|
|
245
|
+
data-slot="dropzone-item"
|
|
246
|
+
{...props}
|
|
247
|
+
>
|
|
248
|
+
{renderIcon?.(file)}
|
|
249
|
+
{file.name}
|
|
250
|
+
<Button
|
|
251
|
+
variant="ghost"
|
|
252
|
+
size="icon"
|
|
253
|
+
type="button"
|
|
254
|
+
className="h-6 w-6"
|
|
255
|
+
onClick={onRemove}
|
|
256
|
+
data-slot="dropzone-item-remove"
|
|
257
|
+
>
|
|
258
|
+
<X />
|
|
259
|
+
</Button>
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// --- DropzoneFilesList (internal) ---
|
|
265
|
+
|
|
266
|
+
type DropzoneFilesListProps = {
|
|
267
|
+
files: File[];
|
|
268
|
+
onRemove: (index: number) => void;
|
|
269
|
+
renderFile?: (file: File, remove: () => void) => ReactNode;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
function DropzoneFilesList({
|
|
273
|
+
files,
|
|
274
|
+
onRemove,
|
|
275
|
+
renderFile,
|
|
276
|
+
}: DropzoneFilesListProps) {
|
|
277
|
+
if (files.length === 0) return null;
|
|
278
|
+
return (
|
|
279
|
+
<div className="mt-2 space-y-2">
|
|
280
|
+
{files.map((file, index) =>
|
|
281
|
+
renderFile ? (
|
|
282
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: file name not unique
|
|
283
|
+
<div key={index}>{renderFile(file, () => onRemove(index))}</div>
|
|
284
|
+
) : (
|
|
285
|
+
<DropzoneItem
|
|
286
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: file name not unique
|
|
287
|
+
key={index}
|
|
288
|
+
file={file}
|
|
289
|
+
onRemove={() => onRemove(index)}
|
|
290
|
+
/>
|
|
291
|
+
),
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// --- DropzoneAcceptedFiles / DropzoneRejectedFiles ---
|
|
298
|
+
|
|
299
|
+
export type DropzoneFilesProps = {
|
|
300
|
+
renderFile?: (file: File, remove: () => void) => ReactNode;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
export function DropzoneAcceptedFiles(props: DropzoneFilesProps) {
|
|
304
|
+
const { acceptedFiles, removeAccepted } = useDropzoneCtx();
|
|
305
|
+
return (
|
|
306
|
+
<DropzoneFilesList
|
|
307
|
+
files={acceptedFiles}
|
|
308
|
+
onRemove={removeAccepted}
|
|
309
|
+
{...props}
|
|
310
|
+
/>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function DropzoneRejectedFiles(props: DropzoneFilesProps) {
|
|
315
|
+
const { rejectedFiles, removeRejected } = useDropzoneCtx();
|
|
316
|
+
return (
|
|
317
|
+
<DropzoneFilesList
|
|
318
|
+
files={rejectedFiles}
|
|
319
|
+
onRemove={removeRejected}
|
|
320
|
+
{...props}
|
|
321
|
+
/>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// --- Dropzone (composite) ---
|
|
13
326
|
|
|
14
327
|
export type DropzoneProps = {
|
|
15
|
-
/**
|
|
16
|
-
* Función que se ejecuta cuando se agregan archivos
|
|
17
|
-
*/
|
|
18
328
|
onFileAdd?: (files: File[]) => void;
|
|
19
|
-
/**
|
|
20
|
-
* Función que se ejecuta cuando se elimina un archivo
|
|
21
|
-
*/
|
|
22
329
|
onFileRemove?: (index: number, type: "accepted" | "rejected") => void;
|
|
23
|
-
|
|
24
|
-
* Evento que captura cambios en lista de archivos
|
|
25
|
-
* @default () => {}
|
|
26
|
-
*/
|
|
27
|
-
onChangeFiles?: (files: File[]) => void;
|
|
28
|
-
/**
|
|
29
|
-
* Lista de archivos
|
|
30
|
-
* @default []
|
|
31
|
-
*/
|
|
330
|
+
onFilesChange?: (files: File[]) => void;
|
|
32
331
|
files?: File[];
|
|
33
|
-
/**
|
|
34
|
-
* Número máximo de archivos permitidos
|
|
35
|
-
* @default 1
|
|
36
|
-
*/
|
|
37
332
|
maxFiles?: number;
|
|
38
|
-
/**
|
|
39
|
-
* Lista de extensiones permitidas (e.j. ["image/jpeg", "image/png"])
|
|
40
|
-
* @default []
|
|
41
|
-
*/
|
|
42
333
|
allowedExtensions?: FileTypeValue[];
|
|
43
|
-
/**
|
|
44
|
-
* Tamaño máximo de archivo en bytes
|
|
45
|
-
* @default 5MB
|
|
46
|
-
*/
|
|
47
334
|
maxFileSize?: number;
|
|
48
|
-
/**
|
|
49
|
-
* Permite seleccionar múltiples archivos
|
|
50
|
-
*/
|
|
51
335
|
multiple?: boolean;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
*/
|
|
55
|
-
renderAcceptedFile?: (file: File, removeItem: () => void) => React.ReactNode;
|
|
56
|
-
/**
|
|
57
|
-
* Función para renderizar cada archivo rechazado
|
|
58
|
-
*/
|
|
59
|
-
renderRejectedFile?: (file: File, removeItem: () => void) => React.ReactNode;
|
|
60
|
-
/**
|
|
61
|
-
* Mostrar archivos rechazados
|
|
62
|
-
* @default false
|
|
63
|
-
*/
|
|
336
|
+
renderAcceptedFile?: (file: File, removeItem: () => void) => ReactNode;
|
|
337
|
+
renderRejectedFile?: (file: File, removeItem: () => void) => ReactNode;
|
|
64
338
|
showRejected?: boolean;
|
|
65
|
-
/**
|
|
66
|
-
* Clase CSS para el contenedor
|
|
67
|
-
*/
|
|
68
339
|
className?: string;
|
|
69
|
-
/**
|
|
70
|
-
* Icono personalizado para el dropzone
|
|
71
|
-
* @default UploadCloud
|
|
72
|
-
*/
|
|
73
340
|
icon?: LucideIcon;
|
|
74
|
-
/**
|
|
75
|
-
* Tamaño del icono
|
|
76
|
-
* @default 40
|
|
77
|
-
*/
|
|
78
341
|
iconSize?: number;
|
|
79
|
-
/**
|
|
80
|
-
* Función que se ejecuta cuando se rechazan archivos
|
|
81
|
-
*/
|
|
82
342
|
onError?: (fileErrors: FileError[]) => void;
|
|
343
|
+
label?: string;
|
|
344
|
+
/** Styles applied to each internal slot. */
|
|
345
|
+
classNames?: {
|
|
346
|
+
/** Dashed drop area that contains the icon and label. */
|
|
347
|
+
trigger?: string;
|
|
348
|
+
/** Upload icon rendered inside the trigger. */
|
|
349
|
+
icon?: string;
|
|
350
|
+
/** Text container below the icon (label + meta lines). */
|
|
351
|
+
label?: string;
|
|
352
|
+
/** Wrapper around the accepted/rejected file lists. */
|
|
353
|
+
content?: string;
|
|
354
|
+
};
|
|
83
355
|
} & Omit<ComponentPropsWithoutRef<"div">, "onDrop" | "onError">;
|
|
84
356
|
|
|
85
|
-
export
|
|
357
|
+
export function Dropzone({
|
|
86
358
|
onFileAdd,
|
|
87
359
|
onFileRemove,
|
|
88
|
-
|
|
360
|
+
onFilesChange,
|
|
89
361
|
files,
|
|
90
362
|
maxFiles = 1,
|
|
91
363
|
allowedExtensions = [],
|
|
@@ -95,60 +367,66 @@ export const Dropzone = ({
|
|
|
95
367
|
renderRejectedFile,
|
|
96
368
|
showRejected = false,
|
|
97
369
|
className,
|
|
370
|
+
classNames,
|
|
98
371
|
icon: Icon = UploadCloud,
|
|
99
372
|
iconSize = 40,
|
|
100
373
|
onError,
|
|
374
|
+
label = "Arrastra y suelta archivos aquí o haz clic para seleccionar",
|
|
101
375
|
...props
|
|
102
|
-
}: DropzoneProps)
|
|
103
|
-
const allowedLabels =
|
|
104
|
-
return allowedExtensions.map((ext) => ext.split("/")?.[1]).join(", ");
|
|
105
|
-
}, [allowedExtensions]);
|
|
376
|
+
}: DropzoneProps) {
|
|
377
|
+
const allowedLabels = allowedExtensions.map(mimeToLabel).join(", ");
|
|
106
378
|
|
|
107
379
|
return (
|
|
108
|
-
<
|
|
380
|
+
<DropzoneRoot
|
|
109
381
|
onFileAdd={onFileAdd}
|
|
110
382
|
onFileRemove={onFileRemove}
|
|
111
383
|
files={files}
|
|
112
|
-
|
|
384
|
+
onFilesChange={onFilesChange}
|
|
113
385
|
maxFiles={maxFiles}
|
|
114
386
|
allowedExtensions={allowedExtensions}
|
|
115
387
|
maxFileSize={maxFileSize}
|
|
116
|
-
multiple={multiple
|
|
388
|
+
multiple={multiple}
|
|
117
389
|
className={cn("w-full", className)}
|
|
118
390
|
onError={onError}
|
|
119
391
|
{...props}
|
|
120
392
|
>
|
|
121
|
-
<
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
>
|
|
130
|
-
<Icon
|
|
131
|
-
className="text-muted-foreground pointer-events-none"
|
|
132
|
-
style={{ width: iconSize, height: iconSize }}
|
|
133
|
-
/>
|
|
134
|
-
<div className="text-center text-muted-foreground pointer-events-none">
|
|
135
|
-
<p>Arrastra y suelta archivos aquí o haz clic para seleccionar</p>
|
|
136
|
-
{allowedExtensions.length > 0 && (
|
|
137
|
-
<p className="text-sm">Tipos permitidos: {allowedLabels}</p>
|
|
393
|
+
<DropzoneTrigger
|
|
394
|
+
render={
|
|
395
|
+
<div
|
|
396
|
+
className={cn(
|
|
397
|
+
"w-full min-h-[150px] border-2 border-dashed rounded-lg border-muted p-4",
|
|
398
|
+
"hover:bg-muted/50 data-[drag-active]:border-primary data-[drag-active]:bg-primary/10",
|
|
399
|
+
"transition-colors flex flex-col items-center justify-center gap-4 cursor-pointer",
|
|
400
|
+
classNames?.trigger,
|
|
138
401
|
)}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
402
|
+
>
|
|
403
|
+
<Icon
|
|
404
|
+
className={cn("text-muted-foreground", classNames?.icon)}
|
|
405
|
+
style={{ width: iconSize, height: iconSize }}
|
|
406
|
+
/>
|
|
407
|
+
<div
|
|
408
|
+
className={cn(
|
|
409
|
+
"text-center text-muted-foreground",
|
|
410
|
+
classNames?.label,
|
|
411
|
+
)}
|
|
412
|
+
>
|
|
413
|
+
<p>{label}</p>
|
|
414
|
+
{allowedExtensions.length > 0 && (
|
|
415
|
+
<p className="text-sm">Tipos permitidos: {allowedLabels}</p>
|
|
416
|
+
)}
|
|
417
|
+
<p className="text-sm">
|
|
418
|
+
Tamaño máximo: {Math.floor(maxFileSize / 1024 / 1024)}MB
|
|
419
|
+
</p>
|
|
420
|
+
</div>
|
|
142
421
|
</div>
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
<UploadAcceptedFiles renderFile={renderAcceptedFile} />
|
|
422
|
+
}
|
|
423
|
+
/>
|
|
424
|
+
<DropzoneContent className={classNames?.content}>
|
|
425
|
+
<DropzoneAcceptedFiles renderFile={renderAcceptedFile} />
|
|
148
426
|
{showRejected && (
|
|
149
|
-
<
|
|
427
|
+
<DropzoneRejectedFiles renderFile={renderRejectedFile} />
|
|
150
428
|
)}
|
|
151
|
-
</
|
|
152
|
-
</
|
|
429
|
+
</DropzoneContent>
|
|
430
|
+
</DropzoneRoot>
|
|
153
431
|
);
|
|
154
|
-
}
|
|
432
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { MessageSquareMore, SearchX, UploadCloud } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
Empty,
|
|
6
|
+
EmptyContent,
|
|
7
|
+
EmptyDescription,
|
|
8
|
+
EmptyHeader,
|
|
9
|
+
EmptyMedia,
|
|
10
|
+
EmptyRoot,
|
|
11
|
+
EmptyTitle,
|
|
12
|
+
Stack,
|
|
13
|
+
} from "../../components";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Composite para estados vacíos: sin resultados, sin datos, sin permisos, etc.
|
|
17
|
+
* `Empty` es el composite principal — acepta `icon`, `title`, `description` y
|
|
18
|
+
* `action`. `iconVariant="icon"` muestra el ícono dentro de un chip muted
|
|
19
|
+
* (`size-8`); el default muestra el ícono suelto a `size-12`.
|
|
20
|
+
* `action` se renderiza dentro de `EmptyContent`; `children` escapa al root
|
|
21
|
+
* directamente, útil para layouts que no encajan en el wrapper centrado.
|
|
22
|
+
* Para composición manual usá los primitives: `EmptyRoot`, `EmptyHeader`,
|
|
23
|
+
* `EmptyMedia`, `EmptyTitle`, `EmptyDescription`, `EmptyContent`.
|
|
24
|
+
* Para estilar partes internas usá `classNames`.
|
|
25
|
+
*/
|
|
26
|
+
const meta: Meta<typeof Empty> = {
|
|
27
|
+
title: "Components/Empty",
|
|
28
|
+
component: Empty,
|
|
29
|
+
args: {
|
|
30
|
+
title: "No hay datos",
|
|
31
|
+
},
|
|
32
|
+
argTypes: {
|
|
33
|
+
classNames: { control: false },
|
|
34
|
+
},
|
|
35
|
+
tags: ["beta"],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default meta;
|
|
39
|
+
|
|
40
|
+
type Story = StoryObj<typeof Empty>;
|
|
41
|
+
|
|
42
|
+
export const Default: Story = {};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `className` estila el root. `classNames` expone los slots `header`, `media`,
|
|
46
|
+
* `title`, `description` y `content`.
|
|
47
|
+
*/
|
|
48
|
+
export const WithClassNames: Story = {
|
|
49
|
+
args: {
|
|
50
|
+
icon: <SearchX />,
|
|
51
|
+
iconVariant: "icon",
|
|
52
|
+
title: "Sin resultados",
|
|
53
|
+
description: "Probá con otros filtros.",
|
|
54
|
+
action: <Button size="sm">Limpiar</Button>,
|
|
55
|
+
classNames: {
|
|
56
|
+
title: "text-base",
|
|
57
|
+
description: "italic",
|
|
58
|
+
media: "bg-primary/10 text-primary",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const WithDescription: Story = {
|
|
64
|
+
args: {
|
|
65
|
+
description: "Intentá buscar con otros filtros o revisá más tarde.",
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `iconVariant="icon"` envuelve el ícono en un chip `size-8` con fondo muted.
|
|
71
|
+
* Ideal para estados vacíos compactos (tablas, paneles). El default muestra
|
|
72
|
+
* el ícono suelto a `size-12` para páginas completas o secciones hero.
|
|
73
|
+
*/
|
|
74
|
+
export const IconVariant: Story = {
|
|
75
|
+
args: {
|
|
76
|
+
icon: <SearchX />,
|
|
77
|
+
iconVariant: "icon",
|
|
78
|
+
title: "Sin resultados",
|
|
79
|
+
description: "No encontramos coincidencias para tu búsqueda.",
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const WithAction: Story = {
|
|
84
|
+
args: {
|
|
85
|
+
icon: <MessageSquareMore />,
|
|
86
|
+
title: "No tenés mensajes",
|
|
87
|
+
description: "Cuando recibas uno aparecerá acá.",
|
|
88
|
+
action: (
|
|
89
|
+
<Stack>
|
|
90
|
+
<Button variant="outline">Importar</Button>
|
|
91
|
+
<Button>Crear nuevo</Button>
|
|
92
|
+
</Stack>
|
|
93
|
+
),
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `icon={null}` suprime el media. Útil para estados vacíos en tablas o
|
|
99
|
+
* contextos donde el ícono añade ruido visual innecesario.
|
|
100
|
+
*/
|
|
101
|
+
export const NoIcon: Story = {
|
|
102
|
+
args: {
|
|
103
|
+
icon: null,
|
|
104
|
+
title: "Tabla vacía",
|
|
105
|
+
description: "Agregá una fila para empezar.",
|
|
106
|
+
action: <Button size="sm">Agregar fila</Button>,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* `children` se renderiza fuera de `EmptyContent`, directamente en `EmptyRoot`.
|
|
112
|
+
* Permite añadir elementos que no deben quedar dentro del wrapper centrado —
|
|
113
|
+
* por ejemplo un link de texto o un componente con ancho propio.
|
|
114
|
+
*/
|
|
115
|
+
export const WithChildren: Story = {
|
|
116
|
+
render: () => (
|
|
117
|
+
<Empty
|
|
118
|
+
icon={<UploadCloud />}
|
|
119
|
+
title="Sin archivos"
|
|
120
|
+
description="Subí tu primer archivo para empezar."
|
|
121
|
+
action={<Button>Subir archivo</Button>}
|
|
122
|
+
>
|
|
123
|
+
<p className="text-xs text-muted-foreground">
|
|
124
|
+
Formatos soportados: PDF, PNG, JPG — máx. 10 MB
|
|
125
|
+
</p>
|
|
126
|
+
</Empty>
|
|
127
|
+
),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Composición manual con primitives. Usala cuando necesitás reordenar partes,
|
|
132
|
+
* agregar slots extra, o construir layouts que el composite no cubre.
|
|
133
|
+
*
|
|
134
|
+
* ```tsx
|
|
135
|
+
* <EmptyRoot>
|
|
136
|
+
* <EmptyHeader>
|
|
137
|
+
* <EmptyMedia variant="icon"><Icon /></EmptyMedia>
|
|
138
|
+
* <EmptyTitle>Título</EmptyTitle>
|
|
139
|
+
* <EmptyDescription>Descripción</EmptyDescription>
|
|
140
|
+
* </EmptyHeader>
|
|
141
|
+
* <EmptyContent>
|
|
142
|
+
* <Button>Acción</Button>
|
|
143
|
+
* </EmptyContent>
|
|
144
|
+
* </EmptyRoot>
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
export const Primitive: Story = {
|
|
148
|
+
render: () => (
|
|
149
|
+
<EmptyRoot>
|
|
150
|
+
<EmptyHeader>
|
|
151
|
+
<EmptyMedia variant="icon">
|
|
152
|
+
<SearchX />
|
|
153
|
+
</EmptyMedia>
|
|
154
|
+
<EmptyTitle>Sin resultados</EmptyTitle>
|
|
155
|
+
<EmptyDescription>
|
|
156
|
+
No encontramos nada que coincida. Probá con{" "}
|
|
157
|
+
<a href="#">otros filtros</a>.
|
|
158
|
+
</EmptyDescription>
|
|
159
|
+
</EmptyHeader>
|
|
160
|
+
<EmptyContent>
|
|
161
|
+
<Button variant="outline">Limpiar filtros</Button>
|
|
162
|
+
</EmptyContent>
|
|
163
|
+
</EmptyRoot>
|
|
164
|
+
),
|
|
165
|
+
};
|