{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sortable-table",
  "type": "registry:ui",
  "title": "Sortable table",
  "description": "Explains sorting through row position while keeping data readable.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "data-commerce"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/sortable-table/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "light",
    "signature": "Sorting is expressed through row position and direction",
    "sceneFamily": "product-mono",
    "motionRole": "ui",
    "primaryState": "Data table sorted by its active field",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/sortable-table.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/sortable-table.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst CELL = { type: \"spring\", stiffness: 520, damping: 34, mass: 0.45 } as const;\n\nconst SMALL = { type: \"spring\", stiffness: 700, damping: 46, mass: 0.5 } as const;\n\nconst EASE = [0.23, 1, 0.32, 1] as const;\nconst LEAVE = [0.23, 1, 0.32, 1] as const;\nconst HIDE = { duration: 0.12, ease: LEAVE } as const;\nconst SHOW = { duration: 0.25, ease: EASE } as const;\n\nconst STEP = 0.018;\nconst STEP_CAP = 8;\nconst SETTLE_MS = 380;\n\nexport type SortDirection = \"asc\" | \"desc\";\n\nexport type SortState = { columnId: string; direction: SortDirection };\n\nexport type SortableColumn<T> = {\n  id: string;\n  header: string;\n  width?: string;\n  align?: \"start\" | \"end\";\n  numeric?: boolean;\n  sortable?: boolean;\n  value?: (row: T) => string | number | null | undefined;\n  cell?: (row: T) => ReactNode;\n};\n\nexport type OrderedRow<T> = { id: string; row: T; index: number };\n\nexport type UseSortableRowsOptions<T> = {\n  rows: T[];\n  getRowId: (row: T) => string;\n  getValue: (row: T, columnId: string) => string | number | null | undefined;\n  sort?: SortState | null;\n  defaultSort?: SortState | null;\n  onSortChange?: (next: SortState | null) => void;\n  restoreOriginal?: boolean;\n};\n\nexport function useSortableRows<T>({\n  rows,\n  getRowId,\n  getValue,\n  sort,\n  defaultSort = null,\n  onSortChange,\n  restoreOriginal = true,\n}: UseSortableRowsOptions<T>) {\n  const [internal, setInternal] = useState<SortState | null>(defaultSort);\n\n  const controlled = sort !== undefined;\n  const current = controlled ? sort : internal;\n\n  const collator = useMemo(\n    () => new Intl.Collator(\"en\", { numeric: true, sensitivity: \"base\" }),\n    [],\n  );\n\n  const ordered = useMemo<OrderedRow<T>[]>(() => {\n    const base = rows.map((row, i) => ({ id: getRowId(row), row, i }));\n\n    if (current) {\n      const dir = current.direction === \"asc\" ? 1 : -1;\n      base.sort((x, y) => {\n        const a = getValue(x.row, current.columnId);\n        const b = getValue(y.row, current.columnId);\n        const emptyA = a === null || a === undefined || a === \"\";\n        const emptyB = b === null || b === undefined || b === \"\";\n        if (emptyA || emptyB) {\n          if (emptyA && emptyB) return x.i - y.i;\n          return emptyA ? 1 : -1;\n        }\n        const d =\n          typeof a === \"number\" && typeof b === \"number\"\n            ? a - b\n            : collator.compare(String(a), String(b));\n        return d === 0 ? x.i - y.i : d * dir;\n      });\n    }\n\n    return base.map(({ id, row }, index) => ({ id, row, index }));\n  }, [rows, current, getRowId, getValue, collator]);\n\n  const toggle = useCallback(\n    (columnId: string) => {\n      const next: SortState | null =\n        !current || current.columnId !== columnId\n          ? { columnId, direction: \"asc\" }\n          : current.direction === \"asc\"\n            ? { columnId, direction: \"desc\" }\n            : restoreOriginal\n              ? null\n              : { columnId, direction: \"asc\" };\n\n      if (!controlled) setInternal(next);\n      onSortChange?.(next);\n    },\n    [current, controlled, onSortChange, restoreOriginal],\n  );\n\n  const ariaSort = useCallback(\n    (columnId: string): \"ascending\" | \"descending\" | \"none\" =>\n      current?.columnId === columnId\n        ? current.direction === \"asc\"\n          ? \"ascending\"\n          : \"descending\"\n        : \"none\",\n    [current],\n  );\n\n  return { sort: current, ordered, toggle, ariaSort };\n}\n\nexport type SortableTableProps<T> = {\n  rows: T[];\n  columns: SortableColumn<T>[];\n  getRowId: (row: T) => string;\n  label: string;\n  rowHeight?: number;\n  maxHeight?: number;\n  sort?: SortState | null;\n  defaultSort?: SortState | null;\n  onSortChange?: (next: SortState | null) => void;\n  markable?: boolean;\n  onMarkChange?: (id: string | null) => void;\n  getRowLabel?: (row: T) => string;\n  markLabel?: string;\n  markRowLabel?: (name: string) => string;\n  sortStatus?: (header: string | null, direction: SortDirection | null, rows: number) => string;\n  className?: string;\n};\n\nexport function SortableTable<T>({\n  rows,\n  columns,\n  getRowId,\n  label,\n  rowHeight = 44,\n  maxHeight,\n  sort,\n  defaultSort = null,\n  onSortChange,\n  markable = false,\n  onMarkChange,\n  getRowLabel,\n  markLabel = \"Follow\",\n  markRowLabel = (name) => `Follow ${name}`,\n  sortStatus = (header, direction, count) => header && direction\n    ? `Sorted by ${header}, ${direction === \"asc\" ? \"ascending\" : \"descending\"}. ${count} rows.`\n    : `Original order restored. ${count} rows.`,\n  className = \"\",\n}: SortableTableProps<T>) {\n  const reduced = useReducedMotion();\n  const [marked, setMarked] = useState<string | null>(null);\n  const [touched, setTouched] = useState(false);\n  const [moving, setMoving] = useState(false);\n  const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  useEffect(\n    () => () => {\n      if (settleTimer.current) clearTimeout(settleTimer.current);\n    },\n    [],\n  );\n\n  const getValue = useCallback(\n    (row: T, columnId: string) => {\n      const column = columns.find((c) => c.id === columnId);\n      return column?.value ? column.value(row) : null;\n    },\n    [columns],\n  );\n\n  const { sort: current, ordered, toggle, ariaSort } = useSortableRows<T>({\n    rows,\n    getRowId,\n    getValue,\n    sort,\n    defaultSort,\n    onSortChange,\n  });\n\n  const template = useMemo(\n    () =>\n      (markable ? \"44px \" : \"\") +\n      columns.map((c) => c.width ?? \"minmax(0, 1fr)\").join(\" \"),\n    [columns, markable],\n  );\n\n  const onToggle = (columnId: string) => {\n    setTouched(true);\n    toggle(columnId);\n    if (reduced) return;\n    setMoving(true);\n    if (settleTimer.current) clearTimeout(settleTimer.current);\n    settleTimer.current = setTimeout(() => setMoving(false), SETTLE_MS);\n  };\n\n  const onMark = (id: string) => {\n    const next = marked === id ? null : id;\n    setMarked(next);\n    onMarkChange?.(next);\n  };\n\n  const nameOf = (row: T) =>\n    getRowLabel?.(row) ?? String(columns[0]?.value?.(row) ?? getRowId(row));\n\n  const activeHeader = columns.find((c) => c.id === current?.columnId)?.header;\n\n  const message = !touched ? \"\" : sortStatus(activeHeader ?? null, current?.direction ?? null, rows.length);\n\n  return (\n    <div\n      className={`overflow-hidden rounded-[14px] border border-stone-200 bg-white shadow-[0_1px_2px_rgba(28,25,23,0.06),0_4px_10px_-8px_rgba(28,25,23,0.45)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:shadow-[0_1px_6px_rgba(0,0,0,0.45)] ${className}`}\n    >\n      <div\n        role=\"table\"\n        aria-label={label}\n        aria-rowcount={rows.length + 1}\n        aria-colcount={columns.length + (markable ? 1 : 0)}\n      >\n        <div role=\"rowgroup\">\n          <div\n            role=\"row\"\n            aria-rowindex={1}\n            className=\"grid h-11 items-center gap-x-2 border-b border-stone-200 px-2 dark:border-white/[0.16]\"\n            style={{ gridTemplateColumns: template }}\n          >\n            {markable && (\n              <div role=\"columnheader\" className=\"min-w-0\">\n                <span className=\"sr-only\">{markLabel}</span>\n              </div>\n            )}\n\n            {columns.map((column) => {\n              const state = ariaSort(column.id);\n              const active = state !== \"none\";\n              const end = column.align === \"end\";\n\n              return (\n                <div\n                  key={column.id}\n                  role=\"columnheader\"\n                  aria-sort={column.sortable === false ? undefined : state}\n                  className=\"min-w-0\"\n                >\n                  {column.sortable === false ? (\n                    <span\n                      className={`block truncate px-1.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-stone-500 dark:text-stone-400 ${\n                        end ? \"text-right\" : \"\"\n                      }`}\n                    >\n                      {column.header}\n                    </span>\n                  ) : (\n                    <button\n                      type=\"button\"\n                      onClick={() => onToggle(column.id)}\n                      className={`group flex h-11 w-full items-center gap-1.5 rounded-[6px] px-1.5 outline-none focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.06] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF] ${\n                        end ? \"flex-row-reverse\" : \"\"\n                      }`}\n                    >\n                      <span\n                        className={`truncate text-[11px] font-semibold uppercase tracking-[0.08em] ${\n                          active\n                            ? \"text-stone-700 dark:text-stone-200\"\n                            : \"text-stone-500 group-hover:text-stone-700 dark:text-stone-400 dark:group-hover:text-stone-200\"\n                        }`}\n                      >\n                        {column.header}\n                      </span>\n                      <motion.span\n                        aria-hidden\n                        className=\"shrink-0 text-stone-700 dark:text-stone-200\"\n                        initial={false}\n                        animate={{\n                          rotate: state === \"descending\" ? 180 : 0,\n                          opacity: active ? 1 : 0,\n                          scale: active ? 1 : 0.72,\n                        }}\n                        transition={reduced ? { duration: 0 } : SMALL}\n                      >\n                        <svg width=\"9\" height=\"9\" viewBox=\"0 0 10 10\" fill=\"none\">\n                          <path\n                            d=\"M5 8.6V1.6M5 1.6 2.2 4.4M5 1.6l2.8 2.8\"\n                            stroke=\"currentColor\"\n                            strokeWidth=\"1.4\"\n                            strokeLinecap=\"round\"\n                            strokeLinejoin=\"round\"\n                          />\n                        </svg>\n                      </motion.span>\n                    </button>\n                  )}\n                </div>\n              );\n            })}\n          </div>\n        </div>\n        <div\n          role=\"rowgroup\"\n          className={`relative overflow-y-auto overscroll-contain ${\n            maxHeight ? \"[scrollbar-gutter:stable]\" : \"\"\n          }`}\n          style={{\n            height: (rows.length || 1) * rowHeight,\n            maxHeight,\n          }}\n        >\n          {rows.length === 0 && (\n            <div\n              role=\"row\"\n              className=\"absolute inset-x-0 top-0 flex items-center px-3.5\"\n              style={{ height: rowHeight }}\n            >\n              <span\n                role=\"cell\"\n                className=\"text-[12.5px] text-stone-500 dark:text-stone-400\"\n              >\n                No rows\n              </span>\n            </div>\n          )}\n\n          {ordered.map(({ id, row, index }) => {\n            const isMarked = markable && marked === id;\n\n            return (\n              <motion.div\n                key={id}\n                role=\"row\"\n                aria-rowindex={index + 2}\n                aria-current={isMarked ? true : undefined}\n                initial={false}\n                animate={{ transform: `translate3d(0, ${index * rowHeight}px, 0)` }}\n                transition={\n                  reduced\n                    ? { duration: 0 }\n                    : { ...CELL, delay: Math.min(index, STEP_CAP) * STEP }\n                }\n                className={`absolute inset-x-0 top-0 grid items-center gap-x-2 px-2 transition-colors duration-150 ${\n                  isMarked ? \"bg-stone-100 dark:bg-white/[0.06]\" : \"\"\n                }`}\n                style={{ height: rowHeight, gridTemplateColumns: template }}\n              >\n                {markable && (\n                  <div role=\"cell\" className=\"min-w-0\">\n                    <button\n                      type=\"button\"\n                      aria-pressed={marked === id}\n                      onClick={() => onMark(id)}\n                      className={`flex size-11 items-center justify-center rounded-[7px] border outline-none focus-visible:border-[#4568FF] focus-visible:shadow-[0_1px_3px_rgba(28,25,23,0.18)] dark:focus-visible:border-[#93B0FF] dark:focus-visible:shadow-[0_1px_3px_rgba(0,0,0,0.5)] ${\n                        marked === id\n                          ? \"border-[#4568FF] bg-[#4568FF] text-white dark:border-[#93B0FF] dark:bg-[#93B0FF] dark:text-stone-900\"\n                          : \"border-stone-200 text-transparent dark:border-white/15\"\n                      }`}\n                    >\n                      <span className=\"sr-only\">{markRowLabel(nameOf(row))}</span>\n                      <motion.svg\n                        aria-hidden\n                        width=\"11\"\n                        height=\"11\"\n                        viewBox=\"0 0 12 12\"\n                        fill=\"none\"\n                        initial={false}\n                        animate={{ scale: marked === id ? 1 : 0.4 }}\n                        transition={reduced ? { duration: 0 } : CELL}\n                      >\n                        <path\n                          d=\"M2.6 6.3 4.9 8.6 9.4 3.4\"\n                          stroke=\"currentColor\"\n                          strokeWidth=\"1.6\"\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                        />\n                      </motion.svg>\n                    </button>\n                  </div>\n                )}\n\n                {columns.map((column, c) => {\n                  const raw = column.value?.(row);\n                  const content = column.cell\n                    ? column.cell(row)\n                    : raw === null || raw === undefined || raw === \"\"\n                      ? \"—\"\n                      : String(raw);\n\n                  return (\n                    <div\n                      key={column.id}\n                      role=\"cell\"\n                      className={`min-w-0 truncate px-1.5 text-[13px] ${\n                        column.align === \"end\" ? \"text-right\" : \"\"\n                      } ${column.numeric ? \"tabular-nums\" : \"\"} ${\n                        c === 0\n                          ? \"font-medium text-stone-700 dark:text-stone-200\"\n                          : \"text-stone-500 dark:text-stone-400\"\n                      }`}\n                    >\n                      {content}\n                    </div>\n                  );\n                })}\n              </motion.div>\n            );\n          })}\n\n          <motion.div\n            aria-hidden\n            initial={false}\n            animate={{ opacity: moving ? 0 : 1 }}\n            transition={moving ? HIDE : SHOW}\n            className=\"pointer-events-none absolute inset-0\"\n          >\n            {Array.from({ length: Math.max(0, rows.length - 1) }, (_, i) => (\n              <div\n                key={i}\n                className=\"absolute inset-x-0 border-t border-stone-200 dark:border-white/[0.16]\"\n                style={{ top: (i + 1) * rowHeight }}\n              />\n            ))}\n          </motion.div>\n        </div>\n      </div>\n      <div role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {message}\n      </div>\n    </div>\n  );\n}\n"
    }
  ]
}
