@functionalcms/svelte-components 4.30.0 → 4.32.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.
@@ -1,158 +1,183 @@
1
1
  <script lang="ts">
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
- }
2
+ import { displaySize, type FileDropZoneProps, type FileRejectedReason } from './dropzone.js';
3
+ import { cn } from '../../utils.js';
13
4
 
14
5
  let {
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
- }
6
+ id,
7
+ children,
8
+ maxFiles,
9
+ maxFileSize,
10
+ fileCount,
11
+ disabled = false,
12
+ onUpload,
13
+ onFileRejected,
14
+ accept,
15
+ class: css,
16
+ ...rest
17
+ }: FileDropZoneProps = $props();
32
18
 
33
- function setFiles(newFiles: File[]) {
34
- const filtered = newFiles.filter(
35
- (f: File) => f.size <= maxSize && matchesAccept(f),
19
+ if (maxFiles !== undefined && fileCount === undefined) {
20
+ console.warn(
21
+ 'Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt'
36
22
  );
37
- files = [...files, ...filtered];
38
23
  }
39
24
 
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 + "/");
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);
50
73
  }
51
- return file.type === trimmed;
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;
52
83
  });
53
- }
54
84
 
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
- }
85
+ if (!isAcceptable) return 'File type not allowed';
62
86
 
63
- function onDragOver(e: DragEvent) {
64
- e.preventDefault();
65
- e.dataTransfer.dropEffect = "copy";
66
- isDragging = true;
67
- }
87
+ return undefined;
88
+ };
68
89
 
69
- function onDragLeave(e: DragEvent) {
70
- e.preventDefault();
71
- isDragging = false;
72
- }
73
- </script>
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];
74
97
 
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();
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);
86
106
  }
87
- }}
88
- aria-label="File upload dropzone"
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
+ className
128
+ )}
89
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}
90
173
  <input
91
- id="file-input"
92
- type="file"
174
+ {...rest}
175
+ disabled={!canUploadFiles}
176
+ {id}
93
177
  {accept}
94
- {multiple}
95
- onchange={onInputChange}
96
- bind:this={fileInput}
97
- aria-hidden="true"
178
+ multiple={maxFiles === undefined || maxFiles - (fileCount ?? 0) > 1}
179
+ type="file"
180
+ onchange={change}
181
+ class="hidden"
98
182
  />
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>
183
+ </label>
@@ -1,12 +1,4 @@
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
- }>, {}, "">;
1
+ import { type FileDropZoneProps } from './dropzone.js';
2
+ declare const Dropzone: import("svelte").Component<FileDropZoneProps, {}, "">;
11
3
  type Dropzone = ReturnType<typeof Dropzone>;
12
4
  export default Dropzone;
@@ -1,17 +1,11 @@
1
1
  <script lang="ts">
2
- import Dropzone from './Dropzone.svelte';
3
2
  import Input from './Input.svelte';
4
- import { type Field, FieldType, InputType, SubmitResult } from './form.js';
3
+ import { type Field, FieldType, InputType, SubmitResult} from './form.js';
5
4
  import { fade } from 'svelte/transition';
6
5
 
7
- interface Css {
8
- form?: string;
9
- error?: string;
10
- }
11
-
12
6
  interface Props {
13
- action?: string;
14
- css?: Css;
7
+ action: string;
8
+ css?: string;
15
9
  successMessage?: string;
16
10
  submitButonText?: string;
17
11
  fields?: Array<Field>;
@@ -21,10 +15,10 @@
21
15
  let {
22
16
  action,
23
17
  fields = [],
24
- css = {},
18
+ css = '',
25
19
  submitButonText = 'Submit',
26
20
  successMessage,
27
- onMessageSubmitted
21
+ onMessageSubmitted,
28
22
  }: Props = $props();
29
23
 
30
24
  let form: any;
@@ -39,8 +33,7 @@
39
33
  isSendingMessage = true;
40
34
  event.preventDefault();
41
35
  const formData = new FormData(form);
42
- const formAction = action || window.location.pathname;
43
- const response = await fetch(formAction, {
36
+ const response = await fetch(action, {
44
37
  method: 'POST',
45
38
  body: formData
46
39
  });
@@ -51,17 +44,6 @@
51
44
  console.error('Error submitting form:', error);
52
45
  }
53
46
  };
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
- };
65
47
  </script>
66
48
 
67
49
  {#if showMessage}
@@ -69,7 +51,7 @@
69
51
  {successMessage}
70
52
  </div>
71
53
  {:else}
72
- <form method="POST" bind:this={form} class={css.form}>
54
+ <form method="POST" bind:this={form} class={css}>
73
55
  <fieldset>
74
56
  {#each fields as field}
75
57
  {#if field.type === FieldType.Input}
@@ -80,8 +62,6 @@
80
62
  placeholder={field.placeholder}
81
63
  type={field.subType}
82
64
  isRequired={field.isRequired || false}
83
- value={field.value}
84
- {...field.props}
85
65
  />
86
66
  {:else if field.type === FieldType.Textarea}
87
67
  <Input
@@ -91,67 +71,29 @@
91
71
  type={InputType.Textarea}
92
72
  placeholder={field.placeholder}
93
73
  isRequired={field.isRequired || false}
94
- value={field.value}
95
- />
96
- {:else if field.type === FieldType.Hidden}
97
- <input
98
- id={field.name}
99
- name={field.name}
100
- value={field.value}
101
- type="hidden"
102
- required={field.isRequired || false}
103
74
  />
104
75
  {:else if field.type === FieldType.Radio}
105
76
  {#each field.options as option}
106
77
  <label>
107
78
  {#if option.checked}
108
- <input
109
- type="radio"
110
- name={field.name}
111
- value={option.value}
112
- required={field.isRequired}
113
- checked
114
- />
79
+ <input type="radio" name={field.name} value={option.value} required={field.isRequired} checked/>
115
80
  {:else}
116
- <input
117
- type="radio"
118
- name={field.name}
119
- value={option.value}
120
- required={field.isRequired}
121
- />
81
+ <input type="radio" name={field.name} value={option.value} required={field.isRequired}/>
122
82
  {/if}
123
83
  {option.label}
124
- </label>
84
+ </label>
125
85
  {/each}
126
86
  {:else if field.type === FieldType.Checkbox}
127
87
  {#each field.options as option}
128
88
  <label>
129
89
  {#if option.checked}
130
- <input
131
- type="checkbox"
132
- name={field.name}
133
- value={option.value}
134
- required={field.isRequired}
135
- checked
136
- />
90
+ <input type="checkbox" name={field.name} value={option.value} required={field.isRequired} checked/>
137
91
  {:else}
138
- <input
139
- type="checkbox"
140
- name={field.name}
141
- value={option.value}
142
- required={field.isRequired}
143
- />
92
+ <input type="checkbox" name={field.name} value={option.value} required={field.isRequired} />
144
93
  {/if}
145
94
  {option.label}
146
95
  </label>
147
96
  {/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>
155
97
  {/if}
156
98
  {/each}
157
99
 
@@ -1,11 +1,7 @@
1
1
  import { type Field, SubmitResult } from './form.js';
2
- interface Css {
3
- form?: string;
4
- error?: string;
5
- }
6
2
  interface Props {
7
- action?: string;
8
- css?: Css;
3
+ action: string;
4
+ css?: string;
9
5
  successMessage?: string;
10
6
  submitButonText?: string;
11
7
  fields?: Array<Field>;
@@ -2,9 +2,7 @@ export declare enum FieldType {
2
2
  Input = "input",
3
3
  Textarea = "textarea",
4
4
  Radio = "radio",
5
- Checkbox = "checkbox",
6
- Hidden = "hidden",
7
- DropZone = "dropzone"
5
+ Checkbox = "checkbox"
8
6
  }
9
7
  export declare enum InputType {
10
8
  Text = "text",
@@ -40,13 +38,8 @@ export interface Field {
40
38
  checked?: boolean;
41
39
  }>;
42
40
  checked?: boolean;
43
- value?: string | number | boolean | File[];
44
- props?: any;
45
- error?: string;
46
41
  }
47
42
  export declare enum SubmitResult {
48
43
  Success = "success",
49
44
  Error = "error"
50
45
  }
51
- export declare const readForm: (request: Request, fields: Array<Field>) => Promise<any>;
52
- export declare const refreshPage: () => void;
@@ -4,8 +4,6 @@ export var FieldType;
4
4
  FieldType["Textarea"] = "textarea";
5
5
  FieldType["Radio"] = "radio";
6
6
  FieldType["Checkbox"] = "checkbox";
7
- FieldType["Hidden"] = "hidden";
8
- FieldType["DropZone"] = "dropzone";
9
7
  })(FieldType || (FieldType = {}));
10
8
  export var InputType;
11
9
  (function (InputType) {
@@ -35,16 +33,3 @@ export var SubmitResult;
35
33
  SubmitResult["Success"] = "success";
36
34
  SubmitResult["Error"] = "error";
37
35
  })(SubmitResult || (SubmitResult = {}));
38
- export const readForm = async (request, fields) => {
39
- const formData = await request.formData();
40
- let response = {};
41
- fields.forEach(field => {
42
- response[field.name] = formData.get(field.name);
43
- });
44
- return response;
45
- };
46
- export const refreshPage = () => {
47
- if (window) {
48
- window?.location?.reload();
49
- }
50
- };
@@ -0,0 +1,2 @@
1
+ export declare enum Icons {
2
+ }
@@ -0,0 +1,3 @@
1
+ export var Icons;
2
+ (function (Icons) {
3
+ })(Icons || (Icons = {}));