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 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, Vue, Svelte, anywhere.
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
- ## React
68
+ ## Frameworks
69
69
 
70
- Custom element props must be assigned, not passed as attributes. Use a ref:
70
+ ### React / Next.js
71
71
 
72
72
  ```jsx
73
- import { useEffect, useRef, useState } from "react";
74
- import "form-uploader/element";
75
- import { DEFAULT_FIELDS } from "form-uploader";
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
- export default function ContactForm() {
78
- const formRef = useRef(null);
79
- const [result, setResult] = useState(null);
80
-
81
- useEffect(() => {
82
- const el = formRef.current;
83
- if (!el) return;
84
- el.config = {
85
- fields: [...DEFAULT_FIELDS, { name: "company", label: "Company" }],
86
- files: { maxFiles: 3 },
87
- };
88
- el.onSubmit = async ({ values, files, formData }) => {
89
- setResult({ values, files: files.map((file) => file.name) });
90
- };
91
- }, []);
92
-
93
- return <form-uploader ref={formRef} submit-label="Send" />;
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
@@ -21,6 +21,7 @@ export function createFormUploader(config = {}) {
21
21
  ...field,
22
22
  type: field.type ?? "text",
23
23
  required: field.required ?? false,
24
+ width: field.width ?? "full",
24
25
  }));
25
26
  const filesEnabled = config.files !== false;
26
27
  const fileConfig = {
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 fieldsHtml = this.uploaderInstance.fields
134
- .map((field) => {
135
- const value = escapeHtml(values[field.name] ?? "");
136
- const error = errors[field.name]
137
- ? `<p class="error" part="error">${escapeHtml(errors[field.name])}</p>`
138
- : "";
139
- const label = escapeHtml(field.label || field.name);
140
- const placeholder = field.placeholder
141
- ? ` placeholder="${escapeHtml(field.placeholder)}"`
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
- const required = field.required ? " required" : "";
144
- const safeName = escapeHtml(field.name);
145
- const invalid = errors[field.name] ? ` aria-invalid="true"` : "";
146
- const control = field.type === "textarea"
147
- ? `<textarea part="input" data-field="${safeName}" rows="4"${placeholder}${required}${invalid}>${value}</textarea>`
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
- this.uploaderInstance.setValue(name, el.value);
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}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);})();
@@ -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.1.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
- "typescript": "^7.0.2"
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
  }