@ozankurt/file-chips 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## 1.1.0
4
+
5
+ - `@fileChipsStyles` and `@fileChipsScripts` add the published file's modification time as `?v=`, and the script tag carries the Vite CSP nonce.
6
+ - TypeScript declarations (`dist/file-chips.d.ts`), including the `file-chips:change` event.
7
+ - CI tests Laravel 11, 12 and 13.
8
+
9
+ ## 1.0.0
10
+
11
+ - 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,221 @@
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
+ ![An expense claim form with six file chips; the chip for client-dinner-receipt.jpg has opened a popover with a receipt thumbnail, the file size and date, a note, and View, Download and Remove on save buttons.](docs/images/popover-light.png)
6
+
7
+ - **JavaScript + CSS** on npm as [`@ozankurt/file-chips`](https://www.npmjs.com/package/@ozankurt/file-chips): no dependencies, no framework, about 3 KB minified.
8
+ - **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.
9
+
10
+ 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.
11
+
12
+ ## Laravel
13
+
14
+ ```bash
15
+ composer require ozankurt/laravel-file-chips
16
+ php artisan vendor:publish --tag=file-chips-assets
17
+ ```
18
+
19
+ In your layout:
20
+
21
+ ```blade
22
+ <head>
23
+ @fileChipsStyles
24
+ </head>
25
+ <body>
26
+ ...
27
+ @fileChipsScripts
28
+ </body>
29
+ ```
30
+
31
+ Both URLs carry the published file's modification time (`?v=...`), so browsers fetch the new files after you upgrade and publish the assets again. The script tag gets your CSP nonce when the app uses `Vite::useCspNonce()`.
32
+
33
+ In a form:
34
+
35
+ ```blade
36
+ <form method="POST" action="{{ route('posts.update', $post) }}">
37
+ @csrf
38
+ @method('PUT')
39
+ <x-file-chips :files="$post->getMedia('attachments')" input="delete_media_ids[]" />
40
+ <button>Save</button>
41
+ </form>
42
+ ```
43
+
44
+ ```php
45
+ // Controller
46
+ foreach ($request->input('delete_media_ids', []) as $id) {
47
+ $post->media()->whereKey($id)->first()?->delete();
48
+ }
49
+ ```
50
+
51
+ Always scope the deletion to the owning model as above; the ids come from the browser.
52
+
53
+ Marking a file adds the hidden input and strikes the chip through until the form is saved:
54
+
55
+ ![The PDF chip is outlined in red with its name struck through, and its popover shows a red Marked button.](docs/images/marked-light.png)
56
+
57
+ ### Props
58
+
59
+ | Prop | Type | Default | Description |
60
+ |---|---|---|---|
61
+ | `files` | iterable of `Media`, `FileChip` or arrays | `[]` | The files to show. Renders nothing when empty. |
62
+ | `input` | `string\|null` | `null` | Name of the hidden input that collects ids marked for removal. `null` hides the remove button. |
63
+ | `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. |
64
+
65
+ Other attributes (`id`, `class`, `data-*`, ...) go onto the wrapper `<div>`.
66
+
67
+ ### Files that are not Media Library models
68
+
69
+ Pass arrays or `FileChip` objects:
70
+
71
+ ```blade
72
+ <x-file-chips :files="[
73
+ ['id' => $doc->id, 'name' => $doc->filename, 'size' => $doc->bytes, 'view_url' => route('docs.show', $doc)],
74
+ ]" />
75
+ ```
76
+
77
+ | Key | Required | Notes |
78
+ |---|---|---|
79
+ | `id` | yes | Sent back in the removal input |
80
+ | `name` | yes | File name shown on the chip |
81
+ | `extension` | no | Taken from `name` when missing |
82
+ | `mime_type` | no | Used for the category when the extension is unknown |
83
+ | `size` | no | Bytes (formatted for you) or a ready string |
84
+ | `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. |
85
+ | `excerpt` | no | Short text under the details |
86
+ | `is_image` | no | Derived from extension and MIME type |
87
+ | `created_at` | no | `DateTimeInterface` or a date string |
88
+
89
+ `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.
90
+
91
+ ### Configuration
92
+
93
+ ```bash
94
+ php artisan vendor:publish --tag=file-chips-config
95
+ ```
96
+
97
+ | Key | Default | Description |
98
+ |---|---|---|
99
+ | `asset_path` | `vendor/file-chips` | Where the assets are published |
100
+ | `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. |
101
+ | `date_format` | `d M Y H:i` | Format of the "Added" date |
102
+ | `thumb_conversion` | `thumb` | Media Library conversion used as thumbnail; `null` to skip |
103
+ | `temporary_url_minutes` | `30` | Lifetime of temporary S3 view and thumbnail URLs |
104
+ | `download_url_minutes` | `15` | Lifetime of temporary S3 download URLs |
105
+ | `summary_property` | `summary` | Media custom property shown as excerpt; `null` to skip |
106
+ | `summary_limit` | `160` | Excerpt length |
107
+
108
+ Categories: `pdf`, `image`, `document`, `spreadsheet`, `presentation`, `archive`, `audio`, `video`, `code`, `email`, `file`.
109
+
110
+ Labels are translatable (`php artisan vendor:publish --tag=file-chips-lang`), and the view can be published with `--tag=file-chips-views`.
111
+
112
+ ## JavaScript only
113
+
114
+ ```bash
115
+ npm i @ozankurt/file-chips
116
+ ```
117
+
118
+ ```js
119
+ import FileChips from '@ozankurt/file-chips';
120
+ import '@ozankurt/file-chips/style.css';
121
+
122
+ FileChips.init();
123
+ ```
124
+
125
+ Or with script tags (starts automatically when the DOM is ready; add `data-manual` to the `<script>` tag to start it yourself):
126
+
127
+ ```html
128
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ozankurt/file-chips@1/dist/file-chips.min.css">
129
+ <script src="https://cdn.jsdelivr.net/npm/@ozankurt/file-chips@1/dist/file-chips.min.js" defer></script>
130
+ ```
131
+
132
+ ### Markup contract
133
+
134
+ ```html
135
+ <form>
136
+ <div class="fc-chips" data-file-chips>
137
+ <div data-file-chips-bucket hidden></div>
138
+
139
+ <button type="button" class="fc-chip" data-file-chip="doc-12" aria-controls="doc-12" aria-expanded="false">
140
+ <span class="fc-chip__ext">pdf</span>
141
+ <span class="fc-chip__name">contract.pdf</span>
142
+ </button>
143
+
144
+ <div id="doc-12" class="fc-popover" data-file-chip-popover hidden>
145
+ <!-- anything you like: thumbnail, details, links -->
146
+ <button type="button" class="fc-btn fc-btn--danger" data-file-chip-remove
147
+ data-file-chip-id="12" data-file-chip-input="delete_ids[]"
148
+ data-label-marked="Marked" data-label-unmarked="Remove on save" aria-pressed="false">
149
+ <span data-file-chip-remove-label>Remove on save</span>
150
+ </button>
151
+ </div>
152
+ </div>
153
+ </form>
154
+ ```
155
+
156
+ | Attribute | On | Meaning |
157
+ |---|---|---|
158
+ | `data-file-chips` | container | Groups chips; its bucket receives the hidden inputs |
159
+ | `data-file-chips-bucket` | optional element in the container | Where hidden inputs go; the closest `<form>` is used when missing |
160
+ | `data-file-chip="<id>"` | chip | Opens the popover with that element id |
161
+ | `data-file-chip-popover` | popover | Start it with `hidden` |
162
+ | `data-file-chip-remove` | button in the popover | Toggles the mark; needs `data-file-chip-id` and `data-file-chip-input` |
163
+ | `data-file-chip-remove-label` | element in the button | Text swapped between `data-label-marked` and `data-label-unmarked` |
164
+
165
+ 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.
166
+
167
+ ### API
168
+
169
+ | Call | Description |
170
+ |---|---|
171
+ | `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. |
172
+ | `FileChips.open(chip)` | Open a chip's popover; returns the popover or `null` |
173
+ | `FileChips.close()` | Close the open popover |
174
+ | `FileChips.getOpen()` | The open popover or `null` |
175
+ | `FileChips.position(chip, popover)` | Re-place a popover next to its chip |
176
+
177
+ 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.
178
+
179
+ ## Styling
180
+
181
+ 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):
182
+
183
+ ```css
184
+ .dark {
185
+ --fc-chip-bg: #1f2937;
186
+ --fc-chip-bg-hover: #273244;
187
+ --fc-border: rgba(255, 255, 255, 0.12);
188
+ --fc-popover-bg: #111827;
189
+ --fc-popover-border: rgba(255, 255, 255, 0.12);
190
+ --fc-muted: #9ca3af;
191
+ --fc-thumb-bg: #1f2937;
192
+ }
193
+ ```
194
+
195
+ ![The same form and popover in a dark theme set only through the custom properties above.](docs/images/popover-dark.png)
196
+
197
+ 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`.
198
+
199
+ ## Development
200
+
201
+ ```bash
202
+ npm install && composer install
203
+ npm test # JS (Vitest + happy-dom)
204
+ vendor/bin/pest # PHP
205
+ npm run build # rebuild dist/, which is committed
206
+ npm run examples # the showcase on http://localhost:5183/showcase.html
207
+ npm run screenshots # recapture docs/images/ from the showcase
208
+ npm run typecheck # the TypeScript declarations
209
+ ```
210
+
211
+ CI runs the PHP suite on PHP 8.3 with Laravel 11, and PHP 8.4 with Laravel 12 and 13, plus the JS tests, the typecheck and a check that the committed `dist/` matches the sources.
212
+
213
+ ### TypeScript
214
+
215
+ `dist/file-chips.d.ts` types the JS API and the `file-chips:change` event, so `import FileChips from '@ozankurt/file-chips'` is typed in TypeScript and editors.
216
+
217
+ The showcase takes `?scheme=light|dark` and `?scene=popover|marked`.
218
+
219
+ ## License
220
+
221
+ 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
+ }