{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sticky-columns",
  "type": "registry:ui",
  "title": "Sticky Columns",
  "description": "Pinned columns, header and totals whose edges shade only while something hides beneath.",
  "categories": [
    "tables"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json"
  ],
  "files": [
    {
      "path": "components/ui/sticky-columns.tsx",
      "content": "\"use client\";\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\nexport type StickyColumn<T> = {\n  key: string;\n  header: React.ReactNode;\n  cell?: (row: T) => React.ReactNode;\n  /** Right-aligned tabular figures. */\n  numeric?: boolean;\n  /** Pixel width. Required for pinned columns, since their offsets are sums of widths. */\n  width?: number;\n  /** Keep this column in view while the rest scroll sideways. */\n  pin?: \"start\" | \"end\";\n  /** A cell in the sticky footer row, e.g. a total. */\n  footer?: (rows: T[]) => React.ReactNode;\n  className?: string;\n};\n\nexport type StickyColumnsProps<T> = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  columns: StickyColumn<T>[];\n  rows: T[];\n  getRowId: (row: T) => string;\n  /** Names the table and its scroll region for assistive tech. */\n  caption: string;\n  size?: \"sm\" | \"md\";\n  empty?: React.ReactNode;\n};\n\ntype Edges = { start: boolean; end: boolean; top: boolean; bottom: boolean };\n\n/**\n * Where the scroller is, so pinned edges only cast a shadow when something is actually\n * hidden beneath them. At rest a pinned column reads as part of the table.\n */\nexport function useScrollEdges<E extends HTMLElement>() {\n  const ref = useRef<E>(null);\n  const [edges, setEdges] = useState<Edges>({ start: false, end: false, top: false, bottom: false });\n  const [width, setWidth] = useState<number>();\n  const measure = (el: HTMLElement) => {\n    setWidth(el.clientWidth);\n    // scrollLeft is negative in right-to-left layouts; the magnitude is what matters.\n    const x = Math.abs(el.scrollLeft);\n    const next = {\n      // A pixel of slack: fractional widths leave the scroll position a hair short of its end.\n      start: x > 1,\n      end: x + el.clientWidth < el.scrollWidth - 1,\n      top: el.scrollTop > 1,\n      bottom: el.scrollTop + el.clientHeight < el.scrollHeight - 1,\n    };\n    setEdges((e) => (e.start === next.start && e.end === next.end && e.top === next.top && e.bottom === next.bottom ? e : next));\n  };\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => measure(el));\n    ro.observe(el);\n    if (el.firstElementChild) ro.observe(el.firstElementChild);\n    return () => ro.disconnect();\n  }, []);\n  return { ref, edges, width, onScroll: (e: React.UIEvent<E>) => measure(e.currentTarget) };\n}\n\nexport function StickyColumns<T>({ columns, rows, getRowId, caption, size = \"md\", empty = \"Nothing here yet\", className, style, onScroll, ...rest }: StickyColumnsProps<T>) {\n  const { ref, edges, width, onScroll: track } = useScrollEdges<HTMLDivElement>();\n  const captionId = useId();\n\n  // Pinned columns must leave room to scroll. When they'd take more than 60% of the visible width\n  // (a phone), end pins let go first, then every start pin after the first.\n  const effective = useMemo(() => {\n    if (!width) return columns;\n    const room = width * 0.6;\n    const pinnedWidth = (cols: StickyColumn<T>[]) => cols.reduce((sum, c) => sum + (c.pin ? (c.width ?? 0) : 0), 0);\n    let cols = columns;\n    if (pinnedWidth(cols) > room) cols = cols.map((c) => (c.pin === \"end\" ? { ...c, pin: undefined } : c));\n    if (pinnedWidth(cols) > room) {\n      const first = cols.find((c) => c.pin === \"start\")?.key;\n      cols = cols.map((c) => (c.pin === \"start\" && c.key !== first ? { ...c, pin: undefined } : c));\n    }\n    return cols;\n  }, [columns, width]);\n\n  // Offsets for pinned columns: each start column sits after the ones pinned before it,\n  // each end column before the ones pinned after it.\n  const layout = useMemo(() => {\n    const offsets = new Map<string, number>();\n    let start = 0;\n    for (const c of effective) if (c.pin === \"start\") offsets.set(c.key, (start += c.width ?? 0) - (c.width ?? 0));\n    let end = 0;\n    for (const c of [...effective].reverse()) if (c.pin === \"end\") offsets.set(c.key, (end += c.width ?? 0) - (c.width ?? 0));\n    const lastStart = [...effective].reverse().find((c) => c.pin === \"start\")?.key;\n    const firstEnd = effective.find((c) => c.pin === \"end\")?.key;\n    const total = effective.reduce((sum, c) => sum + (c.width ?? 120), 0);\n    return { offsets, lastStart, firstEnd, start, end, total };\n  }, [effective]);\n\n  const hasFooter = effective.some((c) => c.footer);\n  const h = size === \"sm\" ? \"h-8\" : \"h-10\";\n\n  // Shared classes for a cell in a pinned column, whichever row it sits in.\n  const pinned = (col: StickyColumn<T>) => {\n    if (!col.pin) return \"\";\n    const isEdge = col.key === layout.lastStart || col.key === layout.firstEnd;\n    return cn(\n      \"sticky\",\n      isEdge &&\n        cn(\n          // The edge: a hairline and a soft shadow, one pseudo-element, present only while columns are hidden\n          // beneath it. Drawn outside the cell so the row dividers never get notched at the corner.\n          \"after:pointer-events-none after:absolute after:inset-y-0 after:w-3 after:opacity-0 after:transition-opacity after:duration-200 after:content-['']\",\n          col.pin === \"start\"\n            ? \"after:left-[calc(100%-1px)] after:border-l after:border-line after:bg-linear-to-r after:from-overlay/60 after:to-transparent in-data-scroll-start:after:opacity-100\"\n            : \"after:right-[calc(100%-1px)] after:border-r after:border-line after:bg-linear-to-l after:from-overlay/60 after:to-transparent in-data-scroll-end:after:opacity-100\",\n        ),\n    );\n  };\n  const pinStyle = (col: StickyColumn<T>): React.CSSProperties | undefined =>\n    col.pin === \"start\" ? { left: layout.offsets.get(col.key) } : col.pin === \"end\" ? { right: layout.offsets.get(col.key) } : undefined;\n\n  return (\n    <div\n      ref={ref}\n      role=\"region\"\n      aria-labelledby={captionId}\n      // Focusable so the table can be scrolled sideways from the keyboard.\n      tabIndex={0}\n      data-scroll-start={edges.start || undefined}\n      data-scroll-end={edges.end || undefined}\n      data-scroll-top={edges.top || undefined}\n      data-scroll-bottom={edges.bottom || undefined}\n      onScroll={(e) => {\n        onScroll?.(e);\n        track(e);\n      }}\n      style={{\n        // Anything scrolled into view (a focused link, a found word) lands clear of the pinned columns.\n        scrollPaddingInlineStart: layout.start,\n        scrollPaddingInlineEnd: layout.end,\n        ...style,\n      }}\n      className={cn(\n        \"relative overflow-auto overscroll-contain rounded-xl border border-line bg-frame outline-none\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        className,\n      )}\n      {...rest}\n    >\n      <table style={{ minWidth: layout.total }} className=\"w-full table-fixed border-separate border-spacing-0 text-[13px]\">\n        <caption id={captionId} className=\"sr-only\">\n          {caption}\n        </caption>\n        <colgroup>\n          {effective.map((c) => (\n            <col key={c.key} style={c.width != null ? { width: c.width } : undefined} />\n          ))}\n        </colgroup>\n        <thead>\n          <tr>\n            {effective.map((col) => (\n              <th\n                key={col.key}\n                scope=\"col\"\n                style={pinStyle(col)}\n                className={cn(\n                  \"sticky top-0 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                  // Corners sit above both the header row and the pinned body cells.\n                  col.pin ? \"z-[calc(var(--z-sticky)+1)]\" : \"z-(--z-sticky)\",\n                  pinned(col),\n                  // Rows scrolled beneath the header: a short shade under it, drawn per cell so the seams meet cleanly.\n                  \"before:pointer-events-none before:absolute before:inset-x-0 before:top-full before:h-2 before:bg-linear-to-b before:from-overlay before:to-transparent before:opacity-0 before:transition-opacity before:duration-200 before:content-[''] in-data-scroll-top:before:opacity-40\",\n                )}\n              >\n                <span className=\"block truncate\">{col.header}</span>\n              </th>\n            ))}\n          </tr>\n        </thead>\n        <tbody>\n          {rows.map((row) => (\n            <tr key={getRowId(row)} className=\"group/row\">\n              {effective.map((col, i) => {\n                // The first column names the row, so it is the row's header for screen readers.\n                const Cell = i === 0 ? \"th\" : \"td\";\n                return (\n                  <Cell\n                    key={col.key}\n                    scope={i === 0 ? \"row\" : undefined}\n                    style={pinStyle(col)}\n                    className={cn(\n                      \"border-b border-line bg-frame px-3 font-normal text-fg transition-colors duration-150 group-hover/row:bg-hover\",\n                      !hasFooter && \"group-last/row:border-b-0\",\n                      h,\n                      col.numeric ? \"whitespace-nowrap text-right tabular\" : \"text-left\",\n                      col.pin && \"z-[1]\",\n                      pinned(col),\n                      col.className,\n                    )}\n                  >\n                    {col.cell ? col.cell(row) : String((row as Record<string, unknown>)[col.key] ?? \"\")}\n                  </Cell>\n                );\n              })}\n            </tr>\n          ))}\n          {rows.length === 0 && (\n            <tr>\n              <td colSpan={effective.length} className=\"h-32 p-0\">\n                {/* The empty message stays in view however far the empty table is scrolled. */}\n                <span className=\"sticky left-0 flex h-full w-max items-center px-4 text-[13px] text-fg-3\">\n                  {empty}\n                </span>\n              </td>\n            </tr>\n          )}\n        </tbody>\n        {hasFooter && rows.length > 0 && (\n          <tfoot>\n            <tr>\n              {effective.map((col) => (\n                <td\n                  key={col.key}\n                  style={pinStyle(col)}\n                  className={cn(\n                    \"sticky bottom-0 border-t border-line bg-frame px-3 font-medium text-fg\",\n                    h,\n                    col.numeric ? \"whitespace-nowrap text-right tabular\" : \"text-left\",\n                    col.pin ? \"z-[calc(var(--z-sticky)+1)]\" : \"z-(--z-sticky)\",\n                    pinned(col),\n                    \"before:pointer-events-none before:absolute before:inset-x-0 before:bottom-full before:h-2 before:bg-linear-to-t before:from-overlay before:to-transparent before:opacity-0 before:transition-opacity before:duration-200 before:content-[''] in-data-scroll-bottom:before:opacity-40\",\n                  )}\n                >\n                  {col.footer?.(rows)}\n                </td>\n              ))}\n            </tr>\n          </tfoot>\n        )}\n      </table>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/sticky-columns"
}
