@ozankurt/file-chips 0.0.0-stage → 1.0.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,5 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ - Initial release: framework-free file chips with a preview popover and mark-for-removal (`@ozankurt/file-chips` on npm), and a Laravel `<x-file-chips>` Blade component with optional Spatie Media Library support (`ozankurt/laravel-file-chips`).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ozan Kurt
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,200 @@
1
- # Temporary Holding Version
1
+ # File Chips
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
+ Compact file chips for forms and detail pages. Each chip opens a preview popover with a thumbnail, file details and View / Download links, and can mark the file for removal: a hidden input is added to your form and your server deletes the file on save.
4
+
5
+ - **JavaScript + CSS** on npm as [`@ozankurt/file-chips`](https://www.npmjs.com/package/@ozankurt/file-chips): no dependencies, no framework, about 3 KB minified.
6
+ - **Laravel** on Packagist as `ozankurt/laravel-file-chips`: a `<x-file-chips>` Blade component that renders the markup, with optional [Spatie Media Library](https://spatie.be/docs/laravel-medialibrary) support, and ships the same built JS and CSS.
7
+
8
+ Both come from this one repository and share one markup contract, so you can use the Blade component, write the markup yourself, or render it from any other backend.
9
+
10
+ ## Laravel
11
+
12
+ ```bash
13
+ composer require ozankurt/laravel-file-chips
14
+ php artisan vendor:publish --tag=file-chips-assets
15
+ ```
16
+
17
+ In your layout:
18
+
19
+ ```blade
20
+ <head>
21
+ @fileChipsStyles
22
+ </head>
23
+ <body>
24
+ ...
25
+ @fileChipsScripts
26
+ </body>
27
+ ```
28
+
29
+ In a form:
30
+
31
+ ```blade
32
+ <form method="POST" action="{{ route('posts.update', $post) }}">
33
+ @csrf
34
+ @method('PUT')
35
+ <x-file-chips :files="$post->getMedia('attachments')" input="delete_media_ids[]" />
36
+ <button>Save</button>
37
+ </form>
38
+ ```
39
+
40
+ ```php
41
+ // Controller
42
+ foreach ($request->input('delete_media_ids', []) as $id) {
43
+ $post->media()->whereKey($id)->first()?->delete();
44
+ }
45
+ ```
46
+
47
+ Always scope the deletion to the owning model as above; the ids come from the browser.
48
+
49
+ ### Props
50
+
51
+ | Prop | Type | Default | Description |
52
+ |---|---|---|---|
53
+ | `files` | iterable of `Media`, `FileChip` or arrays | `[]` | The files to show. Renders nothing when empty. |
54
+ | `input` | `string\|null` | `null` | Name of the hidden input that collects ids marked for removal. `null` hides the remove button. |
55
+ | `id-prefix` | `string\|null` | derived | Prefix for the popover element ids. By default it is derived from the files and `input`, so it stays the same across re-renders (Livewire, Turbo, htmx). Set it when two lists on one page show the same files. |
56
+
57
+ Other attributes (`id`, `class`, `data-*`, ...) go onto the wrapper `<div>`.
58
+
59
+ ### Files that are not Media Library models
60
+
61
+ Pass arrays or `FileChip` objects:
62
+
63
+ ```blade
64
+ <x-file-chips :files="[
65
+ ['id' => $doc->id, 'name' => $doc->filename, 'size' => $doc->bytes, 'view_url' => route('docs.show', $doc)],
66
+ ]" />
67
+ ```
68
+
69
+ | Key | Required | Notes |
70
+ |---|---|---|
71
+ | `id` | yes | Sent back in the removal input |
72
+ | `name` | yes | File name shown on the chip |
73
+ | `extension` | no | Taken from `name` when missing |
74
+ | `mime_type` | no | Used for the category when the extension is unknown |
75
+ | `size` | no | Bytes (formatted for you) or a ready string |
76
+ | `thumb_url`, `view_url`, `download_url` | no | `download_url` defaults to `view_url`. Only relative, `http(s):` and `blob:` URLs are kept (plus `data:image/...` for thumbnails); `javascript:` and other schemes are dropped. |
77
+ | `excerpt` | no | Short text under the details |
78
+ | `is_image` | no | Derived from extension and MIME type |
79
+ | `created_at` | no | `DateTimeInterface` or a date string |
80
+
81
+ `FileChip::fromMedia($media)` turns a Media Library model into a chip. S3 disks get temporary URLs; the download link adds a `Content-Disposition: attachment` header that keeps non-ASCII file names. Images browsers cannot show (HEIC, for example) get no thumbnail unless a thumb conversion exists.
82
+
83
+ ### Configuration
84
+
85
+ ```bash
86
+ php artisan vendor:publish --tag=file-chips-config
87
+ ```
88
+
89
+ | Key | Default | Description |
90
+ |---|---|---|
91
+ | `asset_path` | `vendor/file-chips` | Where the assets are published |
92
+ | `icons` | `null` | Icon classes per category, e.g. `['pdf' => 'far fa-file-pdf', 'file' => 'far fa-file']`. `null` shows an extension badge, so no icon font is needed. |
93
+ | `date_format` | `d M Y H:i` | Format of the "Added" date |
94
+ | `thumb_conversion` | `thumb` | Media Library conversion used as thumbnail; `null` to skip |
95
+ | `temporary_url_minutes` | `30` | Lifetime of temporary S3 view and thumbnail URLs |
96
+ | `download_url_minutes` | `15` | Lifetime of temporary S3 download URLs |
97
+ | `summary_property` | `summary` | Media custom property shown as excerpt; `null` to skip |
98
+ | `summary_limit` | `160` | Excerpt length |
99
+
100
+ Categories: `pdf`, `image`, `document`, `spreadsheet`, `presentation`, `archive`, `audio`, `video`, `code`, `email`, `file`.
101
+
102
+ Labels are translatable (`php artisan vendor:publish --tag=file-chips-lang`), and the view can be published with `--tag=file-chips-views`.
103
+
104
+ ## JavaScript only
105
+
106
+ ```bash
107
+ npm i @ozankurt/file-chips
108
+ ```
109
+
110
+ ```js
111
+ import FileChips from '@ozankurt/file-chips';
112
+ import '@ozankurt/file-chips/style.css';
113
+
114
+ FileChips.init();
115
+ ```
116
+
117
+ Or with script tags (starts automatically when the DOM is ready; add `data-manual` to the `<script>` tag to start it yourself):
118
+
119
+ ```html
120
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ozankurt/file-chips@1/dist/file-chips.min.css">
121
+ <script src="https://cdn.jsdelivr.net/npm/@ozankurt/file-chips@1/dist/file-chips.min.js" defer></script>
122
+ ```
123
+
124
+ ### Markup contract
125
+
126
+ ```html
127
+ <form>
128
+ <div class="fc-chips" data-file-chips>
129
+ <div data-file-chips-bucket hidden></div>
130
+
131
+ <button type="button" class="fc-chip" data-file-chip="doc-12" aria-controls="doc-12" aria-expanded="false">
132
+ <span class="fc-chip__ext">pdf</span>
133
+ <span class="fc-chip__name">contract.pdf</span>
134
+ </button>
135
+
136
+ <div id="doc-12" class="fc-popover" data-file-chip-popover hidden>
137
+ <!-- anything you like: thumbnail, details, links -->
138
+ <button type="button" class="fc-btn fc-btn--danger" data-file-chip-remove
139
+ data-file-chip-id="12" data-file-chip-input="delete_ids[]"
140
+ data-label-marked="Marked" data-label-unmarked="Remove on save" aria-pressed="false">
141
+ <span data-file-chip-remove-label>Remove on save</span>
142
+ </button>
143
+ </div>
144
+ </div>
145
+ </form>
146
+ ```
147
+
148
+ | Attribute | On | Meaning |
149
+ |---|---|---|
150
+ | `data-file-chips` | container | Groups chips; its bucket receives the hidden inputs |
151
+ | `data-file-chips-bucket` | optional element in the container | Where hidden inputs go; the closest `<form>` is used when missing |
152
+ | `data-file-chip="<id>"` | chip | Opens the popover with that element id |
153
+ | `data-file-chip-popover` | popover | Start it with `hidden` |
154
+ | `data-file-chip-remove` | button in the popover | Toggles the mark; needs `data-file-chip-id` and `data-file-chip-input` |
155
+ | `data-file-chip-remove-label` | element in the button | Text swapped between `data-label-marked` and `data-label-unmarked` |
156
+
157
+ Keep each popover right after its chip. Popovers stay where they are in the DOM and use `position: fixed`, so they follow the chip in tab order, inherit the CSS variables of their container, and disappear with it when the page re-renders. They flip above the chip when there is no room below, and their position is corrected when a transformed ancestor (for example an animated modal) moves the fixed origin. Outside click and Escape close them; Escape returns focus to the chip.
158
+
159
+ ### API
160
+
161
+ | Call | Description |
162
+ |---|---|
163
+ | `FileChips.init({ root = document })` | Start handling chips inside `root`. A root that is already handled, itself or through an ancestor, is a no-op, so several roots and the script build's automatic start can coexist. Returns a function that stops handling that root. |
164
+ | `FileChips.open(chip)` | Open a chip's popover; returns the popover or `null` |
165
+ | `FileChips.close()` | Close the open popover |
166
+ | `FileChips.getOpen()` | The open popover or `null` |
167
+ | `FileChips.position(chip, popover)` | Re-place a popover next to its chip |
168
+
169
+ Marking and unmarking dispatch a bubbling `file-chips:change` event on the chip with `detail: { id, input, marked }`. The chip gets `fc-chip--marked` and the button `fc-btn--active` plus `aria-pressed="true"` while marked.
170
+
171
+ ## Styling
172
+
173
+ Every colour, radius and size is a CSS custom property. Override them on `:root` or on any wrapper around the chips (popovers inherit them too):
174
+
175
+ ```css
176
+ .dark {
177
+ --fc-chip-bg: #1f2937;
178
+ --fc-chip-bg-hover: #273244;
179
+ --fc-border: rgba(255, 255, 255, 0.12);
180
+ --fc-popover-bg: #111827;
181
+ --fc-popover-border: rgba(255, 255, 255, 0.12);
182
+ --fc-muted: #9ca3af;
183
+ --fc-thumb-bg: #1f2937;
184
+ }
185
+ ```
186
+
187
+ Full list: `--fc-font-size`, `--fc-gap`, `--fc-radius`, `--fc-chip-max-width`, `--fc-chip-bg`, `--fc-chip-bg-hover`, `--fc-border`, `--fc-text`, `--fc-muted`, `--fc-danger`, `--fc-danger-bg`, `--fc-primary`, `--fc-primary-text`, `--fc-focus`, `--fc-ext-bg`, `--fc-popover-width`, `--fc-popover-bg`, `--fc-popover-border`, `--fc-popover-radius`, `--fc-popover-shadow`, `--fc-popover-z`, `--fc-thumb-size`, `--fc-thumb-bg`.
188
+
189
+ ## Development
190
+
191
+ ```bash
192
+ npm install && composer install
193
+ npm test # JS (Vitest + happy-dom)
194
+ vendor/bin/pest # PHP
195
+ npm run build # rebuild dist/, which is committed
196
+ ```
197
+
198
+ ## License
199
+
200
+ MIT
@@ -0,0 +1,200 @@
1
+ // js/popover.js
2
+ var GAP = 8;
3
+ var EDGE = 8;
4
+ var openPopover = null;
5
+ var openChip = null;
6
+ var clamp = (value, min, max) => Math.max(min, Math.min(max, value));
7
+ var popoverFor = (chip) => chip.ownerDocument.getElementById(chip.dataset.fileChip);
8
+ var position = (chip, popover) => {
9
+ const rect = chip.getBoundingClientRect();
10
+ const width = popover.offsetWidth;
11
+ const height = popover.offsetHeight;
12
+ let top = rect.bottom + GAP;
13
+ const left = clamp(rect.left, EDGE, Math.max(EDGE, window.innerWidth - width - EDGE));
14
+ if (top + height > window.innerHeight) {
15
+ const above = rect.top - height - GAP;
16
+ if (above >= EDGE) {
17
+ top = above;
18
+ }
19
+ }
20
+ top = clamp(top, EDGE, window.innerHeight - height - EDGE);
21
+ popover.style.top = "0px";
22
+ popover.style.left = "0px";
23
+ const origin = popover.getBoundingClientRect();
24
+ popover.style.top = `${Math.round(top - origin.top)}px`;
25
+ popover.style.left = `${Math.round(left - origin.left)}px`;
26
+ };
27
+ var close = ({ restoreFocus = false } = {}) => {
28
+ if (!openPopover) {
29
+ return;
30
+ }
31
+ const popover = openPopover;
32
+ const chip = openChip;
33
+ const hadFocus = popover.contains(popover.ownerDocument.activeElement);
34
+ popover.hidden = true;
35
+ chip?.setAttribute("aria-expanded", "false");
36
+ openPopover = null;
37
+ openChip = null;
38
+ if (restoreFocus && hadFocus) {
39
+ chip?.focus();
40
+ }
41
+ };
42
+ var open = (chip) => {
43
+ close();
44
+ const popover = popoverFor(chip);
45
+ if (!popover) {
46
+ return null;
47
+ }
48
+ popover.hidden = false;
49
+ chip.setAttribute("aria-expanded", "true");
50
+ openPopover = popover;
51
+ openChip = chip;
52
+ position(chip, popover);
53
+ if (typeof requestAnimationFrame === "function") {
54
+ requestAnimationFrame(() => openPopover === popover && position(chip, popover));
55
+ }
56
+ return popover;
57
+ };
58
+ var getOpen = () => openPopover;
59
+ var getOpenChip = () => openChip;
60
+
61
+ // js/marks.js
62
+ var DEFAULT_MARKED = "Marked";
63
+ var DEFAULT_UNMARKED = "Remove on save";
64
+ var targetFor = (chip) => chip.closest("[data-file-chips]")?.querySelector("[data-file-chips-bucket]") ?? chip.closest("form");
65
+ var findInput = (target, name, id) => [...target.querySelectorAll('input[type="hidden"]')].find((input) => input.name === name && input.value === id) ?? null;
66
+ var render = (chip, button, marked) => {
67
+ chip.classList.toggle("fc-chip--marked", marked);
68
+ button.classList.toggle("fc-btn--active", marked);
69
+ button.setAttribute("aria-pressed", String(marked));
70
+ const label = button.querySelector("[data-file-chip-remove-label]") ?? button;
71
+ label.textContent = marked ? button.dataset.labelMarked ?? DEFAULT_MARKED : button.dataset.labelUnmarked ?? DEFAULT_UNMARKED;
72
+ };
73
+ var buttonIn = (popover) => popover.querySelector("[data-file-chip-remove]");
74
+ var sync = (chip, popover) => {
75
+ const button = buttonIn(popover);
76
+ const target = targetFor(chip);
77
+ if (!button || !target) {
78
+ return;
79
+ }
80
+ render(chip, button, findInput(target, button.dataset.fileChipInput, button.dataset.fileChipId) !== null);
81
+ };
82
+ var toggle = (chip, button) => {
83
+ const target = targetFor(chip);
84
+ if (!target) {
85
+ console.warn("[FileChips] No [data-file-chips-bucket] or <form> found for this chip; cannot mark it for removal.");
86
+ return;
87
+ }
88
+ const name = button.dataset.fileChipInput;
89
+ const id = button.dataset.fileChipId;
90
+ const existing = findInput(target, name, id);
91
+ if (existing) {
92
+ existing.remove();
93
+ } else {
94
+ const input = chip.ownerDocument.createElement("input");
95
+ input.type = "hidden";
96
+ input.name = name;
97
+ input.value = id;
98
+ target.appendChild(input);
99
+ }
100
+ const marked = !existing;
101
+ render(chip, button, marked);
102
+ chip.dispatchEvent(new CustomEvent("file-chips:change", { bubbles: true, detail: { id, input: name, marked } }));
103
+ };
104
+
105
+ // js/index.js
106
+ var documents = /* @__PURE__ */ new Map();
107
+ var handled = (doc, element) => [...documents.get(doc)?.roots ?? []].some((root) => root.contains(element));
108
+ var openWithState = (chip) => {
109
+ const popover = open(chip);
110
+ if (popover) {
111
+ sync(chip, popover);
112
+ }
113
+ return popover;
114
+ };
115
+ var listenersFor = (doc) => ({
116
+ click(event) {
117
+ const target = event.target;
118
+ const remove = target.closest?.("[data-file-chip-remove]");
119
+ if (remove && getOpen()?.contains(remove)) {
120
+ toggle(getOpenChip(), remove);
121
+ return;
122
+ }
123
+ const chip = target.closest?.("[data-file-chip]");
124
+ if (!chip || !handled(doc, chip)) {
125
+ return;
126
+ }
127
+ event.preventDefault();
128
+ if (getOpenChip() === chip) {
129
+ close();
130
+ } else {
131
+ openWithState(chip);
132
+ }
133
+ },
134
+ mousedown(event) {
135
+ const popover = getOpen();
136
+ if (popover && !popover.contains(event.target) && !event.target.closest?.("[data-file-chip]")) {
137
+ close();
138
+ }
139
+ },
140
+ keydown(event) {
141
+ if (event.key === "Escape" && getOpen()) {
142
+ close({ restoreFocus: true });
143
+ }
144
+ },
145
+ reposition() {
146
+ const popover = getOpen();
147
+ if (popover) {
148
+ position(getOpenChip(), popover);
149
+ }
150
+ }
151
+ });
152
+ var listen = (doc, listeners, method) => {
153
+ doc[method]("click", listeners.click);
154
+ doc[method]("mousedown", listeners.mousedown);
155
+ doc[method]("keydown", listeners.keydown);
156
+ doc.defaultView?.[method]("resize", listeners.reposition);
157
+ doc.defaultView?.[method]("scroll", listeners.reposition, true);
158
+ };
159
+ var init = ({ root = document } = {}) => {
160
+ const doc = root.ownerDocument ?? root;
161
+ if (handled(doc, root)) {
162
+ return () => {
163
+ };
164
+ }
165
+ if (!documents.has(doc)) {
166
+ const listeners = listenersFor(doc);
167
+ documents.set(doc, { roots: /* @__PURE__ */ new Set(), listeners });
168
+ listen(doc, listeners, "addEventListener");
169
+ }
170
+ const entry = documents.get(doc);
171
+ entry.roots.add(root);
172
+ return () => {
173
+ if (!entry.roots.delete(root)) {
174
+ return;
175
+ }
176
+ if (getOpenChip() && root.contains(getOpenChip())) {
177
+ close();
178
+ }
179
+ if (entry.roots.size === 0) {
180
+ listen(doc, entry.listeners, "removeEventListener");
181
+ documents.delete(doc);
182
+ }
183
+ };
184
+ };
185
+ var FileChips = {
186
+ init,
187
+ open: openWithState,
188
+ close: () => close(),
189
+ getOpen,
190
+ position
191
+ };
192
+ var index_default = FileChips;
193
+ export {
194
+ close,
195
+ index_default as default,
196
+ getOpen,
197
+ init,
198
+ position
199
+ };
200
+ //# sourceMappingURL=file-chips.mjs.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../js/popover.js", "../../js/marks.js", "../../js/index.js"],
4
+ "sourcesContent": ["/**\n * Popover open/close state and viewport-aware positioning.\n *\n * Popovers stay in place in the DOM, next to their chip, so they inherit the\n * chip container's CSS variables, follow it in tab order, and disappear with\n * it when the page re-renders. They are position: fixed; position() corrects\n * for ancestors (transforms, filters) that move the fixed origin.\n */\n\nconst GAP = 8;\nconst EDGE = 8;\n\nlet openPopover = null;\nlet openChip = null;\n\nconst clamp = (value, min, max) => Math.max(min, Math.min(max, value));\n\nconst popoverFor = (chip) => chip.ownerDocument.getElementById(chip.dataset.fileChip);\n\n/**\n * Place the popover below the chip, flip it above when there is no room,\n * and keep it inside the viewport.\n */\nconst position = (chip, popover) => {\n const rect = chip.getBoundingClientRect();\n const width = popover.offsetWidth;\n const height = popover.offsetHeight;\n\n let top = rect.bottom + GAP;\n const left = clamp(rect.left, EDGE, Math.max(EDGE, window.innerWidth - width - EDGE));\n\n if (top + height > window.innerHeight) {\n const above = rect.top - height - GAP;\n if (above >= EDGE) {\n top = above;\n }\n }\n\n top = clamp(top, EDGE, window.innerHeight - height - EDGE);\n\n // Where does (0, 0) land? Non-zero when a transformed ancestor becomes\n // the containing block of this fixed element.\n popover.style.top = '0px';\n popover.style.left = '0px';\n const origin = popover.getBoundingClientRect();\n\n popover.style.top = `${Math.round(top - origin.top)}px`;\n popover.style.left = `${Math.round(left - origin.left)}px`;\n};\n\n/**\n * Close the open popover. With restoreFocus, focus that was inside the\n * popover goes back to its chip.\n */\nconst close = ({ restoreFocus = false } = {}) => {\n if (!openPopover) {\n return;\n }\n\n const popover = openPopover;\n const chip = openChip;\n const hadFocus = popover.contains(popover.ownerDocument.activeElement);\n\n popover.hidden = true;\n chip?.setAttribute('aria-expanded', 'false');\n openPopover = null;\n openChip = null;\n\n if (restoreFocus && hadFocus) {\n chip?.focus();\n }\n};\n\n/**\n * Open the popover that belongs to a chip. Returns the popover, or null when\n * the chip has none.\n */\nconst open = (chip) => {\n close();\n\n const popover = popoverFor(chip);\n if (!popover) {\n return null;\n }\n\n popover.hidden = false;\n chip.setAttribute('aria-expanded', 'true');\n openPopover = popover;\n openChip = chip;\n\n position(chip, popover);\n if (typeof requestAnimationFrame === 'function') {\n requestAnimationFrame(() => openPopover === popover && position(chip, popover));\n }\n\n return popover;\n};\n\nconst getOpen = () => openPopover;\n\nconst getOpenChip = () => openChip;\n\nexport { open, close, getOpen, getOpenChip, position, popoverFor };\n", "/**\n * Mark-for-removal: toggles a hidden input carrying the file id so the\n * server can delete the file when the form is saved.\n */\n\nconst DEFAULT_MARKED = 'Marked';\nconst DEFAULT_UNMARKED = 'Remove on save';\n\n/**\n * The element hidden inputs go into: the [data-file-chips-bucket] of the\n * chip's [data-file-chips] container, otherwise the chip's form.\n */\nconst targetFor = (chip) => chip.closest('[data-file-chips]')?.querySelector('[data-file-chips-bucket]') ?? chip.closest('form');\n\n// Compare in JS instead of building a CSS selector, so ids and input\n// names containing quotes or brackets cannot break the lookup.\nconst findInput = (target, name, id) => [...target.querySelectorAll('input[type=\"hidden\"]')]\n .find((input) => input.name === name && input.value === id) ?? null;\n\nconst render = (chip, button, marked) => {\n chip.classList.toggle('fc-chip--marked', marked);\n button.classList.toggle('fc-btn--active', marked);\n button.setAttribute('aria-pressed', String(marked));\n\n const label = button.querySelector('[data-file-chip-remove-label]') ?? button;\n label.textContent = marked\n ? (button.dataset.labelMarked ?? DEFAULT_MARKED)\n : (button.dataset.labelUnmarked ?? DEFAULT_UNMARKED);\n};\n\nconst buttonIn = (popover) => popover.querySelector('[data-file-chip-remove]');\n\n/**\n * Reflect the current mark state of a chip in its popover button.\n */\nconst sync = (chip, popover) => {\n const button = buttonIn(popover);\n const target = targetFor(chip);\n if (!button || !target) {\n return;\n }\n\n render(chip, button, findInput(target, button.dataset.fileChipInput, button.dataset.fileChipId) !== null);\n};\n\n/**\n * Toggle the mark of the chip whose popover holds this button.\n */\nconst toggle = (chip, button) => {\n const target = targetFor(chip);\n if (!target) {\n console.warn('[FileChips] No [data-file-chips-bucket] or <form> found for this chip; cannot mark it for removal.');\n return;\n }\n\n const name = button.dataset.fileChipInput;\n const id = button.dataset.fileChipId;\n const existing = findInput(target, name, id);\n\n if (existing) {\n existing.remove();\n } else {\n const input = chip.ownerDocument.createElement('input');\n input.type = 'hidden';\n input.name = name;\n input.value = id;\n target.appendChild(input);\n }\n\n const marked = !existing;\n render(chip, button, marked);\n chip.dispatchEvent(new CustomEvent('file-chips:change', { bubbles: true, detail: { id, input: name, marked } }));\n};\n\nexport { sync, toggle };\n", "/**\n * File Chips\n *\n * Compact file chips with a preview popover and mark-for-removal.\n * Markup contract (see README):\n * [data-file-chips] container\n * [data-file-chips-bucket] optional element that receives hidden inputs\n * [data-file-chip=\"<id>\"] chip, points at its popover by id\n * [data-file-chip-popover] popover panel (starts hidden)\n * [data-file-chip-remove] toggle button with data-file-chip-id and data-file-chip-input\n */\n\nimport { close, getOpen, getOpenChip, open, position } from './popover.js';\nimport { sync, toggle } from './marks.js';\n\n// One set of document listeners per document, shared by every root on it,\n// so several roots never handle the same event twice.\nconst documents = new Map();\n\nconst handled = (doc, element) => [...(documents.get(doc)?.roots ?? [])].some((root) => root.contains(element));\n\nconst openWithState = (chip) => {\n const popover = open(chip);\n if (popover) {\n sync(chip, popover);\n }\n\n return popover;\n};\n\nconst listenersFor = (doc) => ({\n click(event) {\n const target = event.target;\n\n const remove = target.closest?.('[data-file-chip-remove]');\n if (remove && getOpen()?.contains(remove)) {\n toggle(getOpenChip(), remove);\n return;\n }\n\n const chip = target.closest?.('[data-file-chip]');\n if (!chip || !handled(doc, chip)) {\n return;\n }\n\n event.preventDefault();\n\n if (getOpenChip() === chip) {\n close();\n } else {\n openWithState(chip);\n }\n },\n\n mousedown(event) {\n const popover = getOpen();\n if (popover && !popover.contains(event.target) && !event.target.closest?.('[data-file-chip]')) {\n close();\n }\n },\n\n keydown(event) {\n if (event.key === 'Escape' && getOpen()) {\n close({ restoreFocus: true });\n }\n },\n\n reposition() {\n const popover = getOpen();\n if (popover) {\n position(getOpenChip(), popover);\n }\n },\n});\n\nconst listen = (doc, listeners, method) => {\n doc[method]('click', listeners.click);\n doc[method]('mousedown', listeners.mousedown);\n doc[method]('keydown', listeners.keydown);\n doc.defaultView?.[method]('resize', listeners.reposition);\n doc.defaultView?.[method]('scroll', listeners.reposition, true);\n};\n\n/**\n * Start handling chips inside `root` (default: the whole document). A root\n * that is already handled, itself or through an ancestor, is a no-op.\n * Returns a function that stops handling this root.\n */\nconst init = ({ root = document } = {}) => {\n const doc = root.ownerDocument ?? root;\n\n if (handled(doc, root)) {\n return () => {};\n }\n\n if (!documents.has(doc)) {\n const listeners = listenersFor(doc);\n documents.set(doc, { roots: new Set(), listeners });\n listen(doc, listeners, 'addEventListener');\n }\n\n const entry = documents.get(doc);\n entry.roots.add(root);\n\n return () => {\n if (!entry.roots.delete(root)) {\n return;\n }\n\n if (getOpenChip() && root.contains(getOpenChip())) {\n close();\n }\n\n if (entry.roots.size === 0) {\n listen(doc, entry.listeners, 'removeEventListener');\n documents.delete(doc);\n }\n };\n};\n\nconst FileChips = {\n init,\n open: openWithState,\n close: () => close(),\n getOpen,\n position,\n};\n\nexport { init, close, getOpen, position };\nexport default FileChips;\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,229 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
5
+ var __export = (target, all) => {
6
+ for (var name in all)
7
+ __defProp(target, name, { get: all[name], enumerable: true });
8
+ };
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
18
+
19
+ // js/index.js
20
+ var index_exports = {};
21
+ __export(index_exports, {
22
+ close: () => close,
23
+ default: () => index_default,
24
+ getOpen: () => getOpen,
25
+ init: () => init,
26
+ position: () => position
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+
30
+ // js/popover.js
31
+ var GAP = 8;
32
+ var EDGE = 8;
33
+ var openPopover = null;
34
+ var openChip = null;
35
+ var clamp = (value, min, max) => Math.max(min, Math.min(max, value));
36
+ var popoverFor = (chip) => chip.ownerDocument.getElementById(chip.dataset.fileChip);
37
+ var position = (chip, popover) => {
38
+ const rect = chip.getBoundingClientRect();
39
+ const width = popover.offsetWidth;
40
+ const height = popover.offsetHeight;
41
+ let top = rect.bottom + GAP;
42
+ const left = clamp(rect.left, EDGE, Math.max(EDGE, window.innerWidth - width - EDGE));
43
+ if (top + height > window.innerHeight) {
44
+ const above = rect.top - height - GAP;
45
+ if (above >= EDGE) {
46
+ top = above;
47
+ }
48
+ }
49
+ top = clamp(top, EDGE, window.innerHeight - height - EDGE);
50
+ popover.style.top = "0px";
51
+ popover.style.left = "0px";
52
+ const origin = popover.getBoundingClientRect();
53
+ popover.style.top = `${Math.round(top - origin.top)}px`;
54
+ popover.style.left = `${Math.round(left - origin.left)}px`;
55
+ };
56
+ var close = ({ restoreFocus = false } = {}) => {
57
+ if (!openPopover) {
58
+ return;
59
+ }
60
+ const popover = openPopover;
61
+ const chip = openChip;
62
+ const hadFocus = popover.contains(popover.ownerDocument.activeElement);
63
+ popover.hidden = true;
64
+ chip?.setAttribute("aria-expanded", "false");
65
+ openPopover = null;
66
+ openChip = null;
67
+ if (restoreFocus && hadFocus) {
68
+ chip?.focus();
69
+ }
70
+ };
71
+ var open = (chip) => {
72
+ close();
73
+ const popover = popoverFor(chip);
74
+ if (!popover) {
75
+ return null;
76
+ }
77
+ popover.hidden = false;
78
+ chip.setAttribute("aria-expanded", "true");
79
+ openPopover = popover;
80
+ openChip = chip;
81
+ position(chip, popover);
82
+ if (typeof requestAnimationFrame === "function") {
83
+ requestAnimationFrame(() => openPopover === popover && position(chip, popover));
84
+ }
85
+ return popover;
86
+ };
87
+ var getOpen = () => openPopover;
88
+ var getOpenChip = () => openChip;
89
+
90
+ // js/marks.js
91
+ var DEFAULT_MARKED = "Marked";
92
+ var DEFAULT_UNMARKED = "Remove on save";
93
+ var targetFor = (chip) => chip.closest("[data-file-chips]")?.querySelector("[data-file-chips-bucket]") ?? chip.closest("form");
94
+ var findInput = (target, name, id) => [...target.querySelectorAll('input[type="hidden"]')].find((input) => input.name === name && input.value === id) ?? null;
95
+ var render = (chip, button, marked) => {
96
+ chip.classList.toggle("fc-chip--marked", marked);
97
+ button.classList.toggle("fc-btn--active", marked);
98
+ button.setAttribute("aria-pressed", String(marked));
99
+ const label = button.querySelector("[data-file-chip-remove-label]") ?? button;
100
+ label.textContent = marked ? button.dataset.labelMarked ?? DEFAULT_MARKED : button.dataset.labelUnmarked ?? DEFAULT_UNMARKED;
101
+ };
102
+ var buttonIn = (popover) => popover.querySelector("[data-file-chip-remove]");
103
+ var sync = (chip, popover) => {
104
+ const button = buttonIn(popover);
105
+ const target = targetFor(chip);
106
+ if (!button || !target) {
107
+ return;
108
+ }
109
+ render(chip, button, findInput(target, button.dataset.fileChipInput, button.dataset.fileChipId) !== null);
110
+ };
111
+ var toggle = (chip, button) => {
112
+ const target = targetFor(chip);
113
+ if (!target) {
114
+ console.warn("[FileChips] No [data-file-chips-bucket] or <form> found for this chip; cannot mark it for removal.");
115
+ return;
116
+ }
117
+ const name = button.dataset.fileChipInput;
118
+ const id = button.dataset.fileChipId;
119
+ const existing = findInput(target, name, id);
120
+ if (existing) {
121
+ existing.remove();
122
+ } else {
123
+ const input = chip.ownerDocument.createElement("input");
124
+ input.type = "hidden";
125
+ input.name = name;
126
+ input.value = id;
127
+ target.appendChild(input);
128
+ }
129
+ const marked = !existing;
130
+ render(chip, button, marked);
131
+ chip.dispatchEvent(new CustomEvent("file-chips:change", { bubbles: true, detail: { id, input: name, marked } }));
132
+ };
133
+
134
+ // js/index.js
135
+ var documents = /* @__PURE__ */ new Map();
136
+ var handled = (doc, element) => [...documents.get(doc)?.roots ?? []].some((root) => root.contains(element));
137
+ var openWithState = (chip) => {
138
+ const popover = open(chip);
139
+ if (popover) {
140
+ sync(chip, popover);
141
+ }
142
+ return popover;
143
+ };
144
+ var listenersFor = (doc) => ({
145
+ click(event) {
146
+ const target = event.target;
147
+ const remove = target.closest?.("[data-file-chip-remove]");
148
+ if (remove && getOpen()?.contains(remove)) {
149
+ toggle(getOpenChip(), remove);
150
+ return;
151
+ }
152
+ const chip = target.closest?.("[data-file-chip]");
153
+ if (!chip || !handled(doc, chip)) {
154
+ return;
155
+ }
156
+ event.preventDefault();
157
+ if (getOpenChip() === chip) {
158
+ close();
159
+ } else {
160
+ openWithState(chip);
161
+ }
162
+ },
163
+ mousedown(event) {
164
+ const popover = getOpen();
165
+ if (popover && !popover.contains(event.target) && !event.target.closest?.("[data-file-chip]")) {
166
+ close();
167
+ }
168
+ },
169
+ keydown(event) {
170
+ if (event.key === "Escape" && getOpen()) {
171
+ close({ restoreFocus: true });
172
+ }
173
+ },
174
+ reposition() {
175
+ const popover = getOpen();
176
+ if (popover) {
177
+ position(getOpenChip(), popover);
178
+ }
179
+ }
180
+ });
181
+ var listen = (doc, listeners, method) => {
182
+ doc[method]("click", listeners.click);
183
+ doc[method]("mousedown", listeners.mousedown);
184
+ doc[method]("keydown", listeners.keydown);
185
+ doc.defaultView?.[method]("resize", listeners.reposition);
186
+ doc.defaultView?.[method]("scroll", listeners.reposition, true);
187
+ };
188
+ var init = ({ root = document } = {}) => {
189
+ const doc = root.ownerDocument ?? root;
190
+ if (handled(doc, root)) {
191
+ return () => {
192
+ };
193
+ }
194
+ if (!documents.has(doc)) {
195
+ const listeners = listenersFor(doc);
196
+ documents.set(doc, { roots: /* @__PURE__ */ new Set(), listeners });
197
+ listen(doc, listeners, "addEventListener");
198
+ }
199
+ const entry = documents.get(doc);
200
+ entry.roots.add(root);
201
+ return () => {
202
+ if (!entry.roots.delete(root)) {
203
+ return;
204
+ }
205
+ if (getOpenChip() && root.contains(getOpenChip())) {
206
+ close();
207
+ }
208
+ if (entry.roots.size === 0) {
209
+ listen(doc, entry.listeners, "removeEventListener");
210
+ documents.delete(doc);
211
+ }
212
+ };
213
+ };
214
+ var FileChips = {
215
+ init,
216
+ open: openWithState,
217
+ close: () => close(),
218
+ getOpen,
219
+ position
220
+ };
221
+ var index_default = FileChips;
222
+ // Annotate the CommonJS export names for ESM import in node:
223
+ 0 && (module.exports = {
224
+ close,
225
+ getOpen,
226
+ init,
227
+ position
228
+ });
229
+ module.exports = module.exports.default;
@@ -0,0 +1,215 @@
1
+ /*
2
+ * File Chips
3
+ * Every colour, radius and size is a custom property, so apps can theme the
4
+ * component by overriding the variables on :root or on any ancestor.
5
+ */
6
+
7
+ :root {
8
+ --fc-font-size: 0.875rem;
9
+ --fc-gap: 0.5rem;
10
+ --fc-radius: 0.5rem;
11
+ --fc-chip-max-width: 220px;
12
+ --fc-chip-bg: #fff;
13
+ --fc-chip-bg-hover: rgba(0, 0, 0, 0.03);
14
+ --fc-border: rgba(0, 0, 0, 0.1);
15
+ --fc-text: inherit;
16
+ --fc-muted: #6b7280;
17
+ --fc-danger: #dc3545;
18
+ --fc-danger-bg: rgba(220, 53, 69, 0.06);
19
+ --fc-primary: #2563eb;
20
+ --fc-primary-text: #fff;
21
+ --fc-focus: rgba(37, 99, 235, 0.35);
22
+ --fc-ext-bg: rgba(0, 0, 0, 0.06);
23
+ --fc-popover-width: 520px;
24
+ --fc-popover-bg: #fff;
25
+ --fc-popover-border: rgba(0, 0, 0, 0.12);
26
+ --fc-popover-radius: 0.75rem;
27
+ --fc-popover-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
28
+ --fc-popover-z: 1080;
29
+ --fc-thumb-size: 80px;
30
+ --fc-thumb-bg: #fafafa;
31
+ }
32
+
33
+ .fc-chips {
34
+ display: flex;
35
+ flex-wrap: wrap;
36
+ gap: var(--fc-gap);
37
+ }
38
+
39
+ .fc-chip {
40
+ display: inline-flex;
41
+ align-items: center;
42
+ gap: 0.375rem;
43
+ max-width: var(--fc-chip-max-width);
44
+ padding: 0.375rem 0.5rem;
45
+ border: 1px solid var(--fc-border);
46
+ border-radius: var(--fc-radius);
47
+ background: var(--fc-chip-bg);
48
+ color: var(--fc-text);
49
+ font: inherit;
50
+ font-size: var(--fc-font-size);
51
+ line-height: 1.25;
52
+ text-decoration: none;
53
+ cursor: pointer;
54
+ transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
55
+ }
56
+
57
+ .fc-chip:hover {
58
+ background: var(--fc-chip-bg-hover);
59
+ }
60
+
61
+ .fc-chip:focus-visible,
62
+ .fc-btn:focus-visible {
63
+ outline: none;
64
+ box-shadow: 0 0 0 3px var(--fc-focus);
65
+ }
66
+
67
+ .fc-chip--marked {
68
+ border-color: var(--fc-danger);
69
+ background: var(--fc-danger-bg);
70
+ }
71
+
72
+ .fc-chip--marked .fc-chip__name {
73
+ text-decoration: line-through;
74
+ opacity: 0.8;
75
+ }
76
+
77
+ .fc-chip__name,
78
+ .fc-popover__title {
79
+ overflow: hidden;
80
+ text-overflow: ellipsis;
81
+ white-space: nowrap;
82
+ }
83
+
84
+ .fc-chip__ext {
85
+ flex: none;
86
+ padding: 0.0625rem 0.3125rem;
87
+ border-radius: 0.25rem;
88
+ background: var(--fc-ext-bg);
89
+ font-size: 0.6875rem;
90
+ font-weight: 600;
91
+ letter-spacing: 0.02em;
92
+ text-transform: uppercase;
93
+ }
94
+
95
+ .fc-popover {
96
+ position: fixed;
97
+ z-index: var(--fc-popover-z);
98
+ width: var(--fc-popover-width);
99
+ max-width: 92vw;
100
+ padding: 1rem;
101
+ border: 1px solid var(--fc-popover-border);
102
+ border-radius: var(--fc-popover-radius);
103
+ background: var(--fc-popover-bg);
104
+ box-shadow: var(--fc-popover-shadow);
105
+ color: var(--fc-text);
106
+ font-size: var(--fc-font-size);
107
+ }
108
+
109
+ .fc-popover[hidden] {
110
+ display: none;
111
+ }
112
+
113
+ .fc-popover__head {
114
+ display: flex;
115
+ align-items: flex-start;
116
+ gap: 0.75rem;
117
+ }
118
+
119
+ .fc-popover__info {
120
+ min-width: 0;
121
+ flex: 1;
122
+ }
123
+
124
+ .fc-popover__title {
125
+ margin-bottom: 0.25rem;
126
+ font-weight: 600;
127
+ }
128
+
129
+ .fc-popover__meta,
130
+ .fc-popover__excerpt {
131
+ color: var(--fc-muted);
132
+ font-size: 0.8125rem;
133
+ }
134
+
135
+ .fc-popover__meta {
136
+ display: flex;
137
+ flex-wrap: wrap;
138
+ gap: 0.5rem;
139
+ }
140
+
141
+ .fc-popover__excerpt {
142
+ margin-top: 0.5rem;
143
+ }
144
+
145
+ .fc-popover__actions {
146
+ display: flex;
147
+ flex-wrap: wrap;
148
+ align-items: center;
149
+ gap: 0.5rem;
150
+ margin-top: 0.75rem;
151
+ }
152
+
153
+ .fc-popover__actions [data-file-chip-remove] {
154
+ margin-left: auto;
155
+ }
156
+
157
+ .fc-thumb {
158
+ display: flex;
159
+ flex: none;
160
+ align-items: center;
161
+ justify-content: center;
162
+ width: var(--fc-thumb-size);
163
+ height: var(--fc-thumb-size);
164
+ overflow: hidden;
165
+ border: 1px solid var(--fc-border);
166
+ border-radius: var(--fc-radius);
167
+ background: var(--fc-thumb-bg);
168
+ }
169
+
170
+ .fc-thumb img {
171
+ display: block;
172
+ max-width: 100%;
173
+ max-height: 100%;
174
+ }
175
+
176
+ .fc-thumb .fc-chip__ext {
177
+ font-size: 0.875rem;
178
+ }
179
+
180
+ .fc-btn {
181
+ display: inline-flex;
182
+ align-items: center;
183
+ gap: 0.25rem;
184
+ padding: 0.25rem 0.625rem;
185
+ border: 1px solid transparent;
186
+ border-radius: 0.375rem;
187
+ background: transparent;
188
+ color: inherit;
189
+ font: inherit;
190
+ font-size: 0.8125rem;
191
+ line-height: 1.5;
192
+ text-decoration: none;
193
+ cursor: pointer;
194
+ }
195
+
196
+ .fc-btn--primary {
197
+ border-color: var(--fc-primary);
198
+ background: var(--fc-primary);
199
+ color: var(--fc-primary-text);
200
+ }
201
+
202
+ .fc-btn--outline {
203
+ border-color: var(--fc-primary);
204
+ color: var(--fc-primary);
205
+ }
206
+
207
+ .fc-btn--danger {
208
+ border-color: var(--fc-danger);
209
+ color: var(--fc-danger);
210
+ }
211
+
212
+ .fc-btn--danger.fc-btn--active {
213
+ background: var(--fc-danger);
214
+ color: #fff;
215
+ }
@@ -0,0 +1,206 @@
1
+ (() => {
2
+ // js/popover.js
3
+ var GAP = 8;
4
+ var EDGE = 8;
5
+ var openPopover = null;
6
+ var openChip = null;
7
+ var clamp = (value, min, max) => Math.max(min, Math.min(max, value));
8
+ var popoverFor = (chip) => chip.ownerDocument.getElementById(chip.dataset.fileChip);
9
+ var position = (chip, popover) => {
10
+ const rect = chip.getBoundingClientRect();
11
+ const width = popover.offsetWidth;
12
+ const height = popover.offsetHeight;
13
+ let top = rect.bottom + GAP;
14
+ const left = clamp(rect.left, EDGE, Math.max(EDGE, window.innerWidth - width - EDGE));
15
+ if (top + height > window.innerHeight) {
16
+ const above = rect.top - height - GAP;
17
+ if (above >= EDGE) {
18
+ top = above;
19
+ }
20
+ }
21
+ top = clamp(top, EDGE, window.innerHeight - height - EDGE);
22
+ popover.style.top = "0px";
23
+ popover.style.left = "0px";
24
+ const origin = popover.getBoundingClientRect();
25
+ popover.style.top = `${Math.round(top - origin.top)}px`;
26
+ popover.style.left = `${Math.round(left - origin.left)}px`;
27
+ };
28
+ var close = ({ restoreFocus = false } = {}) => {
29
+ if (!openPopover) {
30
+ return;
31
+ }
32
+ const popover = openPopover;
33
+ const chip = openChip;
34
+ const hadFocus = popover.contains(popover.ownerDocument.activeElement);
35
+ popover.hidden = true;
36
+ chip?.setAttribute("aria-expanded", "false");
37
+ openPopover = null;
38
+ openChip = null;
39
+ if (restoreFocus && hadFocus) {
40
+ chip?.focus();
41
+ }
42
+ };
43
+ var open = (chip) => {
44
+ close();
45
+ const popover = popoverFor(chip);
46
+ if (!popover) {
47
+ return null;
48
+ }
49
+ popover.hidden = false;
50
+ chip.setAttribute("aria-expanded", "true");
51
+ openPopover = popover;
52
+ openChip = chip;
53
+ position(chip, popover);
54
+ if (typeof requestAnimationFrame === "function") {
55
+ requestAnimationFrame(() => openPopover === popover && position(chip, popover));
56
+ }
57
+ return popover;
58
+ };
59
+ var getOpen = () => openPopover;
60
+ var getOpenChip = () => openChip;
61
+
62
+ // js/marks.js
63
+ var DEFAULT_MARKED = "Marked";
64
+ var DEFAULT_UNMARKED = "Remove on save";
65
+ var targetFor = (chip) => chip.closest("[data-file-chips]")?.querySelector("[data-file-chips-bucket]") ?? chip.closest("form");
66
+ var findInput = (target, name, id) => [...target.querySelectorAll('input[type="hidden"]')].find((input) => input.name === name && input.value === id) ?? null;
67
+ var render = (chip, button, marked) => {
68
+ chip.classList.toggle("fc-chip--marked", marked);
69
+ button.classList.toggle("fc-btn--active", marked);
70
+ button.setAttribute("aria-pressed", String(marked));
71
+ const label = button.querySelector("[data-file-chip-remove-label]") ?? button;
72
+ label.textContent = marked ? button.dataset.labelMarked ?? DEFAULT_MARKED : button.dataset.labelUnmarked ?? DEFAULT_UNMARKED;
73
+ };
74
+ var buttonIn = (popover) => popover.querySelector("[data-file-chip-remove]");
75
+ var sync = (chip, popover) => {
76
+ const button = buttonIn(popover);
77
+ const target = targetFor(chip);
78
+ if (!button || !target) {
79
+ return;
80
+ }
81
+ render(chip, button, findInput(target, button.dataset.fileChipInput, button.dataset.fileChipId) !== null);
82
+ };
83
+ var toggle = (chip, button) => {
84
+ const target = targetFor(chip);
85
+ if (!target) {
86
+ console.warn("[FileChips] No [data-file-chips-bucket] or <form> found for this chip; cannot mark it for removal.");
87
+ return;
88
+ }
89
+ const name = button.dataset.fileChipInput;
90
+ const id = button.dataset.fileChipId;
91
+ const existing = findInput(target, name, id);
92
+ if (existing) {
93
+ existing.remove();
94
+ } else {
95
+ const input = chip.ownerDocument.createElement("input");
96
+ input.type = "hidden";
97
+ input.name = name;
98
+ input.value = id;
99
+ target.appendChild(input);
100
+ }
101
+ const marked = !existing;
102
+ render(chip, button, marked);
103
+ chip.dispatchEvent(new CustomEvent("file-chips:change", { bubbles: true, detail: { id, input: name, marked } }));
104
+ };
105
+
106
+ // js/index.js
107
+ var documents = /* @__PURE__ */ new Map();
108
+ var handled = (doc, element) => [...documents.get(doc)?.roots ?? []].some((root) => root.contains(element));
109
+ var openWithState = (chip) => {
110
+ const popover = open(chip);
111
+ if (popover) {
112
+ sync(chip, popover);
113
+ }
114
+ return popover;
115
+ };
116
+ var listenersFor = (doc) => ({
117
+ click(event) {
118
+ const target = event.target;
119
+ const remove = target.closest?.("[data-file-chip-remove]");
120
+ if (remove && getOpen()?.contains(remove)) {
121
+ toggle(getOpenChip(), remove);
122
+ return;
123
+ }
124
+ const chip = target.closest?.("[data-file-chip]");
125
+ if (!chip || !handled(doc, chip)) {
126
+ return;
127
+ }
128
+ event.preventDefault();
129
+ if (getOpenChip() === chip) {
130
+ close();
131
+ } else {
132
+ openWithState(chip);
133
+ }
134
+ },
135
+ mousedown(event) {
136
+ const popover = getOpen();
137
+ if (popover && !popover.contains(event.target) && !event.target.closest?.("[data-file-chip]")) {
138
+ close();
139
+ }
140
+ },
141
+ keydown(event) {
142
+ if (event.key === "Escape" && getOpen()) {
143
+ close({ restoreFocus: true });
144
+ }
145
+ },
146
+ reposition() {
147
+ const popover = getOpen();
148
+ if (popover) {
149
+ position(getOpenChip(), popover);
150
+ }
151
+ }
152
+ });
153
+ var listen = (doc, listeners, method) => {
154
+ doc[method]("click", listeners.click);
155
+ doc[method]("mousedown", listeners.mousedown);
156
+ doc[method]("keydown", listeners.keydown);
157
+ doc.defaultView?.[method]("resize", listeners.reposition);
158
+ doc.defaultView?.[method]("scroll", listeners.reposition, true);
159
+ };
160
+ var init = ({ root = document } = {}) => {
161
+ const doc = root.ownerDocument ?? root;
162
+ if (handled(doc, root)) {
163
+ return () => {
164
+ };
165
+ }
166
+ if (!documents.has(doc)) {
167
+ const listeners = listenersFor(doc);
168
+ documents.set(doc, { roots: /* @__PURE__ */ new Set(), listeners });
169
+ listen(doc, listeners, "addEventListener");
170
+ }
171
+ const entry = documents.get(doc);
172
+ entry.roots.add(root);
173
+ return () => {
174
+ if (!entry.roots.delete(root)) {
175
+ return;
176
+ }
177
+ if (getOpenChip() && root.contains(getOpenChip())) {
178
+ close();
179
+ }
180
+ if (entry.roots.size === 0) {
181
+ listen(doc, entry.listeners, "removeEventListener");
182
+ documents.delete(doc);
183
+ }
184
+ };
185
+ };
186
+ var FileChips = {
187
+ init,
188
+ open: openWithState,
189
+ close: () => close(),
190
+ getOpen,
191
+ position
192
+ };
193
+ var index_default = FileChips;
194
+
195
+ // js/browser.js
196
+ window.FileChips = index_default;
197
+ var script = document.currentScript;
198
+ if (!script?.hasAttribute("data-manual")) {
199
+ if (document.readyState === "loading") {
200
+ document.addEventListener("DOMContentLoaded", () => index_default.init(), { once: true });
201
+ } else {
202
+ index_default.init();
203
+ }
204
+ }
205
+ })();
206
+ //# sourceMappingURL=file-chips.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../js/popover.js", "../js/marks.js", "../js/index.js", "../js/browser.js"],
4
+ "sourcesContent": ["/**\n * Popover open/close state and viewport-aware positioning.\n *\n * Popovers stay in place in the DOM, next to their chip, so they inherit the\n * chip container's CSS variables, follow it in tab order, and disappear with\n * it when the page re-renders. They are position: fixed; position() corrects\n * for ancestors (transforms, filters) that move the fixed origin.\n */\n\nconst GAP = 8;\nconst EDGE = 8;\n\nlet openPopover = null;\nlet openChip = null;\n\nconst clamp = (value, min, max) => Math.max(min, Math.min(max, value));\n\nconst popoverFor = (chip) => chip.ownerDocument.getElementById(chip.dataset.fileChip);\n\n/**\n * Place the popover below the chip, flip it above when there is no room,\n * and keep it inside the viewport.\n */\nconst position = (chip, popover) => {\n const rect = chip.getBoundingClientRect();\n const width = popover.offsetWidth;\n const height = popover.offsetHeight;\n\n let top = rect.bottom + GAP;\n const left = clamp(rect.left, EDGE, Math.max(EDGE, window.innerWidth - width - EDGE));\n\n if (top + height > window.innerHeight) {\n const above = rect.top - height - GAP;\n if (above >= EDGE) {\n top = above;\n }\n }\n\n top = clamp(top, EDGE, window.innerHeight - height - EDGE);\n\n // Where does (0, 0) land? Non-zero when a transformed ancestor becomes\n // the containing block of this fixed element.\n popover.style.top = '0px';\n popover.style.left = '0px';\n const origin = popover.getBoundingClientRect();\n\n popover.style.top = `${Math.round(top - origin.top)}px`;\n popover.style.left = `${Math.round(left - origin.left)}px`;\n};\n\n/**\n * Close the open popover. With restoreFocus, focus that was inside the\n * popover goes back to its chip.\n */\nconst close = ({ restoreFocus = false } = {}) => {\n if (!openPopover) {\n return;\n }\n\n const popover = openPopover;\n const chip = openChip;\n const hadFocus = popover.contains(popover.ownerDocument.activeElement);\n\n popover.hidden = true;\n chip?.setAttribute('aria-expanded', 'false');\n openPopover = null;\n openChip = null;\n\n if (restoreFocus && hadFocus) {\n chip?.focus();\n }\n};\n\n/**\n * Open the popover that belongs to a chip. Returns the popover, or null when\n * the chip has none.\n */\nconst open = (chip) => {\n close();\n\n const popover = popoverFor(chip);\n if (!popover) {\n return null;\n }\n\n popover.hidden = false;\n chip.setAttribute('aria-expanded', 'true');\n openPopover = popover;\n openChip = chip;\n\n position(chip, popover);\n if (typeof requestAnimationFrame === 'function') {\n requestAnimationFrame(() => openPopover === popover && position(chip, popover));\n }\n\n return popover;\n};\n\nconst getOpen = () => openPopover;\n\nconst getOpenChip = () => openChip;\n\nexport { open, close, getOpen, getOpenChip, position, popoverFor };\n", "/**\n * Mark-for-removal: toggles a hidden input carrying the file id so the\n * server can delete the file when the form is saved.\n */\n\nconst DEFAULT_MARKED = 'Marked';\nconst DEFAULT_UNMARKED = 'Remove on save';\n\n/**\n * The element hidden inputs go into: the [data-file-chips-bucket] of the\n * chip's [data-file-chips] container, otherwise the chip's form.\n */\nconst targetFor = (chip) => chip.closest('[data-file-chips]')?.querySelector('[data-file-chips-bucket]') ?? chip.closest('form');\n\n// Compare in JS instead of building a CSS selector, so ids and input\n// names containing quotes or brackets cannot break the lookup.\nconst findInput = (target, name, id) => [...target.querySelectorAll('input[type=\"hidden\"]')]\n .find((input) => input.name === name && input.value === id) ?? null;\n\nconst render = (chip, button, marked) => {\n chip.classList.toggle('fc-chip--marked', marked);\n button.classList.toggle('fc-btn--active', marked);\n button.setAttribute('aria-pressed', String(marked));\n\n const label = button.querySelector('[data-file-chip-remove-label]') ?? button;\n label.textContent = marked\n ? (button.dataset.labelMarked ?? DEFAULT_MARKED)\n : (button.dataset.labelUnmarked ?? DEFAULT_UNMARKED);\n};\n\nconst buttonIn = (popover) => popover.querySelector('[data-file-chip-remove]');\n\n/**\n * Reflect the current mark state of a chip in its popover button.\n */\nconst sync = (chip, popover) => {\n const button = buttonIn(popover);\n const target = targetFor(chip);\n if (!button || !target) {\n return;\n }\n\n render(chip, button, findInput(target, button.dataset.fileChipInput, button.dataset.fileChipId) !== null);\n};\n\n/**\n * Toggle the mark of the chip whose popover holds this button.\n */\nconst toggle = (chip, button) => {\n const target = targetFor(chip);\n if (!target) {\n console.warn('[FileChips] No [data-file-chips-bucket] or <form> found for this chip; cannot mark it for removal.');\n return;\n }\n\n const name = button.dataset.fileChipInput;\n const id = button.dataset.fileChipId;\n const existing = findInput(target, name, id);\n\n if (existing) {\n existing.remove();\n } else {\n const input = chip.ownerDocument.createElement('input');\n input.type = 'hidden';\n input.name = name;\n input.value = id;\n target.appendChild(input);\n }\n\n const marked = !existing;\n render(chip, button, marked);\n chip.dispatchEvent(new CustomEvent('file-chips:change', { bubbles: true, detail: { id, input: name, marked } }));\n};\n\nexport { sync, toggle };\n", "/**\n * File Chips\n *\n * Compact file chips with a preview popover and mark-for-removal.\n * Markup contract (see README):\n * [data-file-chips] container\n * [data-file-chips-bucket] optional element that receives hidden inputs\n * [data-file-chip=\"<id>\"] chip, points at its popover by id\n * [data-file-chip-popover] popover panel (starts hidden)\n * [data-file-chip-remove] toggle button with data-file-chip-id and data-file-chip-input\n */\n\nimport { close, getOpen, getOpenChip, open, position } from './popover.js';\nimport { sync, toggle } from './marks.js';\n\n// One set of document listeners per document, shared by every root on it,\n// so several roots never handle the same event twice.\nconst documents = new Map();\n\nconst handled = (doc, element) => [...(documents.get(doc)?.roots ?? [])].some((root) => root.contains(element));\n\nconst openWithState = (chip) => {\n const popover = open(chip);\n if (popover) {\n sync(chip, popover);\n }\n\n return popover;\n};\n\nconst listenersFor = (doc) => ({\n click(event) {\n const target = event.target;\n\n const remove = target.closest?.('[data-file-chip-remove]');\n if (remove && getOpen()?.contains(remove)) {\n toggle(getOpenChip(), remove);\n return;\n }\n\n const chip = target.closest?.('[data-file-chip]');\n if (!chip || !handled(doc, chip)) {\n return;\n }\n\n event.preventDefault();\n\n if (getOpenChip() === chip) {\n close();\n } else {\n openWithState(chip);\n }\n },\n\n mousedown(event) {\n const popover = getOpen();\n if (popover && !popover.contains(event.target) && !event.target.closest?.('[data-file-chip]')) {\n close();\n }\n },\n\n keydown(event) {\n if (event.key === 'Escape' && getOpen()) {\n close({ restoreFocus: true });\n }\n },\n\n reposition() {\n const popover = getOpen();\n if (popover) {\n position(getOpenChip(), popover);\n }\n },\n});\n\nconst listen = (doc, listeners, method) => {\n doc[method]('click', listeners.click);\n doc[method]('mousedown', listeners.mousedown);\n doc[method]('keydown', listeners.keydown);\n doc.defaultView?.[method]('resize', listeners.reposition);\n doc.defaultView?.[method]('scroll', listeners.reposition, true);\n};\n\n/**\n * Start handling chips inside `root` (default: the whole document). A root\n * that is already handled, itself or through an ancestor, is a no-op.\n * Returns a function that stops handling this root.\n */\nconst init = ({ root = document } = {}) => {\n const doc = root.ownerDocument ?? root;\n\n if (handled(doc, root)) {\n return () => {};\n }\n\n if (!documents.has(doc)) {\n const listeners = listenersFor(doc);\n documents.set(doc, { roots: new Set(), listeners });\n listen(doc, listeners, 'addEventListener');\n }\n\n const entry = documents.get(doc);\n entry.roots.add(root);\n\n return () => {\n if (!entry.roots.delete(root)) {\n return;\n }\n\n if (getOpenChip() && root.contains(getOpenChip())) {\n close();\n }\n\n if (entry.roots.size === 0) {\n listen(doc, entry.listeners, 'removeEventListener');\n documents.delete(doc);\n }\n };\n};\n\nconst FileChips = {\n init,\n open: openWithState,\n close: () => close(),\n getOpen,\n position,\n};\n\nexport { init, close, getOpen, position };\nexport default FileChips;\n", "/**\n * Script-tag entry: exposes window.FileChips and starts it on the whole\n * document once the DOM is ready. Add data-manual to the <script> tag to\n * skip the automatic start and call FileChips.init() yourself.\n */\n\nimport FileChips from './index.js';\n\nwindow.FileChips = FileChips;\n\nconst script = document.currentScript;\n\nif (!script?.hasAttribute('data-manual')) {\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', () => FileChips.init(), { once: true });\n } else {\n FileChips.init();\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1 @@
1
+ :root{--fc-font-size: .875rem;--fc-gap: .5rem;--fc-radius: .5rem;--fc-chip-max-width: 220px;--fc-chip-bg: #fff;--fc-chip-bg-hover: rgba(0, 0, 0, .03);--fc-border: rgba(0, 0, 0, .1);--fc-text: inherit;--fc-muted: #6b7280;--fc-danger: #dc3545;--fc-danger-bg: rgba(220, 53, 69, .06);--fc-primary: #2563eb;--fc-primary-text: #fff;--fc-focus: rgba(37, 99, 235, .35);--fc-ext-bg: rgba(0, 0, 0, .06);--fc-popover-width: 520px;--fc-popover-bg: #fff;--fc-popover-border: rgba(0, 0, 0, .12);--fc-popover-radius: .75rem;--fc-popover-shadow: 0 10px 30px rgba(0, 0, 0, .15);--fc-popover-z: 1080;--fc-thumb-size: 80px;--fc-thumb-bg: #fafafa}.fc-chips{display:flex;flex-wrap:wrap;gap:var(--fc-gap)}.fc-chip{display:inline-flex;align-items:center;gap:.375rem;max-width:var(--fc-chip-max-width);padding:.375rem .5rem;border:1px solid var(--fc-border);border-radius:var(--fc-radius);background:var(--fc-chip-bg);color:var(--fc-text);font:inherit;font-size:var(--fc-font-size);line-height:1.25;text-decoration:none;cursor:pointer;transition:background .15s,border-color .15s,box-shadow .15s}.fc-chip:hover{background:var(--fc-chip-bg-hover)}.fc-chip:focus-visible,.fc-btn:focus-visible{outline:none;box-shadow:0 0 0 3px var(--fc-focus)}.fc-chip--marked{border-color:var(--fc-danger);background:var(--fc-danger-bg)}.fc-chip--marked .fc-chip__name{text-decoration:line-through;opacity:.8}.fc-chip__name,.fc-popover__title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fc-chip__ext{flex:none;padding:.0625rem .3125rem;border-radius:.25rem;background:var(--fc-ext-bg);font-size:.6875rem;font-weight:600;letter-spacing:.02em;text-transform:uppercase}.fc-popover{position:fixed;z-index:var(--fc-popover-z);width:var(--fc-popover-width);max-width:92vw;padding:1rem;border:1px solid var(--fc-popover-border);border-radius:var(--fc-popover-radius);background:var(--fc-popover-bg);box-shadow:var(--fc-popover-shadow);color:var(--fc-text);font-size:var(--fc-font-size)}.fc-popover[hidden]{display:none}.fc-popover__head{display:flex;align-items:flex-start;gap:.75rem}.fc-popover__info{min-width:0;flex:1}.fc-popover__title{margin-bottom:.25rem;font-weight:600}.fc-popover__meta,.fc-popover__excerpt{color:var(--fc-muted);font-size:.8125rem}.fc-popover__meta{display:flex;flex-wrap:wrap;gap:.5rem}.fc-popover__excerpt{margin-top:.5rem}.fc-popover__actions{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem;margin-top:.75rem}.fc-popover__actions [data-file-chip-remove]{margin-left:auto}.fc-thumb{display:flex;flex:none;align-items:center;justify-content:center;width:var(--fc-thumb-size);height:var(--fc-thumb-size);overflow:hidden;border:1px solid var(--fc-border);border-radius:var(--fc-radius);background:var(--fc-thumb-bg)}.fc-thumb img{display:block;max-width:100%;max-height:100%}.fc-thumb .fc-chip__ext{font-size:.875rem}.fc-btn{display:inline-flex;align-items:center;gap:.25rem;padding:.25rem .625rem;border:1px solid transparent;border-radius:.375rem;background:transparent;color:inherit;font:inherit;font-size:.8125rem;line-height:1.5;text-decoration:none;cursor:pointer}.fc-btn--primary{border-color:var(--fc-primary);background:var(--fc-primary);color:var(--fc-primary-text)}.fc-btn--outline{border-color:var(--fc-primary);color:var(--fc-primary)}.fc-btn--danger{border-color:var(--fc-danger);color:var(--fc-danger)}.fc-btn--danger.fc-btn--active{background:var(--fc-danger);color:#fff}
@@ -0,0 +1,2 @@
1
+ (()=>{var c=null,m=null,E=(t,e,n)=>Math.max(e,Math.min(n,t)),b=t=>t.ownerDocument.getElementById(t.dataset.fileChip),f=(t,e)=>{let n=t.getBoundingClientRect(),o=e.offsetWidth,s=e.offsetHeight,i=n.bottom+8,p=E(n.left,8,Math.max(8,window.innerWidth-o-8));if(i+s>window.innerHeight){let g=n.top-s-8;g>=8&&(i=g)}i=E(i,8,window.innerHeight-s-8),e.style.top="0px",e.style.left="0px";let r=e.getBoundingClientRect();e.style.top=`${Math.round(i-r.top)}px`,e.style.left=`${Math.round(p-r.left)}px`},a=({restoreFocus:t=!1}={})=>{if(!c)return;let e=c,n=m,o=e.contains(e.ownerDocument.activeElement);e.hidden=!0,n?.setAttribute("aria-expanded","false"),c=null,m=null,t&&o&&n?.focus()},w=t=>{a();let e=b(t);return e?(e.hidden=!1,t.setAttribute("aria-expanded","true"),c=e,m=t,f(t,e),typeof requestAnimationFrame=="function"&&requestAnimationFrame(()=>c===e&&f(t,e)),e):null},l=()=>c,d=()=>m;var M="Marked",G="Remove on save",y=t=>t.closest("[data-file-chips]")?.querySelector("[data-file-chips-bucket]")??t.closest("form"),v=(t,e,n)=>[...t.querySelectorAll('input[type="hidden"]')].find(o=>o.name===e&&o.value===n)??null,C=(t,e,n)=>{t.classList.toggle("fc-chip--marked",n),e.classList.toggle("fc-btn--active",n),e.setAttribute("aria-pressed",String(n));let o=e.querySelector("[data-file-chip-remove-label]")??e;o.textContent=n?e.dataset.labelMarked??M:e.dataset.labelUnmarked??G},S=t=>t.querySelector("[data-file-chip-remove]"),D=(t,e)=>{let n=S(e),o=y(t);!n||!o||C(t,n,v(o,n.dataset.fileChipInput,n.dataset.fileChipId)!==null)},x=(t,e)=>{let n=y(t);if(!n){console.warn("[FileChips] No [data-file-chips-bucket] or <form> found for this chip; cannot mark it for removal.");return}let o=e.dataset.fileChipInput,s=e.dataset.fileChipId,i=v(n,o,s);if(i)i.remove();else{let r=t.ownerDocument.createElement("input");r.type="hidden",r.name=o,r.value=s,n.appendChild(r)}let p=!i;C(t,e,p),t.dispatchEvent(new CustomEvent("file-chips:change",{bubbles:!0,detail:{id:s,input:o,marked:p}}))};var u=new Map,F=(t,e)=>[...u.get(t)?.roots??[]].some(n=>n.contains(e)),k=t=>{let e=w(t);return e&&D(t,e),e},L=t=>({click(e){let n=e.target,o=n.closest?.("[data-file-chip-remove]");if(o&&l()?.contains(o)){x(d(),o);return}let s=n.closest?.("[data-file-chip]");!s||!F(t,s)||(e.preventDefault(),d()===s?a():k(s))},mousedown(e){let n=l();n&&!n.contains(e.target)&&!e.target.closest?.("[data-file-chip]")&&a()},keydown(e){e.key==="Escape"&&l()&&a({restoreFocus:!0})},reposition(){let e=l();e&&f(d(),e)}}),A=(t,e,n)=>{t[n]("click",e.click),t[n]("mousedown",e.mousedown),t[n]("keydown",e.keydown),t.defaultView?.[n]("resize",e.reposition),t.defaultView?.[n]("scroll",e.reposition,!0)},I=({root:t=document}={})=>{let e=t.ownerDocument??t;if(F(e,t))return()=>{};if(!u.has(e)){let o=L(e);u.set(e,{roots:new Set,listeners:o}),A(e,o,"addEventListener")}let n=u.get(e);return n.roots.add(t),()=>{n.roots.delete(t)&&(d()&&t.contains(d())&&a(),n.roots.size===0&&(A(e,n.listeners,"removeEventListener"),u.delete(e)))}},q={init:I,open:k,close:()=>a(),getOpen:l,position:f};var h=q;window.FileChips=h;var O=document.currentScript;O?.hasAttribute("data-manual")||(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>h.init(),{once:!0}):h.init());})();
2
+ //# sourceMappingURL=file-chips.min.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../js/popover.js", "../js/marks.js", "../js/index.js", "../js/browser.js"],
4
+ "sourcesContent": ["/**\n * Popover open/close state and viewport-aware positioning.\n *\n * Popovers stay in place in the DOM, next to their chip, so they inherit the\n * chip container's CSS variables, follow it in tab order, and disappear with\n * it when the page re-renders. They are position: fixed; position() corrects\n * for ancestors (transforms, filters) that move the fixed origin.\n */\n\nconst GAP = 8;\nconst EDGE = 8;\n\nlet openPopover = null;\nlet openChip = null;\n\nconst clamp = (value, min, max) => Math.max(min, Math.min(max, value));\n\nconst popoverFor = (chip) => chip.ownerDocument.getElementById(chip.dataset.fileChip);\n\n/**\n * Place the popover below the chip, flip it above when there is no room,\n * and keep it inside the viewport.\n */\nconst position = (chip, popover) => {\n const rect = chip.getBoundingClientRect();\n const width = popover.offsetWidth;\n const height = popover.offsetHeight;\n\n let top = rect.bottom + GAP;\n const left = clamp(rect.left, EDGE, Math.max(EDGE, window.innerWidth - width - EDGE));\n\n if (top + height > window.innerHeight) {\n const above = rect.top - height - GAP;\n if (above >= EDGE) {\n top = above;\n }\n }\n\n top = clamp(top, EDGE, window.innerHeight - height - EDGE);\n\n // Where does (0, 0) land? Non-zero when a transformed ancestor becomes\n // the containing block of this fixed element.\n popover.style.top = '0px';\n popover.style.left = '0px';\n const origin = popover.getBoundingClientRect();\n\n popover.style.top = `${Math.round(top - origin.top)}px`;\n popover.style.left = `${Math.round(left - origin.left)}px`;\n};\n\n/**\n * Close the open popover. With restoreFocus, focus that was inside the\n * popover goes back to its chip.\n */\nconst close = ({ restoreFocus = false } = {}) => {\n if (!openPopover) {\n return;\n }\n\n const popover = openPopover;\n const chip = openChip;\n const hadFocus = popover.contains(popover.ownerDocument.activeElement);\n\n popover.hidden = true;\n chip?.setAttribute('aria-expanded', 'false');\n openPopover = null;\n openChip = null;\n\n if (restoreFocus && hadFocus) {\n chip?.focus();\n }\n};\n\n/**\n * Open the popover that belongs to a chip. Returns the popover, or null when\n * the chip has none.\n */\nconst open = (chip) => {\n close();\n\n const popover = popoverFor(chip);\n if (!popover) {\n return null;\n }\n\n popover.hidden = false;\n chip.setAttribute('aria-expanded', 'true');\n openPopover = popover;\n openChip = chip;\n\n position(chip, popover);\n if (typeof requestAnimationFrame === 'function') {\n requestAnimationFrame(() => openPopover === popover && position(chip, popover));\n }\n\n return popover;\n};\n\nconst getOpen = () => openPopover;\n\nconst getOpenChip = () => openChip;\n\nexport { open, close, getOpen, getOpenChip, position, popoverFor };\n", "/**\n * Mark-for-removal: toggles a hidden input carrying the file id so the\n * server can delete the file when the form is saved.\n */\n\nconst DEFAULT_MARKED = 'Marked';\nconst DEFAULT_UNMARKED = 'Remove on save';\n\n/**\n * The element hidden inputs go into: the [data-file-chips-bucket] of the\n * chip's [data-file-chips] container, otherwise the chip's form.\n */\nconst targetFor = (chip) => chip.closest('[data-file-chips]')?.querySelector('[data-file-chips-bucket]') ?? chip.closest('form');\n\n// Compare in JS instead of building a CSS selector, so ids and input\n// names containing quotes or brackets cannot break the lookup.\nconst findInput = (target, name, id) => [...target.querySelectorAll('input[type=\"hidden\"]')]\n .find((input) => input.name === name && input.value === id) ?? null;\n\nconst render = (chip, button, marked) => {\n chip.classList.toggle('fc-chip--marked', marked);\n button.classList.toggle('fc-btn--active', marked);\n button.setAttribute('aria-pressed', String(marked));\n\n const label = button.querySelector('[data-file-chip-remove-label]') ?? button;\n label.textContent = marked\n ? (button.dataset.labelMarked ?? DEFAULT_MARKED)\n : (button.dataset.labelUnmarked ?? DEFAULT_UNMARKED);\n};\n\nconst buttonIn = (popover) => popover.querySelector('[data-file-chip-remove]');\n\n/**\n * Reflect the current mark state of a chip in its popover button.\n */\nconst sync = (chip, popover) => {\n const button = buttonIn(popover);\n const target = targetFor(chip);\n if (!button || !target) {\n return;\n }\n\n render(chip, button, findInput(target, button.dataset.fileChipInput, button.dataset.fileChipId) !== null);\n};\n\n/**\n * Toggle the mark of the chip whose popover holds this button.\n */\nconst toggle = (chip, button) => {\n const target = targetFor(chip);\n if (!target) {\n console.warn('[FileChips] No [data-file-chips-bucket] or <form> found for this chip; cannot mark it for removal.');\n return;\n }\n\n const name = button.dataset.fileChipInput;\n const id = button.dataset.fileChipId;\n const existing = findInput(target, name, id);\n\n if (existing) {\n existing.remove();\n } else {\n const input = chip.ownerDocument.createElement('input');\n input.type = 'hidden';\n input.name = name;\n input.value = id;\n target.appendChild(input);\n }\n\n const marked = !existing;\n render(chip, button, marked);\n chip.dispatchEvent(new CustomEvent('file-chips:change', { bubbles: true, detail: { id, input: name, marked } }));\n};\n\nexport { sync, toggle };\n", "/**\n * File Chips\n *\n * Compact file chips with a preview popover and mark-for-removal.\n * Markup contract (see README):\n * [data-file-chips] container\n * [data-file-chips-bucket] optional element that receives hidden inputs\n * [data-file-chip=\"<id>\"] chip, points at its popover by id\n * [data-file-chip-popover] popover panel (starts hidden)\n * [data-file-chip-remove] toggle button with data-file-chip-id and data-file-chip-input\n */\n\nimport { close, getOpen, getOpenChip, open, position } from './popover.js';\nimport { sync, toggle } from './marks.js';\n\n// One set of document listeners per document, shared by every root on it,\n// so several roots never handle the same event twice.\nconst documents = new Map();\n\nconst handled = (doc, element) => [...(documents.get(doc)?.roots ?? [])].some((root) => root.contains(element));\n\nconst openWithState = (chip) => {\n const popover = open(chip);\n if (popover) {\n sync(chip, popover);\n }\n\n return popover;\n};\n\nconst listenersFor = (doc) => ({\n click(event) {\n const target = event.target;\n\n const remove = target.closest?.('[data-file-chip-remove]');\n if (remove && getOpen()?.contains(remove)) {\n toggle(getOpenChip(), remove);\n return;\n }\n\n const chip = target.closest?.('[data-file-chip]');\n if (!chip || !handled(doc, chip)) {\n return;\n }\n\n event.preventDefault();\n\n if (getOpenChip() === chip) {\n close();\n } else {\n openWithState(chip);\n }\n },\n\n mousedown(event) {\n const popover = getOpen();\n if (popover && !popover.contains(event.target) && !event.target.closest?.('[data-file-chip]')) {\n close();\n }\n },\n\n keydown(event) {\n if (event.key === 'Escape' && getOpen()) {\n close({ restoreFocus: true });\n }\n },\n\n reposition() {\n const popover = getOpen();\n if (popover) {\n position(getOpenChip(), popover);\n }\n },\n});\n\nconst listen = (doc, listeners, method) => {\n doc[method]('click', listeners.click);\n doc[method]('mousedown', listeners.mousedown);\n doc[method]('keydown', listeners.keydown);\n doc.defaultView?.[method]('resize', listeners.reposition);\n doc.defaultView?.[method]('scroll', listeners.reposition, true);\n};\n\n/**\n * Start handling chips inside `root` (default: the whole document). A root\n * that is already handled, itself or through an ancestor, is a no-op.\n * Returns a function that stops handling this root.\n */\nconst init = ({ root = document } = {}) => {\n const doc = root.ownerDocument ?? root;\n\n if (handled(doc, root)) {\n return () => {};\n }\n\n if (!documents.has(doc)) {\n const listeners = listenersFor(doc);\n documents.set(doc, { roots: new Set(), listeners });\n listen(doc, listeners, 'addEventListener');\n }\n\n const entry = documents.get(doc);\n entry.roots.add(root);\n\n return () => {\n if (!entry.roots.delete(root)) {\n return;\n }\n\n if (getOpenChip() && root.contains(getOpenChip())) {\n close();\n }\n\n if (entry.roots.size === 0) {\n listen(doc, entry.listeners, 'removeEventListener');\n documents.delete(doc);\n }\n };\n};\n\nconst FileChips = {\n init,\n open: openWithState,\n close: () => close(),\n getOpen,\n position,\n};\n\nexport { init, close, getOpen, position };\nexport default FileChips;\n", "/**\n * Script-tag entry: exposes window.FileChips and starts it on the whole\n * document once the DOM is ready. Add data-manual to the <script> tag to\n * skip the automatic start and call FileChips.init() yourself.\n */\n\nimport FileChips from './index.js';\n\nwindow.FileChips = FileChips;\n\nconst script = document.currentScript;\n\nif (!script?.hasAttribute('data-manual')) {\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', () => FileChips.init(), { once: true });\n } else {\n FileChips.init();\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["openPopover", "openChip", "clamp", "value", "min", "max", "popoverFor", "chip", "position", "popover", "rect", "width", "height", "top", "left", "above", "origin", "close", "restoreFocus", "hadFocus", "open", "getOpen", "getOpenChip", "DEFAULT_MARKED", "DEFAULT_UNMARKED", "targetFor", "chip", "findInput", "target", "name", "id", "input", "render", "button", "marked", "label", "buttonIn", "popover", "sync", "toggle", "existing", "documents", "handled", "doc", "element", "root", "openWithState", "chip", "popover", "open", "sync", "listenersFor", "event", "target", "remove", "getOpen", "toggle", "getOpenChip", "close", "position", "listen", "listeners", "method", "init", "entry", "FileChips", "index_default", "FileChips", "index_default", "script"]
7
+ }
package/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
- "name": "@ozankurt/file-chips",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@ozankurt/file-chips",
3
+ "version": "1.0.0",
4
+ "description": "Compact file chips with a preview popover and mark-for-removal, framework-free",
5
+ "keywords": [
6
+ "file",
7
+ "attachments",
8
+ "chips",
9
+ "popover",
10
+ "upload",
11
+ "ui"
12
+ ],
13
+ "license": "MIT",
14
+ "author": {
15
+ "name": "Ozan Kurt",
16
+ "email": "me@ozankurt.com",
17
+ "url": "https://ozankurt.com"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/OzanKurt/file-chips.git"
22
+ },
23
+ "homepage": "https://github.com/OzanKurt/file-chips#readme",
24
+ "bugs": "https://github.com/OzanKurt/file-chips/issues",
25
+ "type": "module",
26
+ "main": "dist/file-chips.cjs",
27
+ "module": "dist/esm/file-chips.mjs",
28
+ "unpkg": "dist/file-chips.min.js",
29
+ "jsdelivr": "dist/file-chips.min.js",
30
+ "exports": {
31
+ ".": {
32
+ "import": "./dist/esm/file-chips.mjs",
33
+ "require": "./dist/file-chips.cjs",
34
+ "default": "./dist/esm/file-chips.mjs"
35
+ },
36
+ "./style.css": "./dist/file-chips.css",
37
+ "./dist/*": "./dist/*",
38
+ "./package.json": "./package.json"
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "CHANGELOG.md"
43
+ ],
44
+ "sideEffects": [
45
+ "*.css",
46
+ "./dist/file-chips.js",
47
+ "./dist/file-chips.min.js",
48
+ "./js/browser.js"
49
+ ],
50
+ "engines": {
51
+ "node": ">=20"
52
+ },
53
+ "scripts": {
54
+ "build": "node ./scripts/build.js",
55
+ "test": "vitest run",
56
+ "prepublishOnly": "npm test && npm run build"
57
+ },
58
+ "publishConfig": {
59
+ "access": "public"
60
+ },
61
+ "devDependencies": {
62
+ "esbuild": "^0.28.2",
63
+ "happy-dom": "^21.0.3",
64
+ "vitest": "^5.0.5"
65
+ }
66
+ }