@functionalcms/svelte-components 4.28.0 → 4.30.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.
- package/dist/components/form/Dropzone.svelte +140 -165
- package/dist/components/form/Dropzone.svelte.d.ts +10 -2
- package/dist/components/form/SmartForm.svelte +27 -3
- package/dist/components/form/SmartForm.svelte.d.ts +5 -1
- package/dist/components/form/form.d.ts +4 -2
- package/dist/components/form/form.js +1 -0
- package/package.json +1 -1
|
@@ -1,183 +1,158 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import
|
|
3
|
-
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
label: string;
|
|
6
|
+
buttonLabel: string
|
|
7
|
+
css: string;
|
|
8
|
+
accept: string;
|
|
9
|
+
multiple: boolean;
|
|
10
|
+
maxSize: number;
|
|
11
|
+
renderSelctedFilesPreview?: Snippet<[File[]]>;
|
|
12
|
+
}
|
|
4
13
|
|
|
5
14
|
let {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
label = "Drag and drop files here <br/> or",
|
|
16
|
+
buttonLabel = "Browse",
|
|
17
|
+
css = "dropzone",
|
|
18
|
+
accept = "",
|
|
19
|
+
multiple = true,
|
|
20
|
+
maxSize = Infinity,
|
|
21
|
+
renderSelctedFilesPreview = undefined,
|
|
22
|
+
}: Partial<Props> = $props();
|
|
23
|
+
|
|
24
|
+
let isDragging = $state(false);
|
|
25
|
+
let files = $state<File[]>([]);
|
|
26
|
+
let fileInput: HTMLInputElement;
|
|
27
|
+
|
|
28
|
+
function onInputChange(e) {
|
|
29
|
+
const picked = Array.from<File>(e.currentTarget.files ?? []);
|
|
30
|
+
setFiles(picked);
|
|
31
|
+
}
|
|
18
32
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
33
|
+
function setFiles(newFiles: File[]) {
|
|
34
|
+
const filtered = newFiles.filter(
|
|
35
|
+
(f: File) => f.size <= maxSize && matchesAccept(f),
|
|
22
36
|
);
|
|
37
|
+
files = [...files, ...filtered];
|
|
23
38
|
}
|
|
24
39
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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);
|
|
40
|
+
function matchesAccept(file: File) {
|
|
41
|
+
if (!accept) return true;
|
|
42
|
+
// Basic accept check (MIME or extension)
|
|
43
|
+
return accept.split(",").some((rule) => {
|
|
44
|
+
const trimmed = rule.trim();
|
|
45
|
+
if (trimmed.startsWith("."))
|
|
46
|
+
return file.name.toLowerCase().endsWith(trimmed.toLowerCase());
|
|
47
|
+
if (trimmed.endsWith("/*")) {
|
|
48
|
+
const typeRoot = trimmed.slice(0, -2);
|
|
49
|
+
return file.type.startsWith(typeRoot + "/");
|
|
73
50
|
}
|
|
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;
|
|
51
|
+
return file.type === trimmed;
|
|
83
52
|
});
|
|
53
|
+
}
|
|
84
54
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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);
|
|
55
|
+
function onDrop(e: DragEvent) {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
e.stopPropagation();
|
|
58
|
+
const dropped = Array.from(e.dataTransfer?.files ?? []);
|
|
59
|
+
setFiles(dropped);
|
|
60
|
+
isDragging = false;
|
|
61
|
+
}
|
|
109
62
|
|
|
110
|
-
|
|
111
|
-
|
|
63
|
+
function onDragOver(e: DragEvent) {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
e.dataTransfer.dropEffect = "copy";
|
|
66
|
+
isDragging = true;
|
|
67
|
+
}
|
|
112
68
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
);
|
|
69
|
+
function onDragLeave(e: DragEvent) {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
isDragging = false;
|
|
72
|
+
}
|
|
118
73
|
</script>
|
|
119
74
|
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
75
|
+
<div
|
|
76
|
+
class="{css} {isDragging ? 'dragging' : ''}"
|
|
77
|
+
role="button"
|
|
78
|
+
tabindex="-1"
|
|
79
|
+
ondrop={onDrop}
|
|
80
|
+
ondragover={onDragOver}
|
|
81
|
+
ondragleave={onDragLeave}
|
|
82
|
+
onkeydown={(event) => {
|
|
83
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
fileInput?.click();
|
|
86
|
+
}
|
|
87
|
+
}}
|
|
88
|
+
aria-label="File upload dropzone"
|
|
129
89
|
>
|
|
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
90
|
<input
|
|
174
|
-
|
|
175
|
-
disabled={!canUploadFiles}
|
|
176
|
-
{id}
|
|
177
|
-
{accept}
|
|
178
|
-
multiple={maxFiles === undefined || maxFiles - (fileCount ?? 0) > 1}
|
|
91
|
+
id="file-input"
|
|
179
92
|
type="file"
|
|
180
|
-
|
|
181
|
-
|
|
93
|
+
{accept}
|
|
94
|
+
{multiple}
|
|
95
|
+
onchange={onInputChange}
|
|
96
|
+
bind:this={fileInput}
|
|
97
|
+
aria-hidden="true"
|
|
182
98
|
/>
|
|
183
|
-
|
|
99
|
+
<label for="file-input" class="prompt">
|
|
100
|
+
<span class="title">{@html label}</span>
|
|
101
|
+
<button type="button" class="browse">{buttonLabel}</button>
|
|
102
|
+
</label>
|
|
103
|
+
|
|
104
|
+
{#if files.length}
|
|
105
|
+
{#if renderSelctedFilesPreview}
|
|
106
|
+
{@render renderSelctedFilesPreview(files)}
|
|
107
|
+
{:else}
|
|
108
|
+
<ul class="file-list">
|
|
109
|
+
{#each files as f}
|
|
110
|
+
<li>{f.name} — {(f.size / 1024).toFixed(1)} KB</li>
|
|
111
|
+
{/each}
|
|
112
|
+
</ul>
|
|
113
|
+
{/if}
|
|
114
|
+
{/if}
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<style>
|
|
118
|
+
.dropzone {
|
|
119
|
+
border: 2px dashed #9aa0a6;
|
|
120
|
+
border-radius: 10px;
|
|
121
|
+
padding: 24px;
|
|
122
|
+
text-align: center;
|
|
123
|
+
transition:
|
|
124
|
+
border-color 0.15s,
|
|
125
|
+
background-color 0.15s;
|
|
126
|
+
position: relative;
|
|
127
|
+
cursor: pointer;
|
|
128
|
+
}
|
|
129
|
+
.dropzone.dragging {
|
|
130
|
+
border-color: #1a73e8;
|
|
131
|
+
background-color: #f0f7ff;
|
|
132
|
+
}
|
|
133
|
+
/* Hide native input but keep it accessible via label */
|
|
134
|
+
input[type="file"] {
|
|
135
|
+
position: absolute;
|
|
136
|
+
inset: 0;
|
|
137
|
+
opacity: 0;
|
|
138
|
+
width: 100%;
|
|
139
|
+
height: 100%;
|
|
140
|
+
cursor: pointer;
|
|
141
|
+
}
|
|
142
|
+
.prompt {
|
|
143
|
+
display: grid;
|
|
144
|
+
gap: 8px;
|
|
145
|
+
justify-items: center;
|
|
146
|
+
}
|
|
147
|
+
.browse {
|
|
148
|
+
padding: 6px 12px;
|
|
149
|
+
border: 1px solid #1a73e8;
|
|
150
|
+
background: #1a73e8;
|
|
151
|
+
color: white;
|
|
152
|
+
border-radius: 6px;
|
|
153
|
+
}
|
|
154
|
+
.file-list {
|
|
155
|
+
margin-top: 12px;
|
|
156
|
+
text-align: left;
|
|
157
|
+
}
|
|
158
|
+
</style>
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
declare const Dropzone: import("svelte").Component<
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
declare const Dropzone: import("svelte").Component<Partial<{
|
|
3
|
+
label: string;
|
|
4
|
+
buttonLabel: string;
|
|
5
|
+
css: string;
|
|
6
|
+
accept: string;
|
|
7
|
+
multiple: boolean;
|
|
8
|
+
maxSize: number;
|
|
9
|
+
renderSelctedFilesPreview?: Snippet<[File[]]>;
|
|
10
|
+
}>, {}, "">;
|
|
3
11
|
type Dropzone = ReturnType<typeof Dropzone>;
|
|
4
12
|
export default Dropzone;
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import Dropzone from './Dropzone.svelte';
|
|
2
3
|
import Input from './Input.svelte';
|
|
3
4
|
import { type Field, FieldType, InputType, SubmitResult } from './form.js';
|
|
4
5
|
import { fade } from 'svelte/transition';
|
|
5
6
|
|
|
7
|
+
interface Css {
|
|
8
|
+
form?: string;
|
|
9
|
+
error?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
interface Props {
|
|
7
13
|
action?: string;
|
|
8
|
-
css?:
|
|
14
|
+
css?: Css;
|
|
9
15
|
successMessage?: string;
|
|
10
16
|
submitButonText?: string;
|
|
11
17
|
fields?: Array<Field>;
|
|
@@ -15,7 +21,7 @@
|
|
|
15
21
|
let {
|
|
16
22
|
action,
|
|
17
23
|
fields = [],
|
|
18
|
-
css =
|
|
24
|
+
css = {},
|
|
19
25
|
submitButonText = 'Submit',
|
|
20
26
|
successMessage,
|
|
21
27
|
onMessageSubmitted
|
|
@@ -45,6 +51,17 @@
|
|
|
45
51
|
console.error('Error submitting form:', error);
|
|
46
52
|
}
|
|
47
53
|
};
|
|
54
|
+
const handleFilesSelect = (field: Field) => {
|
|
55
|
+
const processNewUpload = async (files: File[]) => {
|
|
56
|
+
if (!Array.isArray(field.value)) {
|
|
57
|
+
field.value = [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
field.value = [...field.value, ...files];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return processNewUpload;
|
|
64
|
+
};
|
|
48
65
|
</script>
|
|
49
66
|
|
|
50
67
|
{#if showMessage}
|
|
@@ -52,7 +69,7 @@
|
|
|
52
69
|
{successMessage}
|
|
53
70
|
</div>
|
|
54
71
|
{:else}
|
|
55
|
-
<form method="POST" bind:this={form} class={css}>
|
|
72
|
+
<form method="POST" bind:this={form} class={css.form}>
|
|
56
73
|
<fieldset>
|
|
57
74
|
{#each fields as field}
|
|
58
75
|
{#if field.type === FieldType.Input}
|
|
@@ -128,6 +145,13 @@
|
|
|
128
145
|
{option.label}
|
|
129
146
|
</label>
|
|
130
147
|
{/each}
|
|
148
|
+
{:else if field.type === FieldType.DropZone}
|
|
149
|
+
<Dropzone ondrop={handleFilesSelect(field)}>
|
|
150
|
+
{field.label}
|
|
151
|
+
</Dropzone>
|
|
152
|
+
{/if}
|
|
153
|
+
{#if field.error}
|
|
154
|
+
<div class={css.error}>{field.error}</div>
|
|
131
155
|
{/if}
|
|
132
156
|
{/each}
|
|
133
157
|
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { type Field, SubmitResult } from './form.js';
|
|
2
|
+
interface Css {
|
|
3
|
+
form?: string;
|
|
4
|
+
error?: string;
|
|
5
|
+
}
|
|
2
6
|
interface Props {
|
|
3
7
|
action?: string;
|
|
4
|
-
css?:
|
|
8
|
+
css?: Css;
|
|
5
9
|
successMessage?: string;
|
|
6
10
|
submitButonText?: string;
|
|
7
11
|
fields?: Array<Field>;
|
|
@@ -3,7 +3,8 @@ export declare enum FieldType {
|
|
|
3
3
|
Textarea = "textarea",
|
|
4
4
|
Radio = "radio",
|
|
5
5
|
Checkbox = "checkbox",
|
|
6
|
-
Hidden = "hidden"
|
|
6
|
+
Hidden = "hidden",
|
|
7
|
+
DropZone = "dropzone"
|
|
7
8
|
}
|
|
8
9
|
export declare enum InputType {
|
|
9
10
|
Text = "text",
|
|
@@ -39,8 +40,9 @@ export interface Field {
|
|
|
39
40
|
checked?: boolean;
|
|
40
41
|
}>;
|
|
41
42
|
checked?: boolean;
|
|
42
|
-
value?: string | number | boolean;
|
|
43
|
+
value?: string | number | boolean | File[];
|
|
43
44
|
props?: any;
|
|
45
|
+
error?: string;
|
|
44
46
|
}
|
|
45
47
|
export declare enum SubmitResult {
|
|
46
48
|
Success = "success",
|