@aarongustafson/form-dropzone 0.0.0-stage → 0.0.1

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) 2025 Aaron Gustafson
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,160 @@
1
- # Temporary Holding Version
1
+ # `form-dropzone` Web Component
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
+ [![npm version](https://img.shields.io/npm/v/@aarongustafson/form-dropzone.svg)](https://www.npmjs.com/package/@aarongustafson/form-dropzone) [![Build Status](https://img.shields.io/github/actions/workflow/status/aarongustafson/form-dropzone/ci.yml?branch=main)](https://github.com/aarongustafson/form-dropzone/actions)
4
+
5
+ A progressively enhanced file-field wrapper that adds a responsive drag-and-drop surface, accessible status announcements, and optional image previews.
6
+
7
+ ## Demo
8
+
9
+ - [Live demo](https://aarongustafson.github.io/form-dropzone/demo/) ([source](./demo/index.html))
10
+ - [esm.sh demo](https://aarongustafson.github.io/form-dropzone/demo/esm.html) ([source](./demo/esm.html))
11
+ - [unpkg demo](https://aarongustafson.github.io/form-dropzone/demo/unpkg.html) ([source](./demo/unpkg.html))
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install @aarongustafson/form-dropzone
17
+ ```
18
+
19
+ Import the class and register it:
20
+
21
+ ```javascript
22
+ import { FormDropzoneElement } from '@aarongustafson/form-dropzone';
23
+
24
+ customElements.define('form-dropzone', FormDropzoneElement);
25
+ ```
26
+
27
+ Or use the guarded definition helper:
28
+
29
+ ```javascript
30
+ import {
31
+ defineFormDropzone,
32
+ } from '@aarongustafson/form-dropzone/define.js';
33
+
34
+ defineFormDropzone();
35
+ ```
36
+
37
+ Importing `define.js` also attempts to register `<form-dropzone>` automatically in browser environments.
38
+
39
+ ## Usage
40
+
41
+ Supply one `input[type="file"]` and its associated `label`. Both remain in Light DOM and provide a native fallback before the component upgrades or when drag-and-drop is unavailable.
42
+
43
+ ```html
44
+ <form-dropzone>
45
+ <label for="attachments">Choose files</label>
46
+ <input
47
+ id="attachments"
48
+ name="attachments"
49
+ type="file"
50
+ accept=".pdf,image/*"
51
+ multiple
52
+ >
53
+ </form-dropzone>
54
+ ```
55
+
56
+ Clicking the non-interactive drop-zone surface opens the native picker. Dropped files are filtered using the input's comma-separated `accept` value, including file extensions, exact MIME types, and wildcard MIME groups. If `multiple` is absent, the first acceptable file is selected and later acceptable files are rejected. Mixed drops keep acceptable files and announce the rejected files.
57
+
58
+ A successful drop replaces the input's existing `FileList`, then dispatches bubbling `input` and `change` events.
59
+
60
+ ## Attributes
61
+
62
+ | Attribute | Type | Default | Description |
63
+ |---|---|---|---|
64
+ | `drop-label` | string | `Drop file(s) here` | Prompt inserted before the author label. |
65
+ | `separator-label` | string | `or` | Text inserted between the prompt and label. |
66
+ | `received-message` | string | `Received: {files}.` | Accepted-file announcement template. |
67
+ | `rejected-type-message` | string | `Rejected because the file type is not accepted: {files}.` | `accept` rejection template. |
68
+ | `rejected-multiple-message` | string | `Rejected because only one file is allowed: {files}.` | Single-file limit rejection template. |
69
+ | `preview-images` | boolean | absent | Adds image previews for picker and drop selections. |
70
+
71
+ Message templates support `{files}` (a comma-separated filename list) and `{count}`.
72
+
73
+ ```html
74
+ <form-dropzone
75
+ drop-label="Déposez les fichiers ici"
76
+ separator-label="ou"
77
+ received-message="{count} fichier(s) reçu(s) : {files}."
78
+ rejected-type-message="Type de fichier refusé : {files}."
79
+ rejected-multiple-message="Un seul fichier est autorisé. Refusé : {files}."
80
+ >
81
+ <label for="document">Choisir un fichier</label>
82
+ <input id="document" name="document" type="file" accept=".pdf">
83
+ </form-dropzone>
84
+ ```
85
+
86
+ Each attribute has a camel-cased JavaScript property: `dropLabel`, `separatorLabel`, `receivedMessage`, `rejectedTypeMessage`, `rejectedMultipleMessage`, and `previewImages`.
87
+
88
+ ## Image previews
89
+
90
+ Add `preview-images` to preview selected image MIME types. Accepted non-image files remain selected but are not rendered.
91
+
92
+ ```html
93
+ <form-dropzone preview-images>
94
+ <label for="photos">Choose photos</label>
95
+ <input id="photos" name="photos" type="file" accept="image/*" multiple>
96
+ </form-dropzone>
97
+ ```
98
+
99
+ Object URLs are revoked whenever previews are replaced, disabled, or disconnected. Previews are visual only and do not change form submission.
100
+
101
+ ## Styling
102
+
103
+ The component injects low-specificity default styles once. Ordinary author selectors can override the centered layout:
104
+
105
+ ```css
106
+ form-dropzone {
107
+ align-items: start;
108
+ min-block-size: 10rem;
109
+ text-align: start;
110
+ }
111
+
112
+ form-dropzone .form-dropzone__previews {
113
+ grid-template-columns: repeat(2, minmax(0, 1fr));
114
+ }
115
+ ```
116
+
117
+ Stable Light DOM classes:
118
+
119
+ - `.form-dropzone--enhanced`
120
+ - `.form-dropzone--drag-active`
121
+ - `.form-dropzone__prompt`
122
+ - `.form-dropzone__separator`
123
+ - `.form-dropzone__status`
124
+ - `.form-dropzone__previews`
125
+ - `.form-dropzone__preview`
126
+ - `.form-dropzone__preview-image`
127
+ - `.form-dropzone__preview-name`
128
+
129
+ Preview items also expose the filename through `data-file-name`.
130
+
131
+ ## Accessibility and progressive enhancement
132
+
133
+ - The author-provided label and file input remain the keyboard interaction.
134
+ - The wrapper does not add a duplicate button role or tab stop.
135
+ - Drop outcomes are announced through a generated polite, atomic status region.
136
+ - Accepted, invalid-type, and excess-file outcomes can be localized independently.
137
+ - Without the required drag-and-drop and assignable `FileList` APIs, the component does not mutate its children or add behavior.
138
+ - Invalid markup is left unchanged and reported with a console warning.
139
+
140
+ ## Browser support
141
+
142
+ The component requires Custom Elements, ES modules, HTML drag-and-drop file APIs, `DataTransfer`, and an assignable file input `files` property. Unsupported environments retain the original label and input.
143
+
144
+ ## Inspiration
145
+
146
+ The drop-zone layout and interaction were inspired by Nikita Hlopov's article, [“Custom styled input type file”](https://nikitahl.com/custom-styled-input-type-file). Many thanks to Nikita for sharing their approach!
147
+
148
+ ## Development
149
+
150
+ ```bash
151
+ npm install
152
+ npm run test:run
153
+ npm run test:coverage
154
+ npm run lint
155
+ npm run format
156
+ ```
157
+
158
+ ## License
159
+
160
+ MIT © [Aaron Gustafson](https://www.aaron-gustafson.com/)
@@ -0,0 +1,107 @@
1
+ {
2
+ "schemaVersion": "1.0.0",
3
+ "readme": "README.md",
4
+ "modules": [
5
+ {
6
+ "kind": "javascript-module",
7
+ "path": "form-dropzone.js",
8
+ "declarations": [
9
+ {
10
+ "kind": "class",
11
+ "description": "Progressively enhances an associated label and file input with a Light DOM drag-and-drop surface, accessible announcements, and optional image previews.",
12
+ "name": "FormDropzoneElement",
13
+ "members": [
14
+ {
15
+ "kind": "field",
16
+ "name": "dropLabel",
17
+ "type": { "text": "string" },
18
+ "attribute": "drop-label"
19
+ },
20
+ {
21
+ "kind": "field",
22
+ "name": "separatorLabel",
23
+ "type": { "text": "string" },
24
+ "attribute": "separator-label"
25
+ },
26
+ {
27
+ "kind": "field",
28
+ "name": "receivedMessage",
29
+ "type": { "text": "string" },
30
+ "attribute": "received-message"
31
+ },
32
+ {
33
+ "kind": "field",
34
+ "name": "rejectedTypeMessage",
35
+ "type": { "text": "string" },
36
+ "attribute": "rejected-type-message"
37
+ },
38
+ {
39
+ "kind": "field",
40
+ "name": "rejectedMultipleMessage",
41
+ "type": { "text": "string" },
42
+ "attribute": "rejected-multiple-message"
43
+ },
44
+ {
45
+ "kind": "field",
46
+ "name": "previewImages",
47
+ "type": { "text": "boolean" },
48
+ "attribute": "preview-images"
49
+ }
50
+ ],
51
+ "events": [],
52
+ "attributes": [
53
+ {
54
+ "name": "drop-label",
55
+ "type": { "text": "string" },
56
+ "default": "\"Drop file(s) here\"",
57
+ "description": "Drop instruction inserted before the author-provided label."
58
+ },
59
+ {
60
+ "name": "separator-label",
61
+ "type": { "text": "string" },
62
+ "default": "\"or\"",
63
+ "description": "Text inserted between the drop instruction and label."
64
+ },
65
+ {
66
+ "name": "received-message",
67
+ "type": { "text": "string" },
68
+ "default": "\"Received: {files}.\"",
69
+ "description": "Live-region template for accepted files. Supports {files} and {count}."
70
+ },
71
+ {
72
+ "name": "rejected-type-message",
73
+ "type": { "text": "string" },
74
+ "default": "\"Rejected because the file type is not accepted: {files}.\"",
75
+ "description": "Live-region template for files rejected by accept. Supports {files} and {count}."
76
+ },
77
+ {
78
+ "name": "rejected-multiple-message",
79
+ "type": { "text": "string" },
80
+ "default": "\"Rejected because only one file is allowed: {files}.\"",
81
+ "description": "Live-region template for excess files on a single-file input. Supports {files} and {count}."
82
+ },
83
+ {
84
+ "name": "preview-images",
85
+ "type": { "text": "boolean" },
86
+ "default": "false",
87
+ "description": "Renders accepted image selections in a Light DOM preview grid."
88
+ }
89
+ ],
90
+ "superclass": { "name": "HTMLElement" },
91
+ "tagName": "form-dropzone",
92
+ "customElement": true
93
+ }
94
+ ],
95
+ "exports": [
96
+ {
97
+ "kind": "js",
98
+ "name": "FormDropzoneElement",
99
+ "declaration": {
100
+ "name": "FormDropzoneElement",
101
+ "module": "form-dropzone.js"
102
+ }
103
+ }
104
+ ]
105
+ }
106
+ ]
107
+ }
package/define.js ADDED
@@ -0,0 +1,18 @@
1
+ import { FormDropzoneElement } from './form-dropzone.js';
2
+
3
+ export function defineFormDropzone(tagName = 'form-dropzone') {
4
+ const hasWindow = typeof window !== 'undefined';
5
+ const registry = hasWindow ? window.customElements : undefined;
6
+
7
+ if (!registry || typeof registry.define !== 'function') {
8
+ return false;
9
+ }
10
+
11
+ if (!registry.get(tagName)) {
12
+ registry.define(tagName, FormDropzoneElement);
13
+ }
14
+
15
+ return true;
16
+ }
17
+
18
+ defineFormDropzone();
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Progressively enhances an associated label and file input with a Light DOM
3
+ * drag-and-drop surface, accessible announcements, and optional image previews.
4
+ *
5
+ * @element form-dropzone
6
+ */
7
+ export class FormDropzoneElement extends HTMLElement {
8
+ static readonly observedAttributes: string[];
9
+
10
+ /**
11
+ * Returns whether the browser supports the drag-and-drop file APIs required
12
+ * by the component.
13
+ */
14
+ static isSupported(): boolean;
15
+
16
+ constructor();
17
+ connectedCallback(): void;
18
+ disconnectedCallback(): void;
19
+ attributeChangedCallback(
20
+ name: string,
21
+ oldValue: string | null,
22
+ newValue: string | null,
23
+ ): void;
24
+
25
+ get dropLabel(): string;
26
+ set dropLabel(value: string | null | undefined);
27
+
28
+ get separatorLabel(): string;
29
+ set separatorLabel(value: string | null | undefined);
30
+
31
+ get receivedMessage(): string;
32
+ set receivedMessage(value: string | null | undefined);
33
+
34
+ get rejectedTypeMessage(): string;
35
+ set rejectedTypeMessage(value: string | null | undefined);
36
+
37
+ get rejectedMultipleMessage(): string;
38
+ set rejectedMultipleMessage(value: string | null | undefined);
39
+
40
+ get previewImages(): boolean;
41
+ set previewImages(value: boolean);
42
+ }
@@ -0,0 +1,591 @@
1
+ const GENERATED_ATTRIBUTE = 'data-form-dropzone-generated';
2
+ const ENHANCED_CLASS = 'form-dropzone--enhanced';
3
+ const STYLE_ID = 'form-dropzone-styles';
4
+
5
+ const DEFAULTS = {
6
+ dropLabel: 'Drop file(s) here',
7
+ separatorLabel: 'or',
8
+ receivedMessage: 'Received: {files}.',
9
+ rejectedTypeMessage:
10
+ 'Rejected because the file type is not accepted: {files}.',
11
+ rejectedMultipleMessage:
12
+ 'Rejected because only one file is allowed: {files}.',
13
+ };
14
+
15
+ /**
16
+ * FormDropzoneElement - A file field wrapper that builds a customizable drop zone for users to drag files into a form.
17
+ *
18
+ * @element form-dropzone
19
+ *
20
+ * @attr {string} drop-label - Drop instruction shown before the file label
21
+ * @attr {string} separator-label - Text shown between the drop instruction and file label
22
+ * @attr {string} received-message - Announcement template for accepted files
23
+ * @attr {string} rejected-type-message - Announcement template for files rejected by accept
24
+ * @attr {string} rejected-multiple-message - Announcement template for excess files
25
+ * @attr {boolean} preview-images - Whether accepted images are previewed
26
+ */
27
+ export class FormDropzoneElement extends HTMLElement {
28
+ static get observedAttributes() {
29
+ return [
30
+ 'drop-label',
31
+ 'separator-label',
32
+ 'received-message',
33
+ 'rejected-type-message',
34
+ 'rejected-multiple-message',
35
+ 'preview-images',
36
+ ];
37
+ }
38
+
39
+ static isSupported() {
40
+ if (
41
+ typeof DataTransfer !== 'function' ||
42
+ typeof DragEvent !== 'function'
43
+ ) {
44
+ return false;
45
+ }
46
+
47
+ try {
48
+ const input = document.createElement('input');
49
+ input.type = 'file';
50
+ const transfer = new DataTransfer();
51
+ input.files = transfer.files;
52
+ return (
53
+ input.files !== null &&
54
+ typeof input.files.length === 'number' &&
55
+ typeof input.files[Symbol.iterator] === 'function'
56
+ );
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ static injectStyles() {
63
+ if (document.getElementById(STYLE_ID)) {
64
+ return;
65
+ }
66
+
67
+ const style = document.createElement('style');
68
+ style.id = STYLE_ID;
69
+ style.textContent = `
70
+ :where(form-dropzone.form-dropzone--enhanced) {
71
+ align-items: center;
72
+ border: 2px dashed currentColor;
73
+ box-sizing: border-box;
74
+ cursor: pointer;
75
+ display: flex;
76
+ flex-direction: column;
77
+ gap: 0.75rem;
78
+ inline-size: 100%;
79
+ justify-content: center;
80
+ max-inline-size: 100%;
81
+ min-block-size: min(12rem, 50vh);
82
+ padding: clamp(1rem, 4vw, 2rem);
83
+ text-align: center;
84
+ }
85
+
86
+ :where(form-dropzone.form-dropzone--enhanced[hidden]) {
87
+ display: none;
88
+ }
89
+
90
+ :where(.form-dropzone__prompt) {
91
+ font-size: 1.25em;
92
+ font-weight: bold;
93
+ }
94
+
95
+ :where(form-dropzone.form-dropzone--drag-active) {
96
+ background: color-mix(in srgb, currentColor 8%, transparent);
97
+ border-style: solid;
98
+ }
99
+
100
+ :where(.form-dropzone__status) {
101
+ block-size: 1px;
102
+ clip: rect(0 0 0 0);
103
+ clip-path: inset(50%);
104
+ inline-size: 1px;
105
+ overflow: hidden;
106
+ position: absolute;
107
+ white-space: nowrap;
108
+ }
109
+
110
+ :where(.form-dropzone__previews) {
111
+ display: grid;
112
+ gap: 1rem;
113
+ grid-template-columns: repeat(auto-fit, minmax(min(8rem, 100%), 1fr));
114
+ inline-size: 100%;
115
+ list-style: none;
116
+ margin: 0;
117
+ padding: 0;
118
+ }
119
+
120
+ :where(.form-dropzone__preview) {
121
+ display: grid;
122
+ gap: 0.5rem;
123
+ min-inline-size: 0;
124
+ }
125
+
126
+ :where(.form-dropzone__preview-image) {
127
+ aspect-ratio: 1;
128
+ block-size: auto;
129
+ inline-size: 100%;
130
+ object-fit: cover;
131
+ }
132
+
133
+ :where(.form-dropzone__preview-name) {
134
+ overflow-wrap: anywhere;
135
+ }
136
+ `;
137
+ document.head.appendChild(style);
138
+ }
139
+
140
+ constructor() {
141
+ super();
142
+ this._input = null;
143
+ this._label = null;
144
+ this._prompt = null;
145
+ this._separator = null;
146
+ this._status = null;
147
+ this._previews = null;
148
+ this._previewUrls = [];
149
+ this._enhanced = false;
150
+ this._dragDepth = 0;
151
+ this._announcementToken = 0;
152
+ this._handleClick = this._handleClick.bind(this);
153
+ this._handleDragEnter = this._handleDragEnter.bind(this);
154
+ this._handleDragLeave = this._handleDragLeave.bind(this);
155
+ this._handleDragOver = FormDropzoneElement._handleDragOver;
156
+ this._handleDrop = this._handleDrop.bind(this);
157
+ this._handleInputChange = this._handleInputChange.bind(this);
158
+ }
159
+
160
+ connectedCallback() {
161
+ for (const property of [
162
+ 'dropLabel',
163
+ 'separatorLabel',
164
+ 'receivedMessage',
165
+ 'rejectedTypeMessage',
166
+ 'rejectedMultipleMessage',
167
+ 'previewImages',
168
+ ]) {
169
+ this._upgradeProperty(property);
170
+ }
171
+
172
+ if (!FormDropzoneElement.isSupported()) {
173
+ return;
174
+ }
175
+
176
+ const inputs = this.querySelectorAll('input[type="file"]');
177
+ if (inputs.length !== 1) {
178
+ this._reportInvalidMarkup(
179
+ 'Expected exactly one input[type="file"].',
180
+ );
181
+ return;
182
+ }
183
+
184
+ const input = inputs[0];
185
+ const label = Array.from(this.querySelectorAll('label')).find(
186
+ (candidate) =>
187
+ candidate.contains(input) ||
188
+ (Boolean(input.id) && candidate.htmlFor === input.id),
189
+ );
190
+
191
+ if (!label) {
192
+ this._reportInvalidMarkup(
193
+ 'Expected a label associated with the file input.',
194
+ );
195
+ return;
196
+ }
197
+
198
+ this._input = input;
199
+ this._label = label;
200
+ this._enhance();
201
+ }
202
+
203
+ disconnectedCallback() {
204
+ this._teardown();
205
+ }
206
+
207
+ attributeChangedCallback(name, oldValue, newValue) {
208
+ if (oldValue === newValue || !this._enhanced) {
209
+ return;
210
+ }
211
+
212
+ if (name === 'drop-label' && this._prompt) {
213
+ this._prompt.textContent = this.dropLabel;
214
+ }
215
+ if (name === 'separator-label' && this._separator) {
216
+ this._separator.textContent = this.separatorLabel;
217
+ }
218
+ if (name === 'preview-images') {
219
+ this._syncPreviews();
220
+ }
221
+ }
222
+
223
+ get dropLabel() {
224
+ return this.getAttribute('drop-label') || DEFAULTS.dropLabel;
225
+ }
226
+
227
+ set dropLabel(value) {
228
+ this._setStringAttribute('drop-label', value);
229
+ }
230
+
231
+ get separatorLabel() {
232
+ return this.getAttribute('separator-label') || DEFAULTS.separatorLabel;
233
+ }
234
+
235
+ set separatorLabel(value) {
236
+ this._setStringAttribute('separator-label', value);
237
+ }
238
+
239
+ get receivedMessage() {
240
+ return (
241
+ this.getAttribute('received-message') || DEFAULTS.receivedMessage
242
+ );
243
+ }
244
+
245
+ set receivedMessage(value) {
246
+ this._setStringAttribute('received-message', value);
247
+ }
248
+
249
+ get rejectedTypeMessage() {
250
+ return (
251
+ this.getAttribute('rejected-type-message') ||
252
+ DEFAULTS.rejectedTypeMessage
253
+ );
254
+ }
255
+
256
+ set rejectedTypeMessage(value) {
257
+ this._setStringAttribute('rejected-type-message', value);
258
+ }
259
+
260
+ get rejectedMultipleMessage() {
261
+ return (
262
+ this.getAttribute('rejected-multiple-message') ||
263
+ DEFAULTS.rejectedMultipleMessage
264
+ );
265
+ }
266
+
267
+ set rejectedMultipleMessage(value) {
268
+ this._setStringAttribute('rejected-multiple-message', value);
269
+ }
270
+
271
+ get previewImages() {
272
+ return this.hasAttribute('preview-images');
273
+ }
274
+
275
+ set previewImages(value) {
276
+ this.toggleAttribute('preview-images', Boolean(value));
277
+ }
278
+
279
+ _enhance() {
280
+ if (this._enhanced) {
281
+ return;
282
+ }
283
+
284
+ FormDropzoneElement.injectStyles();
285
+
286
+ this._prompt = FormDropzoneElement._createGeneratedElement(
287
+ 'span',
288
+ 'form-dropzone__prompt',
289
+ );
290
+ this._prompt.textContent = this.dropLabel;
291
+ this._separator = FormDropzoneElement._createGeneratedElement(
292
+ 'span',
293
+ 'form-dropzone__separator',
294
+ );
295
+ this._separator.textContent = this.separatorLabel;
296
+ this._status = FormDropzoneElement._createGeneratedElement(
297
+ 'span',
298
+ 'form-dropzone__status',
299
+ );
300
+ this._status.setAttribute('role', 'status');
301
+ this._status.setAttribute('aria-live', 'polite');
302
+ this._status.setAttribute('aria-atomic', 'true');
303
+
304
+ this._label.before(this._prompt, this._separator);
305
+ this.appendChild(this._status);
306
+ this.classList.add(ENHANCED_CLASS);
307
+ this.addEventListener('click', this._handleClick);
308
+ this.addEventListener('dragenter', this._handleDragEnter);
309
+ this.addEventListener('dragleave', this._handleDragLeave);
310
+ this.addEventListener('dragover', this._handleDragOver);
311
+ this.addEventListener('drop', this._handleDrop);
312
+ this._input.addEventListener('change', this._handleInputChange);
313
+ this._enhanced = true;
314
+ this._syncPreviews();
315
+ }
316
+
317
+ _teardown() {
318
+ this.removeEventListener('click', this._handleClick);
319
+ this.removeEventListener('dragenter', this._handleDragEnter);
320
+ this.removeEventListener('dragleave', this._handleDragLeave);
321
+ this.removeEventListener('dragover', this._handleDragOver);
322
+ this.removeEventListener('drop', this._handleDrop);
323
+ this._input?.removeEventListener('change', this._handleInputChange);
324
+ this._removePreviews();
325
+ for (const element of this.querySelectorAll(
326
+ `[${GENERATED_ATTRIBUTE}]`,
327
+ )) {
328
+ element.remove();
329
+ }
330
+ this.classList.remove(ENHANCED_CLASS, 'form-dropzone--drag-active');
331
+ this._input = null;
332
+ this._label = null;
333
+ this._prompt = null;
334
+ this._separator = null;
335
+ this._status = null;
336
+ this._previews = null;
337
+ this._enhanced = false;
338
+ this._dragDepth = 0;
339
+ this._announcementToken += 1;
340
+ }
341
+
342
+ _handleInputChange() {
343
+ this._syncPreviews();
344
+ }
345
+
346
+ _handleClick(event) {
347
+ if (
348
+ event.defaultPrevented ||
349
+ !(event.target instanceof Element) ||
350
+ event.target.closest(
351
+ 'input, label, button, a, select, textarea, [contenteditable]',
352
+ )
353
+ ) {
354
+ return;
355
+ }
356
+
357
+ this._input.click();
358
+ }
359
+
360
+ _handleDragEnter(event) {
361
+ if (!FormDropzoneElement._hasFiles(event.dataTransfer)) {
362
+ return;
363
+ }
364
+
365
+ event.preventDefault();
366
+ this._dragDepth += 1;
367
+ this.classList.add('form-dropzone--drag-active');
368
+ }
369
+
370
+ _handleDragLeave(event) {
371
+ if (!FormDropzoneElement._hasFiles(event.dataTransfer)) {
372
+ return;
373
+ }
374
+
375
+ this._dragDepth = Math.max(0, this._dragDepth - 1);
376
+ if (this._dragDepth === 0) {
377
+ this.classList.remove('form-dropzone--drag-active');
378
+ }
379
+ }
380
+
381
+ static _handleDragOver(event) {
382
+ if (FormDropzoneElement._hasFiles(event.dataTransfer)) {
383
+ event.preventDefault();
384
+ }
385
+ }
386
+
387
+ _handleDrop(event) {
388
+ if (!FormDropzoneElement._hasFiles(event.dataTransfer)) {
389
+ return;
390
+ }
391
+
392
+ event.preventDefault();
393
+ this._dragDepth = 0;
394
+ this.classList.remove('form-dropzone--drag-active');
395
+
396
+ const accepted = [];
397
+ const rejectedType = [];
398
+ const rejectedMultiple = [];
399
+
400
+ for (const file of event.dataTransfer.files) {
401
+ if (!this._matchesAccept(file)) {
402
+ rejectedType.push(file);
403
+ } else if (!this._input.multiple && accepted.length > 0) {
404
+ rejectedMultiple.push(file);
405
+ } else {
406
+ accepted.push(file);
407
+ }
408
+ }
409
+
410
+ if (accepted.length === 0) {
411
+ this._announceDrop({
412
+ accepted,
413
+ rejectedType,
414
+ rejectedMultiple,
415
+ });
416
+ return;
417
+ }
418
+
419
+ const transfer = new DataTransfer();
420
+ for (const file of accepted) {
421
+ transfer.items.add(file);
422
+ }
423
+
424
+ try {
425
+ this._input.files = transfer.files;
426
+ } catch (error) {
427
+ console.error(
428
+ 'form-dropzone: Unable to assign dropped files to the input.',
429
+ error,
430
+ );
431
+ return;
432
+ }
433
+
434
+ this._input.dispatchEvent(new Event('input', { bubbles: true }));
435
+ this._input.dispatchEvent(new Event('change', { bubbles: true }));
436
+ this._announceDrop({ accepted, rejectedType, rejectedMultiple });
437
+ }
438
+
439
+ static _hasFiles(dataTransfer) {
440
+ if (!dataTransfer) {
441
+ return false;
442
+ }
443
+
444
+ return (
445
+ Array.from(dataTransfer.types || []).includes('Files') ||
446
+ dataTransfer.files.length > 0
447
+ );
448
+ }
449
+
450
+ _matchesAccept(file) {
451
+ const accept = this._input.accept.trim();
452
+ if (!accept) {
453
+ return true;
454
+ }
455
+
456
+ const fileName = file.name.toLowerCase();
457
+ const fileType = file.type.toLowerCase();
458
+ return accept
459
+ .split(',')
460
+ .map((token) => token.trim().toLowerCase())
461
+ .filter(Boolean)
462
+ .some((token) => {
463
+ if (token.startsWith('.')) {
464
+ return fileName.endsWith(token);
465
+ }
466
+ if (token.endsWith('/*')) {
467
+ return fileType.startsWith(token.slice(0, -1));
468
+ }
469
+ return fileType === token;
470
+ });
471
+ }
472
+
473
+ _announceDrop({ accepted, rejectedType, rejectedMultiple }) {
474
+ const messages = [];
475
+ for (const [files, template] of [
476
+ [accepted, this.receivedMessage],
477
+ [rejectedType, this.rejectedTypeMessage],
478
+ [rejectedMultiple, this.rejectedMultipleMessage],
479
+ ]) {
480
+ if (files.length > 0) {
481
+ messages.push(
482
+ FormDropzoneElement._formatMessage(template, files),
483
+ );
484
+ }
485
+ }
486
+
487
+ const status = this._status;
488
+ const announcementToken = ++this._announcementToken;
489
+ status.textContent = '';
490
+ queueMicrotask(() => {
491
+ if (
492
+ this._enhanced &&
493
+ this._status === status &&
494
+ this._announcementToken === announcementToken
495
+ ) {
496
+ status.textContent = messages.join(' ');
497
+ }
498
+ });
499
+ }
500
+
501
+ static _formatMessage(template, files) {
502
+ return template
503
+ .replaceAll('{count}', String(files.length))
504
+ .replaceAll('{files}', files.map((file) => file.name).join(', '));
505
+ }
506
+
507
+ _syncPreviews() {
508
+ if (!this.previewImages) {
509
+ this._removePreviews();
510
+ return;
511
+ }
512
+
513
+ if (!this._previews) {
514
+ this._previews = FormDropzoneElement._createGeneratedElement(
515
+ 'ul',
516
+ 'form-dropzone__previews',
517
+ );
518
+ this._status.before(this._previews);
519
+ }
520
+
521
+ this._revokePreviewUrls();
522
+ this._previews.replaceChildren();
523
+
524
+ for (const file of this._input.files) {
525
+ if (!file.type.toLowerCase().startsWith('image/')) {
526
+ continue;
527
+ }
528
+
529
+ const url = URL.createObjectURL(file);
530
+ this._previewUrls.push(url);
531
+
532
+ const item = document.createElement('li');
533
+ item.className = 'form-dropzone__preview';
534
+ item.dataset.fileName = file.name;
535
+
536
+ const image = document.createElement('img');
537
+ image.className = 'form-dropzone__preview-image';
538
+ image.src = url;
539
+ image.alt = '';
540
+
541
+ const name = document.createElement('span');
542
+ name.className = 'form-dropzone__preview-name';
543
+ name.textContent = file.name;
544
+
545
+ item.append(image, name);
546
+ this._previews.appendChild(item);
547
+ }
548
+ }
549
+
550
+ _removePreviews() {
551
+ this._revokePreviewUrls();
552
+ this._previews?.remove();
553
+ this._previews = null;
554
+ }
555
+
556
+ _revokePreviewUrls() {
557
+ for (const url of this._previewUrls) {
558
+ URL.revokeObjectURL(url);
559
+ }
560
+ this._previewUrls = [];
561
+ }
562
+
563
+ static _createGeneratedElement(tagName, className) {
564
+ const element = document.createElement(tagName);
565
+ element.className = className;
566
+ element.setAttribute(GENERATED_ATTRIBUTE, '');
567
+ return element;
568
+ }
569
+
570
+ _setStringAttribute(name, value) {
571
+ if (value === null || value === undefined) {
572
+ this.removeAttribute(name);
573
+ } else {
574
+ this.setAttribute(name, String(value));
575
+ }
576
+ }
577
+
578
+ _upgradeProperty(property) {
579
+ if (!Object.prototype.hasOwnProperty.call(this, property)) {
580
+ return;
581
+ }
582
+
583
+ const value = this[property];
584
+ delete this[property];
585
+ this[property] = value;
586
+ }
587
+
588
+ _reportInvalidMarkup(message) {
589
+ console.warn(`form-dropzone: ${message}`, this);
590
+ }
591
+ }
package/index.js ADDED
@@ -0,0 +1 @@
1
+ export { FormDropzoneElement } from './form-dropzone.js';
package/package.json CHANGED
@@ -1,6 +1,82 @@
1
1
  {
2
- "name": "@aarongustafson/form-dropzone",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@aarongustafson/form-dropzone",
3
+ "version": "0.0.1",
4
+ "description": "A file field wrapper that builds a customizable drop zone for users to drag files into a form.",
5
+ "keywords": [
6
+ "html",
7
+ "custom element",
8
+ "web component"
9
+ ],
10
+ "author": "Aaron Gustafson <aaron@easy-designs.net> (https://www.aaron-gustafson.com/)",
11
+ "license": "MIT",
12
+ "homepage": "https://github.com/aarongustafson/form-dropzone#readme",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/aarongustafson/form-dropzone.git"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/aarongustafson/form-dropzone/issues"
19
+ },
20
+ "type": "module",
21
+ "main": "index.js",
22
+ "module": "form-dropzone.js",
23
+ "customElements": "custom-elements.json",
24
+ "types": "form-dropzone.d.ts",
25
+ "exports": {
26
+ ".": {
27
+ "import": "./index.js",
28
+ "types": "./form-dropzone.d.ts"
29
+ },
30
+ "./form-dropzone.js": {
31
+ "types": "./form-dropzone.d.ts",
32
+ "import": "./form-dropzone.js"
33
+ },
34
+ "./define.js": {
35
+ "import": "./define.js"
36
+ },
37
+ "./custom-elements.json": "./custom-elements.json"
38
+ },
39
+ "files": [
40
+ "form-dropzone.js",
41
+ "form-dropzone.d.ts",
42
+ "define.js",
43
+ "index.js",
44
+ "custom-elements.json",
45
+ "README.md",
46
+ "LICENSE"
47
+ ],
48
+ "engines": {
49
+ "node": ">=22.0.0"
50
+ },
51
+ "packageManager": "npm@11.15.0",
52
+ "scripts": {
53
+ "test": "vitest",
54
+ "test:ui": "vitest --ui",
55
+ "test:run": "vitest run",
56
+ "test:coverage": "vitest run --coverage",
57
+ "lint": "npm run lint:eslint && npm run lint:prettier",
58
+ "format": "npm run format:eslint && npm run format:prettier",
59
+ "lint:eslint": "eslint .",
60
+ "format:eslint": "eslint . --fix",
61
+ "lint:prettier": "prettier \"**/*.js\" --check --ignore-path .gitignore",
62
+ "format:prettier": "prettier \"**/*.js\" --write --ignore-path .gitignore",
63
+ "version": "node scripts/update-demo-versions.js && git add demo/esm.html",
64
+ "postversion": "git push --follow-tags"
65
+ },
66
+ "devDependencies": {
67
+ "@open-wc/eslint-config": "^13.0.0",
68
+ "@testing-library/dom": "^10.4.1",
69
+ "@testing-library/user-event": "^14.6.1",
70
+ "@vitest/coverage-v8": "^4.1.7",
71
+ "@vitest/ui": "^4.1.7",
72
+ "eslint": "^9.39.4",
73
+ "eslint-config-prettier": "^10.1.8",
74
+ "happy-dom": "^20.9.0",
75
+ "marked": "16.4.1",
76
+ "prettier": "^3.8.3",
77
+ "vitest": "^4.1.7"
78
+ },
79
+ "publishConfig": {
80
+ "access": "public"
81
+ }
82
+ }