@functionalcms/svelte-components 4.12.7 → 4.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,37 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { CustomDropzoneProps } from './types.ts';
4
+ interface DefaultDropzone extends CustomDropzoneProps {
5
+ defaultDropzoneElement: HTMLElement | undefined;
6
+ children: Snippet;
7
+ }
8
+ let { defaultDropzoneElement = $bindable(), children, ...props }: DefaultDropzone = $props();
9
+ </script>
10
+
11
+ <div bind:this={defaultDropzoneElement} class="dropzone" {...props}>
12
+ {@render children()}
13
+ </div>
14
+
15
+ <style>
16
+ .dropzone {
17
+ flex: 1;
18
+ display: flex;
19
+ flex-direction: column;
20
+ align-items: center;
21
+ padding: 20px;
22
+ border-width: 2px;
23
+ border-radius: 2px;
24
+ border-color: #eeeeee;
25
+ border-style: dashed;
26
+ background-color: #fafafa;
27
+ color: #bdbdbd;
28
+ outline: none;
29
+ transition: border 0.24s ease-in-out;
30
+ }
31
+ .dropzone:hover {
32
+ border-color: #2196f3;
33
+ }
34
+ .dropzone:focus {
35
+ border-color: #2196f3;
36
+ }
37
+ </style>
@@ -0,0 +1,8 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { CustomDropzoneProps } from './types.ts';
3
+ interface DefaultDropzone extends CustomDropzoneProps {
4
+ defaultDropzoneElement: HTMLElement | undefined;
5
+ children: Snippet;
6
+ }
7
+ declare const DefaultDropzone: import("svelte").Component<DefaultDropzone, {}, "defaultDropzoneElement">;
8
+ export default DefaultDropzone;
@@ -0,0 +1,298 @@
1
+ <script lang="ts">
2
+ import { fromEvent } from 'file-selector';
3
+ import {
4
+ checkFiles,
5
+ generateErrorMessage,
6
+ isEventWithFiles,
7
+ isIeOrEdge,
8
+ isPropagationStopped
9
+ } from './default.js';
10
+ import type {
11
+ DropzoneEventHandler,
12
+ DropzoneProps,
13
+ FromEventFileTypes,
14
+ RejectedFile
15
+ } from './types.ts';
16
+ import type { EventHandler } from 'svelte/elements';
17
+ import DefaultDropzone from './DefaultDropzone.svelte';
18
+ import useDropzone from './UseDropzone.ts';
19
+
20
+ let {
21
+ accept,
22
+ disabled = false,
23
+ maxFileCountPerUpload = Infinity,
24
+ maxSize = Infinity,
25
+ minSize = 0,
26
+ multiple = false,
27
+ preventDropOnDocument = true,
28
+ disableDropzoneClick = false,
29
+ disableDropzoneKeydown = false,
30
+ disableDropzoneDrag = false,
31
+ name = '',
32
+ inputElement = $bindable(),
33
+ required = false,
34
+ dropzoneElement = $bindable(),
35
+ CustomDropzone,
36
+ children,
37
+ onDragenter,
38
+ onDragover,
39
+ onDragleave,
40
+ onDrop,
41
+ onFileDialogCancel
42
+ }: DropzoneProps = $props();
43
+
44
+ let isFileDialogActive: boolean = $state(false);
45
+
46
+ let defaultDropzoneElement: HTMLElement | undefined = $state();
47
+
48
+ let dropzoneRef: HTMLElement | undefined = $derived(dropzoneElement || defaultDropzoneElement);
49
+
50
+ let dragTargetsRef: EventTarget[] = $state([]);
51
+ async function getFileFromEvent<T extends FromEventFileTypes>(
52
+ event: Event
53
+ ): Promise<{ acceptedFiles: T[]; rejectedFiles: RejectedFile<T>[] }> {
54
+ if (isPropagationStopped(event)) {
55
+ return { acceptedFiles: [], rejectedFiles: [] };
56
+ }
57
+ const files = (await fromEvent(event)) as T[];
58
+ const acceptedFiles: T[] = [];
59
+ const rejectedFiles: RejectedFile<T>[] = [];
60
+
61
+ files.forEach((file) => {
62
+ const { isAccepted, errors } = checkFiles({ file, accept, minSize, maxSize });
63
+ if (multiple && files.length > maxFileCountPerUpload) {
64
+ rejectedFiles.push({
65
+ file,
66
+ errors: [...errors, generateErrorMessage('TOO_MANY_FILES', { maxFileCountPerUpload })]
67
+ });
68
+ return;
69
+ }
70
+ if (!multiple && isAccepted && acceptedFiles.length > 0) {
71
+ rejectedFiles.push({
72
+ file,
73
+ errors: [generateErrorMessage('CANNOT_UPLOAD_MULTIPLE_FILES')]
74
+ });
75
+ return;
76
+ }
77
+ if (isAccepted) {
78
+ acceptedFiles.push(file);
79
+ return;
80
+ }
81
+
82
+ rejectedFiles.push({ file, errors });
83
+ });
84
+
85
+ return { acceptedFiles, rejectedFiles };
86
+ }
87
+
88
+ async function triggerEventWithFiles<T extends FromEventFileTypes>(
89
+ event: Event,
90
+ callbackToTrigger: DropzoneEventHandler<T> | undefined
91
+ ) {
92
+ const files = await getFileFromEvent<T>(event);
93
+ if (!files) return { acceptedFiles: [], rejectedFiles: [] };
94
+ const { acceptedFiles, rejectedFiles } = files;
95
+ callbackToTrigger?.({ acceptedFiles, rejectedFiles, event });
96
+
97
+ return files;
98
+ }
99
+ // Fn for opening the file dialog programmatically
100
+ function openFileDialog() {
101
+ if (inputElement) {
102
+ isFileDialogActive = true;
103
+ inputElement.click();
104
+ }
105
+ }
106
+
107
+ // open the file dialog when SPACE/ENTER occurs on the dropzone
108
+ function onDropzoneKeyDown(event: KeyboardEvent) {
109
+ const target = event.target as HTMLElement | null;
110
+ const dropzoneElementType = target?.getAttribute('drozone-element-type');
111
+ // Ignore keyboard events bubbling up the DOM tree
112
+ if (target?.id !== 'dropzone-element' && dropzoneElementType === 'dropzone-element') {
113
+ return;
114
+ }
115
+
116
+ if (event.keyCode === 32 || event.keyCode === 13) {
117
+ event.preventDefault();
118
+ openFileDialog();
119
+ }
120
+ }
121
+
122
+ // open the file dialog when click occurs on the dropzone
123
+ function onDropzoneClick() {
124
+ if (disableDropzoneClick) {
125
+ return;
126
+ }
127
+
128
+ // In IE11/Edge the file-browser dialog is blocking, therefore, use setTimeout()
129
+ // to ensure React can handle state changes
130
+ // See: https://github.com/react-dropzone/react-dropzone/issues/450
131
+ if (isIeOrEdge()) {
132
+ setTimeout(openFileDialog, 0);
133
+ } else {
134
+ openFileDialog();
135
+ }
136
+ }
137
+ const onDropzoneDragEnter: EventHandler<DragEvent> = async (event) => {
138
+ event.preventDefault();
139
+
140
+ const target = event.target;
141
+ if (target) dragTargetsRef = [...dragTargetsRef, target];
142
+
143
+ if (isEventWithFiles(event)) {
144
+ await triggerEventWithFiles(event, onDragenter);
145
+ }
146
+ };
147
+
148
+ const onDropzoneDragOver: EventHandler<DragEvent> = async (event) => {
149
+ event.preventDefault();
150
+
151
+ if (event.dataTransfer) {
152
+ try {
153
+ event.dataTransfer.dropEffect = 'copy';
154
+ } catch {} /* eslint-disable-line no-empty */
155
+ }
156
+
157
+ if (isEventWithFiles(event)) {
158
+ await triggerEventWithFiles(event, onDragover);
159
+ }
160
+
161
+ return false;
162
+ };
163
+
164
+ const onDropzoneDragLeave: EventHandler<DragEvent> = async (event) => {
165
+ event.preventDefault();
166
+
167
+ // Only deactivate once the dropzone and all children have been left
168
+ const targets = dragTargetsRef.filter(
169
+ (target) => dropzoneRef && dropzoneRef.contains(target as Node)
170
+ );
171
+ // Make sure to remove a target present multiple times only once
172
+ // (Firefox may fire dragenter/dragleave multiple times on the same element)
173
+ const target = event.target as HTMLElement;
174
+ const targetIdx = targets.indexOf(target);
175
+ if (targetIdx !== -1) {
176
+ targets.splice(targetIdx, 1);
177
+ }
178
+ dragTargetsRef = targets;
179
+ if (targets.length > 0) {
180
+ return;
181
+ }
182
+
183
+ if (isEventWithFiles(event)) {
184
+ await triggerEventWithFiles(event, onDragleave);
185
+ }
186
+ };
187
+
188
+ const onDropzoneDrop = async (event: DragEvent | Event) => {
189
+ event.preventDefault();
190
+ isFileDialogActive = false;
191
+ dragTargetsRef = [];
192
+ if (isEventWithFiles(event)) {
193
+ const { acceptedFiles } = await triggerEventWithFiles(event, onDrop);
194
+ if ('dataTransfer' in event && event.dataTransfer && inputElement) {
195
+ const dataTransfer = new DataTransfer();
196
+ const incomingFiles = acceptedFiles;
197
+ incomingFiles.forEach((v) => dataTransfer.items.add(v));
198
+ inputElement.files = dataTransfer.files;
199
+ }
200
+ }
201
+ event.stopPropagation();
202
+ };
203
+
204
+ let getHandler = $derived(<T extends Event>(fn: EventHandler<T>) => (disabled ? null : fn));
205
+ let getKeyboardEventHandle = $derived(<T extends Event>(fn: EventHandler<T>) =>
206
+ disableDropzoneKeydown ? null : getHandler(fn)
207
+ );
208
+ let getDragEventHandler = $derived((fn: EventHandler<DragEvent>) =>
209
+ disableDropzoneDrag ? null : getHandler(fn)
210
+ );
211
+
212
+ let defaultPlaceholderString = $derived(
213
+ multiple
214
+ ? "Drag 'n' drop some files here, or click to select files"
215
+ : "Drag 'n' drop a file here, or click to select a file"
216
+ );
217
+
218
+ // allow the entire document to be a drag target
219
+ function onWindowDragOver(event: DragEvent) {
220
+ if (preventDropOnDocument) {
221
+ event.preventDefault();
222
+ }
223
+ }
224
+
225
+ function onWindowDrop(event: DragEvent) {
226
+ const target = event.target as HTMLElement;
227
+ if (!preventDropOnDocument) {
228
+ return;
229
+ }
230
+ if (dropzoneRef?.contains(target)) {
231
+ // If we intercepted an event for our instance, let it propagate down to the instance's onDrop handler
232
+ return;
233
+ }
234
+ event.preventDefault();
235
+ dragTargetsRef = [];
236
+ }
237
+
238
+ function onInputElementClick(event: MouseEvent) {
239
+ event.stopPropagation();
240
+ }
241
+
242
+ function onInputElementCancel(event: Event) {
243
+ isFileDialogActive = false;
244
+ onFileDialogCancel?.();
245
+ }
246
+ const dropzoneProps = $derived({
247
+ 'data-drozone-element-type': 'dropzone-element',
248
+ id: 'dropzone-element',
249
+ tabindex: 0,
250
+ role: 'button',
251
+ onkeydown: getKeyboardEventHandle(onDropzoneKeyDown),
252
+ onclick: getHandler(onDropzoneClick)
253
+ });
254
+ const dropzoneAreaProps = $derived({
255
+ dragenter: getDragEventHandler(onDropzoneDragEnter),
256
+ dragover: getDragEventHandler(onDropzoneDragOver),
257
+ dragleave: getDragEventHandler(onDropzoneDragLeave),
258
+ drop: getDragEventHandler(onDropzoneDrop)
259
+ });
260
+
261
+ $effect(() => {
262
+ const unsubscribe = useDropzone(dropzoneRef, dropzoneAreaProps);
263
+ return () => unsubscribe();
264
+ });
265
+ </script>
266
+
267
+ <svelte:window on:dragover={onWindowDragOver} on:drop={onWindowDrop} />
268
+
269
+ {#snippet dropzoneInput()}
270
+ <input
271
+ accept={accept?.join(',')}
272
+ {multiple}
273
+ {required}
274
+ type="file"
275
+ {name}
276
+ autocomplete="off"
277
+ tabindex="-1"
278
+ onchange={onDropzoneDrop}
279
+ onclick={onInputElementClick}
280
+ bind:this={inputElement}
281
+ style="display: none;"
282
+ oncancel={onInputElementCancel}
283
+ />
284
+ {/snippet}
285
+ {#if CustomDropzone}
286
+ {@render CustomDropzone(dropzoneProps)}
287
+
288
+ {@render dropzoneInput()}
289
+ {:else}
290
+ <DefaultDropzone bind:defaultDropzoneElement {...dropzoneProps}>
291
+ {@render dropzoneInput()}
292
+ {#if children}
293
+ {@render children()}
294
+ {:else}
295
+ <p>{defaultPlaceholderString}</p>
296
+ {/if}
297
+ </DefaultDropzone>
298
+ {/if}
@@ -0,0 +1,4 @@
1
+ import type { DropzoneProps } from './types.ts';
2
+ declare const Dropzone: import("svelte").Component<DropzoneProps, {}, "inputElement" | "dropzoneElement">;
3
+ type Dropzone = ReturnType<typeof Dropzone>;
4
+ export default Dropzone;
@@ -0,0 +1,3 @@
1
+ import type { DropzoneAreaEvents } from "./types.ts";
2
+ declare const useDropzone: (element: HTMLElement | undefined, events: DropzoneAreaEvents) => () => void;
3
+ export default useDropzone;
@@ -0,0 +1,19 @@
1
+ const useDropzone = (element, events) => {
2
+ if (!element)
3
+ return () => { };
4
+ Object.entries(events)
5
+ .forEach(([eventName, handler]) => {
6
+ if (handler) {
7
+ element.addEventListener(eventName, handler);
8
+ }
9
+ });
10
+ return () => {
11
+ Object.entries(events)
12
+ .forEach(([eventName, handler]) => {
13
+ if (handler) {
14
+ element.removeEventListener(eventName, handler);
15
+ }
16
+ });
17
+ };
18
+ };
19
+ export default useDropzone;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Check if the provided file type should be accepted by the input with accept attribute.
3
+ * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Input#attr-accept
4
+ *
5
+ * Inspired by https://github.com/enyo/dropzone
6
+ *
7
+ * @param file {File} https://developer.mozilla.org/en-US/docs/Web/API/File
8
+ * @param accept {string}
9
+ * @returns {boolean}
10
+ */
11
+ import type { FromEventFileTypes, MimeTypes } from "./types.ts";
12
+ export default function (file: FromEventFileTypes, accept: MimeTypes[] | string[]): boolean;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Check if the provided file type should be accepted by the input with accept attribute.
3
+ * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Input#attr-accept
4
+ *
5
+ * Inspired by https://github.com/enyo/dropzone
6
+ *
7
+ * @param file {File} https://developer.mozilla.org/en-US/docs/Web/API/File
8
+ * @param accept {string}
9
+ * @returns {boolean}
10
+ */
11
+ export default function (file, accept) {
12
+ if (file && accept.length > 0) {
13
+ const fileName = "name" in file ? file.name : "";
14
+ const mimeType = (file.type || "").toLowerCase();
15
+ const baseMimeType = mimeType.replace(/\/.*$/, "");
16
+ return accept.some((type) => {
17
+ const validType = type.trim().toLowerCase();
18
+ if (validType.charAt(0) === ".") {
19
+ return fileName.toLowerCase().endsWith(validType);
20
+ }
21
+ else if (validType.endsWith("/*")) {
22
+ // This is something like a image/* mime type
23
+ return baseMimeType === validType.replace(/\/.*$/, "");
24
+ }
25
+ return mimeType === validType;
26
+ });
27
+ }
28
+ return true;
29
+ }
@@ -0,0 +1,31 @@
1
+ import type { FromEventFileTypes, MimeTypes, DropzoneErrorCode } from "./types.ts";
2
+ export declare const FILE_INVALID_TYPE = "file-invalid-type";
3
+ export declare const FILE_TOO_LARGE = "file-too-large";
4
+ export declare const FILE_TOO_SMALL = "file-too-small";
5
+ export declare const TOO_MANY_FILES = "too-many-files";
6
+ export declare const generateErrorMessage: (code: DropzoneErrorCode, props?: {
7
+ accept?: string[];
8
+ minSize?: number;
9
+ maxSize?: number;
10
+ maxFileCountPerUpload?: number;
11
+ }) => {
12
+ code: DropzoneErrorCode;
13
+ message: string;
14
+ };
15
+ export declare function checkFiles({ file, accept, minSize, maxSize }: {
16
+ file: FromEventFileTypes;
17
+ accept: MimeTypes[] | string[] | undefined;
18
+ minSize: number;
19
+ maxSize: number;
20
+ }): {
21
+ errors: {
22
+ code: DropzoneErrorCode;
23
+ message: string;
24
+ }[];
25
+ isAccepted: boolean;
26
+ };
27
+ export declare function getFileTypeErrors(file: FromEventFileTypes, accept?: string[]): DropzoneErrorCode[];
28
+ export declare function getFileSizeErrors(file: FromEventFileTypes, minSize: number, maxSize: number): DropzoneErrorCode[];
29
+ export declare function isPropagationStopped(event: Event): boolean;
30
+ export declare function isEventWithFiles(event: Event | DragEvent): boolean | FileList | null | undefined;
31
+ export declare function isIeOrEdge(userAgent?: string): boolean;
@@ -0,0 +1,78 @@
1
+ import accepts from "./attr-accept.ts";
2
+ // Error codes
3
+ export const FILE_INVALID_TYPE = "file-invalid-type";
4
+ export const FILE_TOO_LARGE = "file-too-large";
5
+ export const FILE_TOO_SMALL = "file-too-small";
6
+ export const TOO_MANY_FILES = "too-many-files";
7
+ export const generateErrorMessage = (code, props) => {
8
+ const errorMessages = {
9
+ INVALID_FILE_TYPE: `File type must be one of ${props?.accept?.join(",")}.`,
10
+ FILE_TOO_LARGE: `File is larger than ${props?.maxSize} bytes.`,
11
+ FILE_TOO_SMALL: `File is smaller than ${props?.minSize} bytes.`,
12
+ TOO_MANY_FILES: `File count cannot be more than ${props?.maxFileCountPerUpload}.`,
13
+ CANNOT_UPLOAD_MULTIPLE_FILES: `Cannot upload multiple files.`,
14
+ UNKOWN: "Unkown error"
15
+ };
16
+ return { code, message: errorMessages[code] || errorMessages["UNKOWN"] };
17
+ };
18
+ export function checkFiles({ file, accept = [], minSize, maxSize }) {
19
+ const fileTypeErrors = getFileTypeErrors(file, accept);
20
+ const fileSizeErrors = getFileSizeErrors(file, minSize, maxSize);
21
+ const errors = [...fileSizeErrors, ...fileTypeErrors];
22
+ const errorWithMessages = errors.map((error) => (generateErrorMessage(error, { accept, minSize, maxSize })));
23
+ return { errors: errorWithMessages, isAccepted: errors?.length === 0 };
24
+ }
25
+ // Firefox versions prior to 53 return a bogus MIME type for every file drag, so dragovers with
26
+ // that MIME type will always be accepted
27
+ export function getFileTypeErrors(file, accept = []) {
28
+ const errors = [];
29
+ const isAcceptableType = file.type === "application/x-moz-file" || accepts(file, accept);
30
+ if (!isAcceptableType) {
31
+ errors.push("INVALID_FILE_TYPE");
32
+ }
33
+ return errors;
34
+ }
35
+ export function getFileSizeErrors(file, minSize, maxSize) {
36
+ let errors = [];
37
+ if (!("size" in file))
38
+ return errors;
39
+ if (isDefined(file.size)) {
40
+ if (isDefined(maxSize) && file.size > maxSize) {
41
+ errors.push("FILE_TOO_LARGE");
42
+ }
43
+ else if (isDefined(minSize) && file.size < minSize) {
44
+ errors.push("FILE_TOO_SMALL");
45
+ }
46
+ }
47
+ return errors;
48
+ }
49
+ function isDefined(value) {
50
+ return value !== undefined && value !== null;
51
+ }
52
+ // React's synthetic events has event.isPropagationStopped,
53
+ // but to remain compatibility with other libs (Preact) fall back
54
+ // to check event.cancelBubble
55
+ export function isPropagationStopped(event) {
56
+ if (typeof event.cancelBubble !== "undefined") {
57
+ return event.cancelBubble;
58
+ }
59
+ return false;
60
+ }
61
+ export function isEventWithFiles(event) {
62
+ if ('dataTransfer' in event && event.dataTransfer) {
63
+ return Array.prototype.some.call(event.dataTransfer.types, (type) => type === "Files" || type === "application/x-moz-file");
64
+ }
65
+ // https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types
66
+ // https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#file
67
+ const target = event.target;
68
+ return target?.files;
69
+ }
70
+ function isIe(userAgent) {
71
+ return (userAgent.indexOf("MSIE") !== -1 || userAgent.indexOf("Trident/") !== -1);
72
+ }
73
+ function isEdge(userAgent) {
74
+ return userAgent.indexOf("Edge/") !== -1;
75
+ }
76
+ export function isIeOrEdge(userAgent = window.navigator.userAgent) {
77
+ return isIe(userAgent) || isEdge(userAgent);
78
+ }
@@ -0,0 +1,60 @@
1
+ export type DropzoneErrorCode = "INVALID_FILE_TYPE" | "FILE_TOO_LARGE" | "FILE_TOO_SMALL" | "TOO_MANY_FILES" | "CANNOT_UPLOAD_MULTIPLE_FILES" | "UNKOWN";
2
+ export type DropzoneErrorTypes = {
3
+ message: string;
4
+ code: DropzoneErrorCode;
5
+ } | null;
6
+ import type { Snippet } from "svelte";
7
+ import type { EventHandler } from "svelte/elements";
8
+ export type FromEventFileTypes = File | DataTransferItem;
9
+ export type RejectedFile<T> = {
10
+ file: T;
11
+ errors: DropzoneErrorTypes[];
12
+ };
13
+ export type DropzoneEvent<T> = {
14
+ acceptedFiles: T[];
15
+ rejectedFiles: RejectedFile<T>[];
16
+ event: DragEvent | Event;
17
+ };
18
+ export type DropzoneEventHandler<T> = (data: DropzoneEvent<T>) => void | undefined;
19
+ export type MimeTypes = "audio/aac" | "application/x-abiword" | "image/apng" | "application/x-freearc" | "image/avif" | "video/x-msvideo" | "application/vnd.amazon.ebook" | "application/octet-stream" | "image/bmp" | "application/x-bzip" | "application/x-bzip2" | "application/x-cdf" | "application/x-csh" | "text/css" | "text/csv" | "application/msword" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" | "application/vnd.ms-fontobject" | "application/epub+zip" | "application/gzip." | "image/gif" | "text/html" | "image/vnd.microsoft.icon" | "text/calendar" | "application/java-archive" | "image/jpeg" | "text/javascript" | "application/json" | "application/ld+json" | "audio/midi, audio/x-midi" | "text/javascript" | "audio/mpeg" | "video/mp4" | "video/mpeg" | "application/vnd.apple.installer+xml" | "application/vnd.oasis.opendocument.presentation" | "application/vnd.oasis.opendocument.spreadsheet" | "application/vnd.oasis.opendocument.text" | "audio/ogg" | "video/ogg" | "application/ogg" | "audio/ogg" | "font/otf" | "image/png" | "application/pdf" | "application/x-httpd-php" | "application/vnd.ms-powerpoint" | "application/vnd.openxmlformats-officedocument.presentationml.presentation" | "application/vnd.rar" | "application/rtf" | "application/x-sh" | "image/svg+xml" | "application/x-tar" | "image/tiff" | "video/mp2t" | "font/ttf" | "text/plain" | "application/vnd.visio" | "audio/wav" | "audio/webm" | "video/webm" | "image/webp" | "font/woff" | "font/woff2" | "application/xhtml+xml" | "application/vnd.ms-excel" | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" | "application/xml" | "application/vnd.mozilla.xul+xml" | "application/zip." | "video/3gpp" | "audio/3gpp" | "video/3gpp2" | "audio/3gpp2" | "application/x-7z-compressed";
20
+ export interface DropzoneAreaEvents {
21
+ dragenter: EventHandler<DragEvent> | null;
22
+ dragover: EventHandler<DragEvent> | null;
23
+ dragleave: EventHandler<DragEvent> | null;
24
+ drop: EventHandler<DragEvent> | null;
25
+ }
26
+ /**
27
+ * Custom Dropzone Event Handlers and props to make element act as dropzone inside the library.
28
+ * Don't override listeners in order to make library function.
29
+ */
30
+ export interface CustomDropzoneProps {
31
+ 'data-drozone-element-type': string;
32
+ id: string;
33
+ tabindex: number;
34
+ role: string;
35
+ onkeydown?: EventHandler<KeyboardEvent> | null;
36
+ onclick?: EventHandler<MouseEvent> | null;
37
+ }
38
+ export interface DropzoneProps {
39
+ accept?: MimeTypes[] | string[];
40
+ disabled?: boolean;
41
+ maxSize?: number;
42
+ minSize?: number;
43
+ multiple?: boolean;
44
+ maxFileCountPerUpload?: number;
45
+ preventDropOnDocument?: boolean;
46
+ disableDropzoneClick?: boolean;
47
+ disableDropzoneKeydown?: boolean;
48
+ disableDropzoneDrag?: boolean;
49
+ name?: string;
50
+ required?: boolean;
51
+ inputElement?: HTMLInputElement;
52
+ dropzoneElement?: HTMLElement | undefined;
53
+ CustomDropzone?: Snippet<[CustomDropzoneProps]> | undefined;
54
+ children?: Snippet;
55
+ onDragenter?: DropzoneEventHandler<DataTransferItem>;
56
+ onDragover?: DropzoneEventHandler<DataTransferItem>;
57
+ onDragleave?: DropzoneEventHandler<DataTransferItem>;
58
+ onDrop?: DropzoneEventHandler<File>;
59
+ onFileDialogCancel?: () => void;
60
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.d.ts CHANGED
@@ -31,10 +31,11 @@ export { default as Switch } from './components/form/Switch.svelte';
31
31
  export { default as ChoiceInput } from './components/form/ChoiceInput.svelte';
32
32
  export type { ChoiceInputOption } from './components/form/utils.js';
33
33
  export { default as AntiBot } from './components/form/AntiBot.svelte';
34
- export { default as Dropzone } from './components/form/Dropzone.svelte';
35
34
  export { default as Select } from './components/form/Select.svelte';
36
35
  export { default as Form } from './components/form/Form.svelte';
37
36
  export { type Field, serialize, createForm, readForm, mapEntiresToOptions } from './components/form/form.js';
37
+ export { default as Dropzone } from './components/form/dropzone/Dropzone.svelte';
38
+ export * from './components/form/dropzone/types.js';
38
39
  export { default as Markdown } from './components/content/Markdown.svelte';
39
40
  export { type BlogPost, listAllPosts, importPost } from './components/blog/blog.js';
40
41
  export { default as EasyTools } from './components/integrations/EasyTools.svelte';
package/dist/index.js CHANGED
@@ -43,10 +43,11 @@ export { default as Input } from './components/form/Input.svelte';
43
43
  export { default as Switch } from './components/form/Switch.svelte';
44
44
  export { default as ChoiceInput } from './components/form/ChoiceInput.svelte';
45
45
  export { default as AntiBot } from './components/form/AntiBot.svelte';
46
- export { default as Dropzone } from './components/form/Dropzone.svelte';
47
46
  export { default as Select } from './components/form/Select.svelte';
48
47
  export { default as Form } from './components/form/Form.svelte';
49
48
  export { serialize, createForm, readForm, mapEntiresToOptions } from './components/form/form.js';
49
+ export { default as Dropzone } from './components/form/dropzone/Dropzone.svelte';
50
+ export * from './components/form/dropzone/types.js';
50
51
  /*
51
52
  * Content
52
53
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@functionalcms/svelte-components",
3
- "version": "4.12.7",
3
+ "version": "4.13.0",
4
4
  "watch": {
5
5
  "build": {
6
6
  "patterns": [
@@ -53,6 +53,7 @@
53
53
  "eslint": "^9.26.0",
54
54
  "eslint-config-prettier": "^10.1.2",
55
55
  "eslint-plugin-svelte": "^3.5.1",
56
+ "file-selector": "^2.1.2",
56
57
  "npm-watch": "^0.13.0",
57
58
  "prettier": "^3.5.3",
58
59
  "prettier-plugin-svelte": "^3.3.3",
@@ -69,6 +70,7 @@
69
70
  "ioredis": "^5.6.1",
70
71
  "marked": "^15.0.11",
71
72
  "oauth4webapi": "^3.5.0",
73
+ "svelte-dropzone-runes": "^1.0.3",
72
74
  "sveltekit-superforms": "^2.25.0",
73
75
  "yup": "^1.6.1",
74
76
  "zod": "^3.25.56"
@@ -1,184 +0,0 @@
1
- <script lang="ts">
2
- import { displaySize, type FileDropZoneProps, type FileRejectedReason } from './dropzone.js';
3
- import { cn } from '../../utils.js';
4
-
5
- let {
6
- id,
7
- children,
8
- maxFiles,
9
- maxFileSize,
10
- fileCount,
11
- disabled = false,
12
- onUpload,
13
- onFileRejected,
14
- accept,
15
- css,
16
- ...rest
17
- }: Partial<FileDropZoneProps> = $props();
18
-
19
- if (maxFiles !== undefined && fileCount === undefined) {
20
- console.warn(
21
- 'Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt'
22
- );
23
- }
24
-
25
- let uploading = $state(false);
26
-
27
- const drop = async (
28
- e: DragEvent & {
29
- currentTarget: EventTarget & HTMLLabelElement;
30
- }
31
- ) => {
32
- if (disabled || !canUploadFiles) return;
33
-
34
- e.preventDefault();
35
-
36
- const droppedFiles = Array.from(e.dataTransfer?.files ?? []);
37
-
38
- await upload(droppedFiles);
39
- };
40
-
41
- const change = async (
42
- e: Event & {
43
- currentTarget: EventTarget & HTMLInputElement;
44
- }
45
- ) => {
46
- if (disabled) return;
47
-
48
- const selectedFiles = e.currentTarget.files;
49
-
50
- if (!selectedFiles) return;
51
-
52
- await upload(Array.from(selectedFiles));
53
-
54
- // this if a file fails and we upload the same file again we still get feedback
55
- (e.target as HTMLInputElement).value = '';
56
- };
57
-
58
- const shouldAcceptFile = (file: File, fileNumber: number): FileRejectedReason | undefined => {
59
- if (maxFileSize !== undefined && file.size > maxFileSize) return 'Maximum file size exceeded';
60
-
61
- if (maxFiles !== undefined && fileNumber > maxFiles) return 'Maximum files uploaded';
62
-
63
- if (!accept) return undefined;
64
-
65
- const acceptedTypes = accept.split(',').map((a) => a.trim().toLowerCase());
66
- const fileType = file.type.toLowerCase();
67
- const fileName = file.name.toLowerCase();
68
-
69
- const isAcceptable = acceptedTypes.some((pattern) => {
70
- // check extension like .mp4
71
- if (fileType.startsWith('.')) {
72
- return fileName.endsWith(pattern);
73
- }
74
-
75
- // if pattern has wild card like video/*
76
- if (pattern.endsWith('/*')) {
77
- const baseType = pattern.slice(0, pattern.indexOf('/*'));
78
- return fileType.startsWith(baseType + '/');
79
- }
80
-
81
- // otherwise it must be a specific type like video/mp4
82
- return fileType === pattern;
83
- });
84
-
85
- if (!isAcceptable) return 'File type not allowed';
86
-
87
- return undefined;
88
- };
89
-
90
- const upload = async (uploadFiles: File[]) => {
91
- uploading = true;
92
-
93
- const validFiles: File[] = [];
94
-
95
- for (let i = 0; i < uploadFiles.length; i++) {
96
- const file = uploadFiles[i];
97
-
98
- const rejectedReason = shouldAcceptFile(file, (fileCount ?? 0) + i + 1);
99
-
100
- if (rejectedReason) {
101
- onFileRejected?.({ file, reason: rejectedReason });
102
- continue;
103
- }
104
-
105
- validFiles.push(file);
106
- }
107
-
108
- await onUpload(validFiles);
109
-
110
- uploading = false;
111
- };
112
-
113
- const canUploadFiles = $derived(
114
- !disabled &&
115
- !uploading &&
116
- !(maxFiles !== undefined && fileCount !== undefined && fileCount >= maxFiles)
117
- );
118
- </script>
119
-
120
- <label
121
- ondragover={(e) => e.preventDefault()}
122
- ondrop={drop}
123
- for={id}
124
- aria-disabled={!canUploadFiles}
125
- class={cn(
126
- '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',
127
- css ?? ''
128
- )}
129
- >
130
- {#if children}
131
- {@render children()}
132
- {:else}
133
- <div class="flex flex-col place-items-center justify-center gap-2">
134
- <div
135
- class="flex size-14 place-items-center justify-center rounded-full border border-dashed border-border text-muted-foreground"
136
- >
137
- <svg
138
- xmlns="http://www.w3.org/2000/svg"
139
- width="24"
140
- height="24"
141
- viewBox="0 0 24 24"
142
- fill="none"
143
- stroke="currentColor"
144
- stroke-width="2"
145
- stroke-linecap="round"
146
- stroke-linejoin="round"
147
- class="lucide lucide-upload-icon lucide-upload"
148
- ><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline
149
- points="17 8 12 3 7 8"
150
- /><line x1="12" x2="12" y1="3" y2="15" /></svg
151
- >
152
- </div>
153
- <div class="flex flex-col gap-0.5 text-center">
154
- <span class="font-medium text-muted-foreground">
155
- Drag 'n' drop files here, or click to select files
156
- </span>
157
- {#if maxFiles || maxFileSize}
158
- <span class="text-sm text-muted-foreground/75">
159
- {#if maxFiles}
160
- <span>You can upload {maxFiles} files</span>
161
- {/if}
162
- {#if maxFiles && maxFileSize}
163
- <span>(up to {displaySize(maxFileSize)} each)</span>
164
- {/if}
165
- {#if maxFileSize && !maxFiles}
166
- <span>Maximum size {displaySize(maxFileSize)}</span>
167
- {/if}
168
- </span>
169
- {/if}
170
- </div>
171
- </div>
172
- {/if}
173
- <input
174
- {...rest}
175
- disabled={!canUploadFiles}
176
- {id}
177
- name={id}
178
- {accept}
179
- multiple={maxFiles === undefined || maxFiles - (fileCount ?? 0) > 1}
180
- type="file"
181
- onchange={change}
182
- class="hidden"
183
- />
184
- </label>
@@ -1,4 +0,0 @@
1
- import { type FileDropZoneProps } from './dropzone.js';
2
- declare const Dropzone: import("svelte").Component<Partial<FileDropZoneProps>, {}, "">;
3
- type Dropzone = ReturnType<typeof Dropzone>;
4
- export default Dropzone;
@@ -1,49 +0,0 @@
1
- export declare const displaySize: (bytes: number) => string;
2
- export declare const BYTE = 1;
3
- export declare const KILOBYTE = 1024;
4
- export declare const MEGABYTE: number;
5
- export declare const GIGABYTE: number;
6
- export declare const ACCEPT_IMAGE = "image/*";
7
- export declare const ACCEPT_VIDEO = "video/*";
8
- export declare const ACCEPT_AUDIO = "audio/*";
9
- import type { Snippet } from 'svelte';
10
- import type { HTMLInputAttributes } from 'svelte/elements';
11
- export type FileRejectedReason = 'Maximum file size exceeded' | 'File type not allowed' | 'Maximum files uploaded';
12
- export interface FileDropZoneProps extends Omit<HTMLInputAttributes, 'multiple'> {
13
- /** Called with the uploaded files when the user drops or clicks and selects their files.
14
- *
15
- * @param files
16
- */
17
- onUpload: (files: File[]) => Promise<void>;
18
- /** The maximum amount files allowed to be uploaded */
19
- maxFiles?: number;
20
- fileCount?: number;
21
- /** The maximum size of a file in bytes */
22
- maxFileSize?: number;
23
- children?: Snippet<[]>;
24
- css: string;
25
- /** Called when a file does not meet the upload criteria (size, or type) */
26
- onFileRejected?: (opts: {
27
- reason: FileRejectedReason;
28
- file: File;
29
- }) => void;
30
- /** Takes a comma separated list of one or more file types.
31
- *
32
- * [MDN Reference](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept)
33
- *
34
- * ### Usage
35
- * ```svelte
36
- * <FileDropZone
37
- * accept=".doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
38
- * />
39
- * ```
40
- *
41
- * ### Common Values
42
- * ```svelte
43
- * <FileDropZone accept="audio/*"/>
44
- * <FileDropZone accept="image/*"/>
45
- * <FileDropZone accept="video/*"/>
46
- * ```
47
- */
48
- accept?: string;
49
- }
@@ -1,18 +0,0 @@
1
- export const displaySize = (bytes) => {
2
- if (bytes < KILOBYTE)
3
- return `${bytes.toFixed(0)} B`;
4
- if (bytes < MEGABYTE)
5
- return `${(bytes / KILOBYTE).toFixed(0)} KB`;
6
- if (bytes < GIGABYTE)
7
- return `${(bytes / MEGABYTE).toFixed(0)} MB`;
8
- return `${(bytes / GIGABYTE).toFixed(0)} GB`;
9
- };
10
- // Utilities for working with file sizes
11
- export const BYTE = 1;
12
- export const KILOBYTE = 1024;
13
- export const MEGABYTE = 1024 * KILOBYTE;
14
- export const GIGABYTE = 1024 * MEGABYTE;
15
- // utilities for limiting accepted files
16
- export const ACCEPT_IMAGE = 'image/*';
17
- export const ACCEPT_VIDEO = 'video/*';
18
- export const ACCEPT_AUDIO = 'audio/*';