form-uploader 0.0.0-stage → 0.2.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/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +268 -2
- package/dist/core.d.ts +88 -0
- package/dist/core.js +163 -0
- package/dist/element.d.ts +37 -0
- package/dist/element.js +360 -0
- package/dist/form-uploader.global.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/react.d.ts +20 -0
- package/dist/react.js +81 -0
- package/dist/vue.d.ts +51 -0
- package/dist/vue.js +82 -0
- package/package.json +77 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- React (`form-uploader/react`) and Vue (`form-uploader/vue`) wrappers with submit/change events and reset/validate handles.
|
|
6
|
+
- Layout config: half-width fields, titled groups, per-field help text.
|
|
7
|
+
- `form-change` snapshots and opt-in `validateOnChange` with focus-safe live errors.
|
|
8
|
+
- Single-file browser build (`dist/form-uploader.global.js`) for script-tag use.
|
|
9
|
+
- Documented Angular and server-rendered (Django/Flask) patterns.
|
|
10
|
+
|
|
11
|
+
## 0.1.0
|
|
12
|
+
|
|
13
|
+
- Initial release: framework-agnostic core, `<form-uploader>` element, validation, multi-image upload, CSS-variable theming.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Elbeg Darkhanbaatar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,269 @@
|
|
|
1
|
-
#
|
|
1
|
+
# form-uploader
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
One form for every site: contact fields plus multi-image upload. A framework-agnostic core plus a drop-in `<form-uploader>` custom element. Zero runtime dependencies, ESM only, TypeScript types included.
|
|
4
|
+
|
|
5
|
+
- Drop-in element works in plain HTML and anywhere, with React and Vue wrappers included.
|
|
6
|
+
- Core module is DOM-free, so you can drive your own UI or run it in Node.
|
|
7
|
+
- Validation, file limits, and `FormData` payload building included.
|
|
8
|
+
- Themed with CSS variables, dark-mode aware, keyboard accessible.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install form-uploader
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Drop-in element
|
|
17
|
+
|
|
18
|
+
```html
|
|
19
|
+
<form-uploader id="u" submit-label="Send"></form-uploader>
|
|
20
|
+
<script type="module">
|
|
21
|
+
import "form-uploader/element";
|
|
22
|
+
|
|
23
|
+
const el = document.getElementById("u");
|
|
24
|
+
el.onSubmit = async ({ formData, values, files }) => {
|
|
25
|
+
await fetch("/api/upload", { method: "POST", body: formData });
|
|
26
|
+
};
|
|
27
|
+
</script>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Serve your page over HTTP, since modules do not load from `file://`.
|
|
31
|
+
|
|
32
|
+
By default the form has Name, Email, and Phone (required) plus Description (optional) and an image picker with previews.
|
|
33
|
+
|
|
34
|
+
### Handling submit
|
|
35
|
+
|
|
36
|
+
Two ways, pick one:
|
|
37
|
+
|
|
38
|
+
```js
|
|
39
|
+
el.onSubmit = async ({ formData, values, files }) => {
|
|
40
|
+
await fetch("/api/upload", { method: "POST", body: formData });
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
el.addEventListener("form-submit", (event) => {
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
console.log(event.detail.values);
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`onSubmit` runs after a valid submit. If you listen for the `form-submit` event (it bubbles and crosses shadow DOM) and call `preventDefault()`, `onSubmit` is skipped. Both receive `{ formData, values, files }`.
|
|
50
|
+
|
|
51
|
+
### Configuring the element
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
import { DEFAULT_FIELDS } from "form-uploader";
|
|
55
|
+
|
|
56
|
+
el.config = {
|
|
57
|
+
fields: [...DEFAULT_FIELDS, { name: "company", label: "Company" }],
|
|
58
|
+
files: { maxFiles: 3 },
|
|
59
|
+
};
|
|
60
|
+
el.reset();
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- `el.config` sets fields and files. Setting it resets the form.
|
|
64
|
+
- `el.reset()` clears values, files, and errors.
|
|
65
|
+
- `el.uploader` exposes the core instance below for direct control.
|
|
66
|
+
- The `submit-label` attribute sets the button text (default `"Submit"`).
|
|
67
|
+
|
|
68
|
+
## Frameworks
|
|
69
|
+
|
|
70
|
+
### React / Next.js
|
|
71
|
+
|
|
72
|
+
```jsx
|
|
73
|
+
import { FormUploader } from "form-uploader/react";
|
|
74
|
+
|
|
75
|
+
<FormUploader
|
|
76
|
+
fields={fields}
|
|
77
|
+
files={{ maxFiles: 3 }}
|
|
78
|
+
submitLabel="Send"
|
|
79
|
+
onSubmit={async ({ formData }) => {
|
|
80
|
+
await fetch("/api/upload", { method: "POST", body: formData });
|
|
81
|
+
}}
|
|
82
|
+
onChange={({ values }) => console.log(values)}
|
|
83
|
+
/>;
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The wrapper is marked client-side, so Next.js App Router pages can import it directly, even from Server Components. `onChange` streams `{ values, files, errors, valid }` snapshots. A `ref` exposes `reset()`, `validate()`, `getValues()`, and `getFiles()`. Equal configs never wipe typed input, so inline props are safe. Requires `react >= 18`.
|
|
87
|
+
|
|
88
|
+
### Vue
|
|
89
|
+
|
|
90
|
+
```vue
|
|
91
|
+
<script setup>
|
|
92
|
+
import { FormUploader } from "form-uploader/vue";
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<template>
|
|
96
|
+
<FormUploader :fields="fields" :files="{ maxFiles: 3 }" submit-label="Send" @submit="send" />
|
|
97
|
+
</template>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Props are `fields`, `files`, `validateOnChange`, and `submitLabel`. Events are `submit` and `change`, carrying the same payloads as the element. `class`, `style`, and other attributes fall through to the element for placement. A template ref exposes `reset()`, `validate()`, `getValues()`, and `getFiles()`. Requires `vue ^3`.
|
|
101
|
+
|
|
102
|
+
### Angular
|
|
103
|
+
|
|
104
|
+
No wrapper needed: bind the element directly.
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import "form-uploader/element";
|
|
108
|
+
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
|
|
109
|
+
import type { FormSubmitDetail, FormUploaderConfig } from "form-uploader";
|
|
110
|
+
|
|
111
|
+
@Component({
|
|
112
|
+
selector: "app-contact",
|
|
113
|
+
standalone: true,
|
|
114
|
+
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
|
115
|
+
template: `
|
|
116
|
+
<form-uploader
|
|
117
|
+
[config]="config"
|
|
118
|
+
submit-label="Send"
|
|
119
|
+
(form-submit)="onSubmit($event.detail)"
|
|
120
|
+
></form-uploader>
|
|
121
|
+
`,
|
|
122
|
+
})
|
|
123
|
+
export class ContactComponent {
|
|
124
|
+
config: FormUploaderConfig = { files: { maxFiles: 3 } };
|
|
125
|
+
|
|
126
|
+
onSubmit(detail: FormSubmitDetail): void {
|
|
127
|
+
fetch("/api/upload", { method: "POST", body: detail.formData });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Import `form-uploader/element` once (for example in `main.ts`) to register the tag. `config` binds as a property and the events carry the same payloads as below. A `ViewChild` reference gives you `reset()` and the core `uploader` for anything else.
|
|
133
|
+
|
|
134
|
+
### Server-rendered pages (Django, Flask, plain HTML)
|
|
135
|
+
|
|
136
|
+
No build step. Pin a version and drop in a script tag:
|
|
137
|
+
|
|
138
|
+
```html
|
|
139
|
+
<script src="https://unpkg.com/form-uploader@0.2.0/dist/form-uploader.global.js"></script>
|
|
140
|
+
<form-uploader submit-label="Send"></form-uploader>
|
|
141
|
+
<script>
|
|
142
|
+
document.querySelector("form-uploader").onSubmit = async ({ formData }) => {
|
|
143
|
+
await fetch("/upload", { method: "POST", body: formData });
|
|
144
|
+
};
|
|
145
|
+
</script>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Core (your own UI)
|
|
149
|
+
|
|
150
|
+
```js
|
|
151
|
+
import { createFormUploader } from "form-uploader";
|
|
152
|
+
|
|
153
|
+
const form = createFormUploader();
|
|
154
|
+
form.setValue("email", "ada@example.com");
|
|
155
|
+
form.addFiles(fileInput.files);
|
|
156
|
+
|
|
157
|
+
const { valid, errors } = form.validate();
|
|
158
|
+
if (valid) {
|
|
159
|
+
await fetch("/api/upload", { method: "POST", body: form.buildPayload() });
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Methods: `setValue(name, value)`, `getValues()`, `getFiles()`, `addFiles(files)`, `removeFile(index)`, `clearFiles()`, `validate()`, `getErrors()`, `buildPayload()`, `reset()`. Also exposed: `fields`, `filesEnabled`, `fileConfig`.
|
|
164
|
+
|
|
165
|
+
`addFiles` accepts an array or a `FileList` and returns `{ added, rejected }`, where each rejection carries a human-readable `reason`.
|
|
166
|
+
|
|
167
|
+
## Fields and files
|
|
168
|
+
|
|
169
|
+
```js
|
|
170
|
+
const form = createFormUploader({
|
|
171
|
+
fields: [
|
|
172
|
+
{ name: "name", label: "Name", required: true },
|
|
173
|
+
{ name: "company", label: "Company", placeholder: "Acme Inc." },
|
|
174
|
+
{
|
|
175
|
+
name: "age",
|
|
176
|
+
label: "Age",
|
|
177
|
+
validate: (value) => (Number(value) >= 18 ? null : "Must be 18+."),
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
files: { maxFiles: 3, maxSize: 2 * 1024 * 1024, accept: "image/*", required: true },
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Field options:
|
|
185
|
+
|
|
186
|
+
- `name` (required), `label`, `type` (`text`, `email`, `tel`, `textarea`; default `text`)
|
|
187
|
+
- `required` (default `false`), `placeholder`
|
|
188
|
+
- `minLength`, `maxLength`, counted in characters
|
|
189
|
+
- `validate(value)` returning an error string or `null`
|
|
190
|
+
- `width` (`full` default, `half` shares a row; stacks on narrow containers)
|
|
191
|
+
- `help` (hint text under the input), `group` (name of a group below)
|
|
192
|
+
|
|
193
|
+
File options:
|
|
194
|
+
|
|
195
|
+
- `fieldName` (default `"images"`), the `FormData` key files are appended under
|
|
196
|
+
- `maxFiles` (default `5`), `maxSize` in bytes per file (default 5 MB)
|
|
197
|
+
- `accept` (default `"image/*"`; empty string allows everything)
|
|
198
|
+
- `required` (default `false`)
|
|
199
|
+
- Pass `files: false` to disable uploads entirely
|
|
200
|
+
|
|
201
|
+
Top-level options:
|
|
202
|
+
|
|
203
|
+
- `groups: [{ name, title, description }]` renders titled cards in order; fields without a group, or with an unknown one, render first
|
|
204
|
+
- `validateOnChange` (default `false`): revalidate on every keystroke with live errors; focus is preserved
|
|
205
|
+
|
|
206
|
+
Validation order per field is required, email format, length, then your `validate`, first failure wins. Email is checked only when non-empty. Phone has no format check since formats vary, so add `validate` or `minLength` if you need one.
|
|
207
|
+
|
|
208
|
+
The payload is a `FormData` with one entry per field plus each file under `fieldName`, ready to `POST` to your backend.
|
|
209
|
+
|
|
210
|
+
## Events
|
|
211
|
+
|
|
212
|
+
Both events bubble and cross shadow DOM.
|
|
213
|
+
|
|
214
|
+
- `form-submit`: a valid submit, detail `{ formData, values, files }`. Calling `preventDefault()` skips `onSubmit`.
|
|
215
|
+
- `form-change`: every keystroke and file change, detail `{ values, files, errors, valid }`. `errors` and `valid` reflect the last validation run, so pair it with `validateOnChange: true` for live validity.
|
|
216
|
+
|
|
217
|
+
## Styling
|
|
218
|
+
|
|
219
|
+
The element inherits your page font and adapts to light and dark mode automatically. Override any variable to theme it:
|
|
220
|
+
|
|
221
|
+
```css
|
|
222
|
+
form-uploader {
|
|
223
|
+
--fu-accent: #7c3aed;
|
|
224
|
+
--fu-radius: 14px;
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
| Variable | Default | Used for |
|
|
229
|
+
| ---------------------- | ----------------------- | ----------------------------- |
|
|
230
|
+
| `--fu-accent` | `#2563eb` | Submit button, focus, accents |
|
|
231
|
+
| `--fu-accent-hover` | `#1d4ed8` | Submit button hover |
|
|
232
|
+
| `--fu-accent-contrast` | `#ffffff` | Text on the accent color |
|
|
233
|
+
| `--fu-text` | `#1c1917` | Labels and input text |
|
|
234
|
+
| `--fu-muted` | `#57534e` | Hints, placeholders, captions |
|
|
235
|
+
| `--fu-border` | `#d6d3d1` | Input and card borders |
|
|
236
|
+
| `--fu-surface` | `#ffffff` | Input and card background |
|
|
237
|
+
| `--fu-surface-soft` | `#f5f5f4` | Dropzone and image backdrop |
|
|
238
|
+
| `--fu-error` | `#b91c1c` | Error text and invalid rings |
|
|
239
|
+
| `--fu-ring` | `rgb(37 99 235 / 0.25)` | Focus ring |
|
|
240
|
+
| `--fu-radius` | `10px` | Inputs, dropzone, button |
|
|
241
|
+
| `--fu-radius-sm` | `8px` | Preview cards |
|
|
242
|
+
|
|
243
|
+
For deeper restyling, every part is exposed via `::part`: `form`, `field`, `label`, `input`, `help`, `error`, `group`, `group-title`, `group-description`, `dropzone`, `dropzone-title`, `dropzone-hint`, `file-input`, `previews`, `preview`, `remove`, `submit`.
|
|
244
|
+
|
|
245
|
+
```css
|
|
246
|
+
form-uploader::part(submit) {
|
|
247
|
+
width: 100%;
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Focus rings, invalid states, and `prefers-reduced-motion` are handled inside the shadow DOM.
|
|
252
|
+
|
|
253
|
+
## Demo and example app
|
|
254
|
+
|
|
255
|
+
- `demo/index.html`: plain-HTML demo. Build first (`npm run build`), serve the repo folder with any static server, and open it.
|
|
256
|
+
- `example/`: Next.js app using the package from local source on port 3000. See `example/README.md`.
|
|
257
|
+
|
|
258
|
+
## Development
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
npm install
|
|
262
|
+
npm test # build + test suite
|
|
263
|
+
npm run format # format everything with Prettier
|
|
264
|
+
npm run format:check
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## License
|
|
268
|
+
|
|
269
|
+
MIT
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export type FieldType = "text" | "email" | "tel" | "textarea";
|
|
2
|
+
export type FieldWidth = "full" | "half";
|
|
3
|
+
export interface FieldConfig {
|
|
4
|
+
name: string;
|
|
5
|
+
label?: string;
|
|
6
|
+
type?: FieldType;
|
|
7
|
+
required?: boolean;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
minLength?: number;
|
|
10
|
+
maxLength?: number;
|
|
11
|
+
width?: FieldWidth;
|
|
12
|
+
help?: string;
|
|
13
|
+
group?: string;
|
|
14
|
+
validate?: (value: string) => string | null;
|
|
15
|
+
}
|
|
16
|
+
export interface FileUploadConfig {
|
|
17
|
+
fieldName?: string;
|
|
18
|
+
maxFiles?: number;
|
|
19
|
+
maxSize?: number;
|
|
20
|
+
accept?: string;
|
|
21
|
+
required?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface FormGroup {
|
|
24
|
+
name: string;
|
|
25
|
+
title: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface FormUploaderConfig {
|
|
29
|
+
fields?: FieldConfig[];
|
|
30
|
+
files?: FileUploadConfig | false;
|
|
31
|
+
groups?: FormGroup[];
|
|
32
|
+
validateOnChange?: boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface NormalizedField extends Omit<FieldConfig, "type" | "required" | "width"> {
|
|
35
|
+
type: FieldType;
|
|
36
|
+
required: boolean;
|
|
37
|
+
width: FieldWidth;
|
|
38
|
+
}
|
|
39
|
+
export interface ResolvedFileUploadConfig {
|
|
40
|
+
fieldName: string;
|
|
41
|
+
maxFiles: number;
|
|
42
|
+
maxSize: number;
|
|
43
|
+
accept: string;
|
|
44
|
+
required: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface FileRejection {
|
|
47
|
+
file: File;
|
|
48
|
+
reason: string;
|
|
49
|
+
}
|
|
50
|
+
export interface AddFilesResult {
|
|
51
|
+
added: File[];
|
|
52
|
+
rejected: FileRejection[];
|
|
53
|
+
}
|
|
54
|
+
export type ValidationErrors = Record<string, string>;
|
|
55
|
+
export interface ValidationResult {
|
|
56
|
+
valid: boolean;
|
|
57
|
+
errors: ValidationErrors;
|
|
58
|
+
}
|
|
59
|
+
export interface FormSubmitDetail {
|
|
60
|
+
formData: FormData;
|
|
61
|
+
values: Record<string, string>;
|
|
62
|
+
files: File[];
|
|
63
|
+
}
|
|
64
|
+
export interface FormChangeDetail {
|
|
65
|
+
values: Record<string, string>;
|
|
66
|
+
files: File[];
|
|
67
|
+
errors: ValidationErrors;
|
|
68
|
+
valid: boolean;
|
|
69
|
+
}
|
|
70
|
+
export declare const DEFAULT_FIELDS: FieldConfig[];
|
|
71
|
+
export declare const DEFAULT_FILE_CONFIG: ResolvedFileUploadConfig;
|
|
72
|
+
export declare const FILES_ERROR_KEY = "files";
|
|
73
|
+
export interface FormUploader {
|
|
74
|
+
readonly fields: NormalizedField[];
|
|
75
|
+
readonly filesEnabled: boolean;
|
|
76
|
+
readonly fileConfig: ResolvedFileUploadConfig;
|
|
77
|
+
setValue(name: string, value: string): boolean;
|
|
78
|
+
getValues(): Record<string, string>;
|
|
79
|
+
getFiles(): File[];
|
|
80
|
+
addFiles(files: File[] | FileList): AddFilesResult;
|
|
81
|
+
removeFile(index: number): boolean;
|
|
82
|
+
clearFiles(): void;
|
|
83
|
+
validate(): ValidationResult;
|
|
84
|
+
getErrors(): ValidationErrors;
|
|
85
|
+
buildPayload(): FormData;
|
|
86
|
+
reset(): void;
|
|
87
|
+
}
|
|
88
|
+
export declare function createFormUploader(config?: FormUploaderConfig): FormUploader;
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
export const DEFAULT_FIELDS = [
|
|
2
|
+
{ name: "name", label: "Name", type: "text", required: true },
|
|
3
|
+
{ name: "email", label: "Email", type: "email", required: true },
|
|
4
|
+
{ name: "phone", label: "Phone", type: "tel", required: true },
|
|
5
|
+
{ name: "description", label: "Description", type: "textarea", required: false },
|
|
6
|
+
];
|
|
7
|
+
for (const field of DEFAULT_FIELDS)
|
|
8
|
+
Object.freeze(field);
|
|
9
|
+
Object.freeze(DEFAULT_FIELDS);
|
|
10
|
+
export const DEFAULT_FILE_CONFIG = {
|
|
11
|
+
fieldName: "images",
|
|
12
|
+
maxFiles: 5,
|
|
13
|
+
maxSize: 5 * 1024 * 1024,
|
|
14
|
+
accept: "image/*",
|
|
15
|
+
required: false,
|
|
16
|
+
};
|
|
17
|
+
export const FILES_ERROR_KEY = "files";
|
|
18
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
19
|
+
export function createFormUploader(config = {}) {
|
|
20
|
+
const fields = (config.fields ?? DEFAULT_FIELDS).map((field) => ({
|
|
21
|
+
...field,
|
|
22
|
+
type: field.type ?? "text",
|
|
23
|
+
required: field.required ?? false,
|
|
24
|
+
width: field.width ?? "full",
|
|
25
|
+
}));
|
|
26
|
+
const filesEnabled = config.files !== false;
|
|
27
|
+
const fileConfig = {
|
|
28
|
+
...DEFAULT_FILE_CONFIG,
|
|
29
|
+
...(config.files === false || config.files === undefined ? {} : config.files),
|
|
30
|
+
};
|
|
31
|
+
let values = {};
|
|
32
|
+
let files = [];
|
|
33
|
+
let errors = {};
|
|
34
|
+
const resetValues = () => {
|
|
35
|
+
values = {};
|
|
36
|
+
for (const field of fields)
|
|
37
|
+
values[field.name] = "";
|
|
38
|
+
};
|
|
39
|
+
resetValues();
|
|
40
|
+
const findField = (name) => fields.find((field) => field.name === name);
|
|
41
|
+
function matchesAccept(file) {
|
|
42
|
+
const accept = fileConfig.accept.trim();
|
|
43
|
+
if (accept === "")
|
|
44
|
+
return true;
|
|
45
|
+
const type = file.type.toLowerCase();
|
|
46
|
+
const name = file.name.toLowerCase();
|
|
47
|
+
return accept
|
|
48
|
+
.split(",")
|
|
49
|
+
.map((token) => token.trim().toLowerCase())
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.some((token) => {
|
|
52
|
+
if (token.endsWith("/*"))
|
|
53
|
+
return type.startsWith(token.slice(0, -1));
|
|
54
|
+
if (token.startsWith("."))
|
|
55
|
+
return name.endsWith(token);
|
|
56
|
+
return type === token;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function setValue(name, value) {
|
|
60
|
+
if (!findField(name))
|
|
61
|
+
return false;
|
|
62
|
+
values[name] = value;
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
function addFiles(input) {
|
|
66
|
+
const incoming = Array.from(input);
|
|
67
|
+
const added = [];
|
|
68
|
+
const rejected = [];
|
|
69
|
+
if (!filesEnabled) {
|
|
70
|
+
for (const file of incoming) {
|
|
71
|
+
rejected.push({ file, reason: "File uploads are disabled." });
|
|
72
|
+
}
|
|
73
|
+
return { added, rejected };
|
|
74
|
+
}
|
|
75
|
+
for (const file of incoming) {
|
|
76
|
+
if (files.length + added.length >= fileConfig.maxFiles) {
|
|
77
|
+
rejected.push({ file, reason: `Only ${fileConfig.maxFiles} file(s) allowed.` });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (file.size > fileConfig.maxSize) {
|
|
81
|
+
rejected.push({ file, reason: `"${file.name}" exceeds the size limit.` });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (!matchesAccept(file)) {
|
|
85
|
+
rejected.push({ file, reason: `"${file.name}" is not an accepted file type.` });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
added.push(file);
|
|
89
|
+
}
|
|
90
|
+
files = files.concat(added);
|
|
91
|
+
return { added, rejected };
|
|
92
|
+
}
|
|
93
|
+
function removeFile(index) {
|
|
94
|
+
if (index < 0 || index >= files.length)
|
|
95
|
+
return false;
|
|
96
|
+
files = files.slice(0, index).concat(files.slice(index + 1));
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
function validateField(field, value) {
|
|
100
|
+
const label = field.label || field.name;
|
|
101
|
+
if (field.required && value.trim() === "")
|
|
102
|
+
return `${label} is required.`;
|
|
103
|
+
if (field.type === "email" && value.trim() !== "" && !EMAIL_PATTERN.test(value.trim())) {
|
|
104
|
+
return "Enter a valid email address.";
|
|
105
|
+
}
|
|
106
|
+
if (field.minLength !== undefined && value.length < field.minLength) {
|
|
107
|
+
return `${label} must be at least ${field.minLength} characters.`;
|
|
108
|
+
}
|
|
109
|
+
if (field.maxLength !== undefined && value.length > field.maxLength) {
|
|
110
|
+
return `${label} must be at most ${field.maxLength} characters.`;
|
|
111
|
+
}
|
|
112
|
+
if (field.validate)
|
|
113
|
+
return field.validate(value);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
function validate() {
|
|
117
|
+
const next = {};
|
|
118
|
+
for (const field of fields) {
|
|
119
|
+
const message = validateField(field, values[field.name] ?? "");
|
|
120
|
+
if (message)
|
|
121
|
+
next[field.name] = message;
|
|
122
|
+
}
|
|
123
|
+
if (filesEnabled && fileConfig.required && files.length === 0) {
|
|
124
|
+
next[FILES_ERROR_KEY] = "At least one file is required.";
|
|
125
|
+
}
|
|
126
|
+
errors = next;
|
|
127
|
+
return { valid: Object.keys(next).length === 0, errors: { ...next } };
|
|
128
|
+
}
|
|
129
|
+
function buildPayload() {
|
|
130
|
+
const formData = new FormData();
|
|
131
|
+
for (const field of fields) {
|
|
132
|
+
formData.append(field.name, values[field.name] ?? "");
|
|
133
|
+
}
|
|
134
|
+
if (filesEnabled) {
|
|
135
|
+
for (const file of files) {
|
|
136
|
+
formData.append(fileConfig.fieldName, file);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return formData;
|
|
140
|
+
}
|
|
141
|
+
function reset() {
|
|
142
|
+
resetValues();
|
|
143
|
+
files = [];
|
|
144
|
+
errors = {};
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
fields,
|
|
148
|
+
filesEnabled,
|
|
149
|
+
fileConfig,
|
|
150
|
+
setValue,
|
|
151
|
+
getValues: () => ({ ...values }),
|
|
152
|
+
getFiles: () => [...files],
|
|
153
|
+
addFiles,
|
|
154
|
+
removeFile,
|
|
155
|
+
clearFiles: () => {
|
|
156
|
+
files = [];
|
|
157
|
+
},
|
|
158
|
+
validate,
|
|
159
|
+
getErrors: () => ({ ...errors }),
|
|
160
|
+
buildPayload,
|
|
161
|
+
reset,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type FormSubmitDetail, type FormUploader, type FormUploaderConfig } from "./core.js";
|
|
2
|
+
export declare const ELEMENT_NAME = "form-uploader";
|
|
3
|
+
export declare const SUBMIT_EVENT = "form-submit";
|
|
4
|
+
export declare const CHANGE_EVENT = "form-change";
|
|
5
|
+
declare const BaseElement: typeof HTMLElement;
|
|
6
|
+
export declare class FormUploaderElement extends BaseElement {
|
|
7
|
+
private uploaderInstance;
|
|
8
|
+
private elementConfig;
|
|
9
|
+
private previewUrls;
|
|
10
|
+
private fileNotice;
|
|
11
|
+
onSubmit?: (detail: FormSubmitDetail) => void | Promise<void>;
|
|
12
|
+
static get observedAttributes(): string[];
|
|
13
|
+
get config(): FormUploaderConfig;
|
|
14
|
+
set config(next: FormUploaderConfig);
|
|
15
|
+
get uploader(): FormUploader;
|
|
16
|
+
connectedCallback(): void;
|
|
17
|
+
disconnectedCallback(): void;
|
|
18
|
+
attributeChangedCallback(): void;
|
|
19
|
+
reset(): void;
|
|
20
|
+
private get submitLabel();
|
|
21
|
+
private fieldHtml;
|
|
22
|
+
private render;
|
|
23
|
+
private afterChange;
|
|
24
|
+
private emitChange;
|
|
25
|
+
private snapshotFocus;
|
|
26
|
+
private restoreFocus;
|
|
27
|
+
private focusFileInput;
|
|
28
|
+
private handleSubmit;
|
|
29
|
+
private syncPreviews;
|
|
30
|
+
private revokePreviews;
|
|
31
|
+
}
|
|
32
|
+
declare global {
|
|
33
|
+
interface HTMLElementTagNameMap {
|
|
34
|
+
"form-uploader": FormUploaderElement;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export {};
|
package/dist/element.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { FILES_ERROR_KEY, createFormUploader, } from "./core.js";
|
|
2
|
+
export const ELEMENT_NAME = "form-uploader";
|
|
3
|
+
export const SUBMIT_EVENT = "form-submit";
|
|
4
|
+
export const CHANGE_EVENT = "form-change";
|
|
5
|
+
const BaseElement = globalThis.HTMLElement ??
|
|
6
|
+
class {
|
|
7
|
+
};
|
|
8
|
+
function escapeHtml(value) {
|
|
9
|
+
return value
|
|
10
|
+
.replace(/&/g, "&")
|
|
11
|
+
.replace(/</g, "<")
|
|
12
|
+
.replace(/>/g, ">")
|
|
13
|
+
.replace(/"/g, """);
|
|
14
|
+
}
|
|
15
|
+
function formatBytes(bytes) {
|
|
16
|
+
if (bytes >= 1024 * 1024)
|
|
17
|
+
return `${Math.round(bytes / (1024 * 1024))} MB`;
|
|
18
|
+
if (bytes >= 1024)
|
|
19
|
+
return `${Math.round(bytes / 1024)} KB`;
|
|
20
|
+
return `${bytes} B`;
|
|
21
|
+
}
|
|
22
|
+
const STYLES = [
|
|
23
|
+
":host { display: block; font: inherit; color-scheme: light dark; container-type: inline-size; }",
|
|
24
|
+
":host {",
|
|
25
|
+
" --fu-accent: #2563eb;",
|
|
26
|
+
" --fu-accent-hover: #1d4ed8;",
|
|
27
|
+
" --fu-accent-contrast: #ffffff;",
|
|
28
|
+
" --fu-text: #1c1917;",
|
|
29
|
+
" --fu-muted: #57534e;",
|
|
30
|
+
" --fu-border: #d6d3d1;",
|
|
31
|
+
" --fu-surface: #ffffff;",
|
|
32
|
+
" --fu-surface-soft: #f5f5f4;",
|
|
33
|
+
" --fu-error: #b91c1c;",
|
|
34
|
+
" --fu-ring: rgb(37 99 235 / 0.25);",
|
|
35
|
+
" --fu-radius: 10px;",
|
|
36
|
+
" --fu-radius-sm: 8px;",
|
|
37
|
+
"}",
|
|
38
|
+
"@media (prefers-color-scheme: dark) {",
|
|
39
|
+
" :host {",
|
|
40
|
+
" --fu-text: #e7e5e4;",
|
|
41
|
+
" --fu-muted: #a8a29e;",
|
|
42
|
+
" --fu-border: #44403c;",
|
|
43
|
+
" --fu-surface: #1c1917;",
|
|
44
|
+
" --fu-surface-soft: #292524;",
|
|
45
|
+
" --fu-error: #f87171;",
|
|
46
|
+
" --fu-ring: rgb(96 165 250 / 0.35);",
|
|
47
|
+
" }",
|
|
48
|
+
"}",
|
|
49
|
+
"form { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; color: var(--fu-text); }",
|
|
50
|
+
".field { min-width: 0; grid-column: span 2; }",
|
|
51
|
+
".field.half { grid-column: span 1; }",
|
|
52
|
+
"@container (max-width: 28rem) { .field.half { grid-column: span 2; } }",
|
|
53
|
+
".group { grid-column: span 2; display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 0; padding: 1rem; border: 1px solid var(--fu-border); border-radius: var(--fu-radius); background: var(--fu-surface); }",
|
|
54
|
+
".group-title { grid-column: span 2; font-size: 0.875rem; font-weight: 600; }",
|
|
55
|
+
".group-description { grid-column: span 2; font-size: 0.8125rem; color: var(--fu-muted); margin: 0; }",
|
|
56
|
+
".help { font-size: 0.8125rem; color: var(--fu-muted); margin: 0.375rem 0 0; }",
|
|
57
|
+
".field > label:not(.dropzone) { display: grid; gap: 0.375rem; font-size: 0.875rem; font-weight: 500; }",
|
|
58
|
+
"input, textarea { font: inherit; color: var(--fu-text); background: var(--fu-surface); border: 1px solid var(--fu-border); border-radius: var(--fu-radius); padding: 0.625rem 0.75rem; width: 100%; box-sizing: border-box; transition: border-color 0.15s ease, box-shadow 0.15s ease; }",
|
|
59
|
+
"input::placeholder, textarea::placeholder { color: var(--fu-muted); opacity: 1; }",
|
|
60
|
+
"input:focus-visible, textarea:focus-visible { outline: none; border-color: var(--fu-accent); box-shadow: 0 0 0 3px var(--fu-ring); }",
|
|
61
|
+
"input[aria-invalid='true'], textarea[aria-invalid='true'] { border-color: var(--fu-error); }",
|
|
62
|
+
"input[aria-invalid='true']:focus-visible, textarea[aria-invalid='true']:focus-visible { box-shadow: 0 0 0 3px color-mix(in srgb, var(--fu-error) 25%, transparent); }",
|
|
63
|
+
"textarea { min-height: 6rem; resize: vertical; }",
|
|
64
|
+
".error { color: var(--fu-error); font-size: 0.8125rem; margin: 0.375rem 0 0; }",
|
|
65
|
+
".dropzone { position: relative; display: grid; justify-items: center; gap: 0.375rem; padding: 1.5rem 1rem; border: 1.5px dashed var(--fu-border); border-radius: var(--fu-radius); background: var(--fu-surface-soft); cursor: pointer; text-align: center; transition: border-color 0.15s ease, box-shadow 0.15s ease; }",
|
|
66
|
+
".dropzone:hover { border-color: var(--fu-accent); }",
|
|
67
|
+
".dropzone:focus-within { outline: none; border-color: var(--fu-accent); box-shadow: 0 0 0 3px var(--fu-ring); }",
|
|
68
|
+
".dropzone input { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; }",
|
|
69
|
+
".dropzone-mark { display: grid; place-items: center; width: 2.5rem; height: 2.5rem; border-radius: 999px; background: var(--fu-accent); color: var(--fu-accent-contrast); font-size: 1.5rem; font-weight: 300; line-height: 1; }",
|
|
70
|
+
".dropzone-title { font-size: 0.875rem; font-weight: 600; }",
|
|
71
|
+
".dropzone-hint { font-size: 0.8125rem; font-weight: 400; color: var(--fu-muted); }",
|
|
72
|
+
".previews { display: grid; grid-template-columns: repeat(auto-fill, minmax(6rem, 1fr)); gap: 0.625rem; margin-top: 0.625rem; }",
|
|
73
|
+
".preview { margin: 0; position: relative; border: 1px solid var(--fu-border); border-radius: var(--fu-radius-sm); overflow: hidden; background: var(--fu-surface); }",
|
|
74
|
+
".preview img { width: 100%; height: 5.25rem; object-fit: cover; display: block; background: var(--fu-surface-soft); }",
|
|
75
|
+
".preview figcaption { font-size: 0.7rem; padding: 0.25rem 0.375rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fu-muted); }",
|
|
76
|
+
".preview button { position: absolute; top: 0.25rem; right: 0.25rem; width: 1.5rem; height: 1.5rem; display: grid; place-items: center; padding: 0; border: none; border-radius: 999px; background: rgb(0 0 0 / 0.6); color: #ffffff; font-size: 1rem; line-height: 1; cursor: pointer; }",
|
|
77
|
+
".preview button:hover { background: rgb(0 0 0 / 0.8); }",
|
|
78
|
+
"button[type='submit'] { justify-self: start; font: inherit; font-weight: 600; color: var(--fu-accent-contrast); background: var(--fu-accent); border: 1px solid transparent; border-radius: var(--fu-radius); padding: 0.625rem 1.25rem; cursor: pointer; transition: background-color 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease; }",
|
|
79
|
+
"button[type='submit']:hover { background: var(--fu-accent-hover); }",
|
|
80
|
+
"button[type='submit']:active { transform: scale(0.98); }",
|
|
81
|
+
"button[type='submit']:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--fu-ring); }",
|
|
82
|
+
"@media (prefers-reduced-motion: reduce) {",
|
|
83
|
+
" *, *::before, *::after { transition: none; }",
|
|
84
|
+
"}",
|
|
85
|
+
].join("\n");
|
|
86
|
+
export class FormUploaderElement extends BaseElement {
|
|
87
|
+
constructor() {
|
|
88
|
+
super(...arguments);
|
|
89
|
+
this.uploaderInstance = createFormUploader({});
|
|
90
|
+
this.elementConfig = {};
|
|
91
|
+
this.previewUrls = new Map();
|
|
92
|
+
this.fileNotice = null;
|
|
93
|
+
}
|
|
94
|
+
static get observedAttributes() {
|
|
95
|
+
return ["submit-label"];
|
|
96
|
+
}
|
|
97
|
+
get config() {
|
|
98
|
+
return this.elementConfig;
|
|
99
|
+
}
|
|
100
|
+
set config(next) {
|
|
101
|
+
this.elementConfig = next ?? {};
|
|
102
|
+
this.uploaderInstance = createFormUploader(this.elementConfig);
|
|
103
|
+
this.fileNotice = null;
|
|
104
|
+
this.revokePreviews();
|
|
105
|
+
this.render();
|
|
106
|
+
}
|
|
107
|
+
get uploader() {
|
|
108
|
+
return this.uploaderInstance;
|
|
109
|
+
}
|
|
110
|
+
connectedCallback() {
|
|
111
|
+
this.render();
|
|
112
|
+
}
|
|
113
|
+
disconnectedCallback() {
|
|
114
|
+
this.revokePreviews();
|
|
115
|
+
}
|
|
116
|
+
attributeChangedCallback() {
|
|
117
|
+
this.render();
|
|
118
|
+
}
|
|
119
|
+
reset() {
|
|
120
|
+
this.uploaderInstance.reset();
|
|
121
|
+
this.fileNotice = null;
|
|
122
|
+
this.render();
|
|
123
|
+
}
|
|
124
|
+
get submitLabel() {
|
|
125
|
+
const host = this;
|
|
126
|
+
if (typeof host.getAttribute !== "function")
|
|
127
|
+
return "Submit";
|
|
128
|
+
return host.getAttribute("submit-label") || "Submit";
|
|
129
|
+
}
|
|
130
|
+
fieldHtml(field, values, errors) {
|
|
131
|
+
const value = escapeHtml(values[field.name] ?? "");
|
|
132
|
+
const error = errors[field.name]
|
|
133
|
+
? `<p class="error" part="error">${escapeHtml(errors[field.name])}</p>`
|
|
134
|
+
: "";
|
|
135
|
+
const label = escapeHtml(field.label || field.name);
|
|
136
|
+
const placeholder = field.placeholder ? ` placeholder="${escapeHtml(field.placeholder)}"` : "";
|
|
137
|
+
const required = field.required ? " required" : "";
|
|
138
|
+
const safeName = escapeHtml(field.name);
|
|
139
|
+
const invalid = errors[field.name] ? ` aria-invalid="true"` : "";
|
|
140
|
+
const width = field.width === "half" ? " half" : "";
|
|
141
|
+
const help = field.help ? `<p class="help" part="help">${escapeHtml(field.help)}</p>` : "";
|
|
142
|
+
const control = field.type === "textarea"
|
|
143
|
+
? `<textarea part="input" data-field="${safeName}" rows="4"${placeholder}${required}${invalid}>${value}</textarea>`
|
|
144
|
+
: `<input part="input" data-field="${safeName}" type="${field.type}" value="${value}"${placeholder}${required}${invalid} />`;
|
|
145
|
+
return `<div class="field${width}" part="field"><label part="label"><span>${label}</span>${control}</label>${help}${error}</div>`;
|
|
146
|
+
}
|
|
147
|
+
render() {
|
|
148
|
+
const host = this;
|
|
149
|
+
if (typeof host.attachShadow !== "function")
|
|
150
|
+
return;
|
|
151
|
+
const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" });
|
|
152
|
+
this.syncPreviews();
|
|
153
|
+
const values = this.uploaderInstance.getValues();
|
|
154
|
+
const errors = this.uploaderInstance.getErrors();
|
|
155
|
+
const files = this.uploaderInstance.getFiles();
|
|
156
|
+
const fileConfig = this.uploaderInstance.fileConfig;
|
|
157
|
+
const groups = this.elementConfig.groups ?? [];
|
|
158
|
+
const knownGroups = new Set(groups.map((group) => group.name));
|
|
159
|
+
const renderField = (field) => this.fieldHtml(field, values, errors);
|
|
160
|
+
const ungroupedHtml = this.uploaderInstance.fields
|
|
161
|
+
.filter((field) => !field.group || !knownGroups.has(field.group))
|
|
162
|
+
.map(renderField)
|
|
163
|
+
.join("");
|
|
164
|
+
const groupsHtml = groups
|
|
165
|
+
.map((group) => {
|
|
166
|
+
const members = this.uploaderInstance.fields.filter((field) => field.group === group.name);
|
|
167
|
+
if (members.length === 0)
|
|
168
|
+
return "";
|
|
169
|
+
const title = escapeHtml(group.title);
|
|
170
|
+
const description = group.description
|
|
171
|
+
? `<p class="group-description" part="group-description">${escapeHtml(group.description)}</p>`
|
|
172
|
+
: "";
|
|
173
|
+
return (`<div class="group" part="group" role="group" aria-label="${title}">` +
|
|
174
|
+
`<span class="group-title" part="group-title">${title}</span>` +
|
|
175
|
+
description +
|
|
176
|
+
members.map(renderField).join("") +
|
|
177
|
+
`</div>`);
|
|
178
|
+
})
|
|
179
|
+
.join("");
|
|
180
|
+
const fieldsHtml = ungroupedHtml + groupsHtml;
|
|
181
|
+
const fileError = errors[FILES_ERROR_KEY] ?? this.fileNotice;
|
|
182
|
+
const dropzoneTitle = fileConfig.accept === "image/*" ? "Choose images" : "Choose files";
|
|
183
|
+
const acceptLabel = fileConfig.accept === "image/*" ? "Images" : fileConfig.accept || "Files";
|
|
184
|
+
const dropzoneHint = `${acceptLabel} up to ${formatBytes(fileConfig.maxSize)} each, max ${fileConfig.maxFiles} ${fileConfig.maxFiles === 1 ? "file" : "files"}`;
|
|
185
|
+
const filesHtml = !this.uploaderInstance.filesEnabled
|
|
186
|
+
? ""
|
|
187
|
+
: `<div class="field" part="field">` +
|
|
188
|
+
`<label class="dropzone" part="label dropzone">` +
|
|
189
|
+
`<span class="dropzone-mark" aria-hidden="true">+</span>` +
|
|
190
|
+
`<span class="dropzone-title" part="dropzone-title">${dropzoneTitle}</span>` +
|
|
191
|
+
`<span class="dropzone-hint" part="dropzone-hint">${escapeHtml(dropzoneHint)}</span>` +
|
|
192
|
+
`<input part="file-input" data-files type="file" multiple accept="${escapeHtml(fileConfig.accept)}" />` +
|
|
193
|
+
`</label>` +
|
|
194
|
+
(fileError ? `<p class="error" part="error">${escapeHtml(fileError)}</p>` : "") +
|
|
195
|
+
`<div class="previews" part="previews">${files
|
|
196
|
+
.map((file, index) => `<figure class="preview" part="preview">` +
|
|
197
|
+
`<img src="${this.previewUrls.get(file) ?? ""}" alt="${escapeHtml(file.name)}" />` +
|
|
198
|
+
`<figcaption>${escapeHtml(file.name)}</figcaption>` +
|
|
199
|
+
`<button type="button" part="remove" data-remove="${index}" aria-label="Remove ${escapeHtml(file.name)}">×</button>` +
|
|
200
|
+
`</figure>`)
|
|
201
|
+
.join("")}</div></div>`;
|
|
202
|
+
shadow.innerHTML =
|
|
203
|
+
`<style>${STYLES}</style>` +
|
|
204
|
+
`<form part="form" novalidate>${fieldsHtml}${filesHtml}` +
|
|
205
|
+
`<button type="submit" part="submit">${escapeHtml(this.submitLabel)}</button></form>`;
|
|
206
|
+
shadow.querySelector("form")?.addEventListener("submit", (event) => {
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
void this.handleSubmit();
|
|
209
|
+
});
|
|
210
|
+
shadow.querySelectorAll("[data-field]").forEach((el) => {
|
|
211
|
+
el.addEventListener("input", () => {
|
|
212
|
+
const name = el.dataset.field;
|
|
213
|
+
if (!name)
|
|
214
|
+
return;
|
|
215
|
+
this.uploaderInstance.setValue(name, el.value);
|
|
216
|
+
this.afterChange();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
const fileInput = shadow.querySelector("[data-files]");
|
|
220
|
+
fileInput?.addEventListener("change", () => {
|
|
221
|
+
if (fileInput.files) {
|
|
222
|
+
const result = this.uploaderInstance.addFiles(fileInput.files);
|
|
223
|
+
this.fileNotice =
|
|
224
|
+
result.rejected.length > 0
|
|
225
|
+
? result.rejected.map((rejection) => rejection.reason).join(" ")
|
|
226
|
+
: null;
|
|
227
|
+
}
|
|
228
|
+
fileInput.value = "";
|
|
229
|
+
if (this.elementConfig.validateOnChange)
|
|
230
|
+
this.uploaderInstance.validate();
|
|
231
|
+
this.render();
|
|
232
|
+
this.focusFileInput();
|
|
233
|
+
this.emitChange();
|
|
234
|
+
});
|
|
235
|
+
shadow.querySelectorAll("[data-remove]").forEach((button) => {
|
|
236
|
+
button.addEventListener("click", () => {
|
|
237
|
+
this.uploaderInstance.removeFile(Number(button.dataset.remove));
|
|
238
|
+
this.fileNotice = null;
|
|
239
|
+
if (this.elementConfig.validateOnChange)
|
|
240
|
+
this.uploaderInstance.validate();
|
|
241
|
+
this.render();
|
|
242
|
+
this.focusFileInput();
|
|
243
|
+
this.emitChange();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
afterChange() {
|
|
248
|
+
if (this.elementConfig.validateOnChange) {
|
|
249
|
+
const focus = this.snapshotFocus();
|
|
250
|
+
this.uploaderInstance.validate();
|
|
251
|
+
this.render();
|
|
252
|
+
this.restoreFocus(focus);
|
|
253
|
+
}
|
|
254
|
+
this.emitChange();
|
|
255
|
+
}
|
|
256
|
+
emitChange() {
|
|
257
|
+
const errors = this.uploaderInstance.getErrors();
|
|
258
|
+
const detail = {
|
|
259
|
+
values: this.uploaderInstance.getValues(),
|
|
260
|
+
files: this.uploaderInstance.getFiles(),
|
|
261
|
+
errors,
|
|
262
|
+
valid: Object.keys(errors).length === 0,
|
|
263
|
+
};
|
|
264
|
+
const host = this;
|
|
265
|
+
host.dispatchEvent(new CustomEvent(CHANGE_EVENT, { detail, bubbles: true, composed: true }));
|
|
266
|
+
}
|
|
267
|
+
snapshotFocus() {
|
|
268
|
+
const host = this;
|
|
269
|
+
const active = host.shadowRoot?.activeElement;
|
|
270
|
+
const name = active?.dataset?.field ?? null;
|
|
271
|
+
let start = -1;
|
|
272
|
+
let end = -1;
|
|
273
|
+
if (name && active && (active.tagName === "INPUT" || active.tagName === "TEXTAREA")) {
|
|
274
|
+
try {
|
|
275
|
+
const control = active;
|
|
276
|
+
start = control.selectionStart ?? -1;
|
|
277
|
+
end = control.selectionEnd ?? -1;
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
start = -1;
|
|
281
|
+
end = -1;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { name, start, end };
|
|
285
|
+
}
|
|
286
|
+
restoreFocus(focus) {
|
|
287
|
+
if (!focus.name)
|
|
288
|
+
return;
|
|
289
|
+
const host = this;
|
|
290
|
+
const controls = host.shadowRoot?.querySelectorAll("[data-field]");
|
|
291
|
+
let next = null;
|
|
292
|
+
if (controls) {
|
|
293
|
+
for (const el of controls) {
|
|
294
|
+
if (el.dataset.field === focus.name)
|
|
295
|
+
next = el;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (next)
|
|
299
|
+
next.focus({ preventScroll: true });
|
|
300
|
+
if (focus.start >= 0 && next && (next.tagName === "INPUT" || next.tagName === "TEXTAREA")) {
|
|
301
|
+
try {
|
|
302
|
+
next.setSelectionRange(focus.start, focus.end);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
focusFileInput() {
|
|
310
|
+
const host = this;
|
|
311
|
+
const fileInput = host.shadowRoot?.querySelector("[data-files]");
|
|
312
|
+
fileInput?.focus({ preventScroll: true });
|
|
313
|
+
}
|
|
314
|
+
async handleSubmit() {
|
|
315
|
+
this.fileNotice = null;
|
|
316
|
+
const { valid } = this.uploaderInstance.validate();
|
|
317
|
+
this.render();
|
|
318
|
+
if (!valid) {
|
|
319
|
+
const host = this;
|
|
320
|
+
const firstInvalid = host.shadowRoot?.querySelector("[aria-invalid='true']");
|
|
321
|
+
firstInvalid?.focus();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const detail = {
|
|
325
|
+
formData: this.uploaderInstance.buildPayload(),
|
|
326
|
+
values: this.uploaderInstance.getValues(),
|
|
327
|
+
files: this.uploaderInstance.getFiles(),
|
|
328
|
+
};
|
|
329
|
+
const host = this;
|
|
330
|
+
const cancelled = !host.dispatchEvent(new CustomEvent(SUBMIT_EVENT, { detail, bubbles: true, composed: true }));
|
|
331
|
+
if (!cancelled)
|
|
332
|
+
await this.onSubmit?.(detail);
|
|
333
|
+
}
|
|
334
|
+
syncPreviews() {
|
|
335
|
+
const files = this.uploaderInstance.getFiles();
|
|
336
|
+
for (const [file, url] of this.previewUrls) {
|
|
337
|
+
if (!files.includes(file)) {
|
|
338
|
+
URL.revokeObjectURL(url);
|
|
339
|
+
this.previewUrls.delete(file);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function")
|
|
343
|
+
return;
|
|
344
|
+
for (const file of files) {
|
|
345
|
+
if (!this.previewUrls.has(file))
|
|
346
|
+
this.previewUrls.set(file, URL.createObjectURL(file));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
revokePreviews() {
|
|
350
|
+
for (const url of this.previewUrls.values()) {
|
|
351
|
+
if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
|
|
352
|
+
URL.revokeObjectURL(url);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
this.previewUrls.clear();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (typeof customElements !== "undefined" && !customElements.get(ELEMENT_NAME)) {
|
|
359
|
+
customElements.define(ELEMENT_NAME, FormUploaderElement);
|
|
360
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
"use strict";(()=>{var E=[{name:"name",label:"Name",type:"text",required:!0},{name:"email",label:"Email",type:"email",required:!0},{name:"phone",label:"Phone",type:"tel",required:!0},{name:"description",label:"Description",type:"textarea",required:!1}];for(let d of E)Object.freeze(d);Object.freeze(E);var U={fieldName:"images",maxFiles:5,maxSize:5*1024*1024,accept:"image/*",required:!1},y="files",k=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;function L(d={}){let h=(d.fields??E).map(t=>({...t,type:t.type??"text",required:t.required??!1,width:t.width??"full"})),e=d.files!==!1,i={...U,...d.files===!1||d.files===void 0?{}:d.files},s={},n=[],c={},u=()=>{s={};for(let t of h)s[t.name]=""};u();let f=t=>h.find(o=>o.name===t);function g(t){let o=i.accept.trim();if(o==="")return!0;let l=t.type.toLowerCase(),r=t.name.toLowerCase();return o.split(",").map(a=>a.trim().toLowerCase()).filter(Boolean).some(a=>a.endsWith("/*")?l.startsWith(a.slice(0,-1)):a.startsWith(".")?r.endsWith(a):l===a)}function m(t,o){return f(t)?(s[t]=o,!0):!1}function v(t){let o=Array.from(t),l=[],r=[];if(!e){for(let a of o)r.push({file:a,reason:"File uploads are disabled."});return{added:l,rejected:r}}for(let a of o){if(n.length+l.length>=i.maxFiles){r.push({file:a,reason:`Only ${i.maxFiles} file(s) allowed.`});continue}if(a.size>i.maxSize){r.push({file:a,reason:`"${a.name}" exceeds the size limit.`});continue}if(!g(a)){r.push({file:a,reason:`"${a.name}" is not an accepted file type.`});continue}l.push(a)}return n=n.concat(l),{added:l,rejected:r}}function F(t){return t<0||t>=n.length?!1:(n=n.slice(0,t).concat(n.slice(t+1)),!0)}function x(t,o){let l=t.label||t.name;return t.required&&o.trim()===""?`${l} is required.`:t.type==="email"&&o.trim()!==""&&!k.test(o.trim())?"Enter a valid email address.":t.minLength!==void 0&&o.length<t.minLength?`${l} must be at least ${t.minLength} characters.`:t.maxLength!==void 0&&o.length>t.maxLength?`${l} must be at most ${t.maxLength} characters.`:t.validate?t.validate(o):null}function b(){let t={};for(let o of h){let l=x(o,s[o.name]??"");l&&(t[o.name]=l)}return e&&i.required&&n.length===0&&(t[y]="At least one file is required."),c=t,{valid:Object.keys(t).length===0,errors:{...t}}}function w(){let t=new FormData;for(let o of h)t.append(o.name,s[o.name]??"");if(e)for(let o of n)t.append(i.fieldName,o);return t}function $(){u(),n=[],c={}}return{fields:h,filesEnabled:e,fileConfig:i,setValue:m,getValues:()=>({...s}),getFiles:()=>[...n],addFiles:v,removeFile:F,clearFiles:()=>{n=[]},validate:b,getErrors:()=>({...c}),buildPayload:w,reset:$}}var z="form-uploader",H="form-submit",M="form-change",N=globalThis.HTMLElement??class{};function p(d){return d.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}function S(d){return d>=1024*1024?`${Math.round(d/(1024*1024))} MB`:d>=1024?`${Math.round(d/1024)} KB`:`${d} B`}var q=[":host { display: block; font: inherit; color-scheme: light dark; container-type: inline-size; }",":host {"," --fu-accent: #2563eb;"," --fu-accent-hover: #1d4ed8;"," --fu-accent-contrast: #ffffff;"," --fu-text: #1c1917;"," --fu-muted: #57534e;"," --fu-border: #d6d3d1;"," --fu-surface: #ffffff;"," --fu-surface-soft: #f5f5f4;"," --fu-error: #b91c1c;"," --fu-ring: rgb(37 99 235 / 0.25);"," --fu-radius: 10px;"," --fu-radius-sm: 8px;","}","@media (prefers-color-scheme: dark) {"," :host {"," --fu-text: #e7e5e4;"," --fu-muted: #a8a29e;"," --fu-border: #44403c;"," --fu-surface: #1c1917;"," --fu-surface-soft: #292524;"," --fu-error: #f87171;"," --fu-ring: rgb(96 165 250 / 0.35);"," }","}","form { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; color: var(--fu-text); }",".field { min-width: 0; grid-column: span 2; }",".field.half { grid-column: span 1; }","@container (max-width: 28rem) { .field.half { grid-column: span 2; } }",".group { grid-column: span 2; display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 0; padding: 1rem; border: 1px solid var(--fu-border); border-radius: var(--fu-radius); background: var(--fu-surface); }",".group-title { grid-column: span 2; font-size: 0.875rem; font-weight: 600; }",".group-description { grid-column: span 2; font-size: 0.8125rem; color: var(--fu-muted); margin: 0; }",".help { font-size: 0.8125rem; color: var(--fu-muted); margin: 0.375rem 0 0; }",".field > label:not(.dropzone) { display: grid; gap: 0.375rem; font-size: 0.875rem; font-weight: 500; }","input, textarea { font: inherit; color: var(--fu-text); background: var(--fu-surface); border: 1px solid var(--fu-border); border-radius: var(--fu-radius); padding: 0.625rem 0.75rem; width: 100%; box-sizing: border-box; transition: border-color 0.15s ease, box-shadow 0.15s ease; }","input::placeholder, textarea::placeholder { color: var(--fu-muted); opacity: 1; }","input:focus-visible, textarea:focus-visible { outline: none; border-color: var(--fu-accent); box-shadow: 0 0 0 3px var(--fu-ring); }","input[aria-invalid='true'], textarea[aria-invalid='true'] { border-color: var(--fu-error); }","input[aria-invalid='true']:focus-visible, textarea[aria-invalid='true']:focus-visible { box-shadow: 0 0 0 3px color-mix(in srgb, var(--fu-error) 25%, transparent); }","textarea { min-height: 6rem; resize: vertical; }",".error { color: var(--fu-error); font-size: 0.8125rem; margin: 0.375rem 0 0; }",".dropzone { position: relative; display: grid; justify-items: center; gap: 0.375rem; padding: 1.5rem 1rem; border: 1.5px dashed var(--fu-border); border-radius: var(--fu-radius); background: var(--fu-surface-soft); cursor: pointer; text-align: center; transition: border-color 0.15s ease, box-shadow 0.15s ease; }",".dropzone:hover { border-color: var(--fu-accent); }",".dropzone:focus-within { outline: none; border-color: var(--fu-accent); box-shadow: 0 0 0 3px var(--fu-ring); }",".dropzone input { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; }",".dropzone-mark { display: grid; place-items: center; width: 2.5rem; height: 2.5rem; border-radius: 999px; background: var(--fu-accent); color: var(--fu-accent-contrast); font-size: 1.5rem; font-weight: 300; line-height: 1; }",".dropzone-title { font-size: 0.875rem; font-weight: 600; }",".dropzone-hint { font-size: 0.8125rem; font-weight: 400; color: var(--fu-muted); }",".previews { display: grid; grid-template-columns: repeat(auto-fill, minmax(6rem, 1fr)); gap: 0.625rem; margin-top: 0.625rem; }",".preview { margin: 0; position: relative; border: 1px solid var(--fu-border); border-radius: var(--fu-radius-sm); overflow: hidden; background: var(--fu-surface); }",".preview img { width: 100%; height: 5.25rem; object-fit: cover; display: block; background: var(--fu-surface-soft); }",".preview figcaption { font-size: 0.7rem; padding: 0.25rem 0.375rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fu-muted); }",".preview button { position: absolute; top: 0.25rem; right: 0.25rem; width: 1.5rem; height: 1.5rem; display: grid; place-items: center; padding: 0; border: none; border-radius: 999px; background: rgb(0 0 0 / 0.6); color: #ffffff; font-size: 1rem; line-height: 1; cursor: pointer; }",".preview button:hover { background: rgb(0 0 0 / 0.8); }","button[type='submit'] { justify-self: start; font: inherit; font-weight: 600; color: var(--fu-accent-contrast); background: var(--fu-accent); border: 1px solid transparent; border-radius: var(--fu-radius); padding: 0.625rem 1.25rem; cursor: pointer; transition: background-color 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease; }","button[type='submit']:hover { background: var(--fu-accent-hover); }","button[type='submit']:active { transform: scale(0.98); }","button[type='submit']:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--fu-ring); }","@media (prefers-reduced-motion: reduce) {"," *, *::before, *::after { transition: none; }","}"].join(`
|
|
2
|
+
`),C=class extends N{constructor(){super(...arguments);this.uploaderInstance=L({});this.elementConfig={};this.previewUrls=new Map;this.fileNotice=null}static get observedAttributes(){return["submit-label"]}get config(){return this.elementConfig}set config(e){this.elementConfig=e??{},this.uploaderInstance=L(this.elementConfig),this.fileNotice=null,this.revokePreviews(),this.render()}get uploader(){return this.uploaderInstance}connectedCallback(){this.render()}disconnectedCallback(){this.revokePreviews()}attributeChangedCallback(){this.render()}reset(){this.uploaderInstance.reset(),this.fileNotice=null,this.render()}get submitLabel(){let e=this;return typeof e.getAttribute!="function"?"Submit":e.getAttribute("submit-label")||"Submit"}fieldHtml(e,i,s){let n=p(i[e.name]??""),c=s[e.name]?`<p class="error" part="error">${p(s[e.name])}</p>`:"",u=p(e.label||e.name),f=e.placeholder?` placeholder="${p(e.placeholder)}"`:"",g=e.required?" required":"",m=p(e.name),v=s[e.name]?' aria-invalid="true"':"",F=e.width==="half"?" half":"",x=e.help?`<p class="help" part="help">${p(e.help)}</p>`:"",b=e.type==="textarea"?`<textarea part="input" data-field="${m}" rows="4"${f}${g}${v}>${n}</textarea>`:`<input part="input" data-field="${m}" type="${e.type}" value="${n}"${f}${g}${v} />`;return`<div class="field${F}" part="field"><label part="label"><span>${u}</span>${b}</label>${x}${c}</div>`}render(){let e=this;if(typeof e.attachShadow!="function")return;let i=e.shadowRoot??e.attachShadow({mode:"open"});this.syncPreviews();let s=this.uploaderInstance.getValues(),n=this.uploaderInstance.getErrors(),c=this.uploaderInstance.getFiles(),u=this.uploaderInstance.fileConfig,f=this.elementConfig.groups??[],g=new Set(f.map(r=>r.name)),m=r=>this.fieldHtml(r,s,n),v=this.uploaderInstance.fields.filter(r=>!r.group||!g.has(r.group)).map(m).join(""),F=f.map(r=>{let a=this.uploaderInstance.fields.filter(R=>R.group===r.name);if(a.length===0)return"";let T=p(r.title),I=r.description?`<p class="group-description" part="group-description">${p(r.description)}</p>`:"";return`<div class="group" part="group" role="group" aria-label="${T}"><span class="group-title" part="group-title">${T}</span>`+I+a.map(m).join("")+"</div>"}).join(""),x=v+F,b=n[y]??this.fileNotice,w=u.accept==="image/*"?"Choose images":"Choose files",t=`${u.accept==="image/*"?"Images":u.accept||"Files"} up to ${S(u.maxSize)} each, max ${u.maxFiles} ${u.maxFiles===1?"file":"files"}`,o=this.uploaderInstance.filesEnabled?`<div class="field" part="field"><label class="dropzone" part="label dropzone"><span class="dropzone-mark" aria-hidden="true">+</span><span class="dropzone-title" part="dropzone-title">${w}</span><span class="dropzone-hint" part="dropzone-hint">${p(t)}</span><input part="file-input" data-files type="file" multiple accept="${p(u.accept)}" /></label>`+(b?`<p class="error" part="error">${p(b)}</p>`:"")+`<div class="previews" part="previews">${c.map((r,a)=>`<figure class="preview" part="preview"><img src="${this.previewUrls.get(r)??""}" alt="${p(r.name)}" /><figcaption>${p(r.name)}</figcaption><button type="button" part="remove" data-remove="${a}" aria-label="Remove ${p(r.name)}">\xD7</button></figure>`).join("")}</div></div>`:"";i.innerHTML=`<style>${q}</style><form part="form" novalidate>${x}${o}<button type="submit" part="submit">${p(this.submitLabel)}</button></form>`,i.querySelector("form")?.addEventListener("submit",r=>{r.preventDefault(),this.handleSubmit()}),i.querySelectorAll("[data-field]").forEach(r=>{r.addEventListener("input",()=>{let a=r.dataset.field;a&&(this.uploaderInstance.setValue(a,r.value),this.afterChange())})});let l=i.querySelector("[data-files]");l?.addEventListener("change",()=>{if(l.files){let r=this.uploaderInstance.addFiles(l.files);this.fileNotice=r.rejected.length>0?r.rejected.map(a=>a.reason).join(" "):null}l.value="",this.elementConfig.validateOnChange&&this.uploaderInstance.validate(),this.render(),this.focusFileInput(),this.emitChange()}),i.querySelectorAll("[data-remove]").forEach(r=>{r.addEventListener("click",()=>{this.uploaderInstance.removeFile(Number(r.dataset.remove)),this.fileNotice=null,this.elementConfig.validateOnChange&&this.uploaderInstance.validate(),this.render(),this.focusFileInput(),this.emitChange()})})}afterChange(){if(this.elementConfig.validateOnChange){let e=this.snapshotFocus();this.uploaderInstance.validate(),this.render(),this.restoreFocus(e)}this.emitChange()}emitChange(){let e=this.uploaderInstance.getErrors(),i={values:this.uploaderInstance.getValues(),files:this.uploaderInstance.getFiles(),errors:e,valid:Object.keys(e).length===0};this.dispatchEvent(new CustomEvent(M,{detail:i,bubbles:!0,composed:!0}))}snapshotFocus(){let i=this.shadowRoot?.activeElement,s=i?.dataset?.field??null,n=-1,c=-1;if(s&&i&&(i.tagName==="INPUT"||i.tagName==="TEXTAREA"))try{let u=i;n=u.selectionStart??-1,c=u.selectionEnd??-1}catch{n=-1,c=-1}return{name:s,start:n,end:c}}restoreFocus(e){if(!e.name)return;let s=this.shadowRoot?.querySelectorAll("[data-field]"),n=null;if(s)for(let c of s)c.dataset.field===e.name&&(n=c);if(n&&n.focus({preventScroll:!0}),e.start>=0&&n&&(n.tagName==="INPUT"||n.tagName==="TEXTAREA"))try{n.setSelectionRange(e.start,e.end)}catch{return}}focusFileInput(){this.shadowRoot?.querySelector("[data-files]")?.focus({preventScroll:!0})}async handleSubmit(){this.fileNotice=null;let{valid:e}=this.uploaderInstance.validate();if(this.render(),!e){this.shadowRoot?.querySelector("[aria-invalid='true']")?.focus();return}let i={formData:this.uploaderInstance.buildPayload(),values:this.uploaderInstance.getValues(),files:this.uploaderInstance.getFiles()};!this.dispatchEvent(new CustomEvent(H,{detail:i,bubbles:!0,composed:!0}))||await this.onSubmit?.(i)}syncPreviews(){let e=this.uploaderInstance.getFiles();for(let[i,s]of this.previewUrls)e.includes(i)||(URL.revokeObjectURL(s),this.previewUrls.delete(i));if(!(typeof URL>"u"||typeof URL.createObjectURL!="function"))for(let i of e)this.previewUrls.has(i)||this.previewUrls.set(i,URL.createObjectURL(i))}revokePreviews(){for(let e of this.previewUrls.values())typeof URL<"u"&&typeof URL.revokeObjectURL=="function"&&URL.revokeObjectURL(e);this.previewUrls.clear()}};typeof customElements<"u"&&!customElements.get(z)&&customElements.define(z,C);})();
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./core.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./core.js";
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import "./element.js";
|
|
3
|
+
import type { FieldConfig, FileUploadConfig, FormChangeDetail, FormSubmitDetail, ValidationResult } from "./core.js";
|
|
4
|
+
export interface ReactFormUploaderProps {
|
|
5
|
+
fields?: FieldConfig[];
|
|
6
|
+
files?: FileUploadConfig | false;
|
|
7
|
+
validateOnChange?: boolean;
|
|
8
|
+
submitLabel?: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
style?: CSSProperties;
|
|
11
|
+
onSubmit?: (detail: FormSubmitDetail) => void | Promise<void>;
|
|
12
|
+
onChange?: (detail: FormChangeDetail) => void;
|
|
13
|
+
}
|
|
14
|
+
export interface ReactFormUploaderHandle {
|
|
15
|
+
reset(): void;
|
|
16
|
+
validate(): ValidationResult;
|
|
17
|
+
getValues(): Record<string, string>;
|
|
18
|
+
getFiles(): File[];
|
|
19
|
+
}
|
|
20
|
+
export declare const FormUploader: import("react").ForwardRefExoticComponent<ReactFormUploaderProps & import("react").RefAttributes<ReactFormUploaderHandle>>;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createElement, forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
|
3
|
+
import "./element.js";
|
|
4
|
+
function isSameValue(left, right) {
|
|
5
|
+
if (Object.is(left, right))
|
|
6
|
+
return true;
|
|
7
|
+
if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
if (Array.isArray(left) !== Array.isArray(right))
|
|
11
|
+
return false;
|
|
12
|
+
const leftRecord = left;
|
|
13
|
+
const rightRecord = right;
|
|
14
|
+
const leftKeys = Object.keys(leftRecord);
|
|
15
|
+
if (leftKeys.length !== Object.keys(rightRecord).length)
|
|
16
|
+
return false;
|
|
17
|
+
return leftKeys.every((key) => isSameValue(leftRecord[key], rightRecord[key]));
|
|
18
|
+
}
|
|
19
|
+
export const FormUploader = forwardRef(function FormUploader(props, ref) {
|
|
20
|
+
const hostRef = useRef(null);
|
|
21
|
+
const elRef = useRef(null);
|
|
22
|
+
const appliedRef = useRef(null);
|
|
23
|
+
const submitRef = useRef(props.onSubmit);
|
|
24
|
+
submitRef.current = props.onSubmit;
|
|
25
|
+
const changeRef = useRef(props.onChange);
|
|
26
|
+
changeRef.current = props.onChange;
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const host = hostRef.current;
|
|
29
|
+
if (!host)
|
|
30
|
+
return;
|
|
31
|
+
const el = document.createElement("form-uploader");
|
|
32
|
+
host.appendChild(el);
|
|
33
|
+
elRef.current = el;
|
|
34
|
+
el.onSubmit = (detail) => {
|
|
35
|
+
void submitRef.current?.(detail);
|
|
36
|
+
};
|
|
37
|
+
const handleChange = (event) => {
|
|
38
|
+
changeRef.current?.(event.detail);
|
|
39
|
+
};
|
|
40
|
+
el.addEventListener("form-change", handleChange);
|
|
41
|
+
return () => {
|
|
42
|
+
el.removeEventListener("form-change", handleChange);
|
|
43
|
+
el.onSubmit = undefined;
|
|
44
|
+
host.removeChild(el);
|
|
45
|
+
if (elRef.current === el)
|
|
46
|
+
elRef.current = null;
|
|
47
|
+
appliedRef.current = null;
|
|
48
|
+
};
|
|
49
|
+
}, []);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const el = elRef.current;
|
|
52
|
+
if (!el)
|
|
53
|
+
return;
|
|
54
|
+
const next = {
|
|
55
|
+
fields: props.fields,
|
|
56
|
+
files: props.files,
|
|
57
|
+
validateOnChange: props.validateOnChange,
|
|
58
|
+
};
|
|
59
|
+
if (appliedRef.current && isSameValue(appliedRef.current, next))
|
|
60
|
+
return;
|
|
61
|
+
appliedRef.current = next;
|
|
62
|
+
el.config = next;
|
|
63
|
+
if (props.submitLabel === undefined)
|
|
64
|
+
el.removeAttribute("submit-label");
|
|
65
|
+
else
|
|
66
|
+
el.setAttribute("submit-label", props.submitLabel);
|
|
67
|
+
});
|
|
68
|
+
useImperativeHandle(ref, () => ({
|
|
69
|
+
reset: () => {
|
|
70
|
+
elRef.current?.reset();
|
|
71
|
+
},
|
|
72
|
+
validate: () => elRef.current?.uploader.validate() ?? { valid: false, errors: {} },
|
|
73
|
+
getValues: () => elRef.current?.uploader.getValues() ?? {},
|
|
74
|
+
getFiles: () => elRef.current?.uploader.getFiles() ?? [],
|
|
75
|
+
}), []);
|
|
76
|
+
return createElement("div", {
|
|
77
|
+
ref: hostRef,
|
|
78
|
+
className: props.className,
|
|
79
|
+
style: props.style,
|
|
80
|
+
});
|
|
81
|
+
});
|
package/dist/vue.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { PropType } from "vue";
|
|
2
|
+
import "./element.js";
|
|
3
|
+
import type { FieldConfig, FileUploadConfig, FormChangeDetail, FormSubmitDetail } from "./core.js";
|
|
4
|
+
export declare const FormUploader: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
|
|
5
|
+
fields: {
|
|
6
|
+
type: PropType<FieldConfig[]>;
|
|
7
|
+
default: undefined;
|
|
8
|
+
};
|
|
9
|
+
files: {
|
|
10
|
+
type: PropType<FileUploadConfig | false>;
|
|
11
|
+
default: undefined;
|
|
12
|
+
};
|
|
13
|
+
validateOnChange: {
|
|
14
|
+
type: BooleanConstructor;
|
|
15
|
+
default: undefined;
|
|
16
|
+
};
|
|
17
|
+
submitLabel: {
|
|
18
|
+
type: StringConstructor;
|
|
19
|
+
default: undefined;
|
|
20
|
+
};
|
|
21
|
+
}>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
22
|
+
[key: string]: any;
|
|
23
|
+
}>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
24
|
+
submit: (_detail: FormSubmitDetail) => true;
|
|
25
|
+
change: (_detail: FormChangeDetail) => true;
|
|
26
|
+
}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
|
|
27
|
+
fields: {
|
|
28
|
+
type: PropType<FieldConfig[]>;
|
|
29
|
+
default: undefined;
|
|
30
|
+
};
|
|
31
|
+
files: {
|
|
32
|
+
type: PropType<FileUploadConfig | false>;
|
|
33
|
+
default: undefined;
|
|
34
|
+
};
|
|
35
|
+
validateOnChange: {
|
|
36
|
+
type: BooleanConstructor;
|
|
37
|
+
default: undefined;
|
|
38
|
+
};
|
|
39
|
+
submitLabel: {
|
|
40
|
+
type: StringConstructor;
|
|
41
|
+
default: undefined;
|
|
42
|
+
};
|
|
43
|
+
}>> & Readonly<{
|
|
44
|
+
onChange?: ((_detail: FormChangeDetail) => any) | undefined;
|
|
45
|
+
onSubmit?: ((_detail: FormSubmitDetail) => any) | undefined;
|
|
46
|
+
}>, {
|
|
47
|
+
fields: FieldConfig[];
|
|
48
|
+
files: false | FileUploadConfig;
|
|
49
|
+
validateOnChange: boolean;
|
|
50
|
+
submitLabel: string;
|
|
51
|
+
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
package/dist/vue.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { defineComponent, h, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
2
|
+
import "./element.js";
|
|
3
|
+
function isSameValue(left, right) {
|
|
4
|
+
if (Object.is(left, right))
|
|
5
|
+
return true;
|
|
6
|
+
if (typeof left !== "object" || typeof right !== "object" || left === null || right === null) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
if (Array.isArray(left) !== Array.isArray(right))
|
|
10
|
+
return false;
|
|
11
|
+
const leftRecord = left;
|
|
12
|
+
const rightRecord = right;
|
|
13
|
+
const leftKeys = Object.keys(leftRecord);
|
|
14
|
+
if (leftKeys.length !== Object.keys(rightRecord).length)
|
|
15
|
+
return false;
|
|
16
|
+
return leftKeys.every((key) => isSameValue(leftRecord[key], rightRecord[key]));
|
|
17
|
+
}
|
|
18
|
+
export const FormUploader = defineComponent({
|
|
19
|
+
name: "FormUploader",
|
|
20
|
+
props: {
|
|
21
|
+
fields: { type: Array, default: undefined },
|
|
22
|
+
files: { type: [Object, Boolean], default: undefined },
|
|
23
|
+
validateOnChange: { type: Boolean, default: undefined },
|
|
24
|
+
submitLabel: { type: String, default: undefined },
|
|
25
|
+
},
|
|
26
|
+
emits: {
|
|
27
|
+
submit: (_detail) => true,
|
|
28
|
+
change: (_detail) => true,
|
|
29
|
+
},
|
|
30
|
+
setup(props, { emit, expose }) {
|
|
31
|
+
const elRef = ref(null);
|
|
32
|
+
let applied = null;
|
|
33
|
+
const applyConfig = (force = false) => {
|
|
34
|
+
const el = elRef.value;
|
|
35
|
+
if (!el)
|
|
36
|
+
return;
|
|
37
|
+
const next = {
|
|
38
|
+
fields: props.fields,
|
|
39
|
+
files: props.files,
|
|
40
|
+
validateOnChange: props.validateOnChange,
|
|
41
|
+
};
|
|
42
|
+
if (!force && applied && isSameValue(applied, next))
|
|
43
|
+
return;
|
|
44
|
+
applied = next;
|
|
45
|
+
el.config = next;
|
|
46
|
+
if (props.submitLabel === undefined)
|
|
47
|
+
el.removeAttribute("submit-label");
|
|
48
|
+
else
|
|
49
|
+
el.setAttribute("submit-label", props.submitLabel);
|
|
50
|
+
};
|
|
51
|
+
const handleChange = (event) => {
|
|
52
|
+
emit("change", event.detail);
|
|
53
|
+
};
|
|
54
|
+
onMounted(() => {
|
|
55
|
+
const el = elRef.value;
|
|
56
|
+
if (!el)
|
|
57
|
+
return;
|
|
58
|
+
applyConfig(true);
|
|
59
|
+
el.onSubmit = (detail) => {
|
|
60
|
+
emit("submit", detail);
|
|
61
|
+
};
|
|
62
|
+
el.addEventListener("form-change", handleChange);
|
|
63
|
+
});
|
|
64
|
+
onBeforeUnmount(() => {
|
|
65
|
+
const el = elRef.value;
|
|
66
|
+
if (!el)
|
|
67
|
+
return;
|
|
68
|
+
el.removeEventListener("form-change", handleChange);
|
|
69
|
+
el.onSubmit = undefined;
|
|
70
|
+
});
|
|
71
|
+
watch(() => [props.fields, props.files, props.validateOnChange, props.submitLabel], () => {
|
|
72
|
+
applyConfig();
|
|
73
|
+
});
|
|
74
|
+
expose({
|
|
75
|
+
reset: () => elRef.value?.reset(),
|
|
76
|
+
validate: () => elRef.value?.uploader.validate() ?? { valid: false, errors: {} },
|
|
77
|
+
getValues: () => elRef.value?.uploader.getValues() ?? {},
|
|
78
|
+
getFiles: () => elRef.value?.uploader.getFiles() ?? [],
|
|
79
|
+
});
|
|
80
|
+
return () => h("form-uploader", { ref: elRef });
|
|
81
|
+
},
|
|
82
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,79 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "form-uploader",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Reusable contact + multi-image upload form: framework-agnostic core and a drop-in custom element for any website.",
|
|
5
|
+
"author": "Elbeg Darkhanbaatar",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Ebetheone/package.git"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"form",
|
|
12
|
+
"contact-form",
|
|
13
|
+
"file-upload",
|
|
14
|
+
"image-upload",
|
|
15
|
+
"uploader",
|
|
16
|
+
"web-component",
|
|
17
|
+
"custom-element",
|
|
18
|
+
"form-validation",
|
|
19
|
+
"framework-agnostic"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "./dist/index.js",
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./element": {
|
|
30
|
+
"types": "./dist/element.d.ts",
|
|
31
|
+
"default": "./dist/element.js"
|
|
32
|
+
},
|
|
33
|
+
"./react": {
|
|
34
|
+
"types": "./dist/react.d.ts",
|
|
35
|
+
"default": "./dist/react.js"
|
|
36
|
+
},
|
|
37
|
+
"./vue": {
|
|
38
|
+
"types": "./dist/vue.d.ts",
|
|
39
|
+
"default": "./dist/vue.js"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"dist",
|
|
44
|
+
"README.md",
|
|
45
|
+
"CHANGELOG.md",
|
|
46
|
+
"LICENSE"
|
|
47
|
+
],
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsc -p tsconfig.json && npm run build:browser",
|
|
50
|
+
"build:browser": "esbuild src/element.ts --bundle --format=iife --minify --outfile=dist/form-uploader.global.js",
|
|
51
|
+
"test": "npm run build && node --test test/",
|
|
52
|
+
"prepack": "npm run build",
|
|
53
|
+
"format": "prettier --write .",
|
|
54
|
+
"format:check": "prettier --check ."
|
|
55
|
+
},
|
|
56
|
+
"license": "MIT",
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"react": ">=18",
|
|
59
|
+
"vue": "^3"
|
|
60
|
+
},
|
|
61
|
+
"peerDependenciesMeta": {
|
|
62
|
+
"react": {
|
|
63
|
+
"optional": true
|
|
64
|
+
},
|
|
65
|
+
"vue": {
|
|
66
|
+
"optional": true
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"@types/react": "^19.3.0",
|
|
71
|
+
"esbuild": "^0.28.2",
|
|
72
|
+
"happy-dom": "^21.0.4",
|
|
73
|
+
"prettier": "^3.9.10",
|
|
74
|
+
"react": "^19.3.1",
|
|
75
|
+
"react-dom": "^19.3.1",
|
|
76
|
+
"typescript": "^7.0.2",
|
|
77
|
+
"vue": "^3.5.45"
|
|
78
|
+
}
|
|
79
|
+
}
|