{
  "name": "use-selection",
  "type": "registry:hook",
  "title": "useSelection",
  "description": "The ONE selection model: single/multi over an ordered id list, keyboard focus cursor, shift-range, auto-prune. Selection is staging, never execution.",
  "dependencies": [],
  "files": [
    {
      "path": "hooks/useSelection.ts",
      "type": "registry:hook",
      "content": "'use client'\n\n/**\n * useSelection — the ONE selection model (spec §5).\n * Single or multi selection over an ordered id list, with a keyboard focus\n * cursor, shift-range support, and automatic pruning when the list changes\n * (filter, sort, strike).\n *\n * Selection is staging, never execution (grammar G1): arming a row stages\n * it; committing is always a separate, explicit act.\n *\n * Styling contract (the selected-row look is decided once, here):\n *   selected → bg-fill-selected   focused → ring-1 ring-inset ring-hairline-strong\n *   neither  → hover:bg-fill-hover on a transparent rest state\n *\n * Use when: any list where rows can be armed or checked (tables, setlists,\n * object lists).\n * Don't use when: a single persistent \"active\" object owned by a store\n * (the live thing) — that is the running store's state, not list selection.\n */\n\nimport { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'\n\nconst EMPTY_SET: ReadonlySet<string> = new Set()\n\nexport interface UseSelectionOptions {\n  /** 'multi' (default) allows toggle/range/all; 'single' keeps one id. */\n  mode?: 'single' | 'multi'\n}\n\nexport interface SelectionApi {\n  /** Selected ids, pruned to ids present in the current list. Stable ref. */\n  selectedIds: ReadonlySet<string>\n  /** The keyboard cursor — null when its row left the list. */\n  focusedId: string | null\n  count: number\n  allSelected: boolean\n  isSelected: (id: string) => boolean\n  /** Checkbox semantics: flip membership, move anchor + focus here. */\n  toggle: (id: string) => void\n  /** Replace the selection with this id (plain row click). */\n  select: (id: string) => void\n  /** Shift-click: add the anchor→id span to the selection. */\n  selectRange: (id: string) => void\n  selectAll: () => void\n  /** Header-checkbox semantics: all selected → clear, else select all. */\n  toggleAll: () => void\n  /** Replace the selection wholesale (e.g., re-select failed bulk items). */\n  setSelected: (ids: Iterable<string>) => void\n  clear: () => void\n  focus: (id: string) => void\n  /** Move the cursor by delta rows; returns the newly focused id. */\n  focusDelta: (delta: number) => string | null\n}\n\nexport function useSelection(\n  orderedIds: readonly string[],\n  { mode = 'multi' }: UseSelectionOptions = {}\n): SelectionApi {\n  const [rawSelected, setRawSelected] = useState<ReadonlySet<string>>(EMPTY_SET)\n  const [rawFocusedState, setRawFocusedState] = useState<string | null>(null)\n  // Mirror of the focus cursor — keydown can fire faster than re-renders.\n  const focusedRef = useRef<string | null>(null)\n  const anchorRef = useRef<string | null>(null)\n\n  const setRawFocused = useCallback((id: string | null) => {\n    focusedRef.current = id\n    setRawFocusedState(id)\n  }, [])\n  const rawFocused = rawFocusedState\n\n  const orderedRef = useRef(orderedIds)\n  // Latest-ref mirror in a layout effect, not during render — keydown\n  // handlers read it faster than re-renders land.\n  useLayoutEffect(() => {\n    orderedRef.current = orderedIds\n  })\n\n  const idSet = useMemo(() => new Set(orderedIds), [orderedIds])\n\n  // Prune without mutating state: same ref back when nothing left the list.\n  const selectedIds = useMemo(() => {\n    let pruned = false\n    for (const id of rawSelected) {\n      if (!idSet.has(id)) {\n        pruned = true\n        break\n      }\n    }\n    if (!pruned) return rawSelected\n    const next = new Set<string>()\n    for (const id of rawSelected) {\n      if (idSet.has(id)) next.add(id)\n    }\n    return next.size === 0 ? EMPTY_SET : next\n  }, [rawSelected, idSet])\n\n  const focusedId = rawFocused !== null && idSet.has(rawFocused) ? rawFocused : null\n\n  const select = useCallback(\n    (id: string) => {\n      anchorRef.current = id\n      setRawFocused(id)\n      setRawSelected(new Set([id]))\n    },\n    [setRawFocused]\n  )\n\n  const toggle = useCallback(\n    (id: string) => {\n      if (mode === 'single') {\n        select(id)\n        return\n      }\n      anchorRef.current = id\n      setRawFocused(id)\n      setRawSelected((previous) => {\n        const next = new Set(previous)\n        if (next.has(id)) {\n          next.delete(id)\n        } else {\n          next.add(id)\n        }\n        return next.size === 0 ? EMPTY_SET : next\n      })\n    },\n    [mode, select, setRawFocused]\n  )\n\n  const selectRange = useCallback(\n    (id: string) => {\n      if (mode === 'single') {\n        select(id)\n        return\n      }\n      const ids = orderedRef.current\n      const anchor = anchorRef.current ?? focusedRef.current ?? ids[0]\n      const fromIndex = anchor !== undefined && anchor !== null ? ids.indexOf(anchor) : -1\n      const toIndex = ids.indexOf(id)\n      if (toIndex === -1) return\n      const start = fromIndex === -1 ? toIndex : Math.min(fromIndex, toIndex)\n      const end = fromIndex === -1 ? toIndex : Math.max(fromIndex, toIndex)\n      setRawFocused(id)\n      setRawSelected((previous) => {\n        const next = new Set(previous)\n        for (let index = start; index <= end; index++) {\n          next.add(ids[index])\n        }\n        return next\n      })\n    },\n    [mode, select, setRawFocused]\n  )\n\n  const selectAll = useCallback(() => {\n    if (mode === 'single') return\n    setRawSelected(new Set(orderedRef.current))\n  }, [mode])\n\n  const setSelected = useCallback((ids: Iterable<string>) => {\n    const next = new Set(ids)\n    setRawSelected(next.size === 0 ? EMPTY_SET : next)\n  }, [])\n\n  const clear = useCallback(() => {\n    anchorRef.current = null\n    setRawSelected(EMPTY_SET)\n  }, [])\n\n  const allSelected = orderedIds.length > 0 && selectedIds.size === orderedIds.length\n\n  const toggleAll = useCallback(() => {\n    const ids = orderedRef.current\n    setRawSelected((previous) => {\n      let presentCount = 0\n      for (const id of ids) {\n        if (previous.has(id)) presentCount += 1\n      }\n      return presentCount === ids.length && ids.length > 0 ? EMPTY_SET : new Set(ids)\n    })\n  }, [])\n\n  const focus = useCallback(\n    (id: string) => {\n      setRawFocused(id)\n    },\n    [setRawFocused]\n  )\n\n  const focusDelta = useCallback(\n    (delta: number): string | null => {\n      const ids = orderedRef.current\n      if (ids.length === 0) return null\n      const current = focusedRef.current\n      const currentIndex = current !== null ? ids.indexOf(current) : -1\n      const nextIndex =\n        currentIndex === -1\n          ? delta > 0\n            ? 0\n            : ids.length - 1\n          : Math.min(ids.length - 1, Math.max(0, currentIndex + delta))\n      const nextId = ids[nextIndex] ?? null\n      setRawFocused(nextId)\n      return nextId\n    },\n    [setRawFocused]\n  )\n\n  const isSelected = useCallback((id: string) => selectedIds.has(id), [selectedIds])\n\n  return {\n    selectedIds,\n    focusedId,\n    count: selectedIds.size,\n    allSelected,\n    isSelected,\n    toggle,\n    select,\n    selectRange,\n    selectAll,\n    toggleAll,\n    setSelected,\n    clear,\n    focus,\n    focusDelta,\n  }\n}\n",
      "target": "hooks/useSelection.ts"
    }
  ],
  "meta": {
    "candlelight": {
      "category": "hook",
      "gRules": [
        "G1"
      ],
      "useWhen": "Any list where rows can be armed or checked.",
      "dontUseWhen": "A single persistent 'active' object owned by a store (the live thing) — that is the running store's state, not list selection.",
      "states": [
        "selected → bg-fill-selected",
        "focused cursor → inset hairline ring",
        "neither → hover ramp on transparent rest"
      ],
      "keyboard": [
        {
          "keys": "↑↓ / shift-click / ⌘A",
          "does": "wire focusDelta, selectRange, toggleAll through the surface map"
        }
      ],
      "acceptanceTests": [
        "Selection prunes automatically when the list changes (filter, sort, strike) without breaking set identity.",
        "The styling contract is decided once, here — rows read it, never restyle it."
      ]
    }
  }
}