form-uploader 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Elbeg Darkhanbaatar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,204 @@
1
- # Temporary Holding Version
1
+ # form-uploader
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ One form for every site: contact fields plus multi-image upload. A framework-agnostic core plus a drop-in `<form-uploader>` custom element. Zero runtime dependencies, ESM only, TypeScript types included.
4
+
5
+ - Drop-in element works in plain HTML, React, Vue, Svelte, anywhere.
6
+ - Core module is DOM-free, so you can drive your own UI or run it in Node.
7
+ - Validation, file limits, and `FormData` payload building included.
8
+ - Themed with CSS variables, dark-mode aware, keyboard accessible.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install form-uploader
14
+ ```
15
+
16
+ ## Drop-in element
17
+
18
+ ```html
19
+ <form-uploader id="u" submit-label="Send"></form-uploader>
20
+ <script type="module">
21
+ import "form-uploader/element";
22
+
23
+ const el = document.getElementById("u");
24
+ el.onSubmit = async ({ formData, values, files }) => {
25
+ await fetch("/api/upload", { method: "POST", body: formData });
26
+ };
27
+ </script>
28
+ ```
29
+
30
+ Serve your page over HTTP, since modules do not load from `file://`.
31
+
32
+ By default the form has Name, Email, and Phone (required) plus Description (optional) and an image picker with previews.
33
+
34
+ ### Handling submit
35
+
36
+ Two ways, pick one:
37
+
38
+ ```js
39
+ el.onSubmit = async ({ formData, values, files }) => {
40
+ await fetch("/api/upload", { method: "POST", body: formData });
41
+ };
42
+
43
+ el.addEventListener("form-submit", (event) => {
44
+ event.preventDefault();
45
+ console.log(event.detail.values);
46
+ });
47
+ ```
48
+
49
+ `onSubmit` runs after a valid submit. If you listen for the `form-submit` event (it bubbles and crosses shadow DOM) and call `preventDefault()`, `onSubmit` is skipped. Both receive `{ formData, values, files }`.
50
+
51
+ ### Configuring the element
52
+
53
+ ```js
54
+ import { DEFAULT_FIELDS } from "form-uploader";
55
+
56
+ el.config = {
57
+ fields: [...DEFAULT_FIELDS, { name: "company", label: "Company" }],
58
+ files: { maxFiles: 3 },
59
+ };
60
+ el.reset();
61
+ ```
62
+
63
+ - `el.config` sets fields and files. Setting it resets the form.
64
+ - `el.reset()` clears values, files, and errors.
65
+ - `el.uploader` exposes the core instance below for direct control.
66
+ - The `submit-label` attribute sets the button text (default `"Submit"`).
67
+
68
+ ## React
69
+
70
+ Custom element props must be assigned, not passed as attributes. Use a ref:
71
+
72
+ ```jsx
73
+ import { useEffect, useRef, useState } from "react";
74
+ import "form-uploader/element";
75
+ import { DEFAULT_FIELDS } from "form-uploader";
76
+
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" />;
94
+ }
95
+ ```
96
+
97
+ ## Core (your own UI)
98
+
99
+ ```js
100
+ import { createFormUploader } from "form-uploader";
101
+
102
+ const form = createFormUploader();
103
+ form.setValue("email", "ada@example.com");
104
+ form.addFiles(fileInput.files);
105
+
106
+ const { valid, errors } = form.validate();
107
+ if (valid) {
108
+ await fetch("/api/upload", { method: "POST", body: form.buildPayload() });
109
+ }
110
+ ```
111
+
112
+ Methods: `setValue(name, value)`, `getValues()`, `getFiles()`, `addFiles(files)`, `removeFile(index)`, `clearFiles()`, `validate()`, `getErrors()`, `buildPayload()`, `reset()`. Also exposed: `fields`, `filesEnabled`, `fileConfig`.
113
+
114
+ `addFiles` accepts an array or a `FileList` and returns `{ added, rejected }`, where each rejection carries a human-readable `reason`.
115
+
116
+ ## Fields and files
117
+
118
+ ```js
119
+ const form = createFormUploader({
120
+ fields: [
121
+ { name: "name", label: "Name", required: true },
122
+ { name: "company", label: "Company", placeholder: "Acme Inc." },
123
+ {
124
+ name: "age",
125
+ label: "Age",
126
+ validate: (value) => (Number(value) >= 18 ? null : "Must be 18+."),
127
+ },
128
+ ],
129
+ files: { maxFiles: 3, maxSize: 2 * 1024 * 1024, accept: "image/*", required: true },
130
+ });
131
+ ```
132
+
133
+ Field options:
134
+
135
+ - `name` (required), `label`, `type` (`text`, `email`, `tel`, `textarea`; default `text`)
136
+ - `required` (default `false`), `placeholder`
137
+ - `minLength`, `maxLength`, counted in characters
138
+ - `validate(value)` returning an error string or `null`
139
+
140
+ File options:
141
+
142
+ - `fieldName` (default `"images"`), the `FormData` key files are appended under
143
+ - `maxFiles` (default `5`), `maxSize` in bytes per file (default 5 MB)
144
+ - `accept` (default `"image/*"`; empty string allows everything)
145
+ - `required` (default `false`)
146
+ - Pass `files: false` to disable uploads entirely
147
+
148
+ 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
+
150
+ The payload is a `FormData` with one entry per field plus each file under `fieldName`, ready to `POST` to your backend.
151
+
152
+ ## Styling
153
+
154
+ The element inherits your page font and adapts to light and dark mode automatically. Override any variable to theme it:
155
+
156
+ ```css
157
+ form-uploader {
158
+ --fu-accent: #7c3aed;
159
+ --fu-radius: 14px;
160
+ }
161
+ ```
162
+
163
+ | Variable | Default | Used for |
164
+ | ---------------------- | ----------------------- | ----------------------------- |
165
+ | `--fu-accent` | `#2563eb` | Submit button, focus, accents |
166
+ | `--fu-accent-hover` | `#1d4ed8` | Submit button hover |
167
+ | `--fu-accent-contrast` | `#ffffff` | Text on the accent color |
168
+ | `--fu-text` | `#1c1917` | Labels and input text |
169
+ | `--fu-muted` | `#57534e` | Hints, placeholders, captions |
170
+ | `--fu-border` | `#d6d3d1` | Input and card borders |
171
+ | `--fu-surface` | `#ffffff` | Input and card background |
172
+ | `--fu-surface-soft` | `#f5f5f4` | Dropzone and image backdrop |
173
+ | `--fu-error` | `#b91c1c` | Error text and invalid rings |
174
+ | `--fu-ring` | `rgb(37 99 235 / 0.25)` | Focus ring |
175
+ | `--fu-radius` | `10px` | Inputs, dropzone, button |
176
+ | `--fu-radius-sm` | `8px` | Preview cards |
177
+
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`.
179
+
180
+ ```css
181
+ form-uploader::part(submit) {
182
+ width: 100%;
183
+ }
184
+ ```
185
+
186
+ Focus rings, invalid states, and `prefers-reduced-motion` are handled inside the shadow DOM.
187
+
188
+ ## Demo and example app
189
+
190
+ - `demo/index.html`: plain-HTML demo. Build first (`npm run build`), serve the repo folder with any static server, and open it.
191
+ - `example/`: Next.js app using the package from local source on port 3000. See `example/README.md`.
192
+
193
+ ## Development
194
+
195
+ ```bash
196
+ npm install
197
+ npm test # build + test suite
198
+ npm run format # format everything with Prettier
199
+ npm run format:check
200
+ ```
201
+
202
+ ## License
203
+
204
+ MIT
package/dist/core.d.ts ADDED
@@ -0,0 +1,70 @@
1
+ export type FieldType = "text" | "email" | "tel" | "textarea";
2
+ export interface FieldConfig {
3
+ name: string;
4
+ label?: string;
5
+ type?: FieldType;
6
+ required?: boolean;
7
+ placeholder?: string;
8
+ minLength?: number;
9
+ maxLength?: number;
10
+ validate?: (value: string) => string | null;
11
+ }
12
+ export interface FileUploadConfig {
13
+ fieldName?: string;
14
+ maxFiles?: number;
15
+ maxSize?: number;
16
+ accept?: string;
17
+ required?: boolean;
18
+ }
19
+ export interface FormUploaderConfig {
20
+ fields?: FieldConfig[];
21
+ files?: FileUploadConfig | false;
22
+ }
23
+ export interface NormalizedField extends Omit<FieldConfig, "type" | "required"> {
24
+ type: FieldType;
25
+ required: boolean;
26
+ }
27
+ export interface ResolvedFileUploadConfig {
28
+ fieldName: string;
29
+ maxFiles: number;
30
+ maxSize: number;
31
+ accept: string;
32
+ required: boolean;
33
+ }
34
+ export interface FileRejection {
35
+ file: File;
36
+ reason: string;
37
+ }
38
+ export interface AddFilesResult {
39
+ added: File[];
40
+ rejected: FileRejection[];
41
+ }
42
+ export type ValidationErrors = Record<string, string>;
43
+ export interface ValidationResult {
44
+ valid: boolean;
45
+ errors: ValidationErrors;
46
+ }
47
+ export interface FormSubmitDetail {
48
+ formData: FormData;
49
+ values: Record<string, string>;
50
+ files: File[];
51
+ }
52
+ export declare const DEFAULT_FIELDS: FieldConfig[];
53
+ export declare const DEFAULT_FILE_CONFIG: ResolvedFileUploadConfig;
54
+ export declare const FILES_ERROR_KEY = "files";
55
+ export interface FormUploader {
56
+ readonly fields: NormalizedField[];
57
+ readonly filesEnabled: boolean;
58
+ readonly fileConfig: ResolvedFileUploadConfig;
59
+ setValue(name: string, value: string): boolean;
60
+ getValues(): Record<string, string>;
61
+ getFiles(): File[];
62
+ addFiles(files: File[] | FileList): AddFilesResult;
63
+ removeFile(index: number): boolean;
64
+ clearFiles(): void;
65
+ validate(): ValidationResult;
66
+ getErrors(): ValidationErrors;
67
+ buildPayload(): FormData;
68
+ reset(): void;
69
+ }
70
+ export declare function createFormUploader(config?: FormUploaderConfig): FormUploader;
package/dist/core.js ADDED
@@ -0,0 +1,162 @@
1
+ export const DEFAULT_FIELDS = [
2
+ { name: "name", label: "Name", type: "text", required: true },
3
+ { name: "email", label: "Email", type: "email", required: true },
4
+ { name: "phone", label: "Phone", type: "tel", required: true },
5
+ { name: "description", label: "Description", type: "textarea", required: false },
6
+ ];
7
+ for (const field of DEFAULT_FIELDS)
8
+ Object.freeze(field);
9
+ Object.freeze(DEFAULT_FIELDS);
10
+ export const DEFAULT_FILE_CONFIG = {
11
+ fieldName: "images",
12
+ maxFiles: 5,
13
+ maxSize: 5 * 1024 * 1024,
14
+ accept: "image/*",
15
+ required: false,
16
+ };
17
+ export const FILES_ERROR_KEY = "files";
18
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
19
+ export function createFormUploader(config = {}) {
20
+ const fields = (config.fields ?? DEFAULT_FIELDS).map((field) => ({
21
+ ...field,
22
+ type: field.type ?? "text",
23
+ required: field.required ?? false,
24
+ }));
25
+ const filesEnabled = config.files !== false;
26
+ const fileConfig = {
27
+ ...DEFAULT_FILE_CONFIG,
28
+ ...(config.files === false || config.files === undefined ? {} : config.files),
29
+ };
30
+ let values = {};
31
+ let files = [];
32
+ let errors = {};
33
+ const resetValues = () => {
34
+ values = {};
35
+ for (const field of fields)
36
+ values[field.name] = "";
37
+ };
38
+ resetValues();
39
+ const findField = (name) => fields.find((field) => field.name === name);
40
+ function matchesAccept(file) {
41
+ const accept = fileConfig.accept.trim();
42
+ if (accept === "")
43
+ return true;
44
+ const type = file.type.toLowerCase();
45
+ const name = file.name.toLowerCase();
46
+ return accept
47
+ .split(",")
48
+ .map((token) => token.trim().toLowerCase())
49
+ .filter(Boolean)
50
+ .some((token) => {
51
+ if (token.endsWith("/*"))
52
+ return type.startsWith(token.slice(0, -1));
53
+ if (token.startsWith("."))
54
+ return name.endsWith(token);
55
+ return type === token;
56
+ });
57
+ }
58
+ function setValue(name, value) {
59
+ if (!findField(name))
60
+ return false;
61
+ values[name] = value;
62
+ return true;
63
+ }
64
+ function addFiles(input) {
65
+ const incoming = Array.from(input);
66
+ const added = [];
67
+ const rejected = [];
68
+ if (!filesEnabled) {
69
+ for (const file of incoming) {
70
+ rejected.push({ file, reason: "File uploads are disabled." });
71
+ }
72
+ return { added, rejected };
73
+ }
74
+ for (const file of incoming) {
75
+ if (files.length + added.length >= fileConfig.maxFiles) {
76
+ rejected.push({ file, reason: `Only ${fileConfig.maxFiles} file(s) allowed.` });
77
+ continue;
78
+ }
79
+ if (file.size > fileConfig.maxSize) {
80
+ rejected.push({ file, reason: `"${file.name}" exceeds the size limit.` });
81
+ continue;
82
+ }
83
+ if (!matchesAccept(file)) {
84
+ rejected.push({ file, reason: `"${file.name}" is not an accepted file type.` });
85
+ continue;
86
+ }
87
+ added.push(file);
88
+ }
89
+ files = files.concat(added);
90
+ return { added, rejected };
91
+ }
92
+ function removeFile(index) {
93
+ if (index < 0 || index >= files.length)
94
+ return false;
95
+ files = files.slice(0, index).concat(files.slice(index + 1));
96
+ return true;
97
+ }
98
+ function validateField(field, value) {
99
+ const label = field.label || field.name;
100
+ if (field.required && value.trim() === "")
101
+ return `${label} is required.`;
102
+ if (field.type === "email" && value.trim() !== "" && !EMAIL_PATTERN.test(value.trim())) {
103
+ return "Enter a valid email address.";
104
+ }
105
+ if (field.minLength !== undefined && value.length < field.minLength) {
106
+ return `${label} must be at least ${field.minLength} characters.`;
107
+ }
108
+ if (field.maxLength !== undefined && value.length > field.maxLength) {
109
+ return `${label} must be at most ${field.maxLength} characters.`;
110
+ }
111
+ if (field.validate)
112
+ return field.validate(value);
113
+ return null;
114
+ }
115
+ function validate() {
116
+ const next = {};
117
+ for (const field of fields) {
118
+ const message = validateField(field, values[field.name] ?? "");
119
+ if (message)
120
+ next[field.name] = message;
121
+ }
122
+ if (filesEnabled && fileConfig.required && files.length === 0) {
123
+ next[FILES_ERROR_KEY] = "At least one file is required.";
124
+ }
125
+ errors = next;
126
+ return { valid: Object.keys(next).length === 0, errors: { ...next } };
127
+ }
128
+ function buildPayload() {
129
+ const formData = new FormData();
130
+ for (const field of fields) {
131
+ formData.append(field.name, values[field.name] ?? "");
132
+ }
133
+ if (filesEnabled) {
134
+ for (const file of files) {
135
+ formData.append(fileConfig.fieldName, file);
136
+ }
137
+ }
138
+ return formData;
139
+ }
140
+ function reset() {
141
+ resetValues();
142
+ files = [];
143
+ errors = {};
144
+ }
145
+ return {
146
+ fields,
147
+ filesEnabled,
148
+ fileConfig,
149
+ setValue,
150
+ getValues: () => ({ ...values }),
151
+ getFiles: () => [...files],
152
+ addFiles,
153
+ removeFile,
154
+ clearFiles: () => {
155
+ files = [];
156
+ },
157
+ validate,
158
+ getErrors: () => ({ ...errors }),
159
+ buildPayload,
160
+ reset,
161
+ };
162
+ }
@@ -0,0 +1,30 @@
1
+ import { type FormSubmitDetail, type FormUploader, type FormUploaderConfig } from "./core.js";
2
+ export declare const ELEMENT_NAME = "form-uploader";
3
+ export declare const SUBMIT_EVENT = "form-submit";
4
+ declare const BaseElement: typeof HTMLElement;
5
+ export declare class FormUploaderElement extends BaseElement {
6
+ private uploaderInstance;
7
+ private elementConfig;
8
+ private previewUrls;
9
+ private fileNotice;
10
+ onSubmit?: (detail: FormSubmitDetail) => void | Promise<void>;
11
+ static get observedAttributes(): string[];
12
+ get config(): FormUploaderConfig;
13
+ set config(next: FormUploaderConfig);
14
+ get uploader(): FormUploader;
15
+ connectedCallback(): void;
16
+ disconnectedCallback(): void;
17
+ attributeChangedCallback(): void;
18
+ reset(): void;
19
+ private get submitLabel();
20
+ private render;
21
+ private handleSubmit;
22
+ private syncPreviews;
23
+ private revokePreviews;
24
+ }
25
+ declare global {
26
+ interface HTMLElementTagNameMap {
27
+ "form-uploader": FormUploaderElement;
28
+ }
29
+ }
30
+ export {};
@@ -0,0 +1,250 @@
1
+ import { FILES_ERROR_KEY, createFormUploader, } from "./core.js";
2
+ export const ELEMENT_NAME = "form-uploader";
3
+ export const SUBMIT_EVENT = "form-submit";
4
+ const BaseElement = globalThis.HTMLElement ??
5
+ class {
6
+ };
7
+ function escapeHtml(value) {
8
+ return value
9
+ .replace(/&/g, "&amp;")
10
+ .replace(/</g, "&lt;")
11
+ .replace(/>/g, "&gt;")
12
+ .replace(/"/g, "&quot;");
13
+ }
14
+ function formatBytes(bytes) {
15
+ if (bytes >= 1024 * 1024)
16
+ return `${Math.round(bytes / (1024 * 1024))} MB`;
17
+ if (bytes >= 1024)
18
+ return `${Math.round(bytes / 1024)} KB`;
19
+ return `${bytes} B`;
20
+ }
21
+ const STYLES = [
22
+ ":host { display: block; font: inherit; color-scheme: light dark; }",
23
+ ":host {",
24
+ " --fu-accent: #2563eb;",
25
+ " --fu-accent-hover: #1d4ed8;",
26
+ " --fu-accent-contrast: #ffffff;",
27
+ " --fu-text: #1c1917;",
28
+ " --fu-muted: #57534e;",
29
+ " --fu-border: #d6d3d1;",
30
+ " --fu-surface: #ffffff;",
31
+ " --fu-surface-soft: #f5f5f4;",
32
+ " --fu-error: #b91c1c;",
33
+ " --fu-ring: rgb(37 99 235 / 0.25);",
34
+ " --fu-radius: 10px;",
35
+ " --fu-radius-sm: 8px;",
36
+ "}",
37
+ "@media (prefers-color-scheme: dark) {",
38
+ " :host {",
39
+ " --fu-text: #e7e5e4;",
40
+ " --fu-muted: #a8a29e;",
41
+ " --fu-border: #44403c;",
42
+ " --fu-surface: #1c1917;",
43
+ " --fu-surface-soft: #292524;",
44
+ " --fu-error: #f87171;",
45
+ " --fu-ring: rgb(96 165 250 / 0.35);",
46
+ " }",
47
+ "}",
48
+ "form { display: grid; gap: 1rem; color: var(--fu-text); }",
49
+ ".field { min-width: 0; }",
50
+ ".field > label:not(.dropzone) { display: grid; gap: 0.375rem; font-size: 0.875rem; font-weight: 500; }",
51
+ "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
+ "input::placeholder, textarea::placeholder { color: var(--fu-muted); opacity: 1; }",
53
+ "input:focus-visible, textarea:focus-visible { outline: none; border-color: var(--fu-accent); box-shadow: 0 0 0 3px var(--fu-ring); }",
54
+ "input[aria-invalid='true'], textarea[aria-invalid='true'] { border-color: var(--fu-error); }",
55
+ "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); }",
56
+ "textarea { min-height: 6rem; resize: vertical; }",
57
+ ".error { color: var(--fu-error); font-size: 0.8125rem; margin: 0.375rem 0 0; }",
58
+ ".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; }",
59
+ ".dropzone:hover { border-color: var(--fu-accent); }",
60
+ ".dropzone:focus-within { outline: none; border-color: var(--fu-accent); box-shadow: 0 0 0 3px var(--fu-ring); }",
61
+ ".dropzone input { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; }",
62
+ ".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; }",
63
+ ".dropzone-title { font-size: 0.875rem; font-weight: 600; }",
64
+ ".dropzone-hint { font-size: 0.8125rem; font-weight: 400; color: var(--fu-muted); }",
65
+ ".previews { display: grid; grid-template-columns: repeat(auto-fill, minmax(6rem, 1fr)); gap: 0.625rem; margin-top: 0.625rem; }",
66
+ ".preview { margin: 0; position: relative; border: 1px solid var(--fu-border); border-radius: var(--fu-radius-sm); overflow: hidden; background: var(--fu-surface); }",
67
+ ".preview img { width: 100%; height: 5.25rem; object-fit: cover; display: block; background: var(--fu-surface-soft); }",
68
+ ".preview figcaption { font-size: 0.7rem; padding: 0.25rem 0.375rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fu-muted); }",
69
+ ".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; }",
70
+ ".preview button:hover { background: rgb(0 0 0 / 0.8); }",
71
+ "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; }",
72
+ "button[type='submit']:hover { background: var(--fu-accent-hover); }",
73
+ "button[type='submit']:active { transform: scale(0.98); }",
74
+ "button[type='submit']:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--fu-ring); }",
75
+ "@media (prefers-reduced-motion: reduce) {",
76
+ " *, *::before, *::after { transition: none; }",
77
+ "}",
78
+ ].join("\n");
79
+ export class FormUploaderElement extends BaseElement {
80
+ constructor() {
81
+ super(...arguments);
82
+ this.uploaderInstance = createFormUploader({});
83
+ this.elementConfig = {};
84
+ this.previewUrls = new Map();
85
+ this.fileNotice = null;
86
+ }
87
+ static get observedAttributes() {
88
+ return ["submit-label"];
89
+ }
90
+ get config() {
91
+ return this.elementConfig;
92
+ }
93
+ set config(next) {
94
+ this.elementConfig = next ?? {};
95
+ this.uploaderInstance = createFormUploader(this.elementConfig);
96
+ this.fileNotice = null;
97
+ this.revokePreviews();
98
+ this.render();
99
+ }
100
+ get uploader() {
101
+ return this.uploaderInstance;
102
+ }
103
+ connectedCallback() {
104
+ this.render();
105
+ }
106
+ disconnectedCallback() {
107
+ this.revokePreviews();
108
+ }
109
+ attributeChangedCallback() {
110
+ this.render();
111
+ }
112
+ reset() {
113
+ this.uploaderInstance.reset();
114
+ this.fileNotice = null;
115
+ this.render();
116
+ }
117
+ get submitLabel() {
118
+ const host = this;
119
+ if (typeof host.getAttribute !== "function")
120
+ return "Submit";
121
+ return host.getAttribute("submit-label") || "Submit";
122
+ }
123
+ render() {
124
+ const host = this;
125
+ if (typeof host.attachShadow !== "function")
126
+ return;
127
+ const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" });
128
+ this.syncPreviews();
129
+ const values = this.uploaderInstance.getValues();
130
+ const errors = this.uploaderInstance.getErrors();
131
+ const files = this.uploaderInstance.getFiles();
132
+ 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)}"`
142
+ : "";
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>`;
150
+ })
151
+ .join("");
152
+ const fileError = errors[FILES_ERROR_KEY] ?? this.fileNotice;
153
+ const dropzoneTitle = fileConfig.accept === "image/*" ? "Choose images" : "Choose files";
154
+ const acceptLabel = fileConfig.accept === "image/*" ? "Images" : fileConfig.accept || "Files";
155
+ const dropzoneHint = `${acceptLabel} up to ${formatBytes(fileConfig.maxSize)} each, max ${fileConfig.maxFiles} ${fileConfig.maxFiles === 1 ? "file" : "files"}`;
156
+ const filesHtml = !this.uploaderInstance.filesEnabled
157
+ ? ""
158
+ : `<div class="field" part="field">` +
159
+ `<label class="dropzone" part="label dropzone">` +
160
+ `<span class="dropzone-mark" aria-hidden="true">+</span>` +
161
+ `<span class="dropzone-title" part="dropzone-title">${dropzoneTitle}</span>` +
162
+ `<span class="dropzone-hint" part="dropzone-hint">${escapeHtml(dropzoneHint)}</span>` +
163
+ `<input part="file-input" data-files type="file" multiple accept="${escapeHtml(fileConfig.accept)}" />` +
164
+ `</label>` +
165
+ (fileError ? `<p class="error" part="error">${escapeHtml(fileError)}</p>` : "") +
166
+ `<div class="previews" part="previews">${files
167
+ .map((file, index) => `<figure class="preview" part="preview">` +
168
+ `<img src="${this.previewUrls.get(file) ?? ""}" alt="${escapeHtml(file.name)}" />` +
169
+ `<figcaption>${escapeHtml(file.name)}</figcaption>` +
170
+ `<button type="button" part="remove" data-remove="${index}" aria-label="Remove ${escapeHtml(file.name)}">×</button>` +
171
+ `</figure>`)
172
+ .join("")}</div></div>`;
173
+ shadow.innerHTML =
174
+ `<style>${STYLES}</style>` +
175
+ `<form part="form" novalidate>${fieldsHtml}${filesHtml}` +
176
+ `<button type="submit" part="submit">${escapeHtml(this.submitLabel)}</button></form>`;
177
+ shadow.querySelector("form")?.addEventListener("submit", (event) => {
178
+ event.preventDefault();
179
+ void this.handleSubmit();
180
+ });
181
+ shadow.querySelectorAll("[data-field]").forEach((el) => {
182
+ el.addEventListener("input", () => {
183
+ const name = el.dataset.field;
184
+ if (name)
185
+ this.uploaderInstance.setValue(name, el.value);
186
+ });
187
+ });
188
+ const fileInput = shadow.querySelector("[data-files]");
189
+ fileInput?.addEventListener("change", () => {
190
+ if (fileInput.files) {
191
+ const result = this.uploaderInstance.addFiles(fileInput.files);
192
+ this.fileNotice =
193
+ result.rejected.length > 0
194
+ ? result.rejected.map((rejection) => rejection.reason).join(" ")
195
+ : null;
196
+ }
197
+ fileInput.value = "";
198
+ this.render();
199
+ });
200
+ shadow.querySelectorAll("[data-remove]").forEach((button) => {
201
+ button.addEventListener("click", () => {
202
+ this.uploaderInstance.removeFile(Number(button.dataset.remove));
203
+ this.fileNotice = null;
204
+ this.render();
205
+ });
206
+ });
207
+ }
208
+ async handleSubmit() {
209
+ this.fileNotice = null;
210
+ const { valid } = this.uploaderInstance.validate();
211
+ this.render();
212
+ if (!valid)
213
+ return;
214
+ const detail = {
215
+ formData: this.uploaderInstance.buildPayload(),
216
+ values: this.uploaderInstance.getValues(),
217
+ files: this.uploaderInstance.getFiles(),
218
+ };
219
+ const host = this;
220
+ const cancelled = !host.dispatchEvent(new CustomEvent(SUBMIT_EVENT, { detail, bubbles: true, composed: true }));
221
+ if (!cancelled)
222
+ await this.onSubmit?.(detail);
223
+ }
224
+ syncPreviews() {
225
+ const files = this.uploaderInstance.getFiles();
226
+ for (const [file, url] of this.previewUrls) {
227
+ if (!files.includes(file)) {
228
+ URL.revokeObjectURL(url);
229
+ this.previewUrls.delete(file);
230
+ }
231
+ }
232
+ if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function")
233
+ return;
234
+ for (const file of files) {
235
+ if (!this.previewUrls.has(file))
236
+ this.previewUrls.set(file, URL.createObjectURL(file));
237
+ }
238
+ }
239
+ revokePreviews() {
240
+ for (const url of this.previewUrls.values()) {
241
+ if (typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") {
242
+ URL.revokeObjectURL(url);
243
+ }
244
+ }
245
+ this.previewUrls.clear();
246
+ }
247
+ }
248
+ if (typeof customElements !== "undefined" && !customElements.get(ELEMENT_NAME)) {
249
+ customElements.define(ELEMENT_NAME, FormUploaderElement);
250
+ }
@@ -0,0 +1 @@
1
+ export * from "./core.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from "./core.js";
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "form-uploader",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Reusable contact + multi-image upload form: framework-agnostic core and a drop-in custom element for any website.",
5
+ "author": "Elbeg Darkhanbaatar",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Ebetheone/package.git"
9
+ },
10
+ "keywords": [
11
+ "form",
12
+ "contact-form",
13
+ "file-upload",
14
+ "image-upload",
15
+ "uploader",
16
+ "web-component",
17
+ "custom-element",
18
+ "form-validation",
19
+ "framework-agnostic"
20
+ ],
21
+ "type": "module",
22
+ "main": "./dist/index.js",
23
+ "types": "./dist/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "default": "./dist/index.js"
28
+ },
29
+ "./element": {
30
+ "types": "./dist/element.d.ts",
31
+ "default": "./dist/element.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.json",
41
+ "test": "npm run build && node --test test/",
42
+ "prepack": "npm run build",
43
+ "format": "prettier --write .",
44
+ "format:check": "prettier --check ."
45
+ },
46
+ "license": "MIT",
47
+ "devDependencies": {
48
+ "prettier": "^3.9.10",
49
+ "typescript": "^7.0.2"
50
+ }
51
+ }