{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "row-selection",
  "type": "registry:ui",
  "title": "Row Selection",
  "description": "Shift-click ranges that tick in sequence, and a bulk bar that rises with a rolling count.",
  "categories": [
    "tables"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/row-selection.tsx",
      "content": "\"use client\";\nimport { Checkbox } from \"@base-ui/react/checkbox\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Loader, X } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* ------------------------------------------------------------------ */\n/* The selection model on its own                                      */\n/* ------------------------------------------------------------------ */\n\nexport type RowSelectionOptions = {\n  /** Every selectable id, in the order the rows are shown. Ranges follow this order. */\n  ids: string[];\n  selected?: string[];\n  defaultSelected?: string[];\n  onSelectedChange?: (selected: string[]) => void;\n};\n\n/**\n * Selection with ranges. Shift-toggling a row applies that row's new state to\n * every row between it and the last row toggled, the way file lists behave.\n */\nexport function useRowSelection({ ids, selected: selectedProp, defaultSelected = [], onSelectedChange }: RowSelectionOptions) {\n  const [selected, setSelected] = useControllableState({ value: selectedProp, defaultValue: defaultSelected, onChange: onSelectedChange });\n  const set = useMemo(() => new Set(selected), [selected]);\n  const anchor = useRef<string | null>(null);\n  // The rows a shift-range just touched, in sweep order, so their ticks can draw one after another.\n  const [sweep, setSweep] = useState<Map<string, number>>(() => new Map());\n\n  // Selection only ever holds rows that still exist.\n  const present = useMemo(() => ids.filter((id) => set.has(id)), [ids, set]);\n  const count = present.length;\n  const all: boolean | \"mixed\" = count === 0 ? false : count === ids.length ? true : \"mixed\";\n\n  const commit = useCallback(\n    (next: Set<string>) => setSelected(ids.filter((id) => next.has(id))),\n    [ids, setSelected],\n  );\n\n  const toggle = useCallback(\n    (id: string, { range = false }: { range?: boolean } = {}) => {\n      const next = new Set(present);\n      const value = !next.has(id);\n      const from = anchor.current != null ? ids.indexOf(anchor.current) : -1;\n      const to = ids.indexOf(id);\n      if (range && from >= 0 && to >= 0 && from !== to) {\n        const step = to > from ? 1 : -1;\n        const order = new Map<string, number>();\n        for (let i = from, n = 0; i !== to + step; i += step, n++) {\n          if (value) next.add(ids[i]);\n          else next.delete(ids[i]);\n          order.set(ids[i], n);\n        }\n        setSweep(order);\n      } else {\n        if (value) next.add(id);\n        else next.delete(id);\n        setSweep(new Map());\n      }\n      anchor.current = id;\n      commit(next);\n    },\n    [present, ids, commit],\n  );\n\n  const toggleAll = useCallback(() => {\n    anchor.current = null;\n    setSweep(new Map());\n    // Mixed goes to all, as a tri-state checkbox should; only a full selection clears.\n    commit(all === true ? new Set() : new Set(ids));\n  }, [all, ids, commit]);\n\n  const clear = useCallback(() => {\n    anchor.current = null;\n    setSweep(new Map());\n    commit(new Set());\n  }, [commit]);\n\n  return { selected: present, count, all, isSelected: (id: string) => set.has(id), toggle, toggleAll, clear, sweep };\n}\n\n/* ------------------------------------------------------------------ */\n/* The table                                                           */\n/* ------------------------------------------------------------------ */\n\nexport type SelectionColumn<T> = {\n  key: string;\n  header: React.ReactNode;\n  cell?: (row: T) => React.ReactNode;\n  numeric?: boolean;\n  width?: number | string;\n  className?: string;\n};\n\nexport type RowSelectionTableProps<T> = Omit<React.ComponentProps<\"div\">, \"children\" | \"defaultValue\"> & {\n  columns: SelectionColumn<T>[];\n  rows: T[];\n  getRowId: (row: T) => string;\n  /** Names each row's checkbox: \"Select {label}\". Defaults to the row id. */\n  getRowLabel?: (row: T) => string;\n  caption: string;\n  selected?: string[];\n  defaultSelected?: string[];\n  onSelectedChange?: (selected: string[]) => void;\n  /** Pressing anywhere on a row toggles it. Links and buttons inside the row keep their own click. */\n  rowClickSelects?: boolean;\n  /** The bulk actions, shown in the bar that rises while anything is selected. */\n  actions?: (selection: { selected: string[]; clear: () => void }) => React.ReactNode;\n  /** The noun in the bar and announcements, e.g. { one: \"invoice\", other: \"invoices\" }. Defaults to \"selected\" alone. */\n  noun?: { one: string; other: string };\n  empty?: React.ReactNode;\n  size?: \"sm\" | \"md\";\n  minWidth?: number;\n};\n\nconst interactive = \"a,button,input,select,textarea,label,[role=checkbox],[data-row-select-ignore]\";\n\nexport function RowSelectionTable<T>({\n  columns,\n  rows,\n  getRowId,\n  getRowLabel,\n  caption,\n  selected: selectedProp,\n  defaultSelected,\n  onSelectedChange,\n  rowClickSelects = true,\n  actions,\n  noun,\n  empty = \"Nothing here yet\",\n  size = \"md\",\n  minWidth,\n  className,\n  onKeyDown,\n  ...rest\n}: RowSelectionTableProps<T>) {\n  const ids = useMemo(() => rows.map(getRowId), [rows, getRowId]);\n  const sel = useRowSelection({ ids, selected: selectedProp, defaultSelected, onSelectedChange });\n  const reduce = useReducedMotion();\n  const captionId = useId();\n  const shift = useRef(false);\n  const open = sel.count > 0;\n  const allBox = useRef<HTMLSpanElement>(null);\n  const scroller = useRef<HTMLDivElement>(null);\n  const [edges, setEdges] = useState({ left: false, right: false });\n\n  // Which sides hide more columns, for the edge fades when the table scrolls sideways.\n  const measure = (el: HTMLDivElement) => {\n    const left = el.scrollLeft > 1;\n    const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;\n    setEdges((e) => (e.left === left && e.right === right ? e : { left, right }));\n  };\n  useEffect(() => {\n    const el = scroller.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => measure(el));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  const barFocus = useRef(false);\n\n  // When the bar closes with focus inside it (an action ran, or Esc), focus lands on select-all\n  // instead of falling to the page.\n  useEffect(() => {\n    if (!open && barFocus.current) {\n      barFocus.current = false;\n      allBox.current?.focus({ preventScroll: true });\n    }\n  }, [open]);\n  const h = size === \"sm\" ? \"h-8\" : \"h-10\";\n\n  const phrase = (n: number) => `${n} ${noun ? (n === 1 ? noun.one : noun.other) + \" \" : \"\"}selected`;\n\n  return (\n    <div\n      data-size={size}\n      onKeyDown={(e) => {\n        onKeyDown?.(e);\n        if (e.defaultPrevented) return;\n        const typing = (e.target as HTMLElement).closest(\"input:not([type=checkbox]),textarea,[contenteditable=true]\");\n        if (typing) return;\n        // Escape lets go of the selection; ⌘A / Ctrl+A takes every row, without selecting the page's text.\n        if (e.key === \"Escape\" && open) {\n          e.preventDefault();\n          sel.clear();\n        } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"a\" && ids.length) {\n          e.preventDefault();\n          if (sel.all !== true) sel.toggleAll();\n        }\n      }}\n      className={cn(\"@container relative flex flex-col overflow-hidden rounded-xl border border-line bg-frame\", className)}\n      {...rest}\n    >\n      <div\n        ref={scroller}\n        onScroll={(e) => measure(e.currentTarget)}\n        // Leave room under the last row so the bar never covers a row you can't scroll to.\n        className={cn(\"max-h-[inherit] min-h-0 flex-1 overflow-auto overscroll-contain transition-[padding] duration-200\", open && actions && \"pb-14\")}\n      >\n        <table aria-labelledby={captionId} style={{ minWidth }} className=\"w-full border-separate border-spacing-0 text-[13px]\">\n          <caption id={captionId} className=\"sr-only\">\n            {caption}\n          </caption>\n          <colgroup>\n            <col style={{ width: 44 }} />\n            {columns.map((c) => (\n              <col key={c.key} style={c.width != null ? { width: c.width } : undefined} />\n            ))}\n          </colgroup>\n          <thead>\n            <tr>\n              <th scope=\"col\" className=\"sticky top-0 z-(--z-sticky) border-b border-line bg-frame p-0\">\n                <span className={cn(\"flex items-center justify-center\", size === \"sm\" ? \"h-8\" : \"h-9\")}>\n                  <SelectBox\n                    boxRef={allBox}\n                    checked={sel.all === true}\n                    indeterminate={sel.all === \"mixed\"}\n                    disabled={!ids.length}\n                    onToggle={sel.toggleAll}\n                    label=\"Select all rows\"\n                  />\n                </span>\n              </th>\n              {columns.map((col) => (\n                <th\n                  key={col.key}\n                  scope=\"col\"\n                  className={cn(\n                    \"sticky top-0 z-(--z-sticky) whitespace-nowrap border-b border-line bg-frame px-3 text-[12px] font-medium text-fg-3\",\n                    size === \"sm\" ? \"h-8\" : \"h-9\",\n                    col.numeric ? \"text-right\" : \"text-left\",\n                  )}\n                >\n                  {col.header}\n                </th>\n              ))}\n            </tr>\n          </thead>\n          <tbody>\n            <AnimatePresence initial={false}>\n              {rows.map((row) => {\n                const id = getRowId(row);\n                const on = sel.isSelected(id);\n                return (\n                  <motion.tr\n                    key={id}\n                    layout={reduce ? false : \"position\"}\n                    // Removed rows fade where they stand; the rows below then close the gap. Restored rows fade back in.\n                    initial={{ opacity: 0 }}\n                    animate={{ opacity: 1, transition: { duration: 0.2, ease: ease.out } }}\n                    exit={{ opacity: 0, transition: { duration: reduce ? 0.1 : 0.14, ease: ease.in } }}\n                    transition={spring.soft}\n                    data-selected={on || undefined}\n                    onMouseDown={(e) => {\n                      // A shift-click is a range, not a text selection.\n                      if (e.shiftKey) e.preventDefault();\n                    }}\n                    onClick={(e) => {\n                      if (!rowClickSelects || (e.target as HTMLElement).closest(interactive)) return;\n                      if (window.getSelection()?.toString()) return;\n                      sel.toggle(id, { range: e.shiftKey });\n                      // Keep focus with the row, so Space, Shift+Space and Esc carry on from where the pointer was.\n                      e.currentTarget.querySelector<HTMLElement>(\"[role=checkbox]\")?.focus({ preventScroll: true, focusVisible: false } as FocusOptions);\n                    }}\n                    className={cn(\n                      \"group/row relative bg-frame transition-colors duration-150\",\n                      rowClickSelects && \"cursor-default\",\n                      on ? \"bg-hover hover:bg-hover\" : \"hover:bg-hover\",\n                    )}\n                  >\n                    <td className={cn(\"relative border-b border-line p-0 group-last/row:border-b-0\", h)}>\n                      {/* A hairline down the leading edge marks selected rows even where the wash is faint. */}\n                      <span\n                        aria-hidden\n                        className={cn(\n                          \"absolute inset-y-0 left-0 w-0.5 origin-center bg-fg transition-[scale,opacity] duration-200 ease-out-expo motion-reduce:transition-none\",\n                          on ? \"scale-y-100 opacity-100\" : \"scale-y-0 opacity-0\",\n                        )}\n                      />\n                      <span className=\"flex h-full items-center justify-center\">\n                        <SelectBox\n                          checked={on}\n                          onToggle={() => sel.toggle(id, { range: shift.current })}\n                          onShift={(v) => (shift.current = v)}\n                          delay={sel.sweep.get(id)}\n                          label={`Select ${getRowLabel ? getRowLabel(row) : id}`}\n                        />\n                      </span>\n                    </td>\n                    {columns.map((col) => (\n                      <td\n                        key={col.key}\n                        className={cn(\n                          \"border-b border-line px-3 text-fg group-last/row:border-b-0\",\n                          h,\n                          col.numeric ? \"whitespace-nowrap text-right tabular\" : \"text-left\",\n                          col.className,\n                        )}\n                      >\n                        {col.cell ? col.cell(row) : String((row as Record<string, unknown>)[col.key] ?? \"\")}\n                      </td>\n                    ))}\n                  </motion.tr>\n                );\n              })}\n            </AnimatePresence>\n            {rows.length === 0 && (\n              <tr>\n                <td colSpan={columns.length + 1} className=\"h-32 px-4 text-center text-[13px] text-fg-3\">\n                  {empty}\n                </td>\n              </tr>\n            )}\n          </tbody>\n        </table>\n      </div>\n\n      <div aria-hidden className={cn(\"pointer-events-none absolute inset-y-0 left-0 z-(--z-sticky) w-4 bg-linear-to-r from-overlay to-transparent transition-opacity duration-200\", edges.left ? \"opacity-50\" : \"opacity-0\")} />\n      <div aria-hidden className={cn(\"pointer-events-none absolute inset-y-0 right-0 z-(--z-sticky) w-4 bg-linear-to-l from-overlay to-transparent transition-opacity duration-200\", edges.right ? \"opacity-50\" : \"opacity-0\")} />\n\n      {/* A selection that exists on first paint shows its bar in place; only later selections slide it up. */}\n      <AnimatePresence initial={false}>\n        {open && actions && (\n          <BulkBar\n            key=\"bar\"\n            count={sel.count}\n            noun={noun}\n            onClear={sel.clear}\n            onFocus={() => (barFocus.current = true)}\n            onBlur={(e) => {\n              if (e.relatedTarget && !e.currentTarget.contains(e.relatedTarget)) barFocus.current = false;\n            }}\n          >\n            {actions({ selected: sel.selected, clear: sel.clear })}\n          </BulkBar>\n        )}\n      </AnimatePresence>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {sel.count ? phrase(sel.count) : \"\"}\n      </span>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* The bar                                                             */\n/* ------------------------------------------------------------------ */\n\nfunction BulkBar({\n  count,\n  noun,\n  onClear,\n  onFocus,\n  onBlur,\n  children,\n}: {\n  count: number;\n  noun?: { one: string; other: string };\n  onClear: () => void;\n  onFocus: () => void;\n  onBlur: (e: React.FocusEvent<HTMLDivElement>) => void;\n  children: React.ReactNode;\n}) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.div\n      role=\"toolbar\"\n      aria-label=\"Bulk actions\"\n      onFocus={onFocus}\n      onBlur={onBlur}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, y: 16, scale: 0.96 }}\n      animate={{ opacity: 1, y: 0, scale: 1 }}\n      exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, y: 10, scale: 0.98, transition: { duration: 0.14, ease: ease.in } }}\n      transition={reduce ? { duration: 0.15 } : spring.snappy}\n      className=\"absolute inset-x-0 bottom-2.5 z-(--z-dropdown) mx-auto flex h-10 w-max max-w-[calc(100%-20px)] origin-bottom items-center gap-0.5 rounded-xl border border-line-2 bg-raised pl-3 pr-1 shadow-pop\"\n    >\n      <span className=\"flex shrink-0 items-baseline gap-1 whitespace-nowrap pr-2 text-[12.5px] text-fg\">\n        <NumberFlow value={count} className=\"font-medium tabular\" />\n        <span className=\"text-fg-2\">\n          {noun ? (count === 1 ? noun.one : noun.other) + \" \" : \"\"}selected\n        </span>\n      </span>\n      <span aria-hidden className=\"mx-1 h-4 w-px shrink-0 bg-line-2\" />\n      <div className=\"flex min-w-0 items-center gap-0.5\">{children}</div>\n      <span aria-hidden className=\"mx-1 h-4 w-px shrink-0 bg-line-2\" />\n      <button\n        type=\"button\"\n        onClick={onClear}\n        aria-label=\"Clear selection\"\n        title=\"Clear selection (Esc)\"\n        className=\"relative grid size-7 shrink-0 place-items-center rounded-md text-fg-3 outline-none transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3 active:scale-[0.92] pointer-coarse:after:absolute pointer-coarse:after:-inset-2 pointer-coarse:after:content-['']\"\n      >\n        <X size={14} />\n      </button>\n    </motion.div>\n  );\n}\n\nexport type BulkActionProps = Omit<React.ComponentProps<\"button\">, \"onClick\"> & {\n  /** Runs the action. Return a promise and the button holds a spinner, at the same width, until it settles. */\n  onAction?: () => void | Promise<unknown>;\n  icon?: React.ReactNode;\n  variant?: \"default\" | \"danger\";\n};\n\n/** A button sized and styled for the bulk action bar. */\nexport function BulkAction({ onAction, icon, variant = \"default\", disabled, className, children, ...rest }: BulkActionProps) {\n  const [busy, setBusy] = useState(false);\n  const alive = useRef(true);\n  useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n    };\n  }, []);\n\n  return (\n    <button\n      type=\"button\"\n      data-variant={variant}\n      aria-busy={busy || undefined}\n      disabled={disabled}\n      onClick={async () => {\n        if (busy) return;\n        const result = onAction?.();\n        if (result && typeof (result as Promise<unknown>).then === \"function\") {\n          setBusy(true);\n          try {\n            await result;\n          } finally {\n            if (alive.current) setBusy(false);\n          }\n        }\n      }}\n      className={cn(\n        \"relative inline-flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-md px-2 text-[12.5px] font-medium outline-none\",\n        // On touch the target grows to the bar's full height and a 36px minimum width.\n        \"pointer-coarse:min-w-9 pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-2 pointer-coarse:after:content-['']\",\n        \"transition-[background-color,color,scale] duration-150 active:scale-[0.97] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n        \"disabled:pointer-events-none disabled:opacity-50\",\n        variant === \"danger\" ? \"text-danger hover:bg-danger-soft\" : \"text-fg hover:bg-hover\",\n        busy && \"pointer-events-none\",\n        className,\n      )}\n      {...rest}\n    >\n      <span className={cn(\"inline-flex items-center gap-1.5 transition-opacity duration-150\", busy && \"opacity-0\")}>\n        {icon}\n        {/* In a narrow table the bar keeps the icons and the labels go to screen readers only. */}\n        <span className={cn(icon != null && \"@max-[26rem]:sr-only\")}>{children}</span>\n      </span>\n      {busy && (\n        <span aria-hidden className=\"absolute inset-0 grid place-items-center\">\n          <Loader size={14} className=\"motion-safe:animate-spin\" />\n        </span>\n      )}\n    </button>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* The checkbox                                                        */\n/* ------------------------------------------------------------------ */\n\nfunction SelectBox({\n  boxRef,\n  checked,\n  indeterminate = false,\n  disabled,\n  onToggle,\n  onShift,\n  delay,\n  label,\n}: {\n  boxRef?: React.Ref<HTMLSpanElement>;\n  checked: boolean;\n  indeterminate?: boolean;\n  disabled?: boolean;\n  onToggle: () => void;\n  onShift?: (shift: boolean) => void;\n  /** Position in a shift-range sweep; the tick draws a beat after the one before it. */\n  delay?: number;\n  label: string;\n}) {\n  const reduce = useReducedMotion();\n  const shape = indeterminate ? \"dash\" : checked ? \"check\" : \"none\";\n  const wait = reduce || delay == null ? 0 : Math.min(delay, 8) * 0.018;\n\n  return (\n    <Checkbox.Root\n      ref={boxRef}\n      checked={checked}\n      indeterminate={indeterminate}\n      disabled={disabled}\n      aria-label={label}\n      onPointerDown={(e) => onShift?.(e.shiftKey)}\n      onKeyDown={(e) => onShift?.(e.shiftKey)}\n      onCheckedChange={() => onToggle()}\n      // In a shift-range the fill waits for its turn too, so the sweep reads as one motion.\n      style={{ transitionDelay: checked && wait ? `${wait}s` : undefined }}\n      className={(state) =>\n        cn(\n          \"relative grid size-4 shrink-0 cursor-default place-items-center rounded-[4.5px] border outline-none\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"transition-[background-color,border-color,scale] duration-150 ease-out-expo motion-safe:active:scale-[0.86] active:duration-100\",\n          // The box draws at 16px; the target reaches the full cell.\n          \"after:absolute after:-inset-3 after:content-['']\",\n          state.checked || state.indeterminate ? \"border-fg bg-fg text-frame\" : \"border-fg-4 bg-raised hover:border-fg-3\",\n          state.disabled && \"opacity-40\",\n        )\n      }\n    >\n      <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className=\"pointer-events-none size-full\">\n        <motion.path\n          d=\"M3.75 8.25 6.75 11.25 12.25 4.75\"\n          stroke=\"currentColor\"\n          strokeWidth={1.9}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          initial={false}\n          animate={{ pathLength: shape === \"check\" ? 1 : 0, opacity: shape === \"check\" ? 1 : 0 }}\n          transition={\n            reduce\n              ? { duration: 0 }\n              : shape === \"check\"\n                ? { pathLength: { duration: 0.22, ease: ease.out, delay: 0.03 + wait }, opacity: { duration: 0.01, delay: 0.03 + wait } }\n                : { duration: 0.1, ease: ease.in }\n          }\n        />\n        <motion.path\n          d=\"M4.5 8h7\"\n          stroke=\"currentColor\"\n          strokeWidth={1.9}\n          strokeLinecap=\"round\"\n          initial={false}\n          animate={{ pathLength: shape === \"dash\" ? 1 : 0, opacity: shape === \"dash\" ? 1 : 0 }}\n          transition={reduce ? { duration: 0 } : { duration: shape === \"dash\" ? 0.18 : 0.08, ease: ease.out }}\n        />\n      </svg>\n    </Checkbox.Root>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/row-selection"
}
