Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<select multiple>`),
`'bracket'` (`name[]`), or `'delimited'` (one input joined by `delimiter`).
- Chosen rows are marked `aria-selected` + `.liveselect__opt--chosen`; the
listbox is `aria-multiselectable`.
- **`enhance()`** auto-detects `<select multiple>`, 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
Expand Down
39 changes: 37 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<select>` replacement** — `LiveSelect.enhance(selectEl)` upgrades an existing `<select>` in place; a hidden `<input name>` means it submits inside a plain `<form>` like a native control.
- 🔁 **Drop-in `<select>` replacement** — `LiveSelect.enhance(selectEl)` upgrades an existing `<select>` in place; a hidden `<input name>` means it submits inside a plain `<form>` like a native control. Also upgrades `<select multiple>`.
- 🏷 **Multiple selection** — `multiple: true` for removable chips, array values, `maxItems`, and configurable form submission (`repeat`/`bracket`/`delimited`). See [Multiple selection](#multiple-selection).
- 🎨 **Fully themeable** — restyle with `--liveselect-*` CSS custom properties or target the BEM-ish classes; ships a light and dark theme.
- 🧩 **Custom item templates** — render each result row _and_ the `[+ Add]` row however you like with `renderOption` / `renderCreate`; return a DOM node (XSS-safe) or an HTML string. See [Custom item templates](#custom-item-templates).
- ♿ **Accessible by default** — full ARIA combobox/listbox wiring (`aria-activedescendant`, `aria-selected`, live-region announcements) so keyboard nav is screen-reader friendly. Optional **grouped options** with `<optgroup>`-style headings.
Expand Down Expand Up @@ -178,6 +179,10 @@ per-framework integration (HTML, Express, EJS, Blaze).
| `groupBy` | `(option) => string` | — | Group results under headings. See [Grouped options](#grouped-options). |
| `highlight` | `boolean` | `false` | Wrap the matched query substring in each result with `<mark>`. Ignored for `renderOption` rows. |
| `cache` | `boolean` | `false` | Cache async results by query+scope+limit so repeats skip the network. Cleared by `setSource()`/`setScope()`. |
| `multiple` | `boolean` | `false` | Multi-select mode — chips, array value. See [Multiple selection](#multiple-selection). |
| `maxItems` | `number` | — | Cap the number of selections (multiple mode). |
| `submitFormat` | `'repeat'`\|`'bracket'`\|`'delimited'` | `'repeat'` | How multiple values submit in a plain form. |
| `delimiter` | `string` | `','` | Joiner for `submitFormat: 'delimited'`. |
| `classPrefix` | `string` | `'liveselect'` | CSS class prefix. |
| `texts` | `object` | — | `{ searching, noResults, searchFailed, required }`, plus optional `more(shown, total) => string`. |

Expand Down Expand Up @@ -232,6 +237,33 @@ The normalized option passed in is `{ value, label, sublabel, raw }`, where
label/sublabel. The `createLabel` option still works for a plain-text add row;
`renderCreate` supersedes it when both are set.

## Multiple selection

Set `multiple: true` for a tags/chips multi-select. Selections render as removable
chips; the value becomes an **array** throughout the API.

```js
const ms = new LiveSelect('#tags', {
name: 'tags',
multiple: true,
source: ['react', 'vue', 'svelte', 'angular', 'solid'],
maxItems: 3, // optional cap
onChange: (values, options) => console.log(values), // ['react','vue']
});
ms.getValue(); // → ['react', 'vue'] (an array in multiple mode)
ms.setValue(['react', 'svelte']); // value is an array too
```

- **Add**: click a row, or type + Enter. **Remove**: click a chip’s ×, or press
Backspace on an empty input. Re-selecting a chosen row toggles it off.
- **Form submission** (`submitFormat`): `'repeat'` (default) emits one hidden
input per value sharing `name` — exactly like a native `<select multiple>`, 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 `<select multiple>` to this mode and keeps the
original element’s selected options in sync.
- The `liveselect:change` detail carries `{ name, value: string[], options: [] }`.

## Grouped options

Render results under headings (like `<optgroup>`) by giving options a `group`
Expand Down Expand Up @@ -278,6 +310,9 @@ focus() · open() · close() · setSource(src) · setScope(obj)
setDisabled(bool) · destroy()
```

In **multiple** mode, `getValue()` returns an array of values, `getOption()` an
array of options, and `setValue()` accepts an array.

## Events

Besides the `onChange` callback, the control dispatches a **bubbling**
Expand All @@ -296,7 +331,7 @@ It also emits bubbling lifecycle events for integration hooks:
| `liveselect:open` | `{ name }` | the menu opens |
| `liveselect:close` | `{ name }` | the menu closes |
| `liveselect:search` | `{ name, query }` | a search runs (after debounce / `minChars`) |
| `liveselect:change` | `{ name, value, option }` | a selection or clear happens |
| `liveselect:change` | `{ name, value, option }` | a selection or clear happens (in multiple mode: `{ name, value: [], options: [] }`) |

## Validation

Expand Down
49 changes: 49 additions & 0 deletions dist/liveselect.css
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,47 @@
.liveselect__clear:hover { background: var(--liveselect-hover); color: var(--liveselect-text); }
.liveselect__clear[hidden] { display: none; }

/* ---- Multiple selection (chips) ---- */
.liveselect__tags { display: contents; }
.liveselect__tags[hidden] { display: none; }

.liveselect--multi .liveselect__control {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
padding: 6px 38px 6px 8px;
background: var(--liveselect-bg);
border: 1px solid var(--liveselect-border);
border-radius: var(--liveselect-radius);
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.liveselect--multi .liveselect__control:focus-within {
border-color: var(--liveselect-border-focus);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--liveselect-border-focus) 22%, transparent);
}
/* The input becomes a borderless, auto-growing field sitting after the chips. */
.liveselect--multi .liveselect__input {
flex: 1 1 80px; width: auto; min-width: 80px;
padding: 4px 2px; border: 0; background: transparent; box-shadow: none;
}
.liveselect--multi .liveselect__input:focus { border: 0; box-shadow: none; }

.liveselect__tag {
display: inline-flex; align-items: center; gap: 4px;
padding: 2px 4px 2px 9px;
background: var(--liveselect-hover);
border: 1px solid var(--liveselect-border);
border-radius: 999px;
font-size: 0.86rem; line-height: 1.4; max-width: 100%;
}
.liveselect__tag-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.liveselect__tag-remove {
flex: 0 0 auto; width: 18px; height: 18px;
border: 0; border-radius: 50%; background: transparent;
color: var(--liveselect-text-muted); cursor: pointer;
font-size: 1.05rem; line-height: 1;
display: inline-flex; align-items: center; justify-content: center;
}
.liveselect__tag-remove:hover { background: var(--liveselect-border); color: var(--liveselect-text); }

/* Dropdown menu. */
.liveselect__menu {
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
Expand Down Expand Up @@ -111,6 +152,14 @@
}
.liveselect__opt--disabled:hover { background: transparent; }

/* Chosen row in multiple mode: tinted, with a trailing checkmark. */
.liveselect__opt--chosen { color: var(--liveselect-accent); }
.liveselect__opt--chosen::after {
content: "✓"; position: absolute; right: 12px;
font-weight: 700; color: var(--liveselect-accent);
}
.liveselect--multi .liveselect__opt { position: relative; padding-right: 30px; }

.liveselect__opt-label { font-weight: 600; }
.liveselect__opt-sub { font-size: 0.82rem; color: var(--liveselect-text-muted); }

Expand Down
Loading