@youcan/celeste 0.6.54 → 0.6.56

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 (40) hide show
  1. package/dist/celeste.css +86 -560
  2. package/dist/cjs/chunks/index2.js +28 -0
  3. package/dist/cjs/color-picker.js +6 -4
  4. package/dist/cjs/color-swatch.js +3 -3
  5. package/dist/cjs/file-upload-area.js +280 -0
  6. package/dist/cjs/index.js +3 -9
  7. package/dist/components/color-picker/color-picker.d.ts +2 -0
  8. package/dist/components/color-picker/color-swatch.d.ts +1 -2
  9. package/dist/components/file-upload/file-upload-area.d.ts +43 -0
  10. package/dist/components/file-upload/index.d.ts +2 -0
  11. package/dist/components/index.d.ts +1 -1
  12. package/dist/es/chunks/index2.js +30 -2
  13. package/dist/es/color-picker.js +6 -4
  14. package/dist/es/color-swatch.js +3 -3
  15. package/dist/es/file-upload-area.js +281 -0
  16. package/dist/es/index.js +3 -9
  17. package/package.json +9 -9
  18. package/src/components/color-picker/color-picker.vue +4 -0
  19. package/src/components/color-picker/color-swatch.vue +1 -2
  20. package/src/components/file-upload/file-upload-area.vue +229 -0
  21. package/src/components/file-upload/index.ts +2 -0
  22. package/src/components/file-upload/stories/file-upload-area.stories.ts +76 -0
  23. package/src/components/index.ts +1 -1
  24. package/dist/cjs/context-menu-item.js +0 -134
  25. package/dist/cjs/dropdown-item.js +0 -99
  26. package/dist/cjs/dropdown.js +0 -290
  27. package/dist/components/dropdown/context-menu-item.d.ts +0 -39
  28. package/dist/components/dropdown/dropdown-item.d.ts +0 -30
  29. package/dist/components/dropdown/dropdown.d.ts +0 -55
  30. package/dist/components/dropdown/index.d.ts +0 -6
  31. package/dist/es/context-menu-item.js +0 -135
  32. package/dist/es/dropdown-item.js +0 -100
  33. package/dist/es/dropdown.js +0 -291
  34. package/src/components/dropdown/context-menu-item.vue +0 -317
  35. package/src/components/dropdown/dropdown-item.vue +0 -184
  36. package/src/components/dropdown/dropdown.vue +0 -650
  37. package/src/components/dropdown/index.ts +0 -8
  38. package/src/components/dropdown/stories/context-menu-item.stories.ts +0 -51
  39. package/src/components/dropdown/stories/dropdown-item.stories.ts +0 -33
  40. package/src/components/dropdown/stories/dropdown.stories.ts +0 -53
@@ -0,0 +1,281 @@
1
+ import { shallowRef, computed, toValue, unref, defineComponent, mergeModels, useModel, useTemplateRef, openBlock, createElementBlock, normalizeClass, createElementVNode, toDisplayString, createCommentVNode, createBlock, withCtx, mergeProps } from "vue";
2
+ import Button from "./button.js";
3
+ import { i as isClient, t as toArray, w as watchImmediate, a as tryOnScopeDispose, b as isObject } from "./chunks/index2.js";
4
+ import { c as clsx } from "./chunks/clsx.js";
5
+ import { _ as _export_sfc } from "./chunks/_plugin-vue_export-helper.js";
6
+ const defaultWindow = isClient ? window : void 0;
7
+ function unrefElement(elRef) {
8
+ var _a;
9
+ const plain = toValue(elRef);
10
+ return (_a = plain == null ? void 0 : plain.$el) != null ? _a : plain;
11
+ }
12
+ function useEventListener(...args) {
13
+ const cleanups = [];
14
+ const cleanup = () => {
15
+ cleanups.forEach((fn) => fn());
16
+ cleanups.length = 0;
17
+ };
18
+ const register = (el, event, listener, options) => {
19
+ el.addEventListener(event, listener, options);
20
+ return () => el.removeEventListener(event, listener, options);
21
+ };
22
+ const firstParamTargets = computed(() => {
23
+ const test = toArray(toValue(args[0])).filter((e) => e != null);
24
+ return test.every((e) => typeof e !== "string") ? test : void 0;
25
+ });
26
+ const stopWatch = watchImmediate(
27
+ () => {
28
+ var _a, _b;
29
+ return [
30
+ (_b = (_a = firstParamTargets.value) == null ? void 0 : _a.map((e) => unrefElement(e))) != null ? _b : [defaultWindow].filter((e) => e != null),
31
+ toArray(toValue(firstParamTargets.value ? args[1] : args[0])),
32
+ toArray(unref(firstParamTargets.value ? args[2] : args[1])),
33
+ // @ts-expect-error - TypeScript gets the correct types, but somehow still complains
34
+ toValue(firstParamTargets.value ? args[3] : args[2])
35
+ ];
36
+ },
37
+ ([raw_targets, raw_events, raw_listeners, raw_options]) => {
38
+ cleanup();
39
+ if (!(raw_targets == null ? void 0 : raw_targets.length) || !(raw_events == null ? void 0 : raw_events.length) || !(raw_listeners == null ? void 0 : raw_listeners.length))
40
+ return;
41
+ const optionsClone = isObject(raw_options) ? { ...raw_options } : raw_options;
42
+ cleanups.push(
43
+ ...raw_targets.flatMap(
44
+ (el) => raw_events.flatMap(
45
+ (event) => raw_listeners.map((listener) => register(el, event, listener, optionsClone))
46
+ )
47
+ )
48
+ );
49
+ },
50
+ { flush: "post" }
51
+ );
52
+ const stop = () => {
53
+ stopWatch();
54
+ cleanup();
55
+ };
56
+ tryOnScopeDispose(cleanup);
57
+ return stop;
58
+ }
59
+ function useDropZone(target, options = {}) {
60
+ var _a, _b;
61
+ const isOverDropZone = shallowRef(false);
62
+ const files = shallowRef(null);
63
+ let counter = 0;
64
+ let isValid = true;
65
+ if (isClient) {
66
+ const _options = typeof options === "function" ? { onDrop: options } : options;
67
+ const multiple = (_a = _options.multiple) != null ? _a : true;
68
+ const preventDefaultForUnhandled = (_b = _options.preventDefaultForUnhandled) != null ? _b : false;
69
+ const getFiles = (event) => {
70
+ var _a2, _b2;
71
+ const list = Array.from((_b2 = (_a2 = event.dataTransfer) == null ? void 0 : _a2.files) != null ? _b2 : []);
72
+ return list.length === 0 ? null : multiple ? list : [list[0]];
73
+ };
74
+ const checkDataTypes = (types) => {
75
+ const dataTypes = unref(_options.dataTypes);
76
+ if (typeof dataTypes === "function")
77
+ return dataTypes(types);
78
+ if (!(dataTypes == null ? void 0 : dataTypes.length))
79
+ return true;
80
+ if (types.length === 0)
81
+ return false;
82
+ return types.every(
83
+ (type) => dataTypes.some((allowedType) => type.includes(allowedType))
84
+ );
85
+ };
86
+ const checkValidity = (items) => {
87
+ const types = Array.from(items != null ? items : []).map((item) => item.type);
88
+ const dataTypesValid = checkDataTypes(types);
89
+ const multipleFilesValid = multiple || items.length <= 1;
90
+ return dataTypesValid && multipleFilesValid;
91
+ };
92
+ const isSafari = () => /^(?:(?!chrome|android).)*safari/i.test(navigator.userAgent) && !("chrome" in window);
93
+ const handleDragEvent = (event, eventType) => {
94
+ var _a2, _b2, _c, _d, _e, _f;
95
+ const dataTransferItemList = (_a2 = event.dataTransfer) == null ? void 0 : _a2.items;
96
+ isValid = (_b2 = dataTransferItemList && checkValidity(dataTransferItemList)) != null ? _b2 : false;
97
+ if (preventDefaultForUnhandled) {
98
+ event.preventDefault();
99
+ }
100
+ if (!isSafari() && !isValid) {
101
+ if (event.dataTransfer) {
102
+ event.dataTransfer.dropEffect = "none";
103
+ }
104
+ return;
105
+ }
106
+ event.preventDefault();
107
+ if (event.dataTransfer) {
108
+ event.dataTransfer.dropEffect = "copy";
109
+ }
110
+ const currentFiles = getFiles(event);
111
+ switch (eventType) {
112
+ case "enter":
113
+ counter += 1;
114
+ isOverDropZone.value = true;
115
+ (_c = _options.onEnter) == null ? void 0 : _c.call(_options, null, event);
116
+ break;
117
+ case "over":
118
+ (_d = _options.onOver) == null ? void 0 : _d.call(_options, null, event);
119
+ break;
120
+ case "leave":
121
+ counter -= 1;
122
+ if (counter === 0)
123
+ isOverDropZone.value = false;
124
+ (_e = _options.onLeave) == null ? void 0 : _e.call(_options, null, event);
125
+ break;
126
+ case "drop":
127
+ counter = 0;
128
+ isOverDropZone.value = false;
129
+ if (isValid) {
130
+ files.value = currentFiles;
131
+ (_f = _options.onDrop) == null ? void 0 : _f.call(_options, currentFiles, event);
132
+ }
133
+ break;
134
+ }
135
+ };
136
+ useEventListener(target, "dragenter", (event) => handleDragEvent(event, "enter"));
137
+ useEventListener(target, "dragover", (event) => handleDragEvent(event, "over"));
138
+ useEventListener(target, "dragleave", (event) => handleDragEvent(event, "leave"));
139
+ useEventListener(target, "drop", (event) => handleDragEvent(event, "drop"));
140
+ }
141
+ return {
142
+ files,
143
+ isOverDropZone
144
+ };
145
+ }
146
+ const _hoisted_1 = ["data-over-drop-zone"];
147
+ const _hoisted_2 = { class: "celeste-file-upload-area-content" };
148
+ const _hoisted_3 = { class: "celeste-file-upload-area-label" };
149
+ const _hoisted_4 = {
150
+ key: 0,
151
+ class: "celeste-file-upload-area-description"
152
+ };
153
+ const _hoisted_5 = ["multiple", "accept"];
154
+ const _sfc_main = /* @__PURE__ */ defineComponent({
155
+ __name: "file-upload-area",
156
+ props: /* @__PURE__ */ mergeModels({
157
+ label: { default: "Choose a file or drag & drop it here." },
158
+ description: { default: "JPEG, PNG, PDF, and MP4 formats, up to 50 MB." },
159
+ labelOnDrop: { default: "Drop file to upload" },
160
+ triggerLabel: { default: "Browse Files" },
161
+ maxSize: { default: 2e3 },
162
+ maxFiles: { default: 5 },
163
+ multiple: { type: Boolean, default: false },
164
+ icon: { default: "i-celeste-upload-cloud-2-line" },
165
+ accept: {},
166
+ onTriggerClick: {},
167
+ class: {}
168
+ }, {
169
+ "modelValue": { default: [] },
170
+ "modelModifiers": {}
171
+ }),
172
+ emits: /* @__PURE__ */ mergeModels(["error"], ["update:modelValue"]),
173
+ setup(__props, { emit: __emit }) {
174
+ const props = __props;
175
+ const emit = __emit;
176
+ const files = useModel(__props, "modelValue");
177
+ const dropZoneRef = useTemplateRef("dropZoneRef");
178
+ const { isOverDropZone } = useDropZone(dropZoneRef, { onDrop });
179
+ function handleUpload(e) {
180
+ const input = e.target;
181
+ const newFiles = Array.from(input.files ?? []);
182
+ const filesToProcess = newFiles.slice(0, props.maxFiles);
183
+ const rejectedFiles = newFiles.slice(props.maxFiles);
184
+ if (rejectedFiles.length > 0) {
185
+ emit("error", {
186
+ message: "Files exceeded upload limit",
187
+ type: "count",
188
+ files: rejectedFiles
189
+ });
190
+ }
191
+ const validFiles = filesToProcess.filter(isValidFile);
192
+ files.value = [...validFiles];
193
+ }
194
+ function onDrop(droppedFiles) {
195
+ if (!(droppedFiles == null ? void 0 : droppedFiles.length))
196
+ return;
197
+ const filesToProcess = droppedFiles.slice(0, props.maxFiles);
198
+ const rejectedFiles = droppedFiles.slice(props.maxFiles);
199
+ if (rejectedFiles.length > 0) {
200
+ emit("error", {
201
+ message: "Files exceeded upload limit",
202
+ type: "count",
203
+ files: rejectedFiles
204
+ });
205
+ }
206
+ const validFiles = filesToProcess.filter(isValidFile);
207
+ files.value = [...validFiles];
208
+ }
209
+ function isValidFile(file) {
210
+ var _a;
211
+ const maxBytes = props.maxSize * 1024;
212
+ if (file.size > maxBytes) {
213
+ emit("error", {
214
+ message: `File too large: ${file.name}`,
215
+ type: "size",
216
+ files: [file]
217
+ });
218
+ return false;
219
+ }
220
+ const acceptableTypes = ((_a = props.accept) == null ? void 0 : _a.split(",")) ?? [];
221
+ if (acceptableTypes.length > 0 && !acceptableTypes.includes(file.type)) {
222
+ emit("error", {
223
+ message: "Unsupported type",
224
+ type: "type",
225
+ files: [file]
226
+ });
227
+ return false;
228
+ }
229
+ return true;
230
+ }
231
+ function handleTriggerClick(e) {
232
+ if (props.onTriggerClick) {
233
+ e.preventDefault();
234
+ props.onTriggerClick();
235
+ }
236
+ }
237
+ return (_ctx, _cache) => {
238
+ return openBlock(), createElementBlock("label", {
239
+ ref_key: "dropZoneRef",
240
+ ref: dropZoneRef,
241
+ class: normalizeClass(unref(clsx)(
242
+ "celeste-file-upload-area",
243
+ props.class
244
+ )),
245
+ "data-over-drop-zone": unref(isOverDropZone)
246
+ }, [
247
+ createElementVNode("i", {
248
+ class: normalizeClass(_ctx.icon)
249
+ }, null, 2),
250
+ createElementVNode("div", _hoisted_2, [
251
+ createElementVNode("span", _hoisted_3, toDisplayString(unref(isOverDropZone) ? _ctx.labelOnDrop : _ctx.label), 1),
252
+ !unref(isOverDropZone) ? (openBlock(), createElementBlock("span", _hoisted_4, toDisplayString(_ctx.description), 1)) : createCommentVNode("", true)
253
+ ]),
254
+ !unref(isOverDropZone) ? (openBlock(), createBlock(Button, {
255
+ key: 0,
256
+ "as-child": "",
257
+ intent: "neutral",
258
+ variant: "stroke",
259
+ size: "xs",
260
+ class: "celeste-file-upload-area-trigger",
261
+ onClick: handleTriggerClick
262
+ }, {
263
+ default: withCtx(() => [
264
+ createElementVNode("span", null, toDisplayString(_ctx.triggerLabel), 1)
265
+ ]),
266
+ _: 1
267
+ })) : createCommentVNode("", true),
268
+ createElementVNode("input", mergeProps(_ctx.$attrs, {
269
+ type: "file",
270
+ multiple: _ctx.multiple,
271
+ accept: _ctx.accept,
272
+ onChange: handleUpload
273
+ }), null, 16, _hoisted_5)
274
+ ], 10, _hoisted_1);
275
+ };
276
+ }
277
+ });
278
+ const fileUploadArea = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-6e8a873e"]]);
279
+ export {
280
+ fileUploadArea as default
281
+ };
package/dist/es/index.js CHANGED
@@ -29,9 +29,6 @@ import { _ as _sfc_main$4 } from "./chunks/counter-input.vue_vue_type_style_inde
29
29
  import digitInputGroup from "./digit-input-group.js";
30
30
  import digitInputSlot from "./digit-input-slot.js";
31
31
  import digitInput from "./digit-input.js";
32
- import contextMenuItem from "./context-menu-item.js";
33
- import DropdownItem from "./dropdown-item.js";
34
- import dropdown from "./dropdown.js";
35
32
  import dropdownMenuContent from "./dropdown-menu-content.js";
36
33
  import dropdownMenuGroup from "./dropdown-menu-group.js";
37
34
  import dropdownMenuItem from "./dropdown-menu-item.js";
@@ -42,6 +39,7 @@ import dropdownMenuSubTrigger from "./dropdown-menu-sub-trigger.js";
42
39
  import { _ as _sfc_main$5 } from "./chunks/dropdown-menu-sub.js";
43
40
  import dropdownMenuTrigger from "./dropdown-menu-trigger.js";
44
41
  import dropdownMenu from "./dropdown-menu.js";
42
+ import fileUploadArea from "./file-upload-area.js";
45
43
  import hintText from "./hint-text.js";
46
44
  import Label from "./label.js";
47
45
  import modalBody from "./modal-body.js";
@@ -114,13 +112,10 @@ const components = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.definePr
114
112
  ComboboxTrigger,
115
113
  CompactButton,
116
114
  ContentDivider: _sfc_main$3,
117
- ContextMenuItem: contextMenuItem,
118
115
  CounterInput: _sfc_main$4,
119
116
  DigitInput: digitInput,
120
117
  DigitInputGroup: digitInputGroup,
121
118
  DigitInputSlot: digitInputSlot,
122
- Dropdown: dropdown,
123
- DropdownItem,
124
119
  DropdownMenu: dropdownMenu,
125
120
  DropdownMenuContent: dropdownMenuContent,
126
121
  DropdownMenuGroup: dropdownMenuGroup,
@@ -132,6 +127,7 @@ const components = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.definePr
132
127
  DropdownMenuSubTrigger: dropdownMenuSubTrigger,
133
128
  DropdownMenuTrigger: dropdownMenuTrigger,
134
129
  FancyButton: fancyButton,
130
+ FileUploadArea: fileUploadArea,
135
131
  HintText: hintText,
136
132
  HueSlider,
137
133
  Label,
@@ -263,14 +259,11 @@ export {
263
259
  ComboboxTrigger,
264
260
  CompactButton,
265
261
  _sfc_main$3 as ContentDivider,
266
- contextMenuItem as ContextMenuItem,
267
262
  _sfc_main$4 as CounterInput,
268
263
  DEFAULT_SWATCH,
269
264
  digitInput as DigitInput,
270
265
  digitInputGroup as DigitInputGroup,
271
266
  digitInputSlot as DigitInputSlot,
272
- dropdown as Dropdown,
273
- DropdownItem,
274
267
  dropdownMenu as DropdownMenu,
275
268
  dropdownMenuContent as DropdownMenuContent,
276
269
  dropdownMenuGroup as DropdownMenuGroup,
@@ -282,6 +275,7 @@ export {
282
275
  dropdownMenuSubTrigger as DropdownMenuSubTrigger,
283
276
  dropdownMenuTrigger as DropdownMenuTrigger,
284
277
  fancyButton as FancyButton,
278
+ fileUploadArea as FileUploadArea,
285
279
  HEX_VALIDATION_PATTERN,
286
280
  hintText as HintText,
287
281
  HueSlider,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@youcan/celeste",
3
3
  "type": "module",
4
- "version": "0.6.54",
4
+ "version": "0.6.56",
5
5
  "author": "YouCan <tech@youcan.shop>",
6
6
  "keywords": [
7
7
  "youcan",
@@ -44,15 +44,15 @@
44
44
  "radix-vue": "^1.9.12",
45
45
  "tinycolor2": "^1.6.0",
46
46
  "vue-sonner": "^2.0.2",
47
- "@youcan/celeste-icons": "0.6.54",
48
- "@youcan/celeste-tokens": "0.6.54"
47
+ "@youcan/celeste-icons": "0.6.56",
48
+ "@youcan/celeste-tokens": "0.6.56"
49
49
  },
50
50
  "devDependencies": {
51
- "@chromatic-com/storybook": "^4.0.0",
52
- "@storybook/addon-docs": "^9.0.4",
51
+ "@chromatic-com/storybook": "^4.1.1",
52
+ "@storybook/addon-docs": "^9.1.10",
53
53
  "@storybook/addon-knobs": "^8.0.1",
54
- "@storybook/addon-onboarding": "^9.0.4",
55
- "@storybook/vue3-vite": "^9.0.4",
54
+ "@storybook/addon-onboarding": "^9.1.10",
55
+ "@storybook/vue3-vite": "^9.1.10",
56
56
  "@tsconfig/node20": "^20.1.4",
57
57
  "@types/jsdom": "^21.1.7",
58
58
  "@types/tinycolor2": "^1.4.6",
@@ -64,14 +64,14 @@
64
64
  "@vue/tsconfig": "^0.7.0",
65
65
  "autoprefixer": "^10.4.21",
66
66
  "chromatic": "^11.22.1",
67
- "eslint-plugin-storybook": "9.0.17",
67
+ "eslint-plugin-storybook": "9.1.10",
68
68
  "glob": "^11.0.3",
69
69
  "jsdom": "^25.0.1",
70
70
  "postcss": "^8.5.6",
71
71
  "postcss-nesting": "^13.0.2",
72
72
  "rimraf": "^6.0.1",
73
73
  "sass": "^1.83.1",
74
- "storybook": "^9.0.17",
74
+ "storybook": "^9.1.10",
75
75
  "unocss": "^66.3.3",
76
76
  "vite": "^6.0.7",
77
77
  "vite-plugin-dts": "^4.4.0",
@@ -17,6 +17,7 @@ const props = withDefaults(defineProps<ColorPickerProps>(), {
17
17
  modelValue: 'hsl(240, 100%, 50%)',
18
18
  formats: () => ['hex', 'rgb', 'hsl', 'hsb'],
19
19
  label: 'Pick Color',
20
+ colorSwatchLabel: 'Recommended Colors',
20
21
  });
21
22
 
22
23
  const emit = defineEmits<ColorPickerEmits>();
@@ -246,6 +247,7 @@ export interface ColorPickerProps {
246
247
  class?: HTMLAttributes['class'];
247
248
  formats?: ColorFormat[];
248
249
  defaultFormat?: ColorFormat;
250
+ colorSwatchLabel?: string;
249
251
  }
250
252
 
251
253
  export interface ColorPickerEmits {
@@ -489,6 +491,7 @@ declare global {
489
491
  <ColorSwatch
490
492
  v-else
491
493
  v-model="tinyColorRef"
494
+ :label="colorSwatchLabel"
492
495
  :hue="hueRef"
493
496
  />
494
497
  </div>
@@ -507,6 +510,7 @@ declare global {
507
510
  display: block;
508
511
  width: var(--color-tile-size);
509
512
  height: var(--color-tile-size);
513
+ border: 1px solid var(--color-bg-soft-200);
510
514
  border-radius: var(--radius-4);
511
515
  background-color: v-bind(modelValue);
512
516
  }
@@ -5,7 +5,6 @@ import { computed } from 'vue';
5
5
  import { defineColorModel } from './composable/use-color-model';
6
6
 
7
7
  const props = withDefaults(defineProps<ColorSwatchProps>(), {
8
- label: 'Recommended Colors',
9
8
  swatch: () => DEFAULT_SWATCH,
10
9
  });
11
10
  const emit = defineEmits<ColorSwatchEmits>();
@@ -28,7 +27,7 @@ function isSwatchColorSelected(color: string) {
28
27
  <script lang="ts">
29
28
  export interface ColorSwatchProps {
30
29
  modelValue: tinycolor.Instance;
31
- label?: string;
30
+ label: string;
32
31
  swatch?: string[];
33
32
  defaultColor?: string;
34
33
  }
@@ -0,0 +1,229 @@
1
+ <script setup lang="ts">
2
+ import Button from '@/components/button/button.vue';
3
+ import { useDropZone } from '@vueuse/core';
4
+ import clsx from 'clsx';
5
+ import { type InputHTMLAttributes, useTemplateRef } from 'vue';
6
+
7
+ const props = withDefaults(defineProps<FileUploadAreaProps>(), {
8
+ icon: 'i-celeste-upload-cloud-2-line',
9
+ label: 'Choose a file or drag & drop it here.',
10
+ description: 'JPEG, PNG, PDF, and MP4 formats, up to 50 MB.',
11
+ triggerLabel: 'Browse Files',
12
+ labelOnDrop: 'Drop file to upload',
13
+ multiple: false,
14
+ maxSize: 2000,
15
+ maxFiles: 5,
16
+ });
17
+
18
+ const emit = defineEmits<FileUploadAreaEmit>();
19
+
20
+ const files = defineModel<File[]>({ default: [] });
21
+
22
+ const dropZoneRef = useTemplateRef<HTMLElement>('dropZoneRef');
23
+ const { isOverDropZone } = useDropZone(dropZoneRef, { onDrop });
24
+
25
+ function handleUpload(e: Event) {
26
+ const input = e.target as HTMLInputElement;
27
+ const newFiles = Array.from(input.files ?? []);
28
+
29
+ const filesToProcess = newFiles.slice(0, props.maxFiles);
30
+ const rejectedFiles = newFiles.slice(props.maxFiles);
31
+
32
+ if (rejectedFiles.length > 0) {
33
+ emit('error', {
34
+ message: 'Files exceeded upload limit',
35
+ type: 'count',
36
+ files: rejectedFiles,
37
+ });
38
+ }
39
+
40
+ const validFiles = filesToProcess.filter(isValidFile);
41
+
42
+ files.value = [...validFiles];
43
+ }
44
+
45
+ function onDrop(droppedFiles: File[] | null) {
46
+ if (!droppedFiles?.length)
47
+ return;
48
+
49
+ const filesToProcess = droppedFiles.slice(0, props.maxFiles);
50
+ const rejectedFiles = droppedFiles.slice(props.maxFiles);
51
+
52
+ if (rejectedFiles.length > 0) {
53
+ emit('error', {
54
+ message: 'Files exceeded upload limit',
55
+ type: 'count',
56
+ files: rejectedFiles,
57
+ });
58
+ }
59
+
60
+ const validFiles = filesToProcess.filter(isValidFile);
61
+ files.value = [...validFiles];
62
+ }
63
+
64
+ function isValidFile(file: File): boolean {
65
+ const maxBytes = props.maxSize * 1024;
66
+
67
+ if (file.size > maxBytes) {
68
+ emit('error', {
69
+ message: `File too large: ${file.name}`,
70
+ type: 'size',
71
+ files: [file],
72
+ });
73
+
74
+ return false;
75
+ }
76
+
77
+ const acceptableTypes = props.accept?.split(',') ?? [];
78
+
79
+ if (acceptableTypes.length > 0 && !acceptableTypes.includes(file.type)) {
80
+ emit('error', {
81
+ message: 'Unsupported type',
82
+ type: 'type',
83
+ files: [file],
84
+ });
85
+
86
+ return false;
87
+ }
88
+
89
+ return true;
90
+ }
91
+
92
+ function handleTriggerClick(e: Event) {
93
+ if (props.onTriggerClick) {
94
+ e.preventDefault();
95
+ props.onTriggerClick();
96
+ }
97
+ }
98
+ </script>
99
+
100
+ <script lang="ts">
101
+ export interface FileUploadAreaProps {
102
+ label?: string;
103
+ description?: string;
104
+ labelOnDrop?: string;
105
+ triggerLabel?: string;
106
+ maxSize?: number;
107
+ maxFiles?: number;
108
+ multiple?: boolean;
109
+ icon?: string;
110
+ accept?: InputHTMLAttributes['accept'];
111
+ onTriggerClick?: () => void;
112
+ class?: InputHTMLAttributes['class'];
113
+ }
114
+
115
+ export interface FileError {
116
+ message: string;
117
+ type: 'size' | 'type' | 'count';
118
+ files: File[];
119
+ }
120
+
121
+ export interface FileUploadAreaEmit {
122
+ error: [error: FileError];
123
+ }
124
+ </script>
125
+
126
+ <template>
127
+ <label
128
+ ref="dropZoneRef"
129
+ :class="clsx(
130
+ 'celeste-file-upload-area',
131
+ props.class,
132
+ )"
133
+ :data-over-drop-zone="isOverDropZone"
134
+ >
135
+ <i :class="icon" />
136
+ <div class="celeste-file-upload-area-content">
137
+ <span class="celeste-file-upload-area-label">
138
+ {{ isOverDropZone ? labelOnDrop : label }}
139
+ </span>
140
+ <span
141
+ v-if="!isOverDropZone"
142
+ class="celeste-file-upload-area-description"
143
+ >
144
+ {{ description }}
145
+ </span>
146
+ </div>
147
+ <Button
148
+ v-if="!isOverDropZone"
149
+ as-child
150
+ intent="neutral"
151
+ variant="stroke"
152
+ size="xs"
153
+ class="celeste-file-upload-area-trigger"
154
+ @click="handleTriggerClick"
155
+ >
156
+ <span>{{ triggerLabel }}</span>
157
+ </Button>
158
+ <input
159
+ v-bind="$attrs"
160
+ type="file"
161
+ :multiple="multiple"
162
+ :accept="accept"
163
+ @change="handleUpload"
164
+ >
165
+ </label>
166
+ </template>
167
+
168
+ <style scoped lang="scss">
169
+ .celeste-file-upload-area {
170
+ display: flex;
171
+ box-sizing: border-box;
172
+ flex-direction: column;
173
+ align-items: center;
174
+ justify-content: center;
175
+ min-height: 204px;
176
+ gap: calc(var(--spacing-10) * 2);
177
+ padding: calc(var(--spacing-16) * 2);
178
+ transition: background-color var(--animation-normal) ease-out;
179
+ border: 1px dashed var(--color-stroke-sub-300);
180
+ border-radius: var(--radius-12);
181
+ background-color: var(--color-bg-white-0);
182
+ cursor: pointer;
183
+
184
+ &:hover {
185
+ background-color: var(--color-bg-weak-50);
186
+ }
187
+
188
+ & > i {
189
+ width: 24px;
190
+ height: 24px;
191
+ color: var(--color-icon-sub-600);
192
+ }
193
+
194
+ .celeste-file-upload-area-content {
195
+ display: grid;
196
+ gap: var(--spacing-6);
197
+ text-align: center;
198
+
199
+ .celeste-file-upload-area-label {
200
+ color: var(--color-text-strong-950);
201
+ font: var(--label-sm);
202
+ }
203
+
204
+ .celeste-file-upload-area-description {
205
+ color: var(--color-text-sub-600);
206
+ font: var(--label-xs);
207
+ }
208
+ }
209
+
210
+ .celeste-file-upload-area-trigger {
211
+ background-color: var(--color-bg-white-0) !important;
212
+
213
+ &:hover {
214
+ background-color: transparent !important;
215
+ box-shadow: none !important;
216
+ }
217
+ }
218
+
219
+ & > input[type='file'] {
220
+ position: absolute;
221
+ opacity: 0;
222
+ scale: 0;
223
+ }
224
+
225
+ &[data-over-drop-zone='true'] {
226
+ background-color: var(--color-bg-weak-50);
227
+ }
228
+ }
229
+ </style>
@@ -0,0 +1,2 @@
1
+ export * from './file-upload-area.vue';
2
+ export { default as FileUploadArea } from './file-upload-area.vue';