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

## [3.3.0] - 2026-06-17

### Added
- **Disabled options.** A per-option `disabled: true` makes a row non-selectable:
dimmed, `aria-disabled`, skipped by keyboard navigation, and ignored on click/Enter.
- **`required` form validation.** Required controls now enforce selection via the
Constraint Validation API on the *visible* input, so an empty required control
blocks form submit with a focusable, on-screen validation bubble (message via
`texts.required`). This also makes `enhance()`’s `required` actually enforce —
previously it was dropped because a `display:none` `<select>` isn’t focusable.
- **Match highlighting.** `highlight: true` wraps the matched query substring in
each result’s label/sublabel with `<mark class="liveselect__mark">` (built from
text nodes — still XSS-safe). Ignored for `renderOption` rows.
- **“Showing N of M” footer.** When results are capped by `limit`, a footer shows
the count. Array sources compute the total automatically; async sources can
return `{ items, total }`. Customize via `texts.more(shown, total)`.
- **Lifecycle events.** Bubbling `liveselect:open`, `liveselect:close`, and
`liveselect:search` ({ query }) CustomEvents, alongside `liveselect:change`.
- **Async result caching.** `cache: true` memoizes async results by
query+scope+limit so repeat queries skip the network. Cleared by
`setSource()` / `setScope()`.

### Changed
- `normalizeOption` output now includes a `disabled` field (`false` when absent).

## [3.2.0] - 2026-06-17

### Added
Expand Down
34 changes: 29 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Node/Express, EJS templates, and Blaze**.
- 🎨 **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.
- 🧰 **Polish where it counts** — real `required` form validation, disabled (non-selectable) options, match highlighting, a “Showing N of M” hint, async result caching, and `open`/`close`/`search` lifecycle events.
- 🔒 **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 @@ -175,16 +176,21 @@ per-framework integration (HTML, Express, EJS, Blaze).
| `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). |
| `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()`. |
| `classPrefix` | `string` | `'liveselect'` | CSS class prefix. |
| `texts` | `object` | — | `{ searching, noResults, searchFailed }`. |
| `texts` | `object` | — | `{ searching, noResults, searchFailed, required }`, plus optional `more(shown, total) => string`. |

**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`.
**Option shape:** `{ value, label, sublabel?, group?, disabled?, raw? }`. Loose input
is normalized — a bare string becomes `{ value, label }`; `_id`/`id` map to `value`;
`name`/`title`/`text` map to `label`. `disabled: true` makes a row non-selectable
(dimmed, skipped by keyboard nav).

**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.
to `fetch` (the built-in `remoteSource` already does) to cancel stale requests. An
async source may resolve to a bare array **or** `{ items, total }` — the `total`
drives the “Showing N of M” footer when results are capped by `limit`.

## Custom item templates

Expand Down Expand Up @@ -283,6 +289,24 @@ form.addEventListener('liveselect:change', (e) => {
});
```

It also emits bubbling lifecycle events for integration hooks:

| Event | Detail | Fires when |
|---|---|---|
| `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 |

## Validation

A `required` LiveSelect enforces selection through the **Constraint Validation
API on its visible input** — so an empty required control blocks form submit with
a focusable, on-screen validation bubble (override the message via
`texts.required`). This also fixes `enhance()`: a `display:none` required
`<select>` can’t be focused (browsers reject submit with “An invalid form control
is not focusable”), so `enhance()` moves enforcement to the visible control.

## Theming

Override any token, globally or scoped to one control:
Expand Down
20 changes: 20 additions & 0 deletions dist/liveselect.css
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,27 @@
flex-direction: row; align-items: center; gap: 8px;
}

/* Non-selectable option (per-option `disabled: true`). */
.liveselect__opt--disabled {
opacity: 0.5; cursor: not-allowed;
}
.liveselect__opt--disabled:hover { background: transparent; }

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

/* Matched-substring highlight (opt-in via `highlight: true`). */
.liveselect__mark {
background: var(--liveselect-mark, #fde68a); color: inherit;
border-radius: 2px; padding: 0 1px;
}

/* "Showing N of M" footer when results are capped by `limit`. */
.liveselect__more {
padding: 6px 12px; text-align: center;
color: var(--liveselect-text-muted); font-size: 0.8rem;
}

/* Group heading (from groupBy / option.group): non-selectable, sticky label. */
.liveselect__group {
position: sticky; top: -6px;
Expand All @@ -130,4 +148,6 @@
--liveselect-hover: #1f2c39;
--liveselect-border: #2b3947;
--liveselect-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
--liveselect-mark: #7c5b00;
}
.liveselect--dark .liveselect__mark { color: #ffe9a8; }
Loading