{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"table","type":"registry:ui","title":"Table","description":"Data table with sortable headers (flipping chevron arrows), rows that glide into their new order, hover and selection states with animated checkboxes and a sticky blurred header.","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"registry/ui/table.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\nimport { MotionConfig, motion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n// Checkbox tick/dash are drawn by animating the stroke, same technique as the Checkbox primitive.\nconst css = `\n.ui-tbl-mark path{stroke-dasharray:1;stroke-dashoffset:1;transition:stroke-dashoffset .2s cubic-bezier(.6,0,.4,1)}\n.ui-tbl-cbx:checked:not(:indeterminate)+.ui-tbl-mark .tick,.ui-tbl-cbx:indeterminate+.ui-tbl-mark .dash{stroke-dashoffset:0}\n@media (prefers-reduced-motion:reduce){.ui-tbl-mark path{transition:none}}\n`;\n\nexport type SortDirection = \"asc\" | \"desc\";\nexport type SortState = { key: string; direction: SortDirection } | null;\n\nexport interface TableColumn<T> {\n  /** Field to read (and sort by, unless `sortValue` is given). */\n  key: keyof T & string;\n  header: ReactNode;\n  sortable?: boolean;\n  align?: \"left\" | \"center\" | \"right\";\n  /** Custom cell renderer. */\n  cell?: (row: T) => ReactNode;\n  /** Value used for sorting when the displayed cell differs from the raw field. */\n  sortValue?: (row: T) => string | number;\n  className?: string;\n}\n\nexport interface TableProps<T extends { id: string | number }> {\n  columns: TableColumn<T>[];\n  rows: T[];\n  /** Checkbox column with select-all. On by default; pass `selectable={false}` for a plain table. */\n  selectable?: boolean;\n  selected?: (string | number)[];\n  defaultSelected?: (string | number)[];\n  onSelectionChange?: (ids: (string | number)[]) => void;\n  defaultSort?: SortState;\n  onSortChange?: (sort: SortState) => void;\n  /** Keep the header visible while the body scrolls (set `maxHeight`). */\n  stickyHeader?: boolean;\n  /** Scroll container height, e.g. 320 or \"20rem\". */\n  maxHeight?: number | string;\n  /** Visible caption; also names the table for screen readers. */\n  caption?: ReactNode;\n  /** Shown when there are no rows. */\n  empty?: ReactNode;\n  className?: string;\n}\n\nfunction Check({ checked, indeterminate, onChange, label }: { checked: boolean; indeterminate?: boolean; onChange: () => void; label: string }) {\n  const ref = useRef<HTMLInputElement>(null);\n  useEffect(() => {\n    if (ref.current) ref.current.indeterminate = !!indeterminate;\n  });\n  return (\n    <span className=\"relative grid size-4 place-items-center\">\n      <input\n        ref={ref}\n        type=\"checkbox\"\n        aria-label={label}\n        checked={checked}\n        onChange={onChange}\n        className={cn(\n          \"ui-tbl-cbx peer absolute inset-0 m-0 cursor-pointer appearance-none rounded-[5px] border border-input bg-background outline-none\",\n          \"transition-[background-color,border-color,scale] duration-200 hover:border-primary/60 active:scale-90\",\n          \"checked:border-primary checked:bg-primary indeterminate:border-primary indeterminate:bg-primary focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n        )}\n      />\n      <svg aria-hidden viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={3.5} strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"ui-tbl-mark pointer-events-none relative size-3 text-primary-foreground\">\n        <path className=\"tick\" pathLength={1} d=\"M4.5 12.5l5 5L19.5 7\" />\n        <path className=\"dash\" pathLength={1} d=\"M6 12h12\" />\n      </svg>\n    </span>\n  );\n}\n\n/** Two stacked chevrons: the active one lights up; the pair flips for descending. */\nfunction SortIcon({ state }: { state: SortDirection | undefined }) {\n  return (\n    <svg\n      aria-hidden\n      viewBox=\"0 0 12 16\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.75}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className={cn(\n        \"size-3.5 shrink-0 transition-[rotate,opacity] duration-300 ease-[cubic-bezier(.3,1.4,.5,1)] motion-reduce:transition-none\",\n        state === \"desc\" && \"rotate-180\",\n        state ? \"opacity-100\" : \"opacity-40 group-hover/th:opacity-80\",\n      )}\n    >\n      <path d=\"M3 6l3-3 3 3\" className={cn(\"transition-[translate] duration-300\", state && \"-translate-y-px\")} stroke={state ? \"var(--primary)\" : \"currentColor\"} />\n      <path d=\"M3 10l3 3 3-3\" className={cn(\"transition-opacity duration-300\", state && \"opacity-30\")} />\n    </svg>\n  );\n}\n\nexport function Table<T extends { id: string | number }>({\n  columns,\n  rows,\n  selectable = true,\n  selected: selectedProp,\n  defaultSelected = [],\n  onSelectionChange,\n  defaultSort = null,\n  onSortChange,\n  stickyHeader = true,\n  maxHeight,\n  caption,\n  empty = \"No results.\",\n  className,\n}: TableProps<T>) {\n  const [sort, setSort] = useState<SortState>(defaultSort);\n  const [inner, setInner] = useState(defaultSelected);\n  const selected = selectedProp ?? inner;\n  const selectedSet = useMemo(() => new Set(selected), [selected]);\n\n  const sorted = useMemo(() => {\n    if (!sort) return rows;\n    const col = columns.find((c) => c.key === sort.key);\n    const get = col?.sortValue ?? ((r: T) => r[sort.key as keyof T] as unknown as string | number);\n    const dir = sort.direction === \"asc\" ? 1 : -1;\n    return [...rows].sort((a, b) => {\n      const x = get(a);\n      const y = get(b);\n      return dir * (typeof x === \"number\" && typeof y === \"number\" ? x - y : String(x).localeCompare(String(y), undefined, { numeric: true }));\n    });\n  }, [rows, columns, sort]);\n\n  // Cycle: ascending -> descending -> unsorted.\n  const toggleSort = (key: string) => {\n    const next: SortState = sort?.key !== key ? { key, direction: \"asc\" } : sort.direction === \"asc\" ? { key, direction: \"desc\" } : null;\n    setSort(next);\n    onSortChange?.(next);\n  };\n\n  const setSelected = (ids: (string | number)[]) => {\n    if (selectedProp === undefined) setInner(ids);\n    onSelectionChange?.(ids);\n  };\n  const allOn = rows.length > 0 && rows.every((r) => selectedSet.has(r.id));\n  const someOn = !allOn && rows.some((r) => selectedSet.has(r.id));\n\n  const alignCls = (a?: \"left\" | \"center\" | \"right\") => (a === \"right\" ? \"text-right\" : a === \"center\" ? \"text-center\" : \"text-left\");\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div\n        className={cn(\"relative w-full overflow-auto rounded-xl border border-border bg-card text-card-foreground shadow-sm\", className)}\n        style={{ maxHeight }}\n      >\n        <style href=\"ui-table\" precedence=\"default\">\n          {css}\n        </style>\n        <table className=\"w-full caption-bottom border-separate border-spacing-0 text-sm\">\n          {caption && <caption className=\"border-t border-border px-4 py-3 text-left text-xs text-muted-foreground\">{caption}</caption>}\n          <thead className={cn(stickyHeader && \"sticky top-0 z-10\")}>\n            <tr>\n              {selectable && (\n                <th scope=\"col\" className=\"w-10 border-b border-border bg-card/85 py-3 pl-4 backdrop-blur\">\n                  <Check\n                    checked={allOn}\n                    indeterminate={someOn}\n                    label=\"Select all rows\"\n                    onChange={() => setSelected(allOn ? [] : rows.map((r) => r.id))}\n                  />\n                </th>\n              )}\n              {columns.map((c) => {\n                const state = sort?.key === c.key ? sort.direction : undefined;\n                return (\n                  <th\n                    key={c.key}\n                    scope=\"col\"\n                    aria-sort={state ? (state === \"asc\" ? \"ascending\" : \"descending\") : c.sortable ? \"none\" : undefined}\n                    className={cn(\n                      \"h-11 border-b border-border bg-card/85 px-4 text-xs font-medium tracking-wide whitespace-nowrap text-muted-foreground uppercase backdrop-blur\",\n                      alignCls(c.align),\n                      c.className,\n                    )}\n                  >\n                    {c.sortable ? (\n                      <button\n                        type=\"button\"\n                        onClick={() => toggleSort(c.key)}\n                        className={cn(\n                          \"group/th -mx-2 inline-flex items-center gap-1.5 rounded-md px-2 py-1 uppercase outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60\",\n                          c.align === \"right\" && \"flex-row-reverse\",\n                          state && \"text-foreground\",\n                        )}\n                      >\n                        {c.header}\n                        <SortIcon state={state} />\n                      </button>\n                    ) : (\n                      c.header\n                    )}\n                  </th>\n                );\n              })}\n            </tr>\n          </thead>\n          <tbody>\n            {sorted.map((r) => {\n              const on = selectedSet.has(r.id);\n              return (\n                <motion.tr\n                  key={r.id}\n                  layout=\"position\"\n                  transition={{ type: \"spring\", stiffness: 500, damping: 40 }}\n                  data-state={on ? \"selected\" : undefined}\n                  className={cn(\n                    \"group/row transition-colors duration-200 [&>td]:border-b [&>td]:border-border last:[&>td]:border-b-0\",\n                    on ? \"bg-primary/[0.07]\" : \"hover:bg-muted/50\",\n                  )}\n                >\n                  {selectable && (\n                    <td className=\"relative py-3 pl-4\">\n                      <span\n                        aria-hidden\n                        className={cn(\n                          \"absolute inset-y-0 left-0 w-0.5 origin-center bg-primary transition-[scale] duration-300 ease-[cubic-bezier(.3,1.4,.5,1)] motion-reduce:transition-none\",\n                          on ? \"scale-y-100\" : \"scale-y-0\",\n                        )}\n                      />\n                      <Check\n                        checked={on}\n                        label={`Select row ${r.id}`}\n                        onChange={() => setSelected(on ? selected.filter((x) => x !== r.id) : [...selected, r.id])}\n                      />\n                    </td>\n                  )}\n                  {columns.map((c) => (\n                    <td key={c.key} className={cn(\"px-4 py-3 whitespace-nowrap\", alignCls(c.align), c.className)}>\n                      {c.cell ? c.cell(r) : String(r[c.key] ?? \"\")}\n                    </td>\n                  ))}\n                </motion.tr>\n              );\n            })}\n            {sorted.length === 0 && (\n              <tr>\n                <td colSpan={columns.length + (selectable ? 1 : 0)} className=\"px-4 py-10 text-center text-muted-foreground\">\n                  {empty}\n                </td>\n              </tr>\n            )}\n          </tbody>\n        </table>\n      </div>\n    </MotionConfig>\n  );\n}\n"}]}