@functionalcms/svelte-components 4.27.0 → 4.29.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,183 +1,158 @@
1
1
  <script lang="ts">
2
- import { displaySize, type FileDropZoneProps, type FileRejectedReason } from './dropzone.js';
3
- import { cn } from '../../utils.js';
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
- 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();
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
- if (maxFiles !== undefined && fileCount === undefined) {
20
- console.warn(
21
- 'Make sure to provide FileDropZone with `fileCount` when using the `maxFiles` prompt'
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
- 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);
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
- 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);
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
- uploading = false;
111
- };
63
+ function onDragOver(e: DragEvent) {
64
+ e.preventDefault();
65
+ e.dataTransfer.dropEffect = "copy";
66
+ isDragging = true;
67
+ }
112
68
 
113
- const canUploadFiles = $derived(
114
- !disabled &&
115
- !uploading &&
116
- !(maxFiles !== undefined && fileCount !== undefined && fileCount >= maxFiles)
117
- );
69
+ function onDragLeave(e: DragEvent) {
70
+ e.preventDefault();
71
+ isDragging = false;
72
+ }
118
73
  </script>
119
74
 
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
- )}
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
- {...rest}
175
- disabled={!canUploadFiles}
176
- {id}
177
- {accept}
178
- multiple={maxFiles === undefined || maxFiles - (fileCount ?? 0) > 1}
91
+ id="file-input"
179
92
  type="file"
180
- onchange={change}
181
- class="hidden"
93
+ {accept}
94
+ {multiple}
95
+ onchange={onInputChange}
96
+ bind:this={fileInput}
97
+ aria-hidden="true"
182
98
  />
183
- </label>
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 { type FileDropZoneProps } from './dropzone.js';
2
- declare const Dropzone: import("svelte").Component<FileDropZoneProps, {}, "">;
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?: string;
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
@@ -27,7 +33,6 @@
27
33
 
28
34
  const submitContactForm = async (event: Event) => {
29
35
  try {
30
- debugger;
31
36
  if (isSendingMessage) {
32
37
  return;
33
38
  }
@@ -46,6 +51,17 @@
46
51
  console.error('Error submitting form:', error);
47
52
  }
48
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
+ };
49
65
  </script>
50
66
 
51
67
  {#if showMessage}
@@ -53,7 +69,7 @@
53
69
  {successMessage}
54
70
  </div>
55
71
  {:else}
56
- <form method="POST" bind:this={form} class={css}>
72
+ <form method="POST" bind:this={form} class={css.form}>
57
73
  <fieldset>
58
74
  {#each fields as field}
59
75
  {#if field.type === FieldType.Input}
@@ -65,6 +81,7 @@
65
81
  type={field.subType}
66
82
  isRequired={field.isRequired || false}
67
83
  value={field.value}
84
+ {...field.props}
68
85
  />
69
86
  {:else if field.type === FieldType.Textarea}
70
87
  <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?: string;
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,10 +40,13 @@ export interface Field {
39
40
  checked?: boolean;
40
41
  }>;
41
42
  checked?: boolean;
42
- value?: string | number | boolean;
43
+ value?: string | number | boolean | File[];
44
+ props?: any;
45
+ error?: string;
43
46
  }
44
47
  export declare enum SubmitResult {
45
48
  Success = "success",
46
49
  Error = "error"
47
50
  }
48
51
  export declare const readForm: (request: Request, fields: Array<Field>) => Promise<any>;
52
+ export declare const refreshPage: () => void;
@@ -5,6 +5,7 @@ export var FieldType;
5
5
  FieldType["Radio"] = "radio";
6
6
  FieldType["Checkbox"] = "checkbox";
7
7
  FieldType["Hidden"] = "hidden";
8
+ FieldType["DropZone"] = "dropzone";
8
9
  })(FieldType || (FieldType = {}));
9
10
  export var InputType;
10
11
  (function (InputType) {
@@ -42,3 +43,8 @@ export const readForm = async (request, fields) => {
42
43
  });
43
44
  return response;
44
45
  };
46
+ export const refreshPage = () => {
47
+ if (window) {
48
+ window?.location?.reload();
49
+ }
50
+ };
package/dist/index.d.ts CHANGED
@@ -29,7 +29,7 @@ export { default as Button } from './components/form/Button.svelte';
29
29
  export { default as Input } from './components/form/Input.svelte';
30
30
  export { default as Switch } from './components/form/Switch.svelte';
31
31
  export { default as ChoiceInput } from './components/form/ChoiceInput.svelte';
32
- export { InputType, FieldType, LabelSize, type Field, SubmitResult, readForm } from './components/form/form.js';
32
+ export { InputType, FieldType, LabelSize, type Field, SubmitResult, readForm, refreshPage } from './components/form/form.js';
33
33
  export { default as AntiBot } from './components/form/AntiBot.svelte';
34
34
  export { default as Dropzone } from './components/form/Dropzone.svelte';
35
35
  export { default as Select } from './components/form/Select.svelte';
package/dist/index.js CHANGED
@@ -42,7 +42,7 @@ export { default as Button } from './components/form/Button.svelte';
42
42
  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
- export { InputType, FieldType, LabelSize, SubmitResult, readForm } from './components/form/form.js';
45
+ export { InputType, FieldType, LabelSize, SubmitResult, readForm, refreshPage } from './components/form/form.js';
46
46
  export { default as AntiBot } from './components/form/AntiBot.svelte';
47
47
  export { default as Dropzone } from './components/form/Dropzone.svelte';
48
48
  export { default as Select } from './components/form/Select.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@functionalcms/svelte-components",
3
- "version": "4.27.0",
3
+ "version": "4.29.0",
4
4
  "license": "MIT",
5
5
  "watch": {
6
6
  "build": {