@wavv/ui 2.3.5 → 2.3.7-alpha.1

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.
@@ -1,12 +1,33 @@
1
1
  import { type DropZoneProps, type FileTriggerProps } from 'react-aria-components';
2
2
  import type { MarginPadding, WidthHeight } from './types';
3
+ /** Represents a file managed by the DropZone */
4
+ export type DropZoneFile = {
5
+ /** Unique identifier for the file */
6
+ id: string;
7
+ /** Name of the file */
8
+ name: string;
9
+ /** The File object */
10
+ file: File;
11
+ };
3
12
  type Props = {
4
13
  /** The label to display inside the drop zone */
5
14
  label?: string;
6
- /** Whether to show the file names in the drop zone */
7
- showFileNames?: boolean;
15
+ /** Whether to show the file list below the drop zone with remove buttons */
16
+ showFileList?: boolean;
8
17
  /** Whether the drop target is disabled. If true, the drop target will not accept any drops. */
9
18
  disabled?: DropZoneProps['isDisabled'];
19
+ /** Whether the drop zone is in an invalid state */
20
+ invalid?: boolean;
21
+ /** Error message to display below the file names */
22
+ errorMessage?: string;
23
+ /** Array of supported file format names to display (e.g., ['PNG', 'JPG', 'PDF']) */
24
+ supportedFormats?: string[];
25
+ /** Maximum file size string to display (e.g., '25MB') */
26
+ maxFileSize?: string;
27
+ /** Callback when files are added. */
28
+ onFilesAdded?: (files: DropZoneFile[]) => void;
29
+ /** Callback when files are removed. */
30
+ onFilesRemoved?: (files: DropZoneFile[]) => void;
10
31
  className?: DropZoneProps['className'];
11
32
  style?: DropZoneProps['style'];
12
33
  onDrop?: DropZoneProps['onDrop'];
@@ -22,7 +43,6 @@ type Props = {
22
43
  allowsMultiple?: FileTriggerProps['allowsMultiple'];
23
44
  defaultCamera?: FileTriggerProps['defaultCamera'];
24
45
  acceptDirectory?: FileTriggerProps['acceptDirectory'];
25
- onSelect?: FileTriggerProps['onSelect'];
26
- } & MarginPadding & WidthHeight & Omit<DropZoneProps, 'isDisabled' | 'children'> & Omit<FileTriggerProps, 'children'>;
27
- declare const DropZone: ({ label, showFileNames, disabled, acceptedFileTypes, allowsMultiple, defaultCamera, acceptDirectory, onDrop, onSelect, ...rest }: Props) => import("react/jsx-runtime").JSX.Element;
46
+ } & MarginPadding & WidthHeight & Omit<DropZoneProps, 'isDisabled' | 'children'> & Omit<FileTriggerProps, 'children' | 'onSelect'>;
47
+ declare const DropZone: ({ label, showFileList, disabled, invalid, errorMessage, supportedFormats, maxFileSize, acceptedFileTypes, allowsMultiple, defaultCamera, acceptDirectory, onDrop, onFilesAdded, onFilesRemoved, width, margin, marginTop, marginRight, marginBottom, marginLeft, ...rest }: Props) => import("react/jsx-runtime").JSX.Element;
28
48
  export default DropZone;
@@ -1,65 +1,194 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import styled from "@emotion/styled";
3
- import { useState } from "react";
3
+ import { useId, useState } from "react";
4
4
  import { DropZone, Text } from "react-aria-components";
5
5
  import matchesFileTypes from "../utils/matchesFileTypes.js";
6
- import Ellipsis from "./Ellipsis.js";
6
+ import Button from "./Button/index.js";
7
7
  import FileTrigger from "./FileTrigger.js";
8
8
  import { marginProps, paddingProps, widthHeightProps } from "./helpers/styledProps.js";
9
- const DropZone_DropZone = ({ label = 'Drop here', showFileNames = true, disabled, acceptedFileTypes, allowsMultiple, defaultCamera, acceptDirectory, onDrop, onSelect, ...rest })=>{
10
- const [displayFiles, setDisplayFiles] = useState('');
11
- const showFileTrigger = !!acceptedFileTypes || !!allowsMultiple || !!defaultCamera || !!acceptDirectory || !!onSelect;
12
- const handleFileDrop = (event)=>{
13
- let files = event.items.filter((file)=>'file' === file.kind);
14
- if (acceptedFileTypes && acceptedFileTypes.length > 0) files = files.filter((file)=>matchesFileTypes(file, acceptedFileTypes));
15
- if (!allowsMultiple && files.length > 1) files = files.slice(0, 1);
16
- const fileNames = files.map(({ name })=>name).join(', ');
17
- setDisplayFiles(fileNames);
9
+ const DropZone_DropZone = ({ label = 'Drop here', showFileList = true, disabled, invalid, errorMessage, supportedFormats, maxFileSize, acceptedFileTypes, allowsMultiple, defaultCamera, acceptDirectory, onDrop, onFilesAdded, onFilesRemoved, width, margin, marginTop, marginRight, marginBottom, marginLeft, ...rest })=>{
10
+ const [files, setFiles] = useState([]);
11
+ const [sizeValidationError, setSizeValidationError] = useState(null);
12
+ const instanceId = useId();
13
+ const hasFileCallbacks = !!onFilesAdded || !!onFilesRemoved;
14
+ const shouldManageFiles = showFileList || hasFileCallbacks;
15
+ const showFileTrigger = !!acceptedFileTypes || !!allowsMultiple || !!defaultCamera || !!acceptDirectory || hasFileCallbacks;
16
+ const generateFileId = (name, index)=>`${instanceId}-${name}-${Date.now()}-${index}`;
17
+ const parseFileSize = (sizeString)=>{
18
+ const match = sizeString.trim().match(/^(\d+(?:\.\d+)?)\s*(KB|MB|GB|TB|B)$/i);
19
+ if (!match) return null;
20
+ const value = parseFloat(match[1]);
21
+ const unit = match[2].toUpperCase();
22
+ const multipliers = {
23
+ B: 1,
24
+ KB: 1024,
25
+ MB: 1048576,
26
+ GB: 1073741824,
27
+ TB: 1099511627776
28
+ };
29
+ return value * (multipliers[unit] || 1);
30
+ };
31
+ const validateFileSizes = (fileList)=>{
32
+ if (!maxFileSize) return {
33
+ valid: fileList,
34
+ oversized: []
35
+ };
36
+ const maxBytes = parseFileSize(maxFileSize);
37
+ if (null === maxBytes) return {
38
+ valid: fileList,
39
+ oversized: []
40
+ };
41
+ const valid = [];
42
+ const oversized = [];
43
+ for (const file of fileList)if (file.size <= maxBytes) valid.push(file);
44
+ else oversized.push(file);
45
+ return {
46
+ valid,
47
+ oversized
48
+ };
49
+ };
50
+ const addFiles = async (newFileList)=>{
51
+ const { valid, oversized } = validateFileSizes(newFileList);
52
+ if (oversized.length > 0) {
53
+ const oversizedNames = oversized.map((file)=>file.name).join(', ');
54
+ setSizeValidationError(`Error: ${oversizedNames} ${1 === oversized.length ? 'exceeds' : 'exceed'} maximum file size of ${maxFileSize}`);
55
+ } else setSizeValidationError(null);
56
+ if (0 === valid.length) return;
57
+ const newEntries = valid.map((file, index)=>({
58
+ id: generateFileId(file.name, index),
59
+ name: file.name,
60
+ file
61
+ }));
62
+ const updatedFiles = allowsMultiple ? [
63
+ ...files,
64
+ ...newEntries
65
+ ] : newEntries;
66
+ setFiles(updatedFiles);
67
+ onFilesAdded?.(newEntries);
68
+ };
69
+ const handleFileDrop = async (event)=>{
70
+ let fileItems = event.items.filter((file)=>'file' === file.kind);
71
+ if (acceptedFileTypes && acceptedFileTypes.length > 0) fileItems = fileItems.filter((file)=>matchesFileTypes(file, acceptedFileTypes));
72
+ if (!allowsMultiple && fileItems.length > 1) fileItems = fileItems.slice(0, 1);
73
+ if (shouldManageFiles) {
74
+ const filePromises = fileItems.map((item)=>{
75
+ if ('getFile' in item && 'function' == typeof item.getFile) return item.getFile();
76
+ return null;
77
+ });
78
+ const resolvedFiles = (await Promise.all(filePromises)).filter((f)=>null !== f);
79
+ await addFiles(resolvedFiles);
80
+ }
18
81
  if (onDrop) onDrop(event);
19
82
  };
20
- const handleFileSelect = (selected)=>{
83
+ const handleFileSelect = async (selected)=>{
21
84
  if (!selected) return;
22
- const files = Array.from(selected);
23
- const fileNames = files.map(({ name })=>name).join(', ');
24
- setDisplayFiles(fileNames);
25
- if (onSelect) onSelect(selected);
85
+ const selectedFiles = Array.from(selected);
86
+ if (shouldManageFiles) await addFiles(selectedFiles);
87
+ };
88
+ const handleRemoveFile = (id)=>{
89
+ const removedFile = files.find((file)=>file.id === id);
90
+ const updatedFiles = files.filter((file)=>file.id !== id);
91
+ setFiles(updatedFiles);
92
+ if (removedFile) onFilesRemoved?.([
93
+ removedFile
94
+ ]);
26
95
  };
27
- return /*#__PURE__*/ jsxs(StyledDropZone, {
28
- isDisabled: disabled,
29
- onDrop: showFileTrigger ? handleFileDrop : onDrop,
30
- ...rest,
96
+ const containerProps = {
97
+ width,
98
+ margin,
99
+ marginTop,
100
+ marginRight,
101
+ marginBottom,
102
+ marginLeft
103
+ };
104
+ const showManagedFileList = showFileList && files.length > 0;
105
+ const showFileInfo = !!supportedFormats || !!maxFileSize;
106
+ return /*#__PURE__*/ jsxs(Container, {
107
+ ...containerProps,
31
108
  children: [
32
- /*#__PURE__*/ jsx(StyledText, {
33
- slot: "label",
34
- children: label
35
- }),
36
- showFileTrigger && /*#__PURE__*/ jsx(SeparatorText, {
37
- children: "or"
109
+ /*#__PURE__*/ jsxs(StyledDropZone, {
110
+ isDisabled: disabled,
111
+ invalid: invalid,
112
+ onDrop: handleFileDrop,
113
+ ...rest,
114
+ children: [
115
+ /*#__PURE__*/ jsxs(TriggerContainer, {
116
+ children: [
117
+ showFileTrigger && /*#__PURE__*/ jsx(FileTrigger, {
118
+ acceptedFileTypes: acceptedFileTypes,
119
+ allowsMultiple: allowsMultiple,
120
+ defaultCamera: defaultCamera,
121
+ acceptDirectory: acceptDirectory,
122
+ onSelect: handleFileSelect
123
+ }),
124
+ /*#__PURE__*/ jsxs(StyledText, {
125
+ slot: "label",
126
+ children: [
127
+ showFileTrigger ? 'or ' : '',
128
+ " ",
129
+ showFileTrigger ? label.toLowerCase() : label
130
+ ]
131
+ })
132
+ ]
133
+ }),
134
+ (errorMessage || sizeValidationError) && /*#__PURE__*/ jsx(ErrorMessageText, {
135
+ children: errorMessage || sizeValidationError
136
+ })
137
+ ]
38
138
  }),
39
- showFileTrigger && /*#__PURE__*/ jsx(FileTrigger, {
40
- acceptedFileTypes: acceptedFileTypes,
41
- allowsMultiple: allowsMultiple,
42
- defaultCamera: defaultCamera,
43
- acceptDirectory: acceptDirectory,
44
- onSelect: handleFileSelect
139
+ showFileInfo && /*#__PURE__*/ jsxs(FileInfo, {
140
+ children: [
141
+ /*#__PURE__*/ jsx(FileInfoLeft, {
142
+ children: supportedFormats && /*#__PURE__*/ jsxs("div", {
143
+ children: [
144
+ "Supported formats: ",
145
+ supportedFormats.join(', ')
146
+ ]
147
+ })
148
+ }),
149
+ /*#__PURE__*/ jsx(FileInfoRight, {
150
+ children: maxFileSize && /*#__PURE__*/ jsxs("div", {
151
+ children: [
152
+ "Maximum file size: ",
153
+ maxFileSize
154
+ ]
155
+ })
156
+ })
157
+ ]
45
158
  }),
46
- displayFiles && showFileNames && /*#__PURE__*/ jsx(FilesContainer, {
47
- title: displayFiles,
48
- children: /*#__PURE__*/ jsx(Ellipsis, {
49
- children: displayFiles
50
- })
159
+ showManagedFileList && /*#__PURE__*/ jsx(FileList, {
160
+ children: files.map((file)=>/*#__PURE__*/ jsxs(FileItem, {
161
+ children: [
162
+ /*#__PURE__*/ jsx(FileName, {
163
+ title: file.name,
164
+ children: file.name
165
+ }),
166
+ !disabled && /*#__PURE__*/ jsx(Button, {
167
+ icon: "close",
168
+ size: "small",
169
+ subtle: true,
170
+ secondary: true,
171
+ onClick: ()=>handleRemoveFile(file.id)
172
+ })
173
+ ]
174
+ }, file.id))
51
175
  })
52
176
  ]
53
177
  });
54
178
  };
55
- const StyledDropZone = styled(DropZone)(({ theme })=>({
179
+ const Container = styled.div({
180
+ display: 'flex',
181
+ flexDirection: 'column',
182
+ gap: 4
183
+ }, widthHeightProps, marginProps);
184
+ const StyledDropZone = styled(DropZone)(({ theme, invalid })=>({
56
185
  display: 'flex',
57
186
  flexDirection: 'column',
58
187
  alignItems: 'center',
59
188
  justifyContent: 'center',
60
189
  gap: theme.size.sm,
61
190
  padding: theme.size.md,
62
- border: `1px dashed ${theme.scale4}`,
191
+ border: `1px dashed ${invalid ? theme.color.error : theme.scale4}`,
63
192
  borderRadius: 8,
64
193
  backgroundColor: 'transparent',
65
194
  outline: 'none',
@@ -84,24 +213,55 @@ const StyledDropZone = styled(DropZone)(({ theme })=>({
84
213
  }
85
214
  }
86
215
  }), widthHeightProps, marginProps, paddingProps);
216
+ const TriggerContainer = styled.div({
217
+ display: 'flex',
218
+ alignItems: 'center',
219
+ justifyContent: 'center',
220
+ gap: 8
221
+ });
87
222
  const StyledText = styled(Text)(({ theme })=>({
88
223
  fontSize: theme.font.size.md,
89
224
  fontWeight: theme.font.weight.medium,
90
225
  lineHeight: '14px',
91
226
  textAlign: 'center'
92
227
  }));
93
- const SeparatorText = styled(Text)(({ theme })=>({
94
- fontSize: theme.font.size.md,
95
- color: theme.scale6,
96
- lineHeight: '14px'
228
+ const ErrorMessageText = styled(StyledText)(({ theme })=>({
229
+ color: theme.color.error
230
+ }));
231
+ const FileList = styled.div(({ theme })=>({
232
+ display: 'flex',
233
+ flexDirection: 'column',
234
+ gap: theme.size.xs
97
235
  }));
98
- const FilesContainer = styled.div(({ theme })=>({
236
+ const FileItem = styled.div(({ theme })=>({
99
237
  display: 'flex',
100
238
  alignItems: 'center',
101
- justifyContent: 'center',
239
+ justifyContent: 'space-between',
240
+ gap: theme.size.xs,
241
+ padding: '4px 8px',
242
+ paddingRight: 4,
243
+ backgroundColor: theme.scale0,
244
+ borderRadius: 4
245
+ }));
246
+ const FileName = styled.div(({ theme })=>({
247
+ fontSize: theme.font.size.sm,
248
+ lineHeight: '14px',
249
+ flex: 1,
250
+ overflow: 'hidden',
251
+ textOverflow: 'ellipsis',
252
+ whiteSpace: 'nowrap'
253
+ }));
254
+ const FileInfo = styled.div(({ theme })=>({
255
+ display: 'flex',
256
+ alignItems: 'center',
257
+ justifyContent: 'space-between',
258
+ gap: 16,
102
259
  fontSize: theme.font.size.sm,
103
- color: theme.scale6,
104
- width: '100%'
260
+ color: theme.scale6
105
261
  }));
262
+ const FileInfoLeft = styled.div({});
263
+ const FileInfoRight = styled.div({
264
+ textAlign: 'right'
265
+ });
106
266
  const components_DropZone = DropZone_DropZone;
107
267
  export { components_DropZone as default };
@@ -4,9 +4,9 @@ import Button from "./Button/index.js";
4
4
  const FileTrigger_FileTrigger = (props)=>/*#__PURE__*/ jsx(FileTrigger, {
5
5
  ...props,
6
6
  children: /*#__PURE__*/ jsx(Button, {
7
- small: true,
8
7
  outline: true,
9
- children: "Select files"
8
+ icon: "upload",
9
+ children: "Upload"
10
10
  })
11
11
  });
12
12
  const components_FileTrigger = FileTrigger_FileTrigger;
package/build/index.d.ts CHANGED
@@ -76,6 +76,7 @@ export type { ThemeOption } from './theme';
76
76
  export type { EditorValue } from './components/Editor';
77
77
  export type { Selection } from 'react-aria-components';
78
78
  export type { DropEvent, DropItem } from 'react-aria';
79
+ export type { DropZoneFile } from './components/DropZone';
79
80
  export type { DotType } from './components/Dot';
80
81
  export { default as useConfirm } from './hooks/useConfirm';
81
82
  export { default as useElementObserver } from './hooks/useElementObserver';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wavv/ui",
3
- "version": "2.3.5",
3
+ "version": "2.3.7-alpha.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -61,7 +61,7 @@
61
61
  "@babel/core": "^7.28.5",
62
62
  "@babel/preset-env": "^7.28.5",
63
63
  "@babel/preset-typescript": "^7.28.5",
64
- "@biomejs/biome": "2.3.6",
64
+ "@biomejs/biome": "2.3.8",
65
65
  "@chromatic-com/storybook": "^4.1.3",
66
66
  "@emotion/babel-plugin": "^11.13.5",
67
67
  "@emotion/react": "^11.14.0",
@@ -69,10 +69,10 @@
69
69
  "@rsbuild/plugin-react": "^1.4.2",
70
70
  "@rsbuild/plugin-svgr": "^1.2.2",
71
71
  "@rslib/core": "^0.18.2",
72
- "@storybook/addon-docs": "^9.1.16",
73
- "@storybook/addon-links": "^9.1.16",
74
- "@storybook/addon-themes": "^9.1.16",
75
- "@storybook/test-runner": "^0.23.0",
72
+ "@storybook/addon-docs": "^10.1.2",
73
+ "@storybook/addon-links": "^10.1.2",
74
+ "@storybook/addon-themes": "^10.1.2",
75
+ "@storybook/test-runner": "^0.24.2",
76
76
  "@svgr/core": "^8.1.0",
77
77
  "@svgr/plugin-jsx": "^8.1.0",
78
78
  "@svgr/plugin-prettier": "^8.1.0",
@@ -104,8 +104,8 @@
104
104
  "react-dom": "^19.2.0",
105
105
  "replace": "^1.2.2",
106
106
  "signale": "^1.4.0",
107
- "storybook": "^9.1.16",
108
- "storybook-react-rsbuild": "^2.1.6",
107
+ "storybook": "^10.1.2",
108
+ "storybook-react-rsbuild": "^3.0.0",
109
109
  "tsc-files": "^1.1.4",
110
110
  "tslib": "^2.8.1",
111
111
  "tsx": "^4.21.0",