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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,26 @@
All notable changes to this project are documented here. This project adheres to
[Semantic Versioning](https://semver.org/).

## [3.2.0] - 2026-06-17

### Added
- **Accessibility (ARIA combobox/listbox pattern).** Options now get stable `id`s;
the active row carries `aria-selected` and is referenced by the input's
`aria-activedescendant`; the input gains `aria-controls` / `aria-haspopup`; and a
visually-hidden `aria-live="polite"` region announces result counts, “Searching…”,
and the no-matches/create state. Keyboard nav is now announced to screen readers.
- **AbortSignal for async sources.** The async `source` `ctx` now includes a real
`signal` (`{ scope, limit, query, signal }`). A newer search aborts the previous
one's signal, and the built-in `remoteSource` passes it to `fetch`, so stale
in-flight requests are cancelled rather than merely ignored. (`destroy()` also
aborts any in-flight request.)
- **Grouped options.** Pass a `groupBy(option) => string` function or a per-option
`group` field to render `<optgroup>`-style headings (`.liveselect__group`).
Results are stably reordered so same-group items sit together.

### Changed
- `normalizeOption` output now includes a `group` field (`''` when absent).

## [3.1.0] - 2026-06-17

### Added
Expand Down
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Node/Express, EJS templates, and Blaze**.
- 🔁 **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.
- 🎨 **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.
- 🔒 **Security-hardened server** — registry-gated collection access, field allow-listing, ReDoS-capped regex, scope filters, tenant isolation hook, prototype-pollution guards.
- 📦 **Zero dependencies**, ~12 KB. Works as a `<script>` tag (`window.LiveSelect`), a CommonJS `require`, or an ES module `import`.

Expand Down Expand Up @@ -173,13 +174,18 @@ per-framework integration (HTML, Express, EJS, Blaze).
| `renderCreate` | `(q, ctx) => Node\|string\|null` | — | Custom template for the `[+ Add]` row. |
| `onCreate` | `async (q, ctx) => option\|null` | — | Do anything; return an option to auto-select. |
| `onChange` | `(value, option) => void` | — | Fires on every selection/clear. |
| `groupBy` | `(option) => string` | — | Group results under headings. See [Grouped options](#grouped-options). |
| `classPrefix` | `string` | `'liveselect'` | CSS class prefix. |
| `texts` | `object` | — | `{ searching, noResults, searchFailed }`. |

**Option shape:** `{ value, label, sublabel?, raw? }`. Loose input is normalized —
**Option shape:** `{ value, label, sublabel?, group?, raw? }`. Loose input is normalized —
a bare string becomes `{ value, label }`; `_id`/`id` map to `value`;
`name`/`title`/`text` map to `label`.

**Async source `ctx`:** `{ scope, limit, query, signal }`. `signal` is an
`AbortSignal` that fires when a newer search supersedes the current one — pass it
to `fetch` (the built-in `remoteSource` already does) to cancel stale requests.

## Custom item templates

By default each row renders an escaped two-line `label` / `sublabel`. To render
Expand Down Expand Up @@ -220,6 +226,44 @@ 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.

## Grouped options

Render results under headings (like `<optgroup>`) by giving options a `group`
field, or by passing a `groupBy(option) => string` function (which takes
precedence). Results are **stably reordered** so same-group items sit together,
preserving the order each group was first seen — so your source doesn't have to
pre-sort.

```js
new LiveSelect('#picker', {
source: [
{ value: 'ca', label: 'California', group: 'US' },
{ value: 'on', label: 'Ontario', group: 'Canada' },
{ value: 'tx', label: 'Texas', group: 'US' },
],
// or, instead of per-option group:
// groupBy: (opt) => opt.raw.country,
});
```

Group headings render as `.liveselect__group` (a non-selectable, sticky label) —
style them like any other token.

## Accessibility

The control implements the [ARIA combobox/listbox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/):

- the input is `role="combobox"` with `aria-expanded`, `aria-controls`, and
`aria-autocomplete="list"`;
- the menu is `role="listbox"`; each row is `role="option"` with a stable `id`;
- the active row carries `aria-selected="true"` and the input's
`aria-activedescendant` points at it, so arrow-key navigation is announced;
- a visually-hidden `aria-live="polite"` region announces result counts,
“Searching…”, and the no-matches / create state.

Full keyboard support: ↑/↓ to move, Enter to select (or trigger `[+ Add]`),
Esc to close.

## Instance API

```
Expand Down
9 changes: 9 additions & 0 deletions dist/liveselect.css
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,15 @@
.liveselect__opt-label { font-weight: 600; }
.liveselect__opt-sub { font-size: 0.82rem; color: var(--liveselect-text-muted); }

/* Group heading (from groupBy / option.group): non-selectable, sticky label. */
.liveselect__group {
position: sticky; top: -6px;
padding: 8px 12px 4px;
background: var(--liveselect-surface);
color: var(--liveselect-text-muted);
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
}

.liveselect__msg { padding: 10px 12px; color: var(--liveselect-text-muted); font-size: 0.92rem; }
.liveselect__error { color: var(--liveselect-danger); font-size: 0.82rem; }
.liveselect__error[hidden] { display: none; }
Expand Down
Loading