form-uploader 0.1.0 → 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/README.md +89 -24
- package/dist/core.d.ts +19 -1
- package/dist/core.js +1 -0
- package/dist/element.d.ts +7 -0
- package/dist/element.js +132 -22
- package/dist/form-uploader.global.js +2 -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 +31 -3
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/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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
4
|
|
|
5
|
-
- Drop-in element works in plain HTML, React
|
|
5
|
+
- Drop-in element works in plain HTML and anywhere, with React and Vue wrappers included.
|
|
6
6
|
- Core module is DOM-free, so you can drive your own UI or run it in Node.
|
|
7
7
|
- Validation, file limits, and `FormData` payload building included.
|
|
8
8
|
- Themed with CSS variables, dark-mode aware, keyboard accessible.
|
|
@@ -65,35 +65,86 @@ el.reset();
|
|
|
65
65
|
- `el.uploader` exposes the core instance below for direct control.
|
|
66
66
|
- The `submit-label` attribute sets the button text (default `"Submit"`).
|
|
67
67
|
|
|
68
|
-
##
|
|
68
|
+
## Frameworks
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
### React / Next.js
|
|
71
71
|
|
|
72
72
|
```jsx
|
|
73
|
-
import {
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
76
105
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
+
}
|
|
94
129
|
}
|
|
95
130
|
```
|
|
96
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
|
+
|
|
97
148
|
## Core (your own UI)
|
|
98
149
|
|
|
99
150
|
```js
|
|
@@ -136,6 +187,8 @@ Field options:
|
|
|
136
187
|
- `required` (default `false`), `placeholder`
|
|
137
188
|
- `minLength`, `maxLength`, counted in characters
|
|
138
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)
|
|
139
192
|
|
|
140
193
|
File options:
|
|
141
194
|
|
|
@@ -145,10 +198,22 @@ File options:
|
|
|
145
198
|
- `required` (default `false`)
|
|
146
199
|
- Pass `files: false` to disable uploads entirely
|
|
147
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
|
+
|
|
148
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.
|
|
149
207
|
|
|
150
208
|
The payload is a `FormData` with one entry per field plus each file under `fieldName`, ready to `POST` to your backend.
|
|
151
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
|
+
|
|
152
217
|
## Styling
|
|
153
218
|
|
|
154
219
|
The element inherits your page font and adapts to light and dark mode automatically. Override any variable to theme it:
|
|
@@ -175,7 +240,7 @@ form-uploader {
|
|
|
175
240
|
| `--fu-radius` | `10px` | Inputs, dropzone, button |
|
|
176
241
|
| `--fu-radius-sm` | `8px` | Preview cards |
|
|
177
242
|
|
|
178
|
-
For deeper restyling, every part is exposed via `::part`: `form`, `field`, `label`, `input`, `error`, `dropzone`, `dropzone-title`, `dropzone-hint`, `file-input`, `previews`, `preview`, `remove`, `submit`.
|
|
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`.
|
|
179
244
|
|
|
180
245
|
```css
|
|
181
246
|
form-uploader::part(submit) {
|
package/dist/core.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type FieldType = "text" | "email" | "tel" | "textarea";
|
|
2
|
+
export type FieldWidth = "full" | "half";
|
|
2
3
|
export interface FieldConfig {
|
|
3
4
|
name: string;
|
|
4
5
|
label?: string;
|
|
@@ -7,6 +8,9 @@ export interface FieldConfig {
|
|
|
7
8
|
placeholder?: string;
|
|
8
9
|
minLength?: number;
|
|
9
10
|
maxLength?: number;
|
|
11
|
+
width?: FieldWidth;
|
|
12
|
+
help?: string;
|
|
13
|
+
group?: string;
|
|
10
14
|
validate?: (value: string) => string | null;
|
|
11
15
|
}
|
|
12
16
|
export interface FileUploadConfig {
|
|
@@ -16,13 +20,21 @@ export interface FileUploadConfig {
|
|
|
16
20
|
accept?: string;
|
|
17
21
|
required?: boolean;
|
|
18
22
|
}
|
|
23
|
+
export interface FormGroup {
|
|
24
|
+
name: string;
|
|
25
|
+
title: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
}
|
|
19
28
|
export interface FormUploaderConfig {
|
|
20
29
|
fields?: FieldConfig[];
|
|
21
30
|
files?: FileUploadConfig | false;
|
|
31
|
+
groups?: FormGroup[];
|
|
32
|
+
validateOnChange?: boolean;
|
|
22
33
|
}
|
|
23
|
-
export interface NormalizedField extends Omit<FieldConfig, "type" | "required"> {
|
|
34
|
+
export interface NormalizedField extends Omit<FieldConfig, "type" | "required" | "width"> {
|
|
24
35
|
type: FieldType;
|
|
25
36
|
required: boolean;
|
|
37
|
+
width: FieldWidth;
|
|
26
38
|
}
|
|
27
39
|
export interface ResolvedFileUploadConfig {
|
|
28
40
|
fieldName: string;
|
|
@@ -49,6 +61,12 @@ export interface FormSubmitDetail {
|
|
|
49
61
|
values: Record<string, string>;
|
|
50
62
|
files: File[];
|
|
51
63
|
}
|
|
64
|
+
export interface FormChangeDetail {
|
|
65
|
+
values: Record<string, string>;
|
|
66
|
+
files: File[];
|
|
67
|
+
errors: ValidationErrors;
|
|
68
|
+
valid: boolean;
|
|
69
|
+
}
|
|
52
70
|
export declare const DEFAULT_FIELDS: FieldConfig[];
|
|
53
71
|
export declare const DEFAULT_FILE_CONFIG: ResolvedFileUploadConfig;
|
|
54
72
|
export declare const FILES_ERROR_KEY = "files";
|
package/dist/core.js
CHANGED
package/dist/element.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type FormSubmitDetail, type FormUploader, type FormUploaderConfig } from "./core.js";
|
|
2
2
|
export declare const ELEMENT_NAME = "form-uploader";
|
|
3
3
|
export declare const SUBMIT_EVENT = "form-submit";
|
|
4
|
+
export declare const CHANGE_EVENT = "form-change";
|
|
4
5
|
declare const BaseElement: typeof HTMLElement;
|
|
5
6
|
export declare class FormUploaderElement extends BaseElement {
|
|
6
7
|
private uploaderInstance;
|
|
@@ -17,7 +18,13 @@ export declare class FormUploaderElement extends BaseElement {
|
|
|
17
18
|
attributeChangedCallback(): void;
|
|
18
19
|
reset(): void;
|
|
19
20
|
private get submitLabel();
|
|
21
|
+
private fieldHtml;
|
|
20
22
|
private render;
|
|
23
|
+
private afterChange;
|
|
24
|
+
private emitChange;
|
|
25
|
+
private snapshotFocus;
|
|
26
|
+
private restoreFocus;
|
|
27
|
+
private focusFileInput;
|
|
21
28
|
private handleSubmit;
|
|
22
29
|
private syncPreviews;
|
|
23
30
|
private revokePreviews;
|
package/dist/element.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { FILES_ERROR_KEY, createFormUploader, } from "./core.js";
|
|
2
2
|
export const ELEMENT_NAME = "form-uploader";
|
|
3
3
|
export const SUBMIT_EVENT = "form-submit";
|
|
4
|
+
export const CHANGE_EVENT = "form-change";
|
|
4
5
|
const BaseElement = globalThis.HTMLElement ??
|
|
5
6
|
class {
|
|
6
7
|
};
|
|
@@ -19,7 +20,7 @@ function formatBytes(bytes) {
|
|
|
19
20
|
return `${bytes} B`;
|
|
20
21
|
}
|
|
21
22
|
const STYLES = [
|
|
22
|
-
":host { display: block; font: inherit; color-scheme: light dark; }",
|
|
23
|
+
":host { display: block; font: inherit; color-scheme: light dark; container-type: inline-size; }",
|
|
23
24
|
":host {",
|
|
24
25
|
" --fu-accent: #2563eb;",
|
|
25
26
|
" --fu-accent-hover: #1d4ed8;",
|
|
@@ -45,8 +46,14 @@ const STYLES = [
|
|
|
45
46
|
" --fu-ring: rgb(96 165 250 / 0.35);",
|
|
46
47
|
" }",
|
|
47
48
|
"}",
|
|
48
|
-
"form { display: grid; gap: 1rem; color: var(--fu-text); }",
|
|
49
|
-
".field { min-width: 0; }",
|
|
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; }",
|
|
50
57
|
".field > label:not(.dropzone) { display: grid; gap: 0.375rem; font-size: 0.875rem; font-weight: 500; }",
|
|
51
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; }",
|
|
52
59
|
"input::placeholder, textarea::placeholder { color: var(--fu-muted); opacity: 1; }",
|
|
@@ -120,6 +127,23 @@ export class FormUploaderElement extends BaseElement {
|
|
|
120
127
|
return "Submit";
|
|
121
128
|
return host.getAttribute("submit-label") || "Submit";
|
|
122
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
|
+
}
|
|
123
147
|
render() {
|
|
124
148
|
const host = this;
|
|
125
149
|
if (typeof host.attachShadow !== "function")
|
|
@@ -130,25 +154,30 @@ export class FormUploaderElement extends BaseElement {
|
|
|
130
154
|
const errors = this.uploaderInstance.getErrors();
|
|
131
155
|
const files = this.uploaderInstance.getFiles();
|
|
132
156
|
const fileConfig = this.uploaderInstance.fileConfig;
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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>`
|
|
142
172
|
: "";
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
: `<input part="input" data-field="${safeName}" type="${field.type}" value="${value}"${placeholder}${required}${invalid} />`;
|
|
149
|
-
return `<div class="field" part="field"><label part="label"><span>${label}</span>${control}</label>${error}</div>`;
|
|
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>`);
|
|
150
178
|
})
|
|
151
179
|
.join("");
|
|
180
|
+
const fieldsHtml = ungroupedHtml + groupsHtml;
|
|
152
181
|
const fileError = errors[FILES_ERROR_KEY] ?? this.fileNotice;
|
|
153
182
|
const dropzoneTitle = fileConfig.accept === "image/*" ? "Choose images" : "Choose files";
|
|
154
183
|
const acceptLabel = fileConfig.accept === "image/*" ? "Images" : fileConfig.accept || "Files";
|
|
@@ -181,8 +210,10 @@ export class FormUploaderElement extends BaseElement {
|
|
|
181
210
|
shadow.querySelectorAll("[data-field]").forEach((el) => {
|
|
182
211
|
el.addEventListener("input", () => {
|
|
183
212
|
const name = el.dataset.field;
|
|
184
|
-
if (name)
|
|
185
|
-
|
|
213
|
+
if (!name)
|
|
214
|
+
return;
|
|
215
|
+
this.uploaderInstance.setValue(name, el.value);
|
|
216
|
+
this.afterChange();
|
|
186
217
|
});
|
|
187
218
|
});
|
|
188
219
|
const fileInput = shadow.querySelector("[data-files]");
|
|
@@ -195,22 +226,101 @@ export class FormUploaderElement extends BaseElement {
|
|
|
195
226
|
: null;
|
|
196
227
|
}
|
|
197
228
|
fileInput.value = "";
|
|
229
|
+
if (this.elementConfig.validateOnChange)
|
|
230
|
+
this.uploaderInstance.validate();
|
|
198
231
|
this.render();
|
|
232
|
+
this.focusFileInput();
|
|
233
|
+
this.emitChange();
|
|
199
234
|
});
|
|
200
235
|
shadow.querySelectorAll("[data-remove]").forEach((button) => {
|
|
201
236
|
button.addEventListener("click", () => {
|
|
202
237
|
this.uploaderInstance.removeFile(Number(button.dataset.remove));
|
|
203
238
|
this.fileNotice = null;
|
|
239
|
+
if (this.elementConfig.validateOnChange)
|
|
240
|
+
this.uploaderInstance.validate();
|
|
204
241
|
this.render();
|
|
242
|
+
this.focusFileInput();
|
|
243
|
+
this.emitChange();
|
|
205
244
|
});
|
|
206
245
|
});
|
|
207
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
|
+
}
|
|
208
314
|
async handleSubmit() {
|
|
209
315
|
this.fileNotice = null;
|
|
210
316
|
const { valid } = this.uploaderInstance.validate();
|
|
211
317
|
this.render();
|
|
212
|
-
if (!valid)
|
|
318
|
+
if (!valid) {
|
|
319
|
+
const host = this;
|
|
320
|
+
const firstInvalid = host.shadowRoot?.querySelector("[aria-invalid='true']");
|
|
321
|
+
firstInvalid?.focus();
|
|
213
322
|
return;
|
|
323
|
+
}
|
|
214
324
|
const detail = {
|
|
215
325
|
formData: this.uploaderInstance.buildPayload(),
|
|
216
326
|
values: this.uploaderInstance.getValues(),
|
|
@@ -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/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,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "form-uploader",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Reusable contact + multi-image upload form: framework-agnostic core and a drop-in custom element for any website.",
|
|
5
5
|
"author": "Elbeg Darkhanbaatar",
|
|
6
6
|
"repository": {
|
|
@@ -29,23 +29,51 @@
|
|
|
29
29
|
"./element": {
|
|
30
30
|
"types": "./dist/element.d.ts",
|
|
31
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"
|
|
32
40
|
}
|
|
33
41
|
},
|
|
34
42
|
"files": [
|
|
35
43
|
"dist",
|
|
36
44
|
"README.md",
|
|
45
|
+
"CHANGELOG.md",
|
|
37
46
|
"LICENSE"
|
|
38
47
|
],
|
|
39
48
|
"scripts": {
|
|
40
|
-
"build": "tsc -p tsconfig.json",
|
|
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",
|
|
41
51
|
"test": "npm run build && node --test test/",
|
|
42
52
|
"prepack": "npm run build",
|
|
43
53
|
"format": "prettier --write .",
|
|
44
54
|
"format:check": "prettier --check ."
|
|
45
55
|
},
|
|
46
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
|
+
},
|
|
47
69
|
"devDependencies": {
|
|
70
|
+
"@types/react": "^19.3.0",
|
|
71
|
+
"esbuild": "^0.28.2",
|
|
72
|
+
"happy-dom": "^21.0.4",
|
|
48
73
|
"prettier": "^3.9.10",
|
|
49
|
-
"
|
|
74
|
+
"react": "^19.3.1",
|
|
75
|
+
"react-dom": "^19.3.1",
|
|
76
|
+
"typescript": "^7.0.2",
|
|
77
|
+
"vue": "^3.5.45"
|
|
50
78
|
}
|
|
51
79
|
}
|