snoopers-combobox-multi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -0
- package/package.json +31 -0
- package/src/combobox-multi.js +332 -0
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# `<combobox-multi>`
|
|
2
|
+
|
|
3
|
+
Framework-agnostic multi-select combobox **web component**. Zero dependencies. All styles encapsulated in Shadow DOM. Works in plain HTML, React, Vue, Svelte, or any other framework.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Drop `src/combobox-multi.js` into your project, or install from npm once published:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @modular365/combobox-multi
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Plain HTML
|
|
16
|
+
|
|
17
|
+
```html
|
|
18
|
+
<combobox-multi
|
|
19
|
+
name="colors"
|
|
20
|
+
label="Pick colours"
|
|
21
|
+
placeholder="Select..."
|
|
22
|
+
value="Red,Blue">
|
|
23
|
+
<script type="application/json">["Red","Green","Blue","Yellow"]</script>
|
|
24
|
+
</combobox-multi>
|
|
25
|
+
|
|
26
|
+
<script type="module">
|
|
27
|
+
import 'https://unpkg.com/@modular365/combobox-multi';
|
|
28
|
+
</script>
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Setting options & value from JavaScript
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
const el = document.querySelector('combobox-multi');
|
|
35
|
+
el.options = ['Tel Aviv', 'Haifa', 'Jerusalem'];
|
|
36
|
+
el.value = ['Haifa']; // array OR CSV string
|
|
37
|
+
|
|
38
|
+
el.addEventListener('change', (e) => {
|
|
39
|
+
console.log(e.detail.value); // ["Haifa"]
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Inside a form
|
|
44
|
+
|
|
45
|
+
The component is **form-associated** — its value serializes as a CSV string under the given `name`.
|
|
46
|
+
|
|
47
|
+
```html
|
|
48
|
+
<form>
|
|
49
|
+
<combobox-multi name="tags"><script type="application/json">["a","b","c"]</script></combobox-multi>
|
|
50
|
+
<button type="submit">Save</button>
|
|
51
|
+
</form>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`new FormData(form).get('tags')` → `"a,c"` (CSV).
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## API
|
|
59
|
+
|
|
60
|
+
### Attributes
|
|
61
|
+
|
|
62
|
+
| Attribute | Type | Default | Description |
|
|
63
|
+
|----------------|-----------------|---------|------------------------------------------------------------|
|
|
64
|
+
| `name` | string | — | Form field name (serializes as CSV via ElementInternals) |
|
|
65
|
+
| `label` | string | — | Visible label above the trigger |
|
|
66
|
+
| `placeholder` | string | `—` | Text shown when nothing is selected |
|
|
67
|
+
| `value` | CSV string | — | Initial selected values, comma-separated |
|
|
68
|
+
| `disabled` | boolean | `false` | Makes the control read-only |
|
|
69
|
+
| `required` | boolean | `false` | Shows a red `*` next to the label |
|
|
70
|
+
| `searchable` | `"true"`/`"false"` | `"true"` | Show/hide the search input in the dropdown |
|
|
71
|
+
| `error` | string | — | Error message shown under the control; adds red border |
|
|
72
|
+
| `dir` | `ltr`/`rtl` | inherit | Writing direction (uses CSS logical props under the hood) |
|
|
73
|
+
|
|
74
|
+
### Properties
|
|
75
|
+
|
|
76
|
+
| Property | Type | Description |
|
|
77
|
+
|------------|-------------|----------------------------------------------------|
|
|
78
|
+
| `options` | `string[]` | Full list of selectable options |
|
|
79
|
+
| `value` | `string[]` | Currently selected options (setter accepts CSV) |
|
|
80
|
+
|
|
81
|
+
### Events
|
|
82
|
+
|
|
83
|
+
| Event | Detail | Fires |
|
|
84
|
+
|----------|---------------------------|--------------------------------------|
|
|
85
|
+
| `change` | `{ value: string[] }` | When selection changes (any way) |
|
|
86
|
+
|
|
87
|
+
### CSS custom properties (overrides)
|
|
88
|
+
|
|
89
|
+
All styles live in the Shadow DOM, but you can tweak the accent palette from the outside via CSS custom props on the host element:
|
|
90
|
+
|
|
91
|
+
```css
|
|
92
|
+
combobox-multi {
|
|
93
|
+
--cbx-accent: #10b981; /* checkbox + chip accent */
|
|
94
|
+
--cbx-chip-bg: #ecfdf5;
|
|
95
|
+
--cbx-chip-text: #065f46;
|
|
96
|
+
--cbx-border: #d1d5db;
|
|
97
|
+
--cbx-border-focus: #10b981;
|
|
98
|
+
--cbx-radius: 8px;
|
|
99
|
+
--cbx-font: 'Inter', sans-serif;
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## RTL / Hebrew
|
|
106
|
+
|
|
107
|
+
The component is RTL-first. Set `dir="rtl"` anywhere up the tree (or on the element itself) and chips, chevron, and label will flip correctly — uses CSS logical properties (`margin-inline-start`, `inset-inline`, etc.).
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Demo
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
cd combobox-multi-package
|
|
115
|
+
npm run demo
|
|
116
|
+
# open http://localhost:5500/demo/
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
---
|
|
120
|
+
|
|
121
|
+
## Why not just use `<select multiple>`?
|
|
122
|
+
|
|
123
|
+
- `<select multiple>` is broken on iOS Safari (you can't deselect individual items without holding Cmd/Ctrl).
|
|
124
|
+
- It doesn't support filtering/search.
|
|
125
|
+
- Its styling is essentially untouchable across browsers.
|
|
126
|
+
|
|
127
|
+
This component solves all three while keeping the standards-based feel: `value`, `name`, `change`, form submission — all work exactly like a native form control.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Browser support
|
|
132
|
+
|
|
133
|
+
- Chrome 90+ / Edge 90+
|
|
134
|
+
- Safari 15+
|
|
135
|
+
- Firefox 98+
|
|
136
|
+
|
|
137
|
+
Uses: Custom Elements v1, Shadow DOM v1, `ElementInternals` (for form association).
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "snoopers-combobox-multi",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-agnostic multi-select combobox web component with built-in search, chips, and form association. Zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/combobox-multi.js",
|
|
7
|
+
"module": "src/combobox-multi.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/combobox-multi.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"keywords": [
|
|
16
|
+
"combobox",
|
|
17
|
+
"multi-select",
|
|
18
|
+
"web-component",
|
|
19
|
+
"custom-element",
|
|
20
|
+
"dropdown",
|
|
21
|
+
"rtl",
|
|
22
|
+
"hebrew"
|
|
23
|
+
],
|
|
24
|
+
"sideEffects": [
|
|
25
|
+
"./src/combobox-multi.js"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"scripts": {
|
|
29
|
+
"demo": "npx --yes serve -l 5500 ."
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <combobox-multi> — framework-agnostic multi-select combobox web component.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* <combobox-multi name="colors" label="Colors" placeholder="Select...">
|
|
6
|
+
* <script type="application/json">["Red","Green","Blue"]</script>
|
|
7
|
+
* </combobox-multi>
|
|
8
|
+
*
|
|
9
|
+
* const el = document.querySelector('combobox-multi');
|
|
10
|
+
* el.options = ['Red','Green','Blue'];
|
|
11
|
+
* el.value = ['Red']; // array or CSV string
|
|
12
|
+
* el.addEventListener('change', e => console.log(e.detail.value));
|
|
13
|
+
*
|
|
14
|
+
* Attributes:
|
|
15
|
+
* name — form field name (serializes as CSV via hidden input)
|
|
16
|
+
* label — visible label above the trigger
|
|
17
|
+
* placeholder — empty-state text (default: "—")
|
|
18
|
+
* value — initial CSV value
|
|
19
|
+
* disabled — readonly
|
|
20
|
+
* dir — "rtl" | "ltr" (inherits from document if omitted)
|
|
21
|
+
* searchable — "true" (default) | "false"
|
|
22
|
+
*
|
|
23
|
+
* Events:
|
|
24
|
+
* change — detail: { value: string[] }
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const ICON_CHEVRON = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`;
|
|
28
|
+
const ICON_X = `<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
|
|
29
|
+
const ICON_SEARCH = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`;
|
|
30
|
+
|
|
31
|
+
const STYLES = `
|
|
32
|
+
:host {
|
|
33
|
+
--cbx-border: #d1d5db;
|
|
34
|
+
--cbx-border-focus: #3b82f6;
|
|
35
|
+
--cbx-bg: #ffffff;
|
|
36
|
+
--cbx-bg-hover: #f9fafb;
|
|
37
|
+
--cbx-text: #111827;
|
|
38
|
+
--cbx-text-muted: #9ca3af;
|
|
39
|
+
--cbx-text-label: #374151;
|
|
40
|
+
--cbx-chip-bg: #eff6ff;
|
|
41
|
+
--cbx-chip-text: #1d4ed8;
|
|
42
|
+
--cbx-accent: #2563eb;
|
|
43
|
+
--cbx-radius: 6px;
|
|
44
|
+
--cbx-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
45
|
+
--cbx-font-size: 14px;
|
|
46
|
+
display: block;
|
|
47
|
+
font-family: var(--cbx-font);
|
|
48
|
+
font-size: var(--cbx-font-size);
|
|
49
|
+
color: var(--cbx-text);
|
|
50
|
+
position: relative;
|
|
51
|
+
}
|
|
52
|
+
:host([disabled]) .trigger { background: #f9fafb; color: var(--cbx-text-muted); cursor: not-allowed; }
|
|
53
|
+
|
|
54
|
+
.field { display: flex; flex-direction: column; gap: 4px; }
|
|
55
|
+
.label { font-weight: 500; color: var(--cbx-text-label); font-size: 13px; }
|
|
56
|
+
.label .req { color: #ef4444; margin-inline-start: 2px; }
|
|
57
|
+
|
|
58
|
+
.trigger-wrap { position: relative; }
|
|
59
|
+
.trigger {
|
|
60
|
+
min-height: 38px; width: 100%;
|
|
61
|
+
display: flex; flex-wrap: wrap; align-items: center; gap: 4px;
|
|
62
|
+
padding: 6px 10px;
|
|
63
|
+
background: var(--cbx-bg);
|
|
64
|
+
border: 1px solid var(--cbx-border);
|
|
65
|
+
border-radius: var(--cbx-radius);
|
|
66
|
+
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
|
67
|
+
cursor: pointer;
|
|
68
|
+
font: inherit; color: inherit;
|
|
69
|
+
text-align: start;
|
|
70
|
+
}
|
|
71
|
+
.trigger:focus-visible { outline: none; border-color: var(--cbx-border-focus); box-shadow: 0 0 0 1px var(--cbx-border-focus); }
|
|
72
|
+
.trigger.error { border-color: #fca5a5; }
|
|
73
|
+
.placeholder { color: var(--cbx-text-muted); }
|
|
74
|
+
|
|
75
|
+
.chip {
|
|
76
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
77
|
+
padding: 2px 6px;
|
|
78
|
+
background: var(--cbx-chip-bg); color: var(--cbx-chip-text);
|
|
79
|
+
border-radius: 4px;
|
|
80
|
+
font-size: 12px; line-height: 1.3;
|
|
81
|
+
}
|
|
82
|
+
.chip-x { display: inline-flex; cursor: pointer; opacity: .7; }
|
|
83
|
+
.chip-x:hover { opacity: 1; }
|
|
84
|
+
|
|
85
|
+
.chevron { margin-inline-start: auto; color: var(--cbx-text-muted); transition: transform .15s; flex-shrink: 0; }
|
|
86
|
+
:host([open]) .chevron { transform: rotate(180deg); }
|
|
87
|
+
|
|
88
|
+
.panel {
|
|
89
|
+
position: absolute; inset-inline: 0; top: 100%; margin-top: 4px;
|
|
90
|
+
background: var(--cbx-bg);
|
|
91
|
+
border: 1px solid #e5e7eb;
|
|
92
|
+
border-radius: var(--cbx-radius);
|
|
93
|
+
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);
|
|
94
|
+
z-index: 1000;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.search {
|
|
98
|
+
display: flex; align-items: center; gap: 8px;
|
|
99
|
+
padding: 8px 10px;
|
|
100
|
+
border-bottom: 1px solid #f3f4f6;
|
|
101
|
+
color: var(--cbx-text-muted);
|
|
102
|
+
}
|
|
103
|
+
.search input {
|
|
104
|
+
flex: 1; background: transparent; border: 0; outline: none;
|
|
105
|
+
font: inherit; color: var(--cbx-text);
|
|
106
|
+
}
|
|
107
|
+
.search.hidden { display: none; }
|
|
108
|
+
|
|
109
|
+
.list {
|
|
110
|
+
max-height: min(60vh, 18rem);
|
|
111
|
+
overflow-y: auto;
|
|
112
|
+
overscroll-behavior: contain;
|
|
113
|
+
-webkit-overflow-scrolling: touch;
|
|
114
|
+
touch-action: pan-y;
|
|
115
|
+
}
|
|
116
|
+
.empty { padding: 8px 12px; color: var(--cbx-text-muted); }
|
|
117
|
+
|
|
118
|
+
.row {
|
|
119
|
+
display: flex; align-items: center; gap: 8px;
|
|
120
|
+
padding: 8px 12px;
|
|
121
|
+
cursor: pointer;
|
|
122
|
+
user-select: none;
|
|
123
|
+
}
|
|
124
|
+
.row:hover { background: var(--cbx-bg-hover); }
|
|
125
|
+
.row input[type="checkbox"] {
|
|
126
|
+
width: 16px; height: 16px;
|
|
127
|
+
accent-color: var(--cbx-accent);
|
|
128
|
+
flex-shrink: 0;
|
|
129
|
+
}
|
|
130
|
+
.row-label {
|
|
131
|
+
flex: 1;
|
|
132
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.err { font-size: 12px; color: #dc2626; margin-top: 2px; }
|
|
136
|
+
`;
|
|
137
|
+
|
|
138
|
+
function parseOptions(input) {
|
|
139
|
+
if (Array.isArray(input)) return input.map(String);
|
|
140
|
+
if (typeof input === 'string' && input.trim()) {
|
|
141
|
+
try { const parsed = JSON.parse(input); if (Array.isArray(parsed)) return parsed.map(String); } catch {}
|
|
142
|
+
return input.split(',').map(s => s.trim()).filter(Boolean);
|
|
143
|
+
}
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
function parseValue(input) {
|
|
147
|
+
if (Array.isArray(input)) return input.map(String);
|
|
148
|
+
if (typeof input === 'string' && input) return input.split(',').map(s => s.trim()).filter(Boolean);
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
class ComboboxMulti extends HTMLElement {
|
|
153
|
+
static formAssociated = true;
|
|
154
|
+
static get observedAttributes() { return ['name', 'label', 'placeholder', 'value', 'disabled', 'searchable', 'error']; }
|
|
155
|
+
|
|
156
|
+
constructor() {
|
|
157
|
+
super();
|
|
158
|
+
this._options = [];
|
|
159
|
+
this._selected = [];
|
|
160
|
+
this._query = '';
|
|
161
|
+
this._open = false;
|
|
162
|
+
this._internals = typeof this.attachInternals === 'function' ? this.attachInternals() : null;
|
|
163
|
+
this.attachShadow({ mode: 'open' });
|
|
164
|
+
this._onDocDown = this._onDocDown.bind(this);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
connectedCallback() {
|
|
168
|
+
const jsonChild = this.querySelector('script[type="application/json"]');
|
|
169
|
+
if (jsonChild && !this._options.length) this._options = parseOptions(jsonChild.textContent);
|
|
170
|
+
|
|
171
|
+
if (this.hasAttribute('value')) this._selected = parseValue(this.getAttribute('value'));
|
|
172
|
+
|
|
173
|
+
this._render();
|
|
174
|
+
document.addEventListener('mousedown', this._onDocDown);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
disconnectedCallback() {
|
|
178
|
+
document.removeEventListener('mousedown', this._onDocDown);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
attributeChangedCallback(name, _old, val) {
|
|
182
|
+
if (name === 'value') this._selected = parseValue(val);
|
|
183
|
+
if (this.shadowRoot.firstChild) this._render();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
get options() { return [...this._options]; }
|
|
187
|
+
set options(v) { this._options = parseOptions(v); this._render(); }
|
|
188
|
+
|
|
189
|
+
get value() { return [...this._selected]; }
|
|
190
|
+
set value(v) {
|
|
191
|
+
const next = parseValue(v);
|
|
192
|
+
this._selected = next;
|
|
193
|
+
this._syncFormValue();
|
|
194
|
+
this._render();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
_syncFormValue() {
|
|
198
|
+
if (this._internals) this._internals.setFormValue(this._selected.join(','));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
_onDocDown(e) {
|
|
202
|
+
if (!this._open) return;
|
|
203
|
+
if (e.composedPath().includes(this)) return;
|
|
204
|
+
this._open = false;
|
|
205
|
+
this._query = '';
|
|
206
|
+
this._render();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
_toggle() {
|
|
210
|
+
if (this.hasAttribute('disabled')) return;
|
|
211
|
+
this._open = !this._open;
|
|
212
|
+
if (!this._open) this._query = '';
|
|
213
|
+
this._render();
|
|
214
|
+
if (this._open) {
|
|
215
|
+
const input = this.shadowRoot.querySelector('.search input');
|
|
216
|
+
if (input) setTimeout(() => input.focus(), 0);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
_toggleOption(opt) {
|
|
221
|
+
const idx = this._selected.indexOf(opt);
|
|
222
|
+
if (idx >= 0) this._selected.splice(idx, 1);
|
|
223
|
+
else this._selected.push(opt);
|
|
224
|
+
this._syncFormValue();
|
|
225
|
+
this._emitChange();
|
|
226
|
+
this._render();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
_removeOption(opt) {
|
|
230
|
+
this._selected = this._selected.filter(v => v !== opt);
|
|
231
|
+
this._syncFormValue();
|
|
232
|
+
this._emitChange();
|
|
233
|
+
this._render();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
_emitChange() {
|
|
237
|
+
this.dispatchEvent(new CustomEvent('change', {
|
|
238
|
+
bubbles: true,
|
|
239
|
+
detail: { value: [...this._selected] },
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
_filtered() {
|
|
244
|
+
const uniq = [...new Set(this._options)];
|
|
245
|
+
const q = this._query.trim().toLowerCase();
|
|
246
|
+
return q ? uniq.filter(o => o.toLowerCase().includes(q)) : uniq;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
_render() {
|
|
250
|
+
if (this._open) this.setAttribute('open', ''); else this.removeAttribute('open');
|
|
251
|
+
|
|
252
|
+
const label = this.getAttribute('label');
|
|
253
|
+
const placeholder = this.getAttribute('placeholder') || '—';
|
|
254
|
+
const disabled = this.hasAttribute('disabled');
|
|
255
|
+
const required = this.hasAttribute('required');
|
|
256
|
+
const searchable = this.getAttribute('searchable') !== 'false';
|
|
257
|
+
const error = this.getAttribute('error');
|
|
258
|
+
const filtered = this._filtered();
|
|
259
|
+
|
|
260
|
+
const chipsHtml = this._selected.length === 0
|
|
261
|
+
? `<span class="placeholder">${placeholder}</span>`
|
|
262
|
+
: this._selected.map(o => `
|
|
263
|
+
<span class="chip" data-opt="${encodeURIComponent(o)}">
|
|
264
|
+
<span class="chip-text">${escapeHtml(o)}</span>
|
|
265
|
+
${disabled ? '' : `<span class="chip-x" data-remove="${encodeURIComponent(o)}">${ICON_X}</span>`}
|
|
266
|
+
</span>`).join('');
|
|
267
|
+
|
|
268
|
+
const listHtml = filtered.length === 0
|
|
269
|
+
? `<div class="empty">—</div>`
|
|
270
|
+
: filtered.map(o => `
|
|
271
|
+
<label class="row" data-opt="${encodeURIComponent(o)}">
|
|
272
|
+
<input type="checkbox" ${this._selected.includes(o) ? 'checked' : ''}>
|
|
273
|
+
<span class="row-label">${escapeHtml(o)}</span>
|
|
274
|
+
</label>`).join('');
|
|
275
|
+
|
|
276
|
+
this.shadowRoot.innerHTML = `
|
|
277
|
+
<style>${STYLES}</style>
|
|
278
|
+
<div class="field">
|
|
279
|
+
${label ? `<label class="label">${escapeHtml(label)}${required ? '<span class="req">*</span>' : ''}</label>` : ''}
|
|
280
|
+
<div class="trigger-wrap">
|
|
281
|
+
<button type="button" class="trigger${error ? ' error' : ''}" ${disabled ? 'disabled' : ''}>
|
|
282
|
+
${chipsHtml}
|
|
283
|
+
${disabled ? '' : `<span class="chevron">${ICON_CHEVRON}</span>`}
|
|
284
|
+
</button>
|
|
285
|
+
${this._open ? `
|
|
286
|
+
<div class="panel">
|
|
287
|
+
<div class="search${searchable ? '' : ' hidden'}">
|
|
288
|
+
${ICON_SEARCH}
|
|
289
|
+
<input type="text" value="${escapeAttr(this._query)}" />
|
|
290
|
+
</div>
|
|
291
|
+
<div class="list">${listHtml}</div>
|
|
292
|
+
</div>` : ''}
|
|
293
|
+
</div>
|
|
294
|
+
${error ? `<p class="err">${escapeHtml(error)}</p>` : ''}
|
|
295
|
+
</div>
|
|
296
|
+
`;
|
|
297
|
+
|
|
298
|
+
const trigger = this.shadowRoot.querySelector('.trigger');
|
|
299
|
+
if (trigger) trigger.addEventListener('click', () => this._toggle());
|
|
300
|
+
|
|
301
|
+
this.shadowRoot.querySelectorAll('[data-remove]').forEach(el => {
|
|
302
|
+
el.addEventListener('click', (e) => {
|
|
303
|
+
e.stopPropagation();
|
|
304
|
+
this._removeOption(decodeURIComponent(el.getAttribute('data-remove')));
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
this.shadowRoot.querySelectorAll('.row').forEach(row => {
|
|
309
|
+
row.addEventListener('click', (e) => {
|
|
310
|
+
if (e.target.tagName !== 'INPUT') e.preventDefault();
|
|
311
|
+
this._toggleOption(decodeURIComponent(row.getAttribute('data-opt')));
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const searchInput = this.shadowRoot.querySelector('.search input');
|
|
316
|
+
if (searchInput) {
|
|
317
|
+
searchInput.addEventListener('input', (e) => { this._query = e.target.value; this._render(); });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function escapeHtml(s) {
|
|
323
|
+
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
324
|
+
}
|
|
325
|
+
function escapeAttr(s) { return escapeHtml(s); }
|
|
326
|
+
|
|
327
|
+
if (typeof customElements !== 'undefined' && !customElements.get('combobox-multi')) {
|
|
328
|
+
customElements.define('combobox-multi', ComboboxMulti);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export { ComboboxMulti };
|
|
332
|
+
export default ComboboxMulti;
|