Selection primitives for collections and composites
Motivation
Ariakit already has most of the structural pieces needed for reusable selection. Collection owns ordered item metadata, and Composite adds active item state and keyboard navigation. There is no shared layer that owns selected item IDs, a range anchor, and the interactions that connect them.
There are already overlapping forms of simple multiple selection. Checkbox values can be arrays, and a Combobox becomes multi-selectable when selectedValue is an array. ComboboxItem currently toggles one value on each activation, without interpreting Shift, Command, or Control. ComboboxSelect was introduced to unify Select and Combobox behavior, so a reusable selection layer could continue that consolidation instead of adding another component-specific state machine.
Multiple selection is more than an array of selected IDs. It has separate cursor, anchor, and selection-base state. Exact behavior also differs across native platforms and applications, as documented in One Way to Select Many. That difference was measured rather than assumed for this design: twelve formalized rules, eight of them read out of shipping source or a published algorithm, produce five different answers for the same four clicks. macOS returns {1,2,3,4,6,7}, the web platform and Windows and GTK return {5,6,7}, Qt and KDE Dolphin return {1..10}, VS Code returns {5,6,7} by a different rule, and React Aria returns {1..7}. So this feature defines predictable policies rather than claiming that one algorithm is universally native.
Relevant standards and precedents:
- The WAI-ARIA APG listbox pattern defines both a recommended modifier-free model and an alternative desktop model. It also keeps focus separate from selection.
- The APG grid pattern adds row, column, and cell-range commands, which means range geometry cannot always be inferred from linear DOM order.
aria-multiselectableapplies to concrete roles such aslistbox,grid,tree, andtreegrid, not to the abstractcompositerole. It is not emitted ontablist: in ARIA 1.3 a multi-selectable tablist means several expanded panels, signalled witharia-expandedon the tabs.- Apple and Microsoft document Shift ranges and discontiguous selection, but Microsoft explicitly separates modifier-free
Multiplebehavior from desktop-styleExtendedbehavior. - The HTML select model and MDN documentation for multiple select establish selectedness and common modifiers without specifying one complete cross-browser anchor algorithm.
- React Aria selection separates selection state, collection behavior, and item behavior. Its architecture is useful precedent, though its
extendSelectionis destructive and itstoggleSelectioncarries an unresolved// TODO: move anchor to last selected key, which is the bug class this design avoids.
Status
The API design is settled and nothing is implemented yet. This description is the specification. Decisions are numbered D1 to D21 so they can be cited in review instead of restated.
Facts are marked [measured] when they were observed in a real engine, framework, or type checker, and [verified] when they were read out of the source in this repository. Everything else is derived.
Usage example
const composite = useCompositeSelectableStore({
defaultSelectedIds: [],
selectableBehavior: "replace",
});
<Composite store={composite} role="listbox">
{rows.map((row) => (
<CompositeItem key={row.id} id={row.id} role="option" render={<CompositeSelectable />}>
{row.name}
</CompositeItem>
))}
</Composite>The id is the membership key. It is the only thing an author adds versus a plain composite, and it is the string they already hold on their data.
CompositeSelectable is deliberately separate from CompositeItem, like CompositeHover, so consumers opt individual items into selection while higher-level components can compose both internally. A future GridRow or DataTable row can be selectable by default and expose a prop to disable it.
The provider form creates and provides the same store, and a bare Composite and CompositeItem inside it find the store with no store prop:
<CompositeSelectableProvider defaultSelectedIds={[]}>
<Composite role="listbox">
<CompositeItem id="one" role="option" render={<CompositeSelectable />}>One</CompositeItem>
<CompositeItem id="two" role="option" render={<CompositeSelectable />}>Two</CompositeItem>
</Composite>
</CompositeSelectableProvider>A multi-value Combobox keeps selectedValue and composes the same engine, with no new props on the Combobox store:
<ComboboxProvider defaultSelectedValue={[]}>
<Combobox />
<ComboboxPopover>
<ComboboxItem value="Apple" render={<CompositeSelectable />} />
<ComboboxItem value="Orange" render={<CompositeSelectable />} />
</ComboboxPopover>
</ComboboxProvider>A multi-value ComboboxSelect is the strongest candidate to compose CompositeSelectable internally by default, because it already behaves as a multi-select listbox. Editable multi-value Comboboxes opt into range gestures explicitly, which avoids conflicts with text editing and filtering.
selectableBehavior in simple terms:
"toggle"works like a group of checkboxes. Clicking an unselected item adds it, clicking a selected item removes it, and other selected items are left alone. Command or Control is not required."replace"works like a desktop file manager. An ordinary click selects only that item and clears the previous selection. Command-click on macOS, or Control-click elsewhere, toggles one item without clearing the others.
Shift extends a range in both, but differently: in "toggle" a Shift range is additive and keeps selections outside the range, while in "replace" a plain Shift range becomes the selection.
Decisions
Architecture
D1. Selection is a Composite extension, not a Collection primitive. createCompositeSelectableStore composes createCompositeStore exactly as that composes createCollectionStore. [measured] The composed store is assignable to the agnostic CompositeStore, to CompositeStore<CompositeSelectableStoreItem>, and to the React CompositeStore that composite.tsx and composite-item.tsx declare for their store prop: 0 errors under this repository's own tsc --strict.
D2. Per-item selection is opt-in, composed with render. <CompositeItem render={<CompositeSelectable />} />. CompositeItem is not modified and never selects by default, so the twelve composite components that never select pay nothing.
D3. Ships from @ariakit/react-components only, with no @ariakit/react re-export. Importable and usable, absent from the documented reference surface, so the contract stays unfrozen while it is exercised.
D4. Zero new props on any published component, and nothing added to CompositeStoreItem. Earlier drafts added a selectable field purely to carry the raw disabled signal past trulyDisabled. It is not needed: [verified] useFocusable writes "aria-disabled": disabled || undefined from the raw prop (focusable.tsx:475) while trulyDisabled is used only for the native attribute (:488), and useCompositeItem runs props = useCommand(props) before returning (composite-item.tsx:541), so the merged props handed to the render element carry aria-disabled even for accessibleWhenDisabled items.
Membership
D5. Membership is selectedIds, keyed by author-supplied collection item ids. Item ids are the vocabulary the composite layer already speaks, and an author who wants a useful selectedIds must supply stable ids regardless.
D6. The requirement is documented, not enforced. A guard can detect an id's presence but never its stability: id={row-${index}} passes any check. The failure it would catch is confined to authors who gave their data no ids, for whom selectedIds was already useless. [verified] CollectionRenderer's getItemId returns item.id when the datum has one and synthesizes `${baseId}/${index}` only as a fallback (collection-renderer.tsx:178-185), so an author who puts ids on their data gets stable keys straight through the virtualizer.
D7. No "all" sentinel. A bare "all" cannot survive the first uncheck. An additive selectionBase: "none" | "all" key is the forward path, and it stays free only because selectedIds ships un-widened.
D8. Intra-collection record aliasing is out of scope for v1. One record cannot occupy two rendered rows of one collection under a single check mark. [verified] Two registrations with one id merge into a single entry via { ...prevItem, ...item }, and unmergeItem then filters the survivor out when either unmounts, removing a still-mounted row from items, aria-setsize, and navigation (collection-store.ts:253-320). Every mainstream tree requires unique node ids. Combobox keeps the aliasing it needs through selectedValue.
Naming: two vocabularies
D9. selected* for state, selectable* for the feature. State: selectedIds, defaultSelectedIds, setSelectedIds, data-selected, selectedAttribute. Feature: CompositeSelectable, useCompositeSelectable, useCompositeSelectableStore, CompositeSelectableProvider, selectableMode, selectableBehavior, and the per-item selectable.
D10. The family is Selectable, not Selection. [verified] "Selection" is already taken in this directory: getTextboxSelection is called at composite-item.tsx:497, getDocument(el).getSelection() at composite/utils.ts:378, and a shipped example is titled "Selection Popover". Meanwhile selectable appears nowhere as a public prop, state key, or type name. Selectable also mirrors Focusable, the library's one existing adjective-named behavior primitive. The one exception is the @private controller member on the store, which stays selection because an object named selectable is grammatically wrong; it is internal and does not participate in the rule.
Range engine
D11. Pointer ranges use the web-platform rule. selection := range(anchor, target), everything else wiped. [measured] Blink 151, Gecko 153 and WebKit 26.5 all do this for <select multiple>, driven through Playwright, each result independently explained by that engine's own source (select_type.cc, nsListControlFrame.cpp). Windows, GTK and ChromeOS match.
D12. The anchor is the last non-Shift target, including one it just deselected. Shift never moves it. Near-unanimous across implementations; the exceptions are macOS, which slides the anchor off a deselected item, Chromium's generic WebUI list, which snaps to the nearest still-selected item, and React Aria, which does not move it at all and carries a // TODO: move anchor to last selected key where it should.
D13. Keyboard ranges use base-union, not the pointer wipe. This is the one place the platform disagrees with itself. [measured] From identical state (click(1), shiftClick(3), modClick(5), giving {1,2,3,5} with anchor 5): mouse shiftClick(4) gives {4,5} in all three engines, while Shift+ArrowUp gives {1,2,3,4,5} in Chromium and WebKit and {4,5} in Firefox. Base-union makes a held Shift+Arrow burst stable and reversible; the wipe destroys a group mid-hold and never restores it on reversal.
D14. Shift+Space counts as keyboard. It arrives as a synthesized click, so event.detail === 0 is the discriminator. It is the same discriminator that stops Shift+Enter from reading as a pointer range, which matters because useCommand forwards the real keyboard modifiers into the click it fires.
D15. The additive modifier plus Shift is platform-shaped, per behavior. In replace it adds the range without wiping (Windows, GTK, Gecko). In toggle it subtracts the range, which is the only bulk-deselect gesture the feature has.
D16. selectableMode defaults to "multiple", selectableBehavior to "toggle". Calling the store hook is already the opt-in, so the default is the thing it exists for. "none" freezes the selection rather than erasing it.
D17. selectOnEnter defaults to true. Matches multi-value Combobox's shipped, test-locked behavior. Items whose Enter should activate something instead opt out.
Geometry and scale
D18. The one-source rule: eligibility and order come from the same place. A range delegate when one is installed, treated as authoritative and not filtered further, or renderedItems filtered by the opt-in registry when none is. Never mixed.
D19. Outside the mounted window, eligibility is opt-in from the data. The auto-published delegate filters on item.selectable === true. Inside the window eligibility is composition; outside it only the data can answer, because the opt-in registry is mount-scoped.
D20. Grid ranges use endpoint containment over rowId. A five-line rule at three factory-internal sites, never applied to isSelected, isSelectable, selectAll, or item ARIA.
D21. CompositeStoreFunctions.move widens; the seam overrides move on the composed store. [measured] Widening is source-compatible in both directions across six tsc assertions, and createCompositeStore.move is untouched, because a one-parameter arrow satisfies the widened type. It is not optional: CompositeItem and Composite declare store as the narrow CompositeStore, so the unwidened two-argument call is TS2554.
Public surface
@ariakit/components
// composite/composite-selectable-store.ts
export interface CompositeSelectableStoreState<T> extends CompositeStoreState<T> {
/**
* The ids of the selected items, in selection order.
*
* Each entry is an item's `id`: a stable string you supply from your own data.
* Automatically generated ids are mount-instance counters. They change when an
* item remounts, which filtering a list does to every item in it, so a selection
* keyed on one will silently move to a different record.
*
* Selecting appends. Deselecting removes the id. Ranges keep the relative order
* of ids already selected and append new ids in collection order, never in
* gesture direction.
* @default []
*/
selectedIds: readonly string[];
/**
* Whether the user can select no items, one item, or several. `"none"` freezes
* the selection: items keep their ARIA and `data-selected`, and every mutating
* operation becomes a no-op. It does not erase what is selected.
* @default "multiple"
*/
selectableMode: "none" | "single" | "multiple";
/**
* What an unmodified activation does when several items can be selected.
* `"toggle"` flips one item and leaves the others alone. `"replace"` is the
* desktop model: an ordinary click replaces the selection, and a Shift range
* replaces it with the anchor-to-target range.
* @default "toggle"
*/
selectableBehavior: "toggle" | "replace";
}
export interface CompositeSelectableStoreFunctions<T> extends CompositeStoreFunctions<T> {
setSelectedIds: SetState<readonly string[]>;
/** Whether the item is currently selected. O(1), backed by a private Set mirror. */
isSelected: (id: string) => boolean;
/** Eligibility only. Deliberately independent of `selectableMode`. */
isSelectable: (id: string) => boolean;
select: (id: string) => void;
deselect: (id: string) => void;
toggle: (id: string) => void;
extend: (id: string, options?: { additive?: boolean }) => void;
selectAll: () => void;
deselectAll: () => void;
/**
* @param options.extend Extends the selection from the anchor to `id` instead of
* only moving the cursor. Ignored by stores that carry no selection.
* @param options.anchor Seats the range anchor on `id`. Pass it for user
* navigation; the library's programmatic `move` calls pass nothing.
*/
move: (id?: string | null, options?: { extend?: boolean; anchor?: boolean }) => void;
/** @private The selection engine. Not part of the public API. */
selection: SelectableController;
}
export interface CompositeSelectableStoreOptions<T> extends CompositeStoreOptions<T> {
defaultSelectedIds?: readonly string[];
/**
* Range geometry and order for items the collection has not mounted.
* `CollectionRenderer` publishes one automatically. Must be referentially stable.
*/
rangeDelegate?: SelectableRangeDelegate | null;
}
/**
* Range geometry for a collection whose items are not all mounted. When a delegate
* is installed its answer is authoritative and is not filtered further.
*/
export interface SelectableRangeDelegate {
/** Keys from `fromId` to `toId` inclusive, in logical order. `null` refuses. */
getKeysInRange(fromId: string, toId: string): readonly string[] | null;
/** Every eligible key in the collection, in logical order. */
getOrderedKeys?(): readonly string[] | null;
}
export function createCompositeSelectableStore<T>(
props?: CompositeSelectableStoreProps<T>,
): CompositeSelectableStore<T>;The range anchor is private to the controller. Nothing renders from it, and keeping it unpublished is what lets grid regions and marquee selection arrive additively later.
// composite/composite-store.ts, TYPE ONLY
- move: (id?: string | null) => void;
+ move: (id?: string | null, options?: { extend?: boolean; anchor?: boolean }) => void;@ariakit/react-components
export interface CompositeSelectableOptions<T extends ElementType = "div"> extends Options<T> {
/**
* Object returned by `useCompositeSelectableStore`. If not provided, the closest
* `Composite` or `CompositeSelectableProvider` context is used.
*/
store?: CompositeSelectableStore;
/**
* Whether this item takes part in selection. Prefer this over conditionally
* removing `CompositeSelectable` from `render`: swapping the render element
* replaces the DOM node while the registration effect does not re-run, leaving
* the store pointing at a detached element.
* @default true
*/
selectable?: boolean;
/** Whether activating the item mutates the selection. @default true */
selectOnClick?: BooleanOrCallback<MouseEvent<HTMLElement>>;
/** Whether `Enter` mutates the selection. `Space` always does. @default true */
selectOnEnter?: BooleanOrCallback<KeyboardEvent<HTMLElement>>;
/**
* Which ARIA attribute carries the selected state. Derived from the item's role
* when omitted. Pass `false` to emit neither.
*/
selectedAttribute?: "aria-selected" | "aria-checked" | false;
}Contexts are created with `createStoreContext<CompositeSelectableSto
Source: ariakit/ariakit