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 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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;