@functionalcms/svelte-components 4.8.10 → 4.8.12

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,376 +1,218 @@
1
- <script lang="ts">
2
- // import { fromEvent } from 'file-selector';
3
- import {
4
- fileAccepted,
5
- fileMatchSize,
6
- isEvtWithFiles,
7
- isIeOrEdge,
8
- isPropagationStopped,
9
- TOO_MANY_FILES_REJECTION
10
- } from './dropzone.ts';
11
- import { onMount, onDestroy, createEventDispatcher } from 'svelte';
12
-
13
- interface Props {
14
- accept: string | Array<string>; // = undefined;
15
- disabled: boolean; // = false;
16
- maxSize: number; // = Infinity;
17
- minSize: number; // = 0;
18
- multiple: boolean; // = true;
19
- preventDropOnDocument: boolean; // = true;
20
- noClick: boolean; // = false;
21
- noKeyboard: boolean; // = false;
22
- noDrag: boolean; // = false;
23
- noDragEventsBubbling: boolean; // = false;
24
- containerClasses: string; // = '';
25
- containerStyles: string; // = '';
26
- disableDefaultStyles: boolean; // = false;
27
- name: string; // = '';
28
- // inputElement?: boolean; // = undefined;
29
- required: boolean; // = false;
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLInputAttributes } from 'svelte/elements';
4
+
5
+ export type FileRejectedReason =
6
+ | 'Maximum file size exceeded'
7
+ | 'File type not allowed'
8
+ | 'Maximum files uploaded';
9
+
10
+ export interface FileDropZoneProps extends Omit<HTMLInputAttributes, 'multiple'> {
11
+ /** Called with the uploaded files when the user drops or clicks and selects their files.
12
+ *
13
+ * @param files
14
+ */
15
+ onUpload: (files: File[]) => Promise<void>;
16
+ /** The maximum amount files allowed to be uploaded */
17
+ maxFiles?: number;
18
+ fileCount?: number;
19
+ /** The maximum size of a file in bytes */
20
+ maxFileSize?: number;
21
+ children?: Snippet<[]>;
22
+ /** Called when a file does not meet the upload criteria (size, or type) */
23
+ onFileRejected?: (opts: { reason: FileRejectedReason; file: File }) => void;
24
+
25
+ // just for extra documentation
26
+ /** Takes a comma separated list of one or more file types.
27
+ *
28
+ * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept)
29
+ *
30
+ * ### Usage
31
+ * ```svelte
32
+ * <FileDropZone
33
+ * accept=".doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
34
+ * />
35
+ * ```
36
+ *
37
+ * ### Common Values
38
+ * ```svelte
39
+ * <FileDropZone accept="audio/*"/>
40
+ * <FileDropZone accept="image/*"/>
41
+ * <FileDropZone accept="video/*"/>
42
+ * ```
43
+ */
44
+ accept?: string;
30
45
  }
46
+ </script>
47
+
48
+ <script lang="ts">
49
+ import Upload from '@lucide/svelte/icons/upload.svelte';
50
+ import { displaySize } from './dropzone.ts';
51
+ import { cn } from '../../utils.ts';
31
52
 
32
53
  let {
33
- accept = undefined,
54
+ id,
55
+ children,
56
+ maxFiles,
57
+ maxFileSize,
58
+ fileCount,
34
59
  disabled = false,
35
- maxSize = Number.MAX_VALUE,
36
- minSize = 0,
37
- multiple = true,
38
- preventDropOnDocument = true,
39
- noClick = false,
40
- noKeyboard = false,
41
- noDrag = false,
42
- noDragEventsBubbling = false,
43
- containerClasses = '',
44
- containerStyles = '',
45
- disableDefaultStyles = false,
46
- name = '',
47
- // inputElement = undefined,
48
- required = false
49
- }: Partial<Props> = $props();
50
-
51
- let inputElement: HTMLInputElement | undefined;
52
-
53
- let state = $state({
54
- isFocused: false,
55
- isFileDialogActive: false,
56
- isDragActive: false,
57
- isDragAccept: false,
58
- isDragReject: false,
59
- draggedFiles: [],
60
- acceptedFiles: [],
61
- fileRejections: []
62
- });
63
-
64
- let rootRef: any;
65
-
66
- function resetState() {
67
- state.isFileDialogActive = false;
68
- state.isDragActive = false;
69
- state.draggedFiles = [];
70
- state.acceptedFiles = [];
71
- state.fileRejections = [];
72
- }
60
+ onUpload,
61
+ onFileRejected,
62
+ accept,
63
+ class: className,
64
+ ...rest
65
+ }: FileDropZoneProps = $props();
73
66
 
74
- // Fn for opening the file dialog programmatically
75
- function openFileDialog() {
76
- if (inputElement) {
77
- inputElement.value = null; // TODO check if null needs to be set
78
- state.isFileDialogActive = true;
79
- inputElement.click();
80
- }
67
+ if (maxFiles !== undefined && fileCount === undefined) {
68
+ console.warn(
69
+ 'Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt'
70
+ );
81
71
  }
82
72
 
83
- // Cb to open the file dialog when SPACE/ENTER occurs on the dropzone
84
- function onKeyDownCb(event) {
85
- // Ignore keyboard events bubbling up the DOM tree
86
- if (!rootRef || !rootRef.isEqualNode(event.target)) {
87
- return;
88
- }
73
+ let uploading = $state(false);
89
74
 
90
- if (event.keyCode === 32 || event.keyCode === 13) {
91
- event.preventDefault();
92
- openFileDialog();
75
+ const drop = async (
76
+ e: DragEvent & {
77
+ currentTarget: EventTarget & HTMLLabelElement;
93
78
  }
94
- }
79
+ ) => {
80
+ if (disabled || !canUploadFiles) return;
95
81
 
96
- // Update focus state for the dropzone
97
- function onFocusCb() {
98
- state.isFocused = true;
99
- }
100
- function onBlurCb() {
101
- state.isFocused = false;
102
- }
82
+ e.preventDefault();
103
83
 
104
- // Cb to open the file dialog when click occurs on the dropzone
105
- function onClickCb() {
106
- if (noClick) {
107
- return;
108
- }
84
+ const droppedFiles = Array.from(e.dataTransfer?.files ?? []);
85
+
86
+ await upload(droppedFiles);
87
+ };
109
88
 
110
- // In IE11/Edge the file-browser dialog is blocking, therefore, use setTimeout()
111
- // to ensure React can handle state changes
112
- // See: https://github.com/react-dropzone/react-dropzone/issues/450
113
- if (isIeOrEdge()) {
114
- setTimeout(openFileDialog, 0);
115
- } else {
116
- openFileDialog();
89
+ const change = async (
90
+ e: Event & {
91
+ currentTarget: EventTarget & HTMLInputElement;
117
92
  }
118
- }
93
+ ) => {
94
+ if (disabled) return;
119
95
 
120
- function onDragEnterCb(event) {
121
- event.preventDefault();
122
- stopPropagation(event);
96
+ const selectedFiles = e.currentTarget.files;
123
97
 
124
- dragTargetsRef = [...dragTargetsRef, event.target];
98
+ if (!selectedFiles) return;
125
99
 
126
- if (isEvtWithFiles(event)) {
127
- Promise.resolve(getFilesFromEvent(event)).then((draggedFiles) => {
128
- if (isPropagationStopped(event) && !noDragEventsBubbling) {
129
- return;
130
- }
100
+ await upload(Array.from(selectedFiles));
131
101
 
132
- state.draggedFiles = draggedFiles;
133
- state.isDragActive = true;
102
+ // this if a file fails and we upload the same file again we still get feedback
103
+ (e.target as HTMLInputElement).value = '';
104
+ };
134
105
 
135
- dispatch('dragenter', {
136
- dragEvent: event
137
- });
138
- });
139
- }
140
- }
141
-
142
- function onDragOverCb(event) {
143
- event.preventDefault();
144
- stopPropagation(event);
106
+ const shouldAcceptFile = (file: File, fileNumber: number): FileRejectedReason | undefined => {
107
+ if (maxFileSize !== undefined && file.size > maxFileSize) return 'Maximum file size exceeded';
145
108
 
146
- if (event.dataTransfer) {
147
- try {
148
- event.dataTransfer.dropEffect = 'copy';
149
- } catch {} /* eslint-disable-line no-empty */
150
- }
109
+ if (maxFiles !== undefined && fileNumber > maxFiles) return 'Maximum files uploaded';
151
110
 
152
- if (isEvtWithFiles(event)) {
153
- dispatch('dragover', {
154
- dragEvent: event
155
- });
156
- }
111
+ if (!accept) return undefined;
157
112
 
158
- return false;
159
- }
113
+ const acceptedTypes = accept.split(',').map((a) => a.trim().toLowerCase());
114
+ const fileType = file.type.toLowerCase();
115
+ const fileName = file.name.toLowerCase();
160
116
 
161
- function onDragLeaveCb(event) {
162
- event.preventDefault();
163
- stopPropagation(event);
164
-
165
- // Only deactivate once the dropzone and all children have been left
166
- const targets = dragTargetsRef.filter((target) => rootRef && rootRef.contains(target));
167
- // Make sure to remove a target present multiple times only once
168
- // (Firefox may fire dragenter/dragleave multiple times on the same element)
169
- const targetIdx = targets.indexOf(event.target);
170
- if (targetIdx !== -1) {
171
- targets.splice(targetIdx, 1);
172
- }
173
- dragTargetsRef = targets;
174
- if (targets.length > 0) {
175
- return;
176
- }
117
+ const isAcceptable = acceptedTypes.some((pattern) => {
118
+ // check extension like .mp4
119
+ if (fileType.startsWith('.')) {
120
+ return fileName.endsWith(pattern);
121
+ }
177
122
 
178
- state.isDragActive = false;
179
- state.draggedFiles = [];
123
+ // if pattern has wild card like video/*
124
+ if (pattern.endsWith('/*')) {
125
+ const baseType = pattern.slice(0, pattern.indexOf('/*'));
126
+ return fileType.startsWith(baseType + '/');
127
+ }
180
128
 
181
- if (isEvtWithFiles(event)) {
182
- dispatch('dragleave', {
183
- dragEvent: event
184
- });
185
- }
186
- }
129
+ // otherwise it must be a specific type like video/mp4
130
+ return fileType === pattern;
131
+ });
187
132
 
188
- function onDropCb(event) {
189
- event.preventDefault();
190
- stopPropagation(event);
191
-
192
- dragTargetsRef = [];
193
-
194
- if (isEvtWithFiles(event)) {
195
- dispatch('filedropped', {
196
- event
197
- });
198
- Promise.resolve(getFilesFromEvent(event)).then((files) => {
199
- if (isPropagationStopped(event) && !noDragEventsBubbling) {
200
- return;
201
- }
202
-
203
- const acceptedFiles = [];
204
- const fileRejections = [];
205
-
206
- files.forEach((file) => {
207
- const [accepted, acceptError] = fileAccepted(file, accept);
208
- const [sizeMatch, sizeError] = fileMatchSize(file, minSize, maxSize);
209
- if (accepted && sizeMatch) {
210
- acceptedFiles.push(file);
211
- } else {
212
- const errors = [acceptError, sizeError].filter((e) => e);
213
- fileRejections.push({ file, errors });
214
- }
215
- });
216
-
217
- if (!multiple && acceptedFiles.length > 1) {
218
- // Reject everything and empty accepted files
219
- acceptedFiles.forEach((file) => {
220
- fileRejections.push({ file, errors: [TOO_MANY_FILES_REJECTION] });
221
- });
222
- acceptedFiles.splice(0);
223
- }
224
-
225
- // Files dropped keep input in sync
226
- if (event.dataTransfer) {
227
- inputElement.files = event.dataTransfer.files;
228
- }
229
-
230
- state.acceptedFiles = acceptedFiles;
231
- state.fileRejections = fileRejections;
232
-
233
- dispatch('drop', {
234
- acceptedFiles,
235
- fileRejections,
236
- event
237
- });
238
-
239
- if (fileRejections.length > 0) {
240
- dispatch('droprejected', {
241
- fileRejections,
242
- event
243
- });
244
- }
245
-
246
- if (acceptedFiles.length > 0) {
247
- dispatch('dropaccepted', {
248
- acceptedFiles,
249
- event
250
- });
251
- }
252
- });
253
- }
254
- resetState();
255
- }
133
+ if (!isAcceptable) return 'File type not allowed';
256
134
 
257
- let composeHandler = (fn: any) => (disabled ? null : fn);
135
+ return undefined;
136
+ };
258
137
 
259
- let composeKeyboardHandler = (fn: any) => (noKeyboard ? null : composeHandler(fn));
138
+ const upload = async (uploadFiles: File[]) => {
139
+ uploading = true;
260
140
 
261
- let composeDragHandler = (fn: any) => (noDrag ? null : composeHandler(fn));
141
+ const validFiles: File[] = [];
262
142
 
263
- let defaultPlaceholderString = $derived(multiple
264
- ? "Drag 'n' drop some files here, or click to select files"
265
- : "Drag 'n' drop a file here, or click to select a file");
143
+ for (let i = 0; i < uploadFiles.length; i++) {
144
+ const file = uploadFiles[i];
266
145
 
267
- function stopPropagation(event) {
268
- if (noDragEventsBubbling) {
269
- event.stopPropagation();
270
- }
271
- }
146
+ const rejectedReason = shouldAcceptFile(file, (fileCount ?? 0) + i + 1);
272
147
 
273
- // allow the entire document to be a drag target
274
- function onDocumentDragOver(event) {
275
- if (preventDropOnDocument) {
276
- event.preventDefault();
277
- }
278
- }
148
+ if (rejectedReason) {
149
+ onFileRejected?.({ file, reason: rejectedReason });
150
+ continue;
151
+ }
279
152
 
280
- let dragTargetsRef = [];
281
- function onDocumentDrop(event) {
282
- if (!preventDropOnDocument) {
283
- return;
284
- }
285
- if (rootRef && rootRef.contains(event.target)) {
286
- // If we intercepted an event for our instance, let it propagate down to the instance's onDrop handler
287
- return;
153
+ validFiles.push(file);
288
154
  }
289
- event.preventDefault();
290
- dragTargetsRef = [];
291
- }
292
155
 
293
- // Update file dialog active state when the window is focused on
294
- function onWindowFocus() {
295
- // Execute the timeout only if the file dialog is opened in the browser
296
- if (state.isFileDialogActive) {
297
- setTimeout(() => {
298
- if (inputElement) {
299
- const { files } = inputElement;
300
-
301
- if (!files.length) {
302
- state.isFileDialogActive = false;
303
- dispatch('filedialogcancel');
304
- }
305
- }
306
- }, 300);
307
- }
308
- }
156
+ await onUpload(validFiles);
309
157
 
310
- onDestroy(() => {
311
- // This is critical for canceling the timeout behaviour on `onWindowFocus()`
312
- inputElement = null;
313
- });
158
+ uploading = false;
159
+ };
314
160
 
315
- function onInputElementClick(event) {
316
- event.stopPropagation();
317
- }
161
+ const canUploadFiles = $derived(
162
+ !disabled &&
163
+ !uploading &&
164
+ !(maxFiles !== undefined && fileCount !== undefined && fileCount >= maxFiles)
165
+ );
318
166
  </script>
319
167
 
320
- <svelte:window on:focus={onWindowFocus} on:dragover={onDocumentDragOver} on:drop={onDocumentDrop} />
321
-
322
- <div
323
- bind:this={rootRef}
324
- tabindex="0"
325
- role="button"
326
- class="{disableDefaultStyles ? '' : 'dropzone'}
327
- {containerClasses}"
328
- style={containerStyles}
329
- on:keydown={composeKeyboardHandler(onKeyDownCb)}
330
- on:focus={composeKeyboardHandler(onFocusCb)}
331
- on:blur={composeKeyboardHandler(onBlurCb)}
332
- on:click={composeHandler(onClickCb)}
333
- on:dragenter={composeDragHandler(onDragEnterCb)}
334
- on:dragover={composeDragHandler(onDragOverCb)}
335
- on:dragleave={composeDragHandler(onDragLeaveCb)}
336
- on:drop={composeDragHandler(onDropCb)}
337
- {...$$restProps}
168
+ <label
169
+ ondragover={(e) => e.preventDefault()}
170
+ ondrop={drop}
171
+ for={id}
172
+ aria-disabled={!canUploadFiles}
173
+ class={cn(
174
+ 'flex h-48 w-full place-items-center justify-center rounded-lg border-2 border-dashed border-border p-6 transition-all hover:cursor-pointer hover:bg-accent/25 aria-disabled:opacity-50 aria-disabled:hover:cursor-not-allowed',
175
+ className
176
+ )}
338
177
  >
178
+ {#if children}
179
+ {@render children()}
180
+ {:else}
181
+ <div class="flex flex-col place-items-center justify-center gap-2">
182
+ <div
183
+ class="flex size-14 place-items-center justify-center rounded-full border border-dashed border-border text-muted-foreground"
184
+ >
185
+ <Upload class="size-7" />
186
+ <!-- <Icon icon="upload" class="size-7" /> -->
187
+ </div>
188
+ <div class="flex flex-col gap-0.5 text-center">
189
+ <span class="font-medium text-muted-foreground">
190
+ Drag 'n' drop files here, or click to select files
191
+ </span>
192
+ {#if maxFiles || maxFileSize}
193
+ <span class="text-sm text-muted-foreground/75">
194
+ {#if maxFiles}
195
+ <span>You can upload {maxFiles} files</span>
196
+ {/if}
197
+ {#if maxFiles && maxFileSize}
198
+ <span>(up to {displaySize(maxFileSize)} each)</span>
199
+ {/if}
200
+ {#if maxFileSize && !maxFiles}
201
+ <span>Maximum size {displaySize(maxFileSize)}</span>
202
+ {/if}
203
+ </span>
204
+ {/if}
205
+ </div>
206
+ </div>
207
+ {/if}
339
208
  <input
340
- accept={accept?.toString()}
341
- {multiple}
342
- {required}
209
+ {...rest}
210
+ disabled={!canUploadFiles}
211
+ {id}
212
+ {accept}
213
+ multiple={maxFiles === undefined || maxFiles - (fileCount ?? 0) > 1}
343
214
  type="file"
344
- {name}
345
- autocomplete="off"
346
- tabindex="-1"
347
- on:change={onDropCb}
348
- on:click={onInputElementClick}
349
- bind:this={inputElement}
350
- style="display: none;"
215
+ onchange={change}
216
+ class="hidden"
351
217
  />
352
- <slot>
353
- <p>{defaultPlaceholderString}</p>
354
- </slot>
355
- </div>
356
-
357
- <style>
358
- .dropzone {
359
- flex: 1;
360
- display: flex;
361
- flex-direction: column;
362
- align-items: center;
363
- padding: 20px;
364
- border-width: 2px;
365
- border-radius: 2px;
366
- border-color: #eeeeee;
367
- border-style: dashed;
368
- background-color: #fafafa;
369
- color: #bdbdbd;
370
- outline: none;
371
- transition: border 0.24s ease-in-out;
372
- }
373
- .dropzone:focus {
374
- border-color: #2196f3;
375
- }
376
- </style>
218
+ </label>
@@ -1,43 +1,43 @@
1
- interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
2
- new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
3
- $$bindings?: Bindings;
4
- } & Exports;
5
- (internal: unknown, props: Props & {
6
- $$events?: Events;
7
- $$slots?: Slots;
8
- }): Exports & {
9
- $set?: any;
10
- $on?: any;
11
- };
12
- z_$$bindings?: Bindings;
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLInputAttributes } from 'svelte/elements';
3
+ export type FileRejectedReason = 'Maximum file size exceeded' | 'File type not allowed' | 'Maximum files uploaded';
4
+ export interface FileDropZoneProps extends Omit<HTMLInputAttributes, 'multiple'> {
5
+ /** Called with the uploaded files when the user drops or clicks and selects their files.
6
+ *
7
+ * @param files
8
+ */
9
+ onUpload: (files: File[]) => Promise<void>;
10
+ /** The maximum amount files allowed to be uploaded */
11
+ maxFiles?: number;
12
+ fileCount?: number;
13
+ /** The maximum size of a file in bytes */
14
+ maxFileSize?: number;
15
+ children?: Snippet<[]>;
16
+ /** Called when a file does not meet the upload criteria (size, or type) */
17
+ onFileRejected?: (opts: {
18
+ reason: FileRejectedReason;
19
+ file: File;
20
+ }) => void;
21
+ /** Takes a comma separated list of one or more file types.
22
+ *
23
+ * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept)
24
+ *
25
+ * ### Usage
26
+ * ```svelte
27
+ * <FileDropZone
28
+ * accept=".doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
29
+ * />
30
+ * ```
31
+ *
32
+ * ### Common Values
33
+ * ```svelte
34
+ * <FileDropZone accept="audio/*"/>
35
+ * <FileDropZone accept="image/*"/>
36
+ * <FileDropZone accept="video/*"/>
37
+ * ```
38
+ */
39
+ accept?: string;
13
40
  }
14
- type $$__sveltets_2_PropsWithChildren<Props, Slots> = Props & (Slots extends {
15
- default: any;
16
- } ? Props extends Record<string, never> ? any : {
17
- children?: any;
18
- } : {});
19
- declare const Dropzone: $$__sveltets_2_IsomorphicComponent<$$__sveltets_2_PropsWithChildren<Partial<{
20
- accept: string | Array<string>;
21
- disabled: boolean;
22
- maxSize: number;
23
- minSize: number;
24
- multiple: boolean;
25
- preventDropOnDocument: boolean;
26
- noClick: boolean;
27
- noKeyboard: boolean;
28
- noDrag: boolean;
29
- noDragEventsBubbling: boolean;
30
- containerClasses: string;
31
- containerStyles: string;
32
- disableDefaultStyles: boolean;
33
- name: string;
34
- required: boolean;
35
- }>, {
36
- default: {};
37
- }>, {
38
- [evt: string]: CustomEvent<any>;
39
- }, {
40
- default: {};
41
- }, {}, "">;
42
- type Dropzone = InstanceType<typeof Dropzone>;
41
+ declare const Dropzone: import("svelte").Component<FileDropZoneProps, {}, "">;
42
+ type Dropzone = ReturnType<typeof Dropzone>;
43
43
  export default Dropzone;