Selection
@ailuracode/alpine-selection
Framework-agnostic selection primitives for Alpine.js — single, multiple, and range modes with anchor tracking.
Install
pnpm add @ailuracode/alpine-selection @ailuracode/alpine-core alpinejsQuick start
import Alpine from "alpinejs";import selection from "@ailuracode/alpine-selection";
Alpine.plugin(selection());Alpine.start();<div x-data="{ items: ['Alpha', 'Bravo', 'Charlie', 'Delta'], itemClass(key) { const snap = $store.selection.instances.list; if (!snap) return ''; return snap.selectedKeys.includes(key) ? 'is-selected' : ''; }, }" x-init="$store.selection.create('list', { mode: 'multiple', keys: items })"> <p x-text="$store.selection.instances.list?.selectedKeys.join(', ') || 'none'"></p> <ul x-bind="$store.selection.listProps('list', { label: 'Choose items' })"> <template x-for="item in items" :key="item"> <li x-bind="$store.selection.itemProps('list', item)" :class="itemClass(item)" @click="$store.selection.toggle('list', item)" x-text="item" ></li> </template> </ul></div>Bind styles and labels to $store.selection.instances[id] (or itemProps / listProps) so Alpine tracks selection changes. Imperative helpers such as isSelected() read the controller directly and do not trigger template updates on their own.
In inline x-data methods, reference component fields with this (or run create from an x-init expression where Alpine injects data scope):
<div x-data="{ items: ['Alpha', 'Bravo'], mode: 'multiple' }" x-init="$store.selection.create('list', { mode, keys: items })">Store API
$store.selection.create(id, options)— register a selection instance$store.selection.destroy(id)/destroyAll()— remove instance(s)$store.selection.replace(id, key)— replace selection$store.selection.toggle(id, key)— toggle membership (multiple mode)$store.selection.extend(id, key)— extend from anchor (range / shift-click)$store.selection.selectAll(id)/clear(id)— bulk commands$store.selection.setMode(id, mode)— switch betweensingle,multiple,range$store.selection.setKeys(id, keys)— update the ordered key registry$store.selection.setDisabledKeys(id, keys)— mark keys as non-selectable$store.selection.setActive(id, key)/setAnchor(id, key)— keyboard / pointer focus$store.selection.instances[id]— readonly snapshot (value,selectedKeys,anchorKey,activeKey,mode)$store.selection.listProps/itemProps— headless listbox ARIA helpers (reactive viainstances)
Store factory (standalone)
Create a store without the full Alpine plugin:
import { createSelectionStore, createSelectionStoreFromController } from "@ailuracode/alpine-selection";
// Fresh controllerconst store = createSelectionStore();store.create("list", { mode: "multiple", keys: ["a", "b", "c"] });store.toggle("list", "a");
// Or wrap an existing controllerimport { SelectionController } from "@ailuracode/alpine-selection";const controller = new SelectionController();const store2 = createSelectionStoreFromController(controller);Adapter factories
Controlled and uncontrolled adapters for framework-agnostic wiring:
import { createControlledAdapter, createUncontrolledAdapter } from "@ailuracode/alpine-selection";
// Controlled — you own the valueconst adapter = createControlledAdapter({ mode: "multiple", value: ["a"], onChange: (detail) => render(detail.value),});
// Uncontrolled — controller owns the stateconst adapter2 = createUncontrolledAdapter(controller, "list", { mode: "multiple", keys: ["a", "b", "c"],});Navigation helpers
import { moveSelectableIndex, moveSelectableKey, firstSelectableIndex, lastSelectableIndex, firstSelectableKey, lastSelectableKey,} from "@ailuracode/alpine-selection";
const nextIndex = moveSelectableIndex(currentIndex, 1, selectableFlags);const nextKey = moveSelectableKey(currentKey, 1, keys, disabledKeys);Use these in keyboard handlers for listbox, command palette, and tab strips.
Controller API (no Alpine)
import { createSelectionController } from "@ailuracode/alpine-selection";
const controller = createSelectionController();controller.create("rows", { mode: "range", keys: ["a", "b", "c"] });controller.on("change", ({ selectedKeys }) => { render(selectedKeys);});Avoiding name collisions
If your application already owns a $store.selection — or another toolkit plugin registers on that name — rename the integration surface without touching the controller:
Alpine.plugin(selectionPlugin({ storeKey: "pick" })); // → $store.pickThe exposed constant DEFAULT_SELECTION_STORE_KEY keeps the rename discoverable from TypeScript.
Serialization
import { serializeSelection, deserializeSelection } from "@ailuracode/alpine-selection";
const encoded = serializeSelection(["a", "c"], "multiple"); // "a,c"const restored = deserializeSelection(encoded, "multiple"); // ["a", "c"]URL integration
import { parseSelectionParam, writeSelectionParam } from "@ailuracode/alpine-selection";
// Read from URLconst params = new URLSearchParams(window.location.search);const value = parseSelectionParam(params, "selected", "multiple");
// Write to URLwriteSelectionParam(params, "selected", ["a", "c"], "multiple");window.history.replaceState(null, "", `?${params}`);Error handling
import { SelectionError } from "@ailuracode/alpine-selection";
try { controller.toggle("unknown-instance", "a");} catch (e) { if (e instanceof SelectionError && e.code === "INSTANCE_NOT_FOUND") { // handle missing instance }}Used by
@ailuracode/alpine-calendar, @ailuracode/alpine-command, @ailuracode/alpine-tabs, and @ailuracode/alpine-accordion build on these primitives internally.
Quick start
import Alpine from "alpinejs";import selection from "@ailuracode/alpine-selection";
Alpine.plugin(selection());Alpine.start();Reactivity in templates
Selection state is mirrored on $store.selection.instances[id]. Read from that snapshot (or use listProps / itemProps, which derive from it) so Alpine re-renders when selection changes:
<p x-text="$store.selection.instances.rows?.selectedKeys.join(', ')"></p>isSelected(), isActive(), and similar helpers are for imperative code (event handlers, tests). They do not register reactive dependencies in templates.
In inline x-data object methods, bare names like mode or items are not in scope — use this.mode / this.items, or call create from an x-init expression:
<div x-data="{ items: ['Alpha', 'Bravo'], mode: 'multiple' }" x-init="$store.selection.create('rows', { mode, keys: items })">Options
| Option | Default | Description |
|---|---|---|
mode |
"single" |
single, multiple, or range |
keys |
[] |
Ordered selectable keys (defines range span order) |
disabledKeys |
[] |
Keys that cannot be selected |
allowDisabledSelection |
false |
Allow selecting disabled keys programmatically |
value / defaultValue |
— | Controlled or initial value |
onChange |
— | Called after each confirmed transition |
Modes
| Mode | Value shape | Typical use |
|---|---|---|
single |
key | null |
Listbox, radio group, tabs |
multiple |
key[] |
Multi-select tables, checklists |
range |
{ from, to? } |
Shift-click ranges, calendars |
Pointer interactions
| Gesture | Command |
|---|---|
| Click | replace(id, key) |
| Ctrl/Cmd + click | toggle(id, key) (multiple) |
| Shift + click | extend(id, key) (range / multiple) |
Call setActive(id, key) on pointer down or click so activeKey stays in sync for keyboard continuation.
Controller (no Alpine)
import { createSelectionController } from "@ailuracode/alpine-selection";
const controller = createSelectionController();controller.create("rows", { mode: "multiple", keys: ["a", "b", "c"] });controller.on("change", ({ selectedKeys }) => { console.log(selectedKeys);});Accessibility
Use listProps and itemProps for WAI-ARIA listbox semantics (role, aria-selected, aria-disabled, aria-multiselectable). Pair keyboard handlers with setActive, moveSelectableKey, and extend for arrow-key and shift-arrow range selection.
Adoption in the toolkit
@ailuracode/alpine-calendar historically used @ailuracode/alpine-selection for date keys, but now ships inline selection state to keep the bundle slim. tabs, accordion, and command followed the same pattern.
| Package | Use |
|---|---|
@ailuracode/alpine-calendar |
(moved to inline state) Date keys bridged to ISO strings |
Consumers of those packages do not need to install @ailuracode/alpine-selection unless they use selection primitives directly.
License
MIT
