From 995669f76da2f8640315c9e12748df5cadeec718 Mon Sep 17 00:00:00 2001 From: Michael Falk Date: Wed, 17 Jun 2026 11:51:28 -0700 Subject: [PATCH] =?UTF-8?q?feat!:=20multiple=20selection=20(chips,=20array?= =?UTF-8?q?=20value,=20submitFormat)=20=E2=80=94=20v4.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headline feature: multi-select via `multiple: true`. Major version marks the size; single-select behavior is unchanged (everything new is gated behind the flag — no breaking changes). - Selections render as removable chips. Add by click or type+Enter; remove via chip × or Backspace on empty input; re-selecting a chosen row toggles it off. - Array value throughout: getValue() → string[], getOption() → option[], onChange(values, options), setValue([...]), change detail { value: [], options: [] }. - maxItems caps selections (and suppresses the create row at the cap). - submitFormat ('repeat' default | 'bracket' | 'delimited' + delimiter) controls plain-form submission; 'repeat' mirrors native , upgrades to multi, and keeps the original element's selected options in sync. 9 new jsdom tests (45 total), CSS for chips/chosen rows, README (Multiple selection section, options table, Events/Instance API notes, Features bullets), a multi-select demo in examples/vanilla.html, CHANGELOG + version bump to 4.0.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 26 ++++ README.md | 39 +++++- dist/liveselect.css | 49 +++++++ dist/liveselect.js | 313 +++++++++++++++++++++++++++++++++++++----- examples/vanilla.html | 22 +++ package.json | 2 +- test/client.test.js | 136 ++++++++++++++++++ 7 files changed, 546 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 374580a..d5301b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,32 @@ All notable changes to this project are documented here. This project adheres to [Semantic Versioning](https://semver.org/). +## [4.0.0] - 2026-06-17 + +Headline feature release — **multiple selection**. The major version marks the +size of the addition; **there are no breaking changes to single-select usage** +(everything new is gated behind `multiple: true`). + +### Added +- **Multiple selection (`multiple: true`).** Selections render as removable chips: + - **Add** by clicking a row or typing + Enter; **remove** via a chip’s × or + Backspace on an empty input; re-selecting a chosen row toggles it off. + - The value is an **array** throughout: `getValue()` → `string[]`, + `getOption()` → `option[]`, `onChange(values, options)`, `setValue([...])`, + and `liveselect:change` detail `{ name, value: [], options: [] }`. + - **`maxItems`** caps selections (and suppresses the create row at the cap). + - **`submitFormat`** controls plain-form submission: `'repeat'` (default — one + hidden input per value sharing the name, like native ``, upgrades to multi mode, and + keeps the original element’s selected options in sync. + +### Changed +- `normalizeOption` output is unchanged; no API removed. Single-select code paths + are byte-for-byte compatible. + ## [3.3.0] - 2026-06-17 ### Added diff --git a/README.md b/README.md index 48256e0..9426e17 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,8 @@ Node/Express, EJS templates, and Blaze**. - 🔎 **Live search** — debounced, keyboard-navigable (↑/↓/Enter/Esc), touch-friendly two-line options. - 🗂 **Any data source** — a plain **array** _or_ an **async function** (wire it to **MongoDB** via the included Express backend, or anything else). - ➕ **`[+ Add new]` row** — appears when the typed text has no match; your `onCreate` can do _anything_ (open a modal, POST to a server, push to an array) and return the new option to auto-select it. -- 🔁 **Drop-in `` in place; a hidden `` means it submits inside a plain `
` like a native control. +- 🔁 **Drop-in `` in place; a hidden `` means it submits inside a plain `` like a native control. Also upgrades ``, so + Express/most frameworks parse `req.body.tags` as an array. `'bracket'` uses + `name="tags[]"` (PHP/Rails); `'delimited'` joins into one input via `delimiter`. +- **`enhance()`** auto-upgrades a `). 'bracket' uses name[]; + * 'delimited' joins into one input via `delimiter` (default ','). + * delimiter string joiner for submitFormat:'delimited' (default ','). * classPrefix CSS class prefix (default 'liveselect') * texts { searching, noResults, searchFailed, required } overrides, plus * optional more(shown, total) => string for the "Showing N of M" footer @@ -159,15 +168,17 @@ this.cp = this.opts.classPrefix || 'liveselect'; this.uid = this.cp + '-' + (++uidSeq); this.texts = Object.assign({}, DEFAULT_TEXTS, this.opts.texts || {}); + this.multi = !!this.opts.multiple; // state - this.query = ''; - this.results = []; - this.isOpen = false; - this.loading = false; - this.error = ''; - this.activeIndex = -1; - this.selected = null; + this.query = ''; + this.results = []; + this.isOpen = false; + this.loading = false; + this.error = ''; + this.activeIndex = -1; + this.selected = null; // single mode: the chosen option (or null) + this.selectedList = []; // multiple mode: array of chosen options this._appliedValue = undefined; this._debounce = null; this._blurTimer = null; @@ -181,9 +192,13 @@ // Initial / controlled value if ('value' in this.opts) { - this.setValue(this.opts.value, this.opts.valueLabel != null - ? { value: this.opts.value, label: this.opts.valueLabel, sublabel: this.opts.valueSublabel || '' } - : undefined); + if (this.multi) { + this.setValue(this.opts.value, this.opts.valueLabel); + } else { + this.setValue(this.opts.value, this.opts.valueLabel != null + ? { value: this.opts.value, label: this.opts.valueLabel, sublabel: this.opts.valueSublabel || '' } + : undefined); + } } this._syncValidity(); // required controls start invalid until a pick is made } @@ -202,6 +217,7 @@ root.className = cp; root.setAttribute('data-liveselect', ''); if (o.disabled) root.classList.add(cp + '--disabled'); + if (this.multi) root.classList.add(cp + '--multi'); var labelHtml = ''; if (o.label) { @@ -212,31 +228,39 @@ root.innerHTML = labelHtml + '
' + + // Multi-select chips render here, before the input. + '' + '' + '' + - '' + + '' + '
' + '' + // Visually-hidden polite live region: announces result counts / states to // screen readers without a visible change. '' + + // Single-select submits via this one hidden input; multi-select generates + // its own inputs into the wrap below (and leaves this one nameless/empty). ''; + (o.name && !this.multi ? ' name="' + escapeHtml(o.name) + '"' : '') + + (o.required && !this.multi ? ' required' : '') + ' value="">' + + ''; this.host.appendChild(root); - this.root = root; - this.input = root.querySelector('[data-liveselect-input]'); - this.clearEl = root.querySelector('[data-liveselect-clear]'); - this.menu = root.querySelector('[data-liveselect-menu]'); - this.errorEl = root.querySelector('[data-liveselect-error]'); - this.liveEl = root.querySelector('[data-liveselect-live]'); - this.hidden = root.querySelector('[data-liveselect-hidden]'); + this.root = root; + this.input = root.querySelector('[data-liveselect-input]'); + this.clearEl = root.querySelector('[data-liveselect-clear]'); + this.menu = root.querySelector('[data-liveselect-menu]'); + this.errorEl = root.querySelector('[data-liveselect-error]'); + this.liveEl = root.querySelector('[data-liveselect-live]'); + this.hidden = root.querySelector('[data-liveselect-hidden]'); + this.tagsEl = root.querySelector('[data-liveselect-tags]'); + this.hiddenList = root.querySelector('[data-liveselect-hidden-list]'); }; // -- event binding --------------------------------------------------------- @@ -275,12 +299,21 @@ }; this._onClearDown = function (e) { e.preventDefault(); self.clear(); }; + // Chip remove buttons (multi mode). mousedown so it beats the input blur. + this._onTagsDown = function (e) { + var rm = e.target.closest('[data-liveselect-remove]'); + if (!rm) return; + e.preventDefault(); + self._deselect(rm.getAttribute('data-liveselect-remove')); + }; + this.input.addEventListener('input', this._onInput); this.input.addEventListener('focus', this._onFocus); this.input.addEventListener('blur', this._onBlur); this.input.addEventListener('keydown', this._onKeydown); this.menu.addEventListener('mousedown', this._onMenuDown); this.clearEl.addEventListener('mousedown', this._onClearDown); + this.tagsEl.addEventListener('mousedown', this._onTagsDown); }; // -- searching ------------------------------------------------------------- @@ -430,13 +463,20 @@ LiveSelect.prototype._canCreate = function () { if (!this.opts.allowCreate || typeof this.opts.onCreate !== 'function') return false; + if (this.multi && this.opts.maxItems != null && this.selectedList.length >= this.opts.maxItems) return false; var q = this.query.trim(); if (!q) return false; var ql = q.toLowerCase(); var exact = this.results.some(function (o) { return o.label.trim().toLowerCase() === ql || (o.sublabel || '').trim().toLowerCase() === ql; }); - return !exact; + if (exact) return false; + // In multi mode a chip already matching the text also counts as "exists". + if (this.multi) { + var chosen = this.selectedList.some(function (o) { return o.label.trim().toLowerCase() === ql; }); + if (chosen) return false; + } + return true; }; LiveSelect.prototype._openCreate = function () { @@ -478,6 +518,13 @@ }; LiveSelect.prototype._handleKeydown = function (e) { + // Multi mode: Backspace on an empty query removes the last chip. + if (this.multi && e.key === 'Backspace' && this.query === '' && this.selectedList.length) { + e.preventDefault(); + this._deselect(this.selectedList[this.selectedList.length - 1].value); + return; + } + var canCreate = this._canCreate(); var max = this.results.length + (canCreate ? 1 : 0) - 1; @@ -507,6 +554,7 @@ LiveSelect.prototype._select = function (opt) { if (opt && opt.disabled) { this.input.focus(); return; } // non-selectable row + if (this.multi) return this._toggle(opt); this.selected = opt; this.query = ''; this._setOpen(false); @@ -517,7 +565,97 @@ this._emit(opt); }; + // ---- multiple-selection helpers ---- + + LiveSelect.prototype._indexOfValue = function (value) { + var v = String(value); + for (var i = 0; i < this.selectedList.length; i++) { + if (this.selectedList[i].value === v) return i; + } + return -1; + }; + + LiveSelect.prototype._isChosen = function (value) { return this._indexOfValue(value) >= 0; }; + + /** Add or toggle-off an option in multiple mode (respecting maxItems). */ + LiveSelect.prototype._toggle = function (opt) { + if (!opt) return; + var i = this._indexOfValue(opt.value); + if (i >= 0) { + this.selectedList.splice(i, 1); // clicking a chosen row removes it + } else { + var max = this.opts.maxItems; + if (max != null && this.selectedList.length >= max) { + this._announce('Maximum of ' + max + ' reached.'); + this.input.focus(); + return; + } + this.selectedList.push(opt); + } + this._afterMultiChange(true); + }; + + /** Remove a value in multiple mode (chip × or Backspace). */ + LiveSelect.prototype._deselect = function (value) { + var i = this._indexOfValue(value); + if (i < 0) return; + this.selectedList.splice(i, 1); + this._afterMultiChange(this.isOpen); + }; + + /** Shared post-change sync for multiple mode. */ + LiveSelect.prototype._afterMultiChange = function (keepOpen) { + clearTimeout(this._blurTimer); // picking from the menu blurred the input; stay open + this.query = ''; + this.activeIndex = -1; + this._renderTags(); + this._syncInput(); + this._syncHidden(); + this._emit(); + this.input.focus(); + if (keepOpen) { this._setOpen(true); this._runSearch(); } + else this._renderMenu(); + }; + + /** Render the selected-value chips (multiple mode). */ + LiveSelect.prototype._renderTags = function () { + if (!this.multi) return; + var cp = this.cp, self = this; + this.tagsEl.textContent = ''; + this.selectedList.forEach(function (o) { + var chip = document.createElement('span'); + chip.className = cp + '__tag'; + var lab = document.createElement('span'); + lab.className = cp + '__tag-label'; + lab.textContent = o.label; // textContent → labels stay escaped + chip.appendChild(lab); + if (!self.opts.disabled) { + var rm = document.createElement('button'); + rm.type = 'button'; + rm.className = cp + '__tag-remove'; + rm.setAttribute('data-liveselect-remove', o.value); + rm.setAttribute('aria-label', 'Remove ' + o.label); + rm.innerHTML = '×'; + chip.appendChild(rm); + } + self.tagsEl.appendChild(chip); + }); + this.tagsEl.hidden = this.selectedList.length === 0; + }; + LiveSelect.prototype._emit = function (opt) { + if (this.multi) { + var values = this.selectedList.map(function (o) { return o.value; }); + var options = this.selectedList.slice(); + if (typeof this.opts.onChange === 'function') { + try { this.opts.onChange(values, options); } catch (e) { /* swallow */ } + } + this.root.dispatchEvent(new CustomEvent('liveselect:change', { + bubbles: true, + detail: { name: this.opts.name || '', value: values, options: options, option: null }, + })); + return; + } var value = opt ? opt.value : ''; if (typeof this.opts.onChange === 'function') { try { this.opts.onChange(value, opt || null); } catch (e) { /* swallow */ } @@ -557,19 +695,56 @@ LiveSelect.prototype._syncInput = function () { if (this.isOpen) { this.input.value = this.query; return; } + if (this.multi) { + this.input.value = ''; // chips carry the selection, not the input + this.input.placeholder = this.opts.placeholder || 'Search…'; + this.clearEl.hidden = !(this.selectedList.length && this.opts.clearable !== false && !this.opts.disabled); + return; + } this.input.value = this.selected ? this.selected.label : ''; this.input.placeholder = this.opts.placeholder || 'Search…'; this.clearEl.hidden = !(this.selected && this.opts.clearable !== false && !this.opts.disabled); }; LiveSelect.prototype._syncHidden = function () { - this.hidden.value = this.selected ? this.selected.value : ''; - this.clearEl.hidden = !(this.selected && this.opts.clearable !== false && !this.opts.disabled); + if (this.multi) { + this._syncHiddenMulti(); + this.clearEl.hidden = !(this.selectedList.length && this.opts.clearable !== false && !this.opts.disabled); + } else { + this.hidden.value = this.selected ? this.selected.value : ''; + this.clearEl.hidden = !(this.selected && this.opts.clearable !== false && !this.opts.disabled); + } this._syncValidity(); // Fire a native change so plain-form listeners / validators react. this.hidden.dispatchEvent(new Event('change', { bubbles: true })); }; + /** Regenerate the hidden inputs for multiple mode per `submitFormat`. */ + LiveSelect.prototype._syncHiddenMulti = function () { + this.hiddenList.textContent = ''; + var name = this.opts.name; + if (!name) return; // no name → nothing submits + var values = this.selectedList.map(function (o) { return o.value; }); + var fmt = this.opts.submitFormat || 'repeat'; + + if (fmt === 'delimited') { + var input = document.createElement('input'); + input.type = 'hidden'; + input.name = name; + input.value = values.join(this.opts.delimiter != null ? this.opts.delimiter : ','); + this.hiddenList.appendChild(input); + return; + } + var fieldName = fmt === 'bracket' ? name + '[]' : name; + for (var i = 0; i < values.length; i++) { + var inp = document.createElement('input'); + inp.type = 'hidden'; + inp.name = fieldName; + inp.value = values[i]; + this.hiddenList.appendChild(inp); + } + }; + /** * Enforce `required` on the *visible* input via the Constraint Validation API. * The visible input is on-screen and focusable, so the browser can show its @@ -579,7 +754,8 @@ */ LiveSelect.prototype._syncValidity = function () { if (!this.input || typeof this.input.setCustomValidity !== 'function') return; - var enforce = this.opts.required && !this.opts.disabled && !this.selected; + var empty = this.multi ? this.selectedList.length === 0 : !this.selected; + var enforce = this.opts.required && !this.opts.disabled && empty; this.input.setCustomValidity(enforce ? (this.texts.required || 'Please select an option.') : ''); }; @@ -713,14 +889,18 @@ lastGroup = g; var isActive = this.activeIndex === i; + var isChosen = this.multi && this._isChosen(o.value); var optId = this.uid + '-opt-' + i; var btn = document.createElement('button'); btn.type = 'button'; btn.id = optId; btn.setAttribute('role', 'option'); - btn.setAttribute('aria-selected', isActive ? 'true' : 'false'); + // Multi: aria-selected reflects chosen state (active is shown by + // aria-activedescendant). Single: aria-selected tracks the active row. + btn.setAttribute('aria-selected', (this.multi ? isChosen : isActive) ? 'true' : 'false'); btn.className = cp + '__opt' + (isActive ? ' ' + cp + '__opt--active' : '') + + (isChosen ? ' ' + cp + '__opt--chosen' : '') + (o.disabled ? ' ' + cp + '__opt--disabled' : ''); btn.setAttribute('data-liveselect-opt', ''); btn.setAttribute('data-liveselect-index', String(i)); @@ -769,11 +949,16 @@ // -- public API ------------------------------------------------------------ - /** Current selected value (the string that submits in a form). */ - LiveSelect.prototype.getValue = function () { return this.selected ? this.selected.value : ''; }; + /** Current value: a string (single) or an array of value strings (multiple). */ + LiveSelect.prototype.getValue = function () { + if (this.multi) return this.selectedList.map(function (o) { return o.value; }); + return this.selected ? this.selected.value : ''; + }; - /** Current selected option object, or null. */ - LiveSelect.prototype.getOption = function () { return this.selected; }; + /** Current option(s): an option/null (single) or an array of options (multiple). */ + LiveSelect.prototype.getOption = function () { + return this.multi ? this.selectedList.slice() : this.selected; + }; /** * setValue — select by value. Pass `option` to set the label without a lookup; @@ -781,6 +966,36 @@ */ LiveSelect.prototype.setValue = function (value, option) { var self = this; + + // Multiple mode: value is an array of values; option (optional) is a parallel + // array of labels or option objects. + if (this.multi) { + var values = Array.isArray(value) ? value : (value == null || value === '' ? [] : [value]); + var labels = Array.isArray(option) ? option : null; + this.selectedList = []; + values.forEach(function (raw, idx) { + var vv = String(raw); + var lbl = labels && labels[idx]; + if (lbl != null && typeof lbl === 'object') { self.selectedList.push(normalizeOption(lbl)); return; } + if (lbl != null) { self.selectedList.push(normalizeOption({ value: vv, label: lbl })); return; } + if (Array.isArray(self.opts.source)) { + var hit = normalizeList(self.opts.source).find(function (o) { return o.value === vv; }); + if (hit) { self.selectedList.push(hit); return; } + } + if (typeof self.opts.resolve === 'function') { + Promise.resolve(self.opts.resolve(vv, { scope: self.opts.scope || {} })) + .then(function (opt) { if (opt) { self.selectedList.push(normalizeOption(opt)); self._renderTags(); self._syncHidden(); } }) + .catch(function () { /* leave unresolved */ }); + return; + } + self.selectedList.push(normalizeOption({ value: vv, label: vv })); + }); + this._renderTags(); + this._syncInput(); + this._syncHidden(); + return; + } + var v = value == null ? '' : String(value); this._appliedValue = v; @@ -809,10 +1024,12 @@ LiveSelect.prototype.clear = function () { this.selected = null; + this.selectedList = []; this.query = ''; this.results = []; this.activeIndex = -1; this._appliedValue = ''; + if (this.multi) this._renderTags(); this._syncInput(); this._syncHidden(); this._emit(null); @@ -856,6 +1073,7 @@ this.input.removeEventListener('keydown', this._onKeydown); this.menu.removeEventListener('mousedown', this._onMenuDown); this.clearEl.removeEventListener('mousedown', this._onClearDown); + this.tagsEl.removeEventListener('mousedown', this._onTagsDown); if (this.root && this.root.parentNode) this.root.parentNode.removeChild(this.root); }; @@ -877,14 +1095,19 @@ var sel = resolveEl(selectElOrSelector); if (!sel || sel.tagName !== 'SELECT') throw new Error('LiveSelect.enhance: a + fire its native change. - if (option && !Array.prototype.some.call(sel.options, function (o) { return o.value === value; })) { - var newOpt = document.createElement('option'); - newOpt.value = value; newOpt.textContent = option.label; - sel.appendChild(newOpt); + if (isMulti) { + // Reflect the array of values back into the + create the option if new. + if (option && !Array.prototype.some.call(sel.options, function (o) { return o.value === value; })) { + var newOpt = document.createElement('option'); + newOpt.value = value; newOpt.textContent = option.label; + sel.appendChild(newOpt); + } + sel.value = value; } - sel.value = value; sel.dispatchEvent(new Event('change', { bubbles: true })); if (typeof userOnChange === 'function') userOnChange(value, option); }; diff --git a/examples/vanilla.html b/examples/vanilla.html index 31b47ef..3ec99b0 100644 --- a/examples/vanilla.html +++ b/examples/vanilla.html @@ -70,6 +70,11 @@

5 · Grouped options (optgroup-style headings)

value: (none)
+ +

6 · Multiple selection (chips · maxItems · create)

+
+
value: []
+