Foundations

Selection

Identifying the items an action applies to: one, several, or a range.

Markdown

Selection identifies the items an action will apply to. It is distinct from focus, which identifies the one item that keyboard input goes to. The two often coincide, but a collection that supports multiple selection must let them part.

The selection model

Click, Command-click and Shift-click, or tap. The diamond marks the anchor; a dashed outline marks what a range adds to. G cannot be selected.

Mode
Behaviour

selected — · anchor — · base —

Code
"use client";

import { Radio, RadioGroup } from "@publictheta/ui/components/radio-group/radio-group.tsx";
import { isCommandKey } from "@publictheta/ui/lib/platform.ts";
import {
  applyIntent,
  inOrder,
  pointerIntent,
  selectAll,
  selectionState,
} from "@publictheta/ui/primitives/selection.ts";
import type {
  SelectionBehavior,
  SelectionMode,
  SelectionState,
} from "@publictheta/ui/primitives/selection.ts";
import { useState } from "react";
import { Controls, Readout } from "../../ui/controls.tsx";

const ITEMS = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"];
const DISABLED = new Set(["G"]);
const ORDER = ITEMS.filter((item) => !DISABLED.has(item));

function describe(keys: string[]): string {
  if (keys.length === 0) return "—";
  const runs: string[][] = [];
  for (const key of keys) {
    const last = runs.at(-1);
    if (last && ITEMS.indexOf(key) === ITEMS.indexOf(last.at(-1)!) + 1) last.push(key);
    else runs.push([key]);
  }
  return runs.map((run) => (run.length > 2 ? `${run[0]}–${run.at(-1)}` : run.join(", "))).join(", ");
}

export default function SelectionModel() {
  const [mode, setMode] = useState<SelectionMode>("multiple");
  const [behavior, setBehavior] = useState<SelectionBehavior>("replace");
  const [state, setState] = useState<SelectionState>(() => selectionState());

  const options = { mode, order: ORDER };

  return (
    <div className="w-full">
      <Controls>
        <RadioGroup
          label="Mode"
          size="small"
          value={mode}
          onValueChange={(next) => {
            setMode(next);
            setState(selectionState());
          }}
        >
          <Radio value="single">Single</Radio>
          <Radio value="multiple">Multiple</Radio>
        </RadioGroup>
        <RadioGroup label="Behaviour" size="small" value={behavior} onValueChange={setBehavior}>
          <Radio value="replace">Replace</Radio>
          <Radio value="toggle">Toggle</Radio>
        </RadioGroup>
      </Controls>

      <div className="mx-auto flex max-w-xl flex-wrap justify-center gap-2 select-none">
        {ITEMS.map((item) => {
          const disabled = DISABLED.has(item);
          const selected = state.selected.has(item);
          const anchor = state.anchor === item;
          const base = state.base.has(item) && mode === "multiple";
          return (
            <button
              key={item}
              type="button"
              disabled={disabled}
              aria-pressed={selected}
              onPointerDown={(event) => {
                if (event.button !== 0) return;
                const intent = pointerIntent(
                  {
                    shiftKey: event.shiftKey,
                    commandKey: isCommandKey(event),
                    pointerType: event.pointerType,
                  },
                  mode,
                  behavior,
                );
                setState((current) => applyIntent(current, intent, item, options));
              }}
              onClick={(event) => {
                // Keyboard activation (Space, Enter) arrives as a click with no pointer.
                if (event.detail !== 0) return;
                const intent = pointerIntent(
                  { shiftKey: event.shiftKey, commandKey: isCommandKey(event), pointerType: "mouse" },
                  mode,
                  behavior,
                );
                setState((current) => applyIntent(current, intent, item, options));
              }}
              className={[
                "relative grid size-12 place-items-center rounded-lg border text-sm font-medium",
                "focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none",
                selected
                  ? "border-transparent bg-selected text-on-selected"
                  : "border-border-muted bg-surface text-fg hover:bg-surface-muted",
                base && !selected ? "border-dashed border-selected" : "",
                disabled ? "cursor-not-allowed text-fg-disabled opacity-60" : "",
              ].join(" ")}
            >
              {item}
              {anchor && (
                <span
                  aria-label="anchor"
                  className="absolute -bottom-1.5 left-1/2 size-2.5 -translate-x-1/2 rotate-45 rounded-[2px] bg-accent ring-2 ring-surface"
                />
              )}
            </button>
          );
        })}
      </div>

      <div className="mt-6 flex justify-center gap-2">
        <button
          type="button"
          disabled={mode === "single"}
          onClick={() => setState((current) => selectAll(current, ORDER))}
          className="rounded-md px-2.5 py-1 text-xs text-fg-muted hover:bg-surface-muted hover:text-fg-strong disabled:opacity-40"
        >
          Select all
        </button>
        <button
          type="button"
          onClick={() => setState(selectionState())}
          className="rounded-md px-2.5 py-1 text-xs text-fg-muted hover:bg-surface-muted hover:text-fg-strong"
        >
          Clear
        </button>
      </div>

      <Readout>
        selected {describe(inOrder(state.selected, ORDER))} · anchor {state.anchor ?? "—"} · base{" "}
        {describe(inOrder(state.base, ORDER))}
      </Readout>
    </div>
  );
}

Best practices

Make the selection unmistakable. A selected item should stand apart from its neighbours at a glance. When the collection loses focus, keep the selection visible in a subdued form, so people can still tell what an action will affect.

Keep focus legible on selected and unselected items alike. In a multiple selection, people move between items without changing what is selected; the focus indicator must remain distinct against both appearances.

Keep several selected items through a press. A press on one of several selected items leaves them all selected until it ends as a click on that one, so that a drag can begin from it with all of them.

Let a drag draw a range. A press on an item that is not selected, dragged, selects from that item to wherever the pointer is, and follows the pointer back as it returns. With Command held the range adds to what was selected. A drag from a selected item draws no range: it is the selection's, to be carried. Keep the drag when the pointer leaves the collection, and scroll while it is held past an edge — at a walk just outside, faster the further out, up to a speed the eye can follow.

Give back what a cancelled gesture took. When the platform takes a gesture back — to scroll, or for a gesture of its own — the selection returns to what it was before the press.

Let a secondary press select what it lands on. A secondary press on an item outside the selection selects it; on an item within the selection it changes nothing, so that a menu opened there acts on the whole selection.

Extend from where focus is. When a selection was not made by a gesture — restored, or given by the application — Shift with an arrow extends it from the focused item.

Change the selection only in response to a selection gesture. Scrolling, resizing, filtering the view or switching away should leave the selection as it was.

Apply an action to the whole selection. Commands such as move, share or delete act on every selected item. When the number is not evident, state it in the command or its confirmation.

Offer multiple selection where acting on several items is a real task. Managing files and messages benefits from it; choosing a single setting does not. Each additional gesture is something people must learn.

Conventions

IntentPointerKeyboardTouch
Select one itemClickArrow keysTap
Add or remove one itemCommand-clickCommand with arrow keys to move, then SpaceTap, in a multiple selection
Select a rangeShift-click, or drag from an unselected itemShift with arrow keys, Page Up, Page Down, Home or EndLong press, then move without lifting
Begin a selection——Long press, in a multiple selection
Select all—Command-A—
Ask for a menuSecondary clickThe platform's menu keyLong press, in a single selection

Shift with Home or End extends to that end whether or not Command is held.

Command is ⌘ on Apple platforms and Ctrl elsewhere. On a Mac, Control-click opens a contextual menu and is never a selection gesture.

The anchor

A range extends from the anchor — the item most recently selected by a click or Command-click — to the item indicated. Extending again redraws the range from the same anchor rather than adding a second range, so people can adjust a range until it is right. A range adds to whatever was selected before it began, which is what lets Command-click and Shift-click combine.

Touch

A finger has no modifier keys, no secondary button and no double click, so the same intents take other forms.

A long press — about half a second without moving — is heard as the platform's request for a contextual menu, and is raised by the collection itself on platforms that raise none. In a single selection it does what a secondary press does: selects the item if it was not selected, and leaves a menu to open on it. In a multiple selection it begins selecting: the item is added, the rest are kept, and while the finger stays down the range from that item follows it, the collection holding still beneath. A long press on an item already selected changes nothing. The tap that would follow a long press is not heard as a tap.

A finger that moves before its press is long is scrolling, and selects nothing.

A tap selects; where selecting would change nothing and items have an action, it performs the action: on the selected item of a single selection, and in a multiple selection that replaces while nothing is selected.

Disabled items

An item that cannot be selected is never selected indirectly: a range passes over it, and Select All leaves it out.

Selection behaviours

Replace. A selection gesture replaces the selection; modifier keys add to it, and selection follows keyboard focus. This is the convention of file browsers and most collections people act on.

Toggle. Every selection gesture adds or removes one item, in the manner of checkboxes, and focus moves independently. It suits collections where choosing several items is the common case, such as a set of filters.

Behaviour

Single

Every intent selects only the key
for (const intent of ["replace", "toggle", "extend"] as const) {
  expect(keys(run(single, ["replace", "a"], [intent, "c"]))).toEqual(["c"]);
}
Toggling the selected key clears the selection
expect(keys(run(single, ["replace", "b"], ["toggle", "b"]))).toEqual([]);
disallowEmpty keeps the selected key when it is toggled
const options = { ...single, disallowEmpty: true };
expect(keys(run(options, ["replace", "b"], ["toggle", "b"]))).toEqual(["b"]);

Multiple

replace selects only the key and makes it the anchor
const state = run(multiple, ["toggle", "a"], ["toggle", "b"], ["replace", "d"]);
expect(keys(state)).toEqual(["d"]);
expect(state.anchor).toBe("d");
toggle adds or removes the key and keeps the rest
expect(keys(run(multiple, ["replace", "a"], ["toggle", "c"]))).toEqual(["a", "c"]);
expect(keys(run(multiple, ["replace", "a"], ["toggle", "c"], ["toggle", "a"]))).toEqual(["c"]);
extend selects every key between the anchor and the key, in either direction
expect(keys(run(multiple, ["replace", "b"], ["extend", "d"]))).toEqual(["b", "c", "d"]);
expect(keys(run(multiple, ["replace", "d"], ["extend", "b"]))).toEqual(["b", "c", "d"]);
Extending again redraws the range from the same anchor
expect(keys(run(multiple, ["replace", "c"], ["extend", "e"], ["extend", "a"]))).toEqual([
  "a",
  "b",
  "c",
]);
A range adds to what was selected before it
expect(keys(run(multiple, ["replace", "a"], ["toggle", "c"], ["extend", "e"]))).toEqual([
  "a",
  "c",
  "d",
  "e",
]);
extend with no anchor selects only the key
expect(keys(run(multiple, ["extend", "c"]))).toEqual(["c"]);
disallowEmpty keeps the last selected key when it is toggled
const options = { ...multiple, disallowEmpty: true };
expect(keys(run(options, ["replace", "b"], ["toggle", "b"]))).toEqual(["b"]);
expect(keys(run(options, ["replace", "b"], ["toggle", "c"], ["toggle", "b"]))).toEqual(["c"]);
selectAll selects every key and keeps the anchor
const state = selectAll(run(multiple, ["replace", "c"]), ORDER);
expect(keys(state)).toEqual(ORDER);
expect(state.anchor).toBe("c");
A key outside the order is ignored
const before = run(multiple, ["replace", "a"]);
for (const intent of ["replace", "toggle", "extend"] as const) {
  expect(applyIntent(before, intent, "z", multiple)).toBe(before);
}
A range never includes a key outside the order, such as a disabled one
const options = { mode: "multiple" as const, order: ["a", "b", "d"] };
expect(inOrder(run(options, ["replace", "a"], ["extend", "d"]).selected, ORDER)).toEqual(["a", "b", "d"]);

Pointer gestures

A plain click replaces; Shift extends; the command key toggles
expect(pointerIntent(mouse, "multiple", "replace")).toBe("replace");
expect(pointerIntent({ ...mouse, shiftKey: true }, "multiple", "replace")).toBe("extend");
expect(pointerIntent({ ...mouse, commandKey: true }, "multiple", "replace")).toBe("toggle");
Toggle behaviour toggles on a plain click
expect(pointerIntent(mouse, "multiple", "toggle")).toBe("toggle");
A touch toggles, since it has no modifier keys
expect(pointerIntent({ ...mouse, pointerType: "touch" }, "multiple", "replace")).toBe("toggle");
In a single selection, only the command key toggles
expect(pointerIntent({ ...mouse, shiftKey: true }, "single", "replace")).toBe("replace");
expect(pointerIntent({ ...mouse, commandKey: true }, "single", "replace")).toBe("toggle");

Taps

In a single selection, a tap acts on the selected item and selects any other
expect(tapActs(new Set(["a"]), "a", "single", "replace")).toBe(true);
expect(tapActs(new Set(["a"]), "b", "single", "replace")).toBe(false);
expect(tapActs(new Set(), "a", "single", "toggle")).toBe(false);
In a multiple selection that replaces, a tap acts only while nothing is selected
expect(tapActs(new Set(), "a", "multiple", "replace")).toBe(true);
expect(tapActs(new Set(["b"]), "a", "multiple", "replace")).toBe(false);
expect(tapActs(new Set(["a"]), "a", "multiple", "replace")).toBe(false);
In a multiple selection that toggles, a tap never acts
expect(tapActs(new Set(), "a", "multiple", "toggle")).toBe(false);

Drags

The item at a position is the one that holds it
expect([0, 29, 30, 59, 70, 129].map(at)).toEqual([0, 0, 1, 1, 2, 3]);
A position between two items is the nearer one's
expect([61, 64, 66, 69].map(at)).toEqual([1, 1, 2, 2]);
A position past either end is the item's at that end
expect(at(-500)).toBe(0);
expect(at(130)).toBe(3);
expect(at(9000)).toBe(3);
Where there are no items there is none to find
expect(itemAt(0, () => ({ start: 0, end: 0 }), 10)).toBe(-1);
A drag is a range redrawn from where it began, so it follows the pointer back
const pressed = applyIntent(selectionState(["e"]), "toggle", "b", multiple);
const out = applyIntent(pressed, "extend", "d", multiple);
expect(keys(out)).toEqual(["b", "c", "d", "e"]);
const back = applyIntent(out, "extend", "a", multiple);
expect(keys(back)).toEqual(["a", "b", "e"]);
Among ten thousand items, finding the one at a position measures at most sixteen
const extents = counted(
  Array.from({ length: 10_000 }, (_, index): Extent => ({ start: index * 30, end: index * 30 + 28 })),
);
for (const position of [0, 29, 4999 * 30 + 29, 123_456, 299_999, 1e9]) {
  extents.reset();
  const found = itemAt(10_000, (index) => extents.array[index]!, position);
  expect(extents.accesses()).toBeLessThanOrEqual(16);
  expect(found).toBe(Math.min(9999, Math.floor(position / 30)));
}
Held past an edge, a drag scrolls faster the further out it is, up to a limit
expect(autoScrollSpeed(0)).toBe(0);
expect(autoScrollSpeed(-20)).toBe(0);
expect(autoScrollSpeed(1)).toBeGreaterThan(0);
expect(autoScrollSpeed(40)).toBeGreaterThan(autoScrollSpeed(10));
expect(autoScrollSpeed(10_000)).toBe(autoScrollSpeed(100_000));

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/selection.json
Source

src/primitives/selection.ts

/**
 * The selection model shared by every collection that selects: lists, trees,
 * grids, canvases. It is pure — keys in, keys out — so it works the same over a
 * DOM collection or a virtualised one with a million rows.
 *
 * Besides the selected keys it tracks the *anchor* (where a range starts) and
 * the *base* (what was selected before the current range), which is what lets
 * ⌘-click then Shift-click add a range to an existing selection, and a second
 * Shift-click re-draw that range instead of adding another.
 */

import type { Extent } from "./navigation.ts";

export type SelectionMode = "single" | "multiple";

/**
 * `replace` — the file-browser convention: a click or an arrow selects one item, and
 * modifiers add to it. `toggle` — every click adds or removes, like checkboxes.
 */
export type SelectionBehavior = "replace" | "toggle";

export type SelectionIntent = "replace" | "toggle" | "extend";

export interface SelectionState {
  readonly selected: ReadonlySet<string>;
  readonly anchor: string | null;
  readonly base: ReadonlySet<string>;
}

export interface SelectionOptions {
  readonly mode: SelectionMode;
  /** The keys that can be selected, in collection order. */
  readonly order: readonly string[];
  readonly disallowEmpty?: boolean;
}

const EMPTY: ReadonlySet<string> = new Set();

export function selectionState(selected: Iterable<string> = []): SelectionState {
  return { selected: new Set(selected), anchor: null, base: EMPTY };
}

function only(key: string): SelectionState {
  return { selected: new Set([key]), anchor: key, base: EMPTY };
}

/** Apply an intent to `key`. Returns the same state when nothing changes. */
export function applyIntent(
  state: SelectionState,
  intent: SelectionIntent,
  key: string,
  { mode, order, disallowEmpty = false }: SelectionOptions,
): SelectionState {
  if (!order.includes(key)) return state;
  const has = state.selected.has(key);

  if (mode === "single" || intent === "replace") {
    if (intent === "toggle" && has) {
      return disallowEmpty ? state : { selected: EMPTY, anchor: key, base: EMPTY };
    }
    return only(key);
  }

  if (intent === "toggle") {
    if (has && disallowEmpty && state.selected.size === 1) return state;
    const selected = new Set(state.selected);
    if (has) selected.delete(key);
    else selected.add(key);
    return { selected, anchor: key, base: selected };
  }

  const from = state.anchor === null ? -1 : order.indexOf(state.anchor);
  if (from === -1) return only(key);
  const to = order.indexOf(key);
  const selected = new Set(state.base);
  for (let i = Math.min(from, to); i <= Math.max(from, to); i += 1) selected.add(order[i]!);
  return { selected, anchor: state.anchor, base: state.base };
}

export function selectAll(state: SelectionState, order: readonly string[]): SelectionState {
  return { selected: new Set(order), anchor: state.anchor, base: EMPTY };
}

/** The selected keys in collection order — the order a consumer should receive them in. */
export function inOrder(selected: ReadonlySet<string>, order: readonly string[]): string[] {
  return order.filter((key) => selected.has(key));
}

export function sameSelection(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
  return a.size === b.size && [...a].every((key) => b.has(key));
}

export interface PointerGesture {
  readonly shiftKey: boolean;
  /** The platform's command key (⌘ / Ctrl). */
  readonly commandKey: boolean;
  readonly pointerType: string;
}

/**
 * What a press on an item means. A touch has no modifier keys, so in a multiple
 * selection a tap adds or removes — otherwise there would be no way to select
 * more than one item by touch.
 */
export function pointerIntent(
  { shiftKey, commandKey, pointerType }: PointerGesture,
  mode: SelectionMode,
  behavior: SelectionBehavior,
): SelectionIntent {
  if (mode === "single") return commandKey ? "toggle" : "replace";
  if (shiftKey) return "extend";
  if (commandKey || behavior === "toggle" || pointerType !== "mouse") return "toggle";
  return "replace";
}

/**
 * Whether a tap performs an item's action rather than selecting. A finger has
 * no double click, so where selecting would change nothing the tap acts: on the
 * selected item of a single selection; and, in a multiple selection that
 * replaces, while nothing is selected — a long press begins a selection there,
 * and taps add to it until it is empty again.
 */
export function tapActs(
  selected: ReadonlySet<string>,
  key: string,
  mode: SelectionMode,
  behavior: SelectionBehavior,
): boolean {
  if (mode === "single") return selected.has(key);
  return behavior === "replace" && selected.size === 0;
}

/**
 * The index of the item at `position` along the axis the items run on — the one
 * a drag has reached. Items lie in order along it, so a search measures few of
 * them however many there are. A position between two items, or past either
 * end, is the nearest item's. `-1` when there are no items.
 */
export function itemAt(count: number, extent: (index: number) => Extent, position: number): number {
  if (count === 0) return -1;
  // The first item that ends after the position.
  let low = 0;
  let high = count;
  while (low < high) {
    const middle = (low + high) >>> 1;
    if (extent(middle).end > position) high = middle;
    else low = middle + 1;
  }
  if (low === count) return count - 1;
  if (low === 0) return 0;
  const { start } = extent(low);
  if (position >= start) return low;
  return position - extent(low - 1).end <= start - position ? low - 1 : low;
}

const AUTO_SCROLL_SLOWEST = 180;
const AUTO_SCROLL_FASTEST = 3000;
const AUTO_SCROLL_GAIN = 24;

/**
 * How fast a drag held `distance` pixels past the edge of its scrolling view
 * scrolls it, in pixels a second: at a walk just outside, faster the further
 * out, up to a limit the eye can still follow.
 */
export function autoScrollSpeed(distance: number): number {
  if (distance <= 0) return 0;
  return Math.min(AUTO_SCROLL_FASTEST, AUTO_SCROLL_SLOWEST + distance * AUTO_SCROLL_GAIN);
}