@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 +11 -0
- package/LICENSE +21 -0
- package/README.md +220 -2
- package/dist/esm/file-chips.mjs +200 -0
- package/dist/esm/file-chips.mjs.map +7 -0
- package/dist/file-chips.cjs +229 -0
- package/dist/file-chips.css +215 -0
- package/dist/file-chips.d.ts +43 -0
- package/dist/file-chips.js +206 -0
- package/dist/file-chips.js.map +7 -0
- package/dist/file-chips.min.css +1 -0
- package/dist/file-chips.min.js +2 -0
- package/dist/file-chips.min.js.map +7 -0
- package/package.json +73 -5
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
|
-
#
|
|
1
|
+
# File Chips
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+
}
|