{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "eased-table-core",
  "title": "Table core primitive",
  "description": "The layer under every Eased table: a TanStack Table v9 feature set with typed column meta, hairline rules, dense and comfortable densities, a hover-revealed sort button, a semantic table renderer, and two motion hooks: useRowFlip (rows glide to a new order) and useSelectionStagger (a range selection lights up row by row).",
  "dependencies": [
    "@tanstack/react-table",
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/_primitives/table-core/table-core.tsx",
      "content": "\"use client\";\n\n/**\n * table-core (P14): the shared layer under every Eased table. TanStack Table owns rows,\n * columns and sort state; this file owns what TanStack leaves to you: the feature set and\n * typed column meta (the adapters), hairline rules, density, the header sort button, and two\n * motion hooks, `useRowFlip` (rows glide to a new order) and `useSelectionStagger` (a range\n * selection lights up row by row).\n */\n\nimport {\n  createColumnHelper,\n  createSortedRowModel,\n  metaHelper,\n  rowSortingFeature,\n  sortFn_alphanumeric,\n  sortFn_basic,\n  sortFn_datetime,\n  sortFn_text,\n  tableFeatures,\n  type Column,\n  type ColumnDef,\n  type ReactTable,\n  type RowData,\n} from \"@tanstack/react-table\";\nimport { animate, motion, motionValue, type MotionValue } from \"motion/react\";\nimport {\n  createContext,\n  useContext,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type ReactNode,\n  type RefObject,\n} from \"react\";\nimport { useScrollFades } from \"../../lib/dom\";\nimport { enter, smooth, stagger, useSafeReducedMotion } from \"../../lib/motion\";\n\n/* ---------- TanStack adapters ----------------------------------------------------------- */\n\n/** Per-column presentation, read by every Eased table. */\nexport type EasedColumnMeta = {\n  /** `end` right-aligns the header and cells and sets tabular numbers. Use it for every numeric column. Default `start`. */\n  align?: \"start\" | \"end\";\n  /** The one emphasised cell per row (usually the name): full-strength text at weight 500. The rest read secondary. */\n  emphasis?: boolean;\n  /**\n   * This column takes the space left over and truncates its content when the table is narrow,\n   * instead of pushing the table into a horizontal scroll. Give it to one text column (the name).\n   */\n  grow?: boolean;\n  /** A width hint for the column, e.g. `\"7rem\"` or `\"40%\"`. */\n  width?: string;\n  /** Classes for the column's header and cells, e.g. `\"@max-md:hidden\"` to drop a column in a narrow container. */\n  className?: string;\n};\n\n/**\n * The feature set every Eased table is built with: sorting (with the four common comparators\n * registered) and typed column meta. One set for all tables, so one column helper serves\n * Table and Sortable Table; Table simply switches sorting off.\n */\nexport const easedFeatures = tableFeatures({\n  rowSortingFeature,\n  sortedRowModel: createSortedRowModel(),\n  sortFns: { alphanumeric: sortFn_alphanumeric, text: sortFn_text, datetime: sortFn_datetime, basic: sortFn_basic },\n  columnMeta: metaHelper<EasedColumnMeta>(),\n});\nexport type EasedFeatures = typeof easedFeatures;\n\n/** A column definition for an Eased table. */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- columns hold mixed value types, as in TanStack's own `columns` option\nexport type EasedColumnDef<TData extends RowData> = ColumnDef<EasedFeatures, TData, any>;\nexport type EasedTable<TData extends RowData> = ReactTable<EasedFeatures, TData>;\n\n/** `const col = easedColumns<Invoice>()` then `col.columns([col.accessor(\"amount\", { meta: { align: \"end\" } })])`. */\nexport function easedColumns<TData extends RowData>() {\n  return createColumnHelper<EasedFeatures, TData>();\n}\n\n/** The `aria-sort` value for a header: set only on the column that is sorted, as the APG recommends. */\nexport function ariaSortOf<TData extends RowData>(column: Column<EasedFeatures, TData>) {\n  const dir = column.getCanSort() ? column.getIsSorted() : false;\n  return dir === \"asc\" ? \"ascending\" : dir === \"desc\" ? \"descending\" : undefined;\n}\n\n/* ---------- Density and hairlines ------------------------------------------------------- */\n\n/** `dense`: 36px rows, 13px text (data-heavy views). `comfortable`: 48px rows, 14px text. Rule 92: one per block. */\nexport type TableDensity = \"dense\" | \"comfortable\";\n\nexport const DENSITY = {\n  dense: { head: \"h-8\", row: \"h-9\", text: \"text-ui-sm\", headText: \"text-ui-xs\" },\n  comfortable: { head: \"h-10\", row: \"h-12\", text: \"text-ui\", headText: \"text-ui-sm\" },\n} as const;\n\nconst DensityContext = createContext<TableDensity>(\"dense\");\n/** The density of the table a cell renders in, so cells (avatars, meters) size themselves. */\nexport const useTableDensity = () => useContext(DensityContext);\n\n/**\n * Hairlines (rule 19): half-pixel inset rings drawn as box-shadow, so they never change a\n * row's box. A bottom rule for rows and the header; they move with a row's transform.\n */\nexport const hairline = {\n  row: \"shadow-[inset_0_-0.5px_0_0_var(--eased-line)]\",\n  head: \"shadow-[inset_0_-0.5px_0_0_var(--eased-line-strong)]\",\n} as const;\n\n/**\n * Horizontal cell padding. Inner cells get 12px; the first and last get `--table-inset`\n * (the parent's `--pad`, 16px in a DemoFrame) so the first column sits on the same line as\n * the panel's title and the last column's right edge on the panel's right line (rule 101).\n */\nexport function cellPad(index: number, count: number) {\n  const left = index === 0 ? \"pl-(--table-inset)\" : \"pl-3\";\n  const right = index === count - 1 ? \"pr-(--table-inset)\" : \"pr-3\";\n  return `${left} ${right}`;\n}\n\n/* ---------- The renderer --------------------------------------------------------------- */\n\nexport type TableViewProps<TData extends RowData> = {\n  table: EasedTable<TData>;\n  /** The table's accessible name, rendered as a visually hidden `<caption>`. */\n  label: string;\n  density?: TableDensity;\n  /** Keep the header pinned while the body scrolls. Give the table a bounded height (e.g. `flex-1 min-h-0`). Default true. */\n  stickyHeader?: boolean;\n  /** Animate rows to a new order (FLIP). Default false; Sortable Table turns it on. */\n  flip?: boolean;\n  /** No FLIP above this many rendered rows. Default 60. */\n  flipMaxRows?: number;\n  /** Shown in place of rows when there are none. */\n  empty?: ReactNode;\n  className?: string;\n  style?: CSSProperties;\n};\n\n// Feathers on the trailing edges only: a top feather would fade the sticky header.\nconst MASK: CSSProperties = {\n  maskImage:\n    \"linear-gradient(to bottom, #000 calc(100% - var(--fade-bottom, 0px)), transparent), linear-gradient(to right, transparent, #000 var(--fade-start, 0px), #000 calc(100% - var(--fade-end, 0px)), transparent)\",\n  maskComposite: \"intersect\",\n  WebkitMaskComposite: \"source-in\",\n};\n\n/**\n * Renders a TanStack table as Eased markup: a semantic `<table>` in a scroller that feathers\n * the edges with more content, a hairline header with sort buttons, and hairline rows.\n */\nexport function TableView<TData extends RowData>({\n  table,\n  label,\n  density = \"dense\",\n  stickyHeader = true,\n  flip = false,\n  flipMaxRows = 60,\n  empty = \"Nothing here yet\",\n  className = \"\",\n  style,\n}: TableViewProps<TData>) {\n  const scroller = useRef<HTMLDivElement>(null);\n  const body = useRef<HTMLTableSectionElement>(null);\n  useScrollFades(scroller, \"x\");\n  useScrollFades(scroller, \"y\");\n\n  const d = DENSITY[density];\n  const rows = table.getRowModel().rows;\n  const count = table.getAllLeafColumns().length;\n  const multi = (table.state.sorting?.length ?? 0) > 1;\n\n  useRowFlip(\n    body,\n    rows.map((r) => r.id),\n    { enabled: flip, maxRows: flipMaxRows },\n  );\n\n  return (\n    <DensityContext.Provider value={density}>\n      <div\n        ref={scroller}\n        style={{ ...MASK, ...style }}\n        className={`relative min-w-0 overflow-auto overscroll-contain [--table-bg:var(--eased-surface)] [--table-inset:var(--pad,16px)] [scrollbar-width:thin] ${className}`}\n      >\n        <table className={`w-full border-separate border-spacing-0 text-left ${d.text}`}>\n          <caption className=\"sr-only\">{label}</caption>\n          <thead>\n            {table.getHeaderGroups().map((group) => (\n              <tr key={group.id}>\n                {group.headers.map((header, i) => {\n                  const meta = header.column.columnDef.meta;\n                  const end = meta?.align === \"end\";\n                  const canSort = header.column.getCanSort();\n                  return (\n                    <th\n                      key={header.id}\n                      scope=\"col\"\n                      colSpan={header.colSpan}\n                      aria-sort={ariaSortOf(header.column)}\n                      style={meta?.width ? { width: meta.width } : undefined}\n                      className={`${d.head} ${d.headText} p-0 align-middle font-medium whitespace-nowrap text-text-muted ${hairline.head} ${\n                        stickyHeader ? \"sticky top-0 z-10 bg-(--table-bg)\" : \"\"\n                      } ${end ? \"text-right\" : \"\"} ${meta?.grow ? \"w-full max-w-0\" : \"\"} ${meta?.className ?? \"\"}`}\n                    >\n                      {header.isPlaceholder ? null : canSort ? (\n                        <SortButton\n                          dir={header.column.getIsSorted()}\n                          next={header.column.getNextSortingOrder() || header.column.getFirstSortDir()}\n                          index={multi ? header.column.getSortIndex() : -1}\n                          end={end}\n                          className={cellPad(i, count)}\n                          onClick={header.column.getToggleSortingHandler()}\n                        >\n                          <table.FlexRender header={header} />\n                        </SortButton>\n                      ) : (\n                        <div className={`flex h-full items-center ${end ? \"justify-end\" : \"\"} ${cellPad(i, count)}`}>\n                          <table.FlexRender header={header} />\n                        </div>\n                      )}\n                    </th>\n                  );\n                })}\n              </tr>\n            ))}\n          </thead>\n          <tbody ref={body}>\n            {rows.length === 0 ? (\n              <tr>\n                <td colSpan={count} className=\"h-24 px-(--table-inset) text-center text-ui-sm text-text-muted\">\n                  {empty}\n                </td>\n              </tr>\n            ) : (\n              rows.map((row, r) => (\n                <tr key={row.id} data-row-key={row.id} className={`group ${d.row} hover-fine:bg-surface-hover`}>\n                  {row.getAllCells().map((cell, i) => {\n                    const meta = cell.column.columnDef.meta;\n                    return (\n                      <td\n                        key={cell.id}\n                        className={`align-middle ${cellPad(i, count)} ${r < rows.length - 1 ? hairline.row : \"\"} ${\n                          meta?.align === \"end\" ? \"text-right tabular-nums\" : \"\"\n                        } ${meta?.emphasis ? \"font-medium text-text\" : \"text-text-secondary\"} ${meta?.grow ? \"w-full max-w-0\" : \"whitespace-nowrap\"} ${meta?.className ?? \"\"}`}\n                      >\n                        <table.FlexRender cell={cell} />\n                      </td>\n                    );\n                  })}\n                </tr>\n              ))\n            )}\n          </tbody>\n        </table>\n      </div>\n    </DensityContext.Provider>\n  );\n}\n\n/* ---------- Sort button ----------------------------------------------------------------- */\n\nexport type SortButtonProps = {\n  /** The column's current direction, or false. */\n  dir: false | \"asc\" | \"desc\";\n  /** The direction a click moves to, previewed by the hover arrow. */\n  next: \"asc\" | \"desc\";\n  /** Zero-based position in a multi-sort, shown as a small number; -1 hides it. */\n  index?: number;\n  /** Right-aligned column: the arrow leads, so the label's right edge stays on the numbers' edge. */\n  end?: boolean;\n  onClick?: (event: unknown) => void;\n  className?: string;\n  children: ReactNode;\n};\n\n/**\n * A full-cell header button. Unsorted, its arrow is hidden until the header is hovered or\n * focused, and then previews the order a click will apply; the active sort's arrow always\n * shows and rotates between up and down on the smooth spring. The arrow's slot is always\n * reserved, so headers never shift (rule 108).\n */\nexport function SortButton({ dir, next, index = -1, end = false, onClick, className = \"\", children }: SortButtonProps) {\n  const reduced = useSafeReducedMotion();\n  const shown = dir || next;\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      className={`group flex h-full w-full cursor-pointer items-center gap-1 rounded-control-xs outline-2 -outline-offset-2 outline-transparent select-none transition-colors duration-150 ease-standard focus-visible:shadow-[inset_0_0_0_2px_var(--eased-ring)] hover-fine:text-text ${\n        end ? \"flex-row-reverse\" : \"\"\n      } ${dir ? \"text-text\" : \"\"} ${className}`}\n    >\n      <span className=\"min-w-0 truncate\">{children}</span>\n      <span aria-hidden className=\"inline-flex size-3.5 shrink-0 items-center justify-center\">\n        <motion.svg\n          viewBox=\"0 0 14 14\"\n          initial={false}\n          animate={{ rotate: shown === \"desc\" ? 180 : 0 }}\n          transition={reduced ? { duration: 0 } : smooth}\n          className={`size-3.5 fill-none stroke-current transition-opacity duration-150 ease-standard ${\n            dir ? \"opacity-100\" : \"opacity-0 group-hover-fine:opacity-60 group-focus-visible:opacity-60\"\n          }`}\n          strokeWidth={1.5}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        >\n          <path d=\"M7 11V3.5M3.75 6.5 7 3.25l3.25 3.25\" />\n        </motion.svg>\n      </span>\n      {/* After the arrow, on the side away from the label, so the label never moves. */}\n      {index >= 0 && dir && <span className=\"-mx-0.5 text-ui-2xs leading-none tabular-nums\"><span className=\"sr-only\">, sort priority </span>{index + 1}</span>}\n    </button>\n  );\n}\n\n/* ---------- useRowFlip ------------------------------------------------------------------ */\n\nexport type RowFlipOptions = {\n  /** Default true. */\n  enabled?: boolean;\n  /** Skip the animation when more rows than this are rendered. Default 60. */\n  maxRows?: number;\n};\n\ntype Track = { el: HTMLElement; y: MotionValue<number>; stop: () => void };\n\n/**\n * FLIP for table rows. Give each row `data-row-key`; pass the container and the current key\n * order. When the order changes, every row that moved starts from where it was on screen and\n * springs to its new place (`smooth`, no overshoot); a row that was not there before fades in.\n * Interrupting mid-flight starts from the row's current visual position, so rapid re-sorts\n * never jump. Positions are `offsetTop`, which ignores transforms and scrolling. Reduced\n * motion and tables above `maxRows` reorder instantly.\n */\nexport function useRowFlip(ref: RefObject<HTMLElement | null>, order: readonly string[], options: RowFlipOptions = {}) {\n  const { enabled = true, maxRows = 60 } = options;\n  const reduced = useSafeReducedMotion();\n  const tops = useRef(new Map<string, number>());\n  const tracks = useRef(new Map<string, Track>());\n  const key = order.join(\"\\u0000\");\n\n  useLayoutEffect(() => {\n    const root = ref.current;\n    if (!root) return;\n    const rows = root.querySelectorAll<HTMLElement>(\"[data-row-key]\");\n    const prev = tops.current;\n    const next = new Map<string, number>();\n    const run = enabled && !reduced && prev.size > 0 && rows.length <= maxRows;\n\n    rows.forEach((el) => {\n      const k = el.dataset.rowKey!;\n      const top = el.offsetTop;\n      next.set(k, top);\n      if (!run) return;\n      const was = prev.get(k);\n      if (was === undefined) {\n        animate(el, { opacity: [0, 1] }, enter);\n        return;\n      }\n      let track = tracks.current.get(k);\n      if (!track || track.el !== el) {\n        track?.stop();\n        const y = motionValue(0);\n        const stop = y.on(\"change\", (v) => {\n          el.style.transform = Math.abs(v) < 0.01 ? \"\" : `translateY(${v}px)`;\n        });\n        track = { el, y, stop };\n        tracks.current.set(k, track);\n      }\n      const delta = was + track.y.get() - top;\n      if (Math.abs(delta) < 0.5) return;\n      track.y.jump(delta);\n      animate(track.y, 0, smooth);\n    });\n    tops.current = next;\n  }, [ref, key, enabled, reduced, maxRows]);\n\n  // Row heights change with density, fonts and width; keep the snapshot current.\n  useLayoutEffect(() => {\n    const root = ref.current;\n    if (!root) return;\n    const ro = new ResizeObserver(() => {\n      const map = new Map<string, number>();\n      root.querySelectorAll<HTMLElement>(\"[data-row-key]\").forEach((el) => map.set(el.dataset.rowKey!, el.offsetTop));\n      tops.current = map;\n    });\n    ro.observe(root);\n    const all = tracks.current;\n    return () => {\n      ro.disconnect();\n      all.forEach((t) => {\n        t.y.stop();\n        t.stop();\n      });\n      all.clear();\n    };\n  }, [ref]);\n}\n\n/* ---------- useSelectionStagger --------------------------------------------------------- */\n\nexport type SelectionStaggerOptions = {\n  /** Seconds between rows. Default `stagger.char` (16ms). */\n  step?: number;\n  /** Cap on any row's delay, seconds (rule 83). Default 0.4. */\n  max?: number;\n  /** The row the range started from (the Shift-click anchor): rows light up outward from it. */\n  anchor?: string;\n};\n\n/**\n * When several rows become selected at once (a Shift-click range, select-all), returns each\n * new row's delay so the highlight runs through the range one row at a time instead of\n * flashing at once. Pass the selection map (TanStack's `rowSelection`) and the rendered row\n * order; use the delay as a `transitionDelay` on the row's background. Single selections and\n * deselections are instant, as is everything under reduced motion.\n */\nexport function useSelectionStagger(\n  selected: Readonly<Record<string, boolean>>,\n  order: readonly string[],\n  { step = stagger.char, max = 0.4, anchor }: SelectionStaggerOptions = {},\n): (id: string) => number {\n  const reduced = useSafeReducedMotion();\n  const [prev, setPrev] = useState(selected);\n  const [delays, setDelays] = useState<ReadonlyMap<string, number>>(() => new Map());\n\n  // Adjusting state while rendering when the input changes (React's \"previous props\" pattern).\n  if (prev !== selected) {\n    setPrev(selected);\n    const added = order.filter((id) => selected[id] && !prev[id]);\n    const from = anchor ? Math.max(0, order.indexOf(anchor)) : 0;\n    const dist = (id: string) => Math.abs(order.indexOf(id) - from);\n    const map = new Map<string, number>();\n    if (added.length > 1) added.sort((a, b) => dist(a) - dist(b)).forEach((id, i) => map.set(id, Math.min(i * step, max)));\n    setDelays(map);\n  }\n\n  return (id) => (reduced ? 0 : (delays.get(id) ?? 0));\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/table-core/table-core.tsx"
    }
  ],
  "type": "registry:ui"
}