@ailura/alpinejs-selection
v0.1.0
Published
Alpine.js headless selection primitives — single/multiple/range on @ailura/alpinejs-core.
Maintainers
Readme
@ailura/alpinejs-selection
Headless single / multiple / range selection — a store of keyed instances, an
x-selectiondirective that owns their lifetime, and aselect(id, key, { behavior })entry point that replaces, toggles and extends on @ailura/alpinejs-core.
Installation
pnpm add @ailura/alpinejs-selection alpinejs
# or
npm install @ailura/alpinejs-selection alpinejsRequires alpinejs@^3.0.0 as peer. @ailura/alpinejs-core is a peer
dependency, not a dependency: no package in this toolkit has a
dependencies block, so the host installs it too.
Usage
This package registers a store, a magic and one directive:
| Registration | Default name | Guard |
| ------------------ | ------------------ | ---------------- |
| $store.selection | "selection" | guardStore |
| $selection | follows storeKey | guardMagic |
| x-selection | "selection" | guardDirective |
1. Alpine — with the directive
The directive creates an instance when its element initialises and destroys it when Alpine removes that element, which is the lifecycle this package asks for:
<div x-data="{ items: ['a', 'b', 'c'], id: $el.dataset.selectionId }">
<ul
x-selection="{ mode: 'multiple', keys: items, disabledKeys: ['c'] }"
role="listbox"
aria-label="Files"
aria-multiselectable="true"
>
<template x-for="key in items" :key="key">
<li role="presentation">
<button
type="button"
role="option"
@click="$store.selection.select(id, key, { behavior: 'toggle' })"
:aria-selected="$store.selection.isSelected(id, key)"
:disabled="!$store.selection.isSelectable(id, key)"
x-text="key"
></button>
</li>
</template>
</ul>
</div>The id reaches the bindings through data-selection-id, which the directive
writes on the element. Reading it as $el.dataset.selectionId in x-data is the
shortest way to hand it to the children; $el.closest('[x-selection]').dataset.selectionId
at each use site works too and does not depend on x-data ordering.
Three forms of the expression — and the "bare id" one is not a bare identifier:
| Expression | Meaning |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| x-selection="'files'" | An explicit id, as a quoted string literal. The quotes are required — see below |
| x-selection="{ id: 'files', mode: 'multiple' }" | The options bag with an explicit id |
| x-selection="{ mode: 'range', keys: items }" | The options bag; without an id one is generated |
| x-selection (no value) | A no-op — not an instance with a generated id. A bare directive would otherwise leak one |
x-selection="files"does not work. An unquoted expression is evaluated as JavaScript, so Alpine resolves a variable namedfilesand throwsReferenceError: files is not definedwhen there is none. This directive does not opt intocreateValueReader'sliteralBareIdentifier, deliberately — the core helper documents that opting in trades awayx-carousel="id"-style expressions that name a real variable. Quote the id, or put it in the options bag asid.
2. Alpine — creating the instance yourself
Alpine.data("picker", () => ({
items: ["a", "b", "c"],
init() {
this.$store.selection.create("files", {
mode: "multiple",
keys: this.items,
disabledKeys: ["c"],
onChange: (detail) => console.log(detail.selectedKeys),
});
},
teardown() {
// Pair this with create(), or `instances` grows one entry per navigation.
this.$store.selection.destroy("files");
},
}));The hand-written form is unchanged by the directive and is what you want when the instance outlives the element that declared it.
3. Standalone (framework-agnostic)
import { createSelectionController } from "@ailura/alpinejs-selection";
const ctrl = createSelectionController(); // already mounted
ctrl.create("rows", { mode: "range", keys: ["a", "b", "c", "d"] });
ctrl.select("rows", "b"); // { behavior: 'replace' } → value { from: "b" }
ctrl.setAnchor("rows", "b");
ctrl.select("rows", "d", { behavior: "extend" }); // value { from: "b", to: "d" }
ctrl.getSnapshot("rows").selectedKeys; // ['b', 'c', 'd'] — the closed span
const off = ctrl.on("change", (detail) => {
detail.id; // "rows"
detail.mode; // 'single' | 'multiple' | 'range'
detail.value; // the mode-shaped value
detail.selectedKeys; // the resolved key list — what a template should bind
detail.previous; // the value before this call
});
ctrl.on("destroy", ({ id }) => console.log(id, "is gone"));
off();
ctrl.destroy(); // destroys every instance and all subscriptionsAPI
| Export | Description | Type |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------- | ---------- |
| SelectionController | Controller class — owns the instance registry, emits change and destroy | class |
| createSelectionController | Factory — (options?) => SelectionController; mounts before returning | function |
| selectionPlugin | Alpine.plugin() factory — (options?) => (alpine) => void; also the package's default export | function |
| DEFAULT_SELECTION_STORE_KEY | Default $store key — "selection" | string |
| DEFAULT_SELECTION_MAGIC_KEY | Default $selection key — the same constant as the store key | string |
| CreateSelectionOptions | Plugin options — { id, storeKey, magicKey, directiveKey } | type |
| SelectionControllerOptions | Factory options — { id } | type |
| SelectionStore | The full $store.selection surface — instances plus 19 commands | type |
| SelectionInstance | Readonly snapshot — { mode, value, keys, disabledKeys, anchorKey, activeKey, selectedKeys, allowDisabledSelection } | type |
| SelectionOptions | Instance options — { mode, keys, disabledKeys, allowDisabledSelection, value, defaultValue, onChange } | type |
| SelectionSelectOptions | { behavior?: 'replace' \| 'toggle' \| 'extend' } | type |
| SelectionMode | 'single' \| 'multiple' \| 'range' | type |
| SelectionBehavior | 'replace' \| 'toggle' \| 'extend' | type |
| SelectionKey | string \| number — a key is compared as a string throughout | type |
| SelectionValue | Mode-shaped value: SelectionKey \| null \| readonly SelectionKey[] \| SelectionRange | type |
| SelectionRange | { from: SelectionKey; to?: SelectionKey } | type |
| SelectionChangeDetail | change payload — { id, mode, value, selectedKeys, previous } | type |
| SelectionDestroyDetail | destroy payload — { id } | type |
| SelectionEvents | Event map — change, destroy | type |
| SelectionPluginCallback | (alpine: Alpine) => void — the Alpine.plugin() callback signature | type |
| SelectionAlpine | Alias for the Alpine type the plugin callback receives | type |
SelectionDirectiveOptions (SelectionOptions & { id?: string }) is declared in
src/types.ts but not exported from the barrel — see
Limitations. So is DEFAULT_SELECTION_DIRECTIVE_KEY.
SelectionController also exposes hasInstance(id), getSnapshot(id),
snapshot(id), snapshotInstances(), and the alias toStoreInstances().
Modes and the value each one holds
The value is mode-shaped. That is the point of the mode — a consumer of the
snapshot does not have to branch — but it also means a value set in the wrong
mode is coerced, not rejected:
| Mode | value shape | Empty value | selectedKeys |
| ---------- | ------------------------- | ----------- | --------------------------------------- |
| single | SelectionKey \| null | null | [key] or [] |
| multiple | readonly SelectionKey[] | [] | the array, filtered to keys |
| range | SelectionRange \| null | null | the closed span between the indices |
selectedKeys is the field to bind. For range it is derived from the positions
of from/to in keys, so a range over a reordered list moves with the list, and
a range whose endpoints are no longer in keys resolves to [].
Store API — $store.selection
instances is a reactive registry, one SelectionInstance per id, rewritten
from the controller on every change.
$store.selection.instances.files?.mode; // 'single' | 'multiple' | 'range'
$store.selection.instances.files?.value; // mode-shaped value
$store.selection.instances.files?.selectedKeys; // readonly — bind this
$store.selection.instances.files?.keys; // the whitelist, as given
$store.selection.instances.files?.disabledKeys; // the blacklist, as given
$store.selection.instances.files?.anchorKey; // null until replace/toggle/extend
$store.selection.instances.files?.activeKey; // null until setActive/select
$store.selection.instances.files?.allowDisabledSelection; // boolean| Command | Behaviour |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| create(id, options?) | Creates an instance. mode defaults to 'single'. Re-creating an id replaces it, emitting change |
| destroy(id) | Removes one instance and emits destroy. An unknown id is a silent no-op |
| destroyAll() | Removes every instance, emits destroy for each, and destroys the controller |
| setKeys(id, keys) | Replaces the key set, then prunes the value down to what still exists. anchorKey/activeKey are cleared if they no longer resolve |
| setDisabledKeys(id, keys) | Replaces the disabled set. Does not re-check the current value |
| setMode(id, mode) | Switches mode, coercing the value. A no-op when the mode is unchanged |
| setValue(id, value) | Sets the value, coerced to the mode. The way to restore a value read from a URL |
| select(id, key, { behavior }) | The entry point. behavior defaults to 'replace'; dispatches to the three named commands |
| replace(id, key) | Selects key alone; sets both anchorKey and activeKey. Refuses unknown and disabled keys |
| toggle(id, key) | single: null ↔ key. multiple: add/remove. range: collapse the span to { from: key }, or null if already in the span |
| extend(id, key) | Extends from anchorKey (or key when there is none). Honours disabled keys |
| clear(id) | null / [], and clears anchorKey. activeKey is not cleared |
| selectAll(id) | Every key that is not disabled. In single and range mode that is the first enabled key, not everything |
| setActive(id, key \| null) | Sets the roving/active marker |
| setAnchor(id, key \| null) | Sets the range anchor |
| getSnapshot(id) | The controller's own snapshot — throws if the id is unknown, unlike the registry read |
| isSelected(id, key) | true when key is in selectedKeys. Reads the registry, so it re-renders |
| isSelectable(id, key) | false for a disabled key (unless allowDisabledSelection), and for an unknown key when keys is non-empty |
| isActive(id, key) | true when key is activeKey. Reads the registry |
| isAnchor(id, key) | true when key is anchorKey. Reads the registry |
isSelected, isActive and isAnchor are the three that read
instances[id] rather than the controller. That is deliberate — they go through
the reactive proxy so a template re-renders — and it is why they return false
rather than throwing for an unknown id, and why they are function expressions
using this and must be called as $store.selection.isSelected(...), never
detached.
Options
interface CreateSelectionOptions {
id?: string; // controller id — defaults to generateId("selection")
storeKey?: string; // $store key — default DEFAULT_SELECTION_STORE_KEY
magicKey?: string; // $magic key — default magicKey ?? storeKey
directiveKey?: string; // x- directive name, without x- — default "selection"
}type SelectionOptions = {
mode?: SelectionMode; // default 'single'
keys?: readonly SelectionKey[]; // default []
disabledKeys?: readonly SelectionKey[]; // default []
allowDisabledSelection?: boolean; // default false
value?: SelectionValue; // wins over defaultValue
defaultValue?: SelectionValue; // used when `value` is absent
onChange?: (detail: SelectionChangeDetail) => void;
};| Option | Default | Effect |
| ------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------- |
| mode | 'single' | Decides the shape of value and of every command's effect |
| keys | [] | The universe. A command on a key not in keys is a silent no-op, so this is the list you must keep current |
| disabledKeys | [] | Refused by replace, extend, selectAll and isSelectable |
| allowDisabledSelection | false | true disables the disabled-key guard — use it for a "select all, disabled rows included" case |
| value | — | Seeded and normalized through normalizeValue for the mode |
| defaultValue | — | Read only when value is absent. With neither, the empty value for the mode |
| onChange | — | Called with the same detail as the change event, after the event is emitted |
keys is a whitelist, not an index. Nothing in this package re-derives it from
the DOM: if the rendered list grows, setKeys must be called.
Avoiding name collisions
Alpine.plugin(selectionPlugin({ storeKey: "files", directiveKey: "file-selection" }));
// → $store.files, $files, x-file-selectionmagicKey follows storeKey — renaming the store renames both, resolved with
resolvePluginKeys, so one storeKey is enough to move the package off a
collided name. All three registrations go through guardStore / guardMagic /
guardDirective, so a collision throws a RegistrationError rather than
silently overwriting. Note that DEFAULT_SELECTION_MAGIC_KEY and
DEFAULT_SELECTION_STORE_KEY are the same constant, while
DEFAULT_SELECTION_DIRECTIVE_KEY is a separate literal that is not exported
— the default directive name is "selection", documented here rather than
discoverable from the package.
Events
import type { SelectionChangeDetail, SelectionDestroyDetail } from "@ailura/alpinejs-selection";
ctrl.on("change", (detail: SelectionChangeDetail) => {
detail.id; // the instance id
detail.mode;
detail.value; // mode-shaped
detail.selectedKeys; // resolved — bind this
detail.previous; // the value before this command
});
ctrl.on("destroy", (detail: SelectionDestroyDetail) => {
detail.id; // removed
});destroy is a separate event because a change detail describes an instance's
state, and by the time a destroy is announced there is no state left to describe.
Without it there is no way to tell a listener an id is gone. Every mutator
except destroy/destroyAll emits change — including setActive and
setAnchor, which do not change the selection at all. A host that re-renders on
every change will therefore see events from pure focus movement.
SSR
SSR-safe — no
window/documentat import time. Nothing here touches the DOM; the controller is pure state plus a typed event emitter.
State is in-memory, so a server render produces nothing: $store.selection.instances
is {} and every command throws (selection instance "<id>" not found) because
invariant guards each one. Create the instance on the client — the
x-selection directive does this at init, or call create() from x-init.
Keys come from your data, so x-selection="{ keys: items }" is only correct if
items is populated by the time the element initialises. x-init runs after
x-selection is registered, so seeding the list in x-init leaves the instance
with keys: [] — pass the keys through $nextTick, or create the instance by
hand with setKeys() once the data lands.
Accessibility
This is a
Primitivespackage and ships no ARIA and no key bindings. It is a selection primitive, and the right widget role depends on the host: the same single/multiple/range value can back alistbox, a grid ofrole="checkbox"tiles, a file table, or shift-click range selection — which has no ARIA pattern at all. Claimingrole="listbox"here would be wrong for most of them.
The read helpers are what a host binds:
<button
role="option"
:aria-selected="$store.selection.isSelected('files', key)"
:aria-disabled="!$store.selection.isSelectable('files', key)"
@click="$store.selection.select('files', key, { behavior: 'toggle' })"
x-text="key"
></button>Bind each attribute individually. An object-form x-bind is evaluated once,
so an object carrying aria-selected freezes at its init value — which is why
this package offers no itemProps()/listProps() helper to spread, and why
there is nothing to configure here.
Keyboard handling is the host's. setActive and setAnchor exist so a host can
drive a roving tabindex or aria-activedescendant from its own key handling;
nothing in this package listens for a key. A selection widget also needs
role="listbox", aria-multiselectable, and per-option aria-selected for the
value to be announced — write them in your markup.
Integration
- @ailura/alpinejs-collection —
setActiveKey(id, key)on a collection andsetActive(id, key)on a selection instance are the same idea at different levels. A keyboard list is usually a collection (filter, sort, page) whose rows are a selection;CollectionController.isSelected()takes a selection instance'sselectedKeysstructurally. - @ailura/alpinejs-virtual — virtualising a selectable list means
keyshas to describe the whole list while the DOM shows a window.selectedKeysresolves againstkeys, not against rendered rows, so paging does not lose the selection.
Limitations
destroyAll()destroys the controller. It clears every instance and then callssuper.destroy(), after which every mutator is a silent no-op and the$store.selectionregistry is frozen for the life of the page. Usedestroy(id)per instance unless you mean to tear the store down.toggle()ignoresdisabledKeys.replace()andextend()refuse a disabled key andisSelectable()reports it asfalse, buttoggle()only checkskeys. A host gating onisSelectable()is safe; a host callingtoggle()without that gate is not.selectAll()insingleandrangemode selects only the first enabled key. That is not a bug — the mode forbids more — but the name reads as "everything", so aselectAllbutton in a single-select list is confusing.clear()leavesactiveKeyset. It clears the value andanchorKey, not the roving marker, so a list with no selection can still report an active row.setDisabledKeys()does not re-validate the current value. A key already selected stays selected after being disabled;isSelected()keeps returningtrue.getSnapshot(id)throws on an unknown id whileisSelected(id, key)and the registry read returnfalse. The mix is deliberate — a command needs an instance, a predicate is asked about every row — but it means a template usinggetSnapshot()on an uncreated id throws inside an Alpine effect.isSelectable()returnstruefor every key whilekeysis empty. The whitelist check is skipped when there are no keys at all, so an instance created withoutkeys— and beforesetKeys()— reports every row as selectable. The commands disagree: they do require membership, so they no-op. Seedkeysatcreate()if you bindisSelectable().x-selection="files"throws. An unquoted expression is evaluated as JavaScript, and Alpine resolvesfilesas a variable — aReferenceErrorwhen there is none. The correct spellings arex-selection="'files'"(a quoted string literal) andx-selection="{ id: 'files' }".CreateSelectionOptions.directiveKeyand the comment abovecreateValueReaderinplugin.tsboth sayx-selection="files"names an explicit id, which is wrong.x-selectionre-creates on every expression change. The directive tears down and recreates the instance whenever its expression re-evaluates to a new value, which means a reactivekeys: itemsre-seeds the instance — resetting the selection. That is why a list bound through the directive should pass a stable expression or usesetKeys()on a hand-created instance.x-selectiondestroys the instance on teardown, and it is the only mechanism that runs. An Alpine store registration has no Alpine-invoked disposer in Alpine 3.17; the directive's owncleanup()is what the runtime drains when the element leaves the tree. A hand-writtencreate()gets nothing, which is the leak the docs warn about.pnpm run typecheckfails in this package's test folder. One error, pre-existing and not insrc/:test/no-widget-claims.test.ts:38calls(fn as (id: string, k: string) => unknown)(controller, "s", "a")— three arguments against a two-argument signature. The guard it is asserting (that noitemProps/listProps-shaped method emits ARIA) still runs correctly, because the loop'stypeof fn !== "function"check means it never reaches that call for any method that does not exist.vp testis green; onlytsccomplains.SelectionDirectiveOptionsandDEFAULT_SELECTION_DIRECTIVE_KEYare not exported fromsrc/index.ts. Both are declared and both are used — the directive's type argument and thedirectiveKeydefault — but neither reaches the barrel, so a consumer cannot name the directive's expression type or read the default directive key as a constant.toStoreInstances()andtoKeyString()are internal. Both are exported fromsrc/controller.tsand used byplugin.ts, but neither is in the barrel.- Keys are compared as strings.
SelectionKeyisstring | number, but every comparison goes throughtoKeyString, socreate({ keys: [1, 2] })andselect(id, '1')agree.selectedKeysis alwaysstring[]. - The
rangemode's span includes disabled keys.extend()only checks its endpoint, so a range fromatodover a list wherebis disabled yields['a','b','c','d']withbin it. Filtering the disabled keys out of the rendered range is the host's.
Size
7.67 kB raw / 2.44 kB gzip · budget 4 kB · externalized peers: alpinejs, @ailura/alpinejs-core · size-limit + publint + attw verified.
Architecture
Primitives layer — controllers own state, Alpine owns reactivity. See canon, guards, and SSR rules in ARCHITECTURE.md.
Testing
pnpm test # vp test (happy-dom)
pnpm run typecheck # tsc --noEmitLicense
MIT
