{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-table",
  "title": "Data Table",
  "description": "A typed data table with sorting, search and row selection, where rows glide into their new order.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "app/registry/data-table/data-table.tsx",
      "content": "'use client';\n\nimport * as React from 'react';\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  motion,\n  useMotionValueEvent,\n  useReducedMotion,\n  useSpring,\n} from 'motion/react';\n\nimport { cn } from '@/lib/utils';\n\n/* ---------------------------------------------------------------------------\n * Iconly on a 24px grid, recolored with `currentColor`. Outline (Light) is the\n * idle mark — search, clear — and fill (Bold) is reserved for a state that is\n * already on: a copied tick, a document in the empty body.\n * ------------------------------------------------------------------------- */\n\ntype IconProps = React.SVGProps<SVGSVGElement>;\n\nfunction Glyph({ children, ...props }: IconProps) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\" {...props}>\n      {children}\n    </svg>\n  );\n}\n\n/** 1.5px stroke beside regular (400) text. */\nfunction StrokeGlyph({ children, ...props }: IconProps) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.5}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n      {...props}\n    >\n      {children}\n    </svg>\n  );\n}\n\n/** Iconly / Bold / Arrow - Down 2, rotated for the other three directions. */\nfunction IconChevron(props: IconProps) {\n  return (\n    <Glyph {...props}>\n      <path\n        transform=\"translate(6, 7)\"\n        d=\"M4.869,9.631 C4.811,9.574 4.563,9.361 4.359,9.162 C3.076,7.997 0.976,4.958 0.335,3.367 C0.232,3.125 0.014,2.514 0,2.188 C0,1.875 0.072,1.577 0.218,1.293 C0.422,0.938 0.743,0.654 1.122,0.498 C1.385,0.397 2.172,0.242 2.186,0.242 C3.047,0.086 4.446,0 5.992,0 C7.465,0 8.807,0.086 9.681,0.213 C9.695,0.228 10.673,0.384 11.008,0.554 C11.62,0.867 12,1.478 12,2.132 L12,2.188 C11.985,2.614 11.605,3.509 11.591,3.509 C10.949,5.014 8.952,7.983 7.625,9.177 C7.625,9.177 7.284,9.513 7.071,9.659 C6.765,9.887 6.386,10 6.007,10 C5.584,10 5.19,9.872 4.869,9.631\"\n      />\n    </Glyph>\n  );\n}\n\n/** Iconly / Light / Search — outline, because a search field is idle until it has a query. */\nfunction IconSearch(props: IconProps) {\n  return (\n    <StrokeGlyph {...props}>\n      <circle cx=\"11.7666\" cy=\"11.7666\" r=\"8.98856\" />\n      <path d=\"M18.0183 18.4851L21.5423 22\" />\n    </StrokeGlyph>\n  );\n}\n\n/** Iconly / Light / Close — the trailing clear on the search field. */\nfunction IconCloseLine(props: IconProps) {\n  return (\n    <StrokeGlyph {...props}>\n      <path d=\"M6 6l12 12M18 6L6 18\" />\n    </StrokeGlyph>\n  );\n}\n\n/** Iconly / Bold / Close Square. */\nfunction IconClose(props: IconProps) {\n  return (\n    <Glyph {...props}>\n      <path\n        transform=\"translate(2, 2)\"\n        d=\"M14.34,0 C17.73,0 20,2.38 20,5.92 L20,5.92 L20,14.091 C20,17.621 17.73,20 14.34,20 L14.34,20 L5.67,20 C2.28,20 0,17.621 0,14.091 L0,14.091 L0,5.92 C0,2.38 2.28,0 5.67,0 L5.67,0 Z M13.01,6.971 C12.67,6.63 12.12,6.63 11.77,6.971 L11.77,6.971 L10,8.75 L8.22,6.971 C7.87,6.63 7.32,6.63 6.98,6.971 C6.64,7.311 6.64,7.871 6.98,8.21 L6.98,8.21 L8.76,9.991 L6.98,11.761 C6.64,12.111 6.64,12.661 6.98,13 C7.15,13.17 7.38,13.261 7.6,13.261 C7.83,13.261 8.05,13.17 8.22,13 L8.22,13 L10,11.231 L11.78,13 C11.95,13.181 12.17,13.261 12.39,13.261 C12.62,13.261 12.84,13.17 13.01,13 C13.35,12.661 13.35,12.111 13.01,11.771 L13.01,11.771 L11.23,9.991 L13.01,8.21 C13.35,7.871 13.35,7.311 13.01,6.971 Z\"\n      />\n    </Glyph>\n  );\n}\n\n/** Iconly / Bold / Document — the empty state. */\nfunction IconDocument(props: IconProps) {\n  return (\n    <Glyph {...props}>\n      <path\n        transform=\"translate(3, 2)\"\n        d=\"M13.191,0 C16.28,0 18,1.78 18,4.83 L18,4.83 L18,15.16 C18,18.26 16.28,20 13.191,20 L13.191,20 L4.81,20 C1.77,20 0,18.26 0,15.16 L0,15.16 L0,4.83 C0,1.78 1.77,0 4.81,0 L4.81,0 Z M5.08,13.74 C4.78,13.71 4.49,13.85 4.33,14.11 C4.17,14.36 4.17,14.69 4.33,14.95 C4.49,15.2 4.78,15.35 5.08,15.31 L5.08,15.31 L12.92,15.31 C13.319,15.27 13.62,14.929 13.62,14.53 C13.62,14.12 13.319,13.78 12.92,13.74 L12.92,13.74 Z M12.92,9.179 L5.08,9.179 C4.649,9.179 4.3,9.53 4.3,9.96 C4.3,10.39 4.649,10.74 5.08,10.74 L5.08,10.74 L12.92,10.74 C13.35,10.74 13.7,10.39 13.7,9.96 C13.7,9.53 13.35,9.179 12.92,9.179 L12.92,9.179 Z M8.069,4.65 L5.08,4.65 L5.08,4.66 C4.649,4.66 4.3,5.01 4.3,5.44 C4.3,5.87 4.649,6.22 5.08,6.22 L5.08,6.22 L8.069,6.22 C8.5,6.22 8.85,5.87 8.85,5.429 C8.85,5 8.5,4.65 8.069,4.65 L8.069,4.65 Z\"\n      />\n    </Glyph>\n  );\n}\n\n/** Iconly / Bold / Copy 2 — the clipboard action. */\nfunction IconCopy(props: IconProps) {\n  return (\n    <Glyph {...props}>\n      <path d=\"M15.565,6.686 C15.709,6.686 15.825,6.57 15.825,6.426 C15.825,4.076 14.315,2.496 12.055,2.496 L6.285,2.496 C4.025,2.496 2.505,4.076 2.505,6.426 L2.505,11.866 C2.505,14.226 4.025,15.806 6.285,15.806 L6.375,15.806 C6.541,15.806 6.675,15.672 6.675,15.506 L6.675,12.126 C6.675,8.976 8.895,6.686 11.945,6.686 L15.565,6.686 Z\" />\n      <path d=\"M17.722,8.186 L11.949,8.186 C9.691,8.186 8.175,9.77 8.175,12.126 L8.175,17.565 C8.175,19.921 9.691,21.505 11.949,21.505 L17.721,21.505 C19.978,21.505 21.495,19.921 21.495,17.565 L21.495,12.126 C21.495,9.77 19.979,8.186 17.722,8.186 Z\" />\n    </Glyph>\n  );\n}\n\n/** Iconly / Bold / Tick Square — the copied confirmation. */\nfunction IconTickSquare(props: IconProps) {\n  return (\n    <Glyph {...props}>\n      <path\n        transform=\"translate(2, 2)\"\n        d=\"M14.34,0 C17.73,0 20,2.38 20,5.92 L20,14.091 C20,17.62 17.73,20 14.34,20 L5.67,20 C2.28,20 0,17.62 0,14.091 L0,5.92 C0,2.38 2.28,0 5.67,0 L14.34,0 Z M14.18,7 C13.84,6.66 13.28,6.66 12.94,7 L8.81,11.13 L7.06,9.38 C6.72,9.04 6.16,9.04 5.82,9.38 C5.48,9.72 5.48,10.27 5.82,10.62 L8.2,12.99 C8.37,13.16 8.59,13.24 8.81,13.24 C9.04,13.24 9.26,13.16 9.43,12.99 L14.18,8.24 C14.52,7.9 14.52,7.35 14.18,7 Z\"\n      />\n    </Glyph>\n  );\n}\n\n/* ------------------------------------------------------------------------- */\n\nexport type DataTableVariant = 'default' | 'bordered' | 'striped' | 'minimal' | 'panel';\nexport type DataTableDensity = 'compact' | 'default' | 'relaxed';\nexport type DataTableAlign = 'start' | 'center' | 'end';\nexport type DataTableSortDirection = 'asc' | 'desc';\nexport type DataTableValue = string | number | boolean | Date | null | undefined;\n\nexport interface DataTableSort {\n  columnId: string;\n  direction: DataTableSortDirection;\n}\n\nexport interface DataTableColumn<T> {\n  /** Stable key. Doubles as the property read off the row when `value` is absent. */\n  id: string;\n  header: React.ReactNode;\n  /** Rendered cell. Falls back to the column's value, printed as text. */\n  cell?: (row: T, index: number) => React.ReactNode;\n  /** The comparable, searchable value behind a cell that renders markup. */\n  value?: (row: T) => DataTableValue;\n  align?: DataTableAlign;\n  width?: number | string;\n  sortable?: boolean;\n  /** End-aligns the column and switches it to tabular figures so digits line up. */\n  numeric?: boolean;\n  /** Drop the column below this breakpoint instead of letting the table scroll. */\n  hideBelow?: 'sm' | 'md' | 'lg';\n  /** Renders this column's footer sum. Defaults to a grouped integer. */\n  formatTotal?: (sum: number) => string;\n  className?: string;\n  headerClassName?: string;\n}\n\nexport interface DataTableQuickFilterOption {\n  value: string;\n  label?: React.ReactNode;\n}\n\nexport interface DataTableQuickFilter<T> {\n  /** Column whose value the pills match against. */\n  columnId: string;\n  /** Accessible name for the pill group, e.g. \"Filter by status\". */\n  label?: string;\n  /** Read the matchable value off a row when the column's own value is not a plain string. */\n  getValue?: (row: T) => string;\n  /** Defaults to the distinct values of the column, in data order. */\n  options?: DataTableQuickFilterOption[];\n  allLabel?: string;\n}\n\nexport interface DataTableSelectionContext<T> {\n  ids: string[];\n  rows: T[];\n  clear: () => void;\n  /** Sweep the selected rows out to the left, then fire `onDelete`. */\n  remove: () => void;\n}\n\nexport interface DataTableRowActionContext {\n  /** Sweep this one row out, then fire `onDelete` with its id. */\n  remove: () => void;\n}\n\nexport interface DataTableProps<T> {\n  data: readonly T[];\n  columns: DataTableColumn<T>[];\n  /** Stable identity per row. Selection and disclosure are keyed on it. */\n  rowId: (row: T) => string;\n  /** Names a row for screen readers, e.g. on its checkbox. Defaults to the first cell. */\n  rowLabel?: (row: T) => string;\n\n  variant?: DataTableVariant;\n  density?: DataTableDensity;\n\n  /** Sentence describing the table for screen readers. Never painted. */\n  caption?: string;\n  /** Heading at the start of the toolbar. */\n  title?: React.ReactNode;\n  /** Controls parked at the end of the toolbar. */\n  toolbar?: React.ReactNode;\n\n  searchable?: boolean;\n  searchPlaceholder?: string;\n  /** Override the haystack a query runs against. */\n  searchText?: (row: T) => string;\n\n  /** One-tap value pills at the end of the toolbar, with live counts. */\n  quickFilter?: DataTableQuickFilter<T>;\n\n  defaultSort?: DataTableSort | null;\n  sort?: DataTableSort | null;\n  onSortChange?: (sort: DataTableSort | null) => void;\n\n  selectable?: boolean;\n  defaultSelectedIds?: string[];\n  selectedIds?: string[];\n  onSelectedChange?: (ids: string[]) => void;\n  /** Actions for the bar that rises over the rows once something is selected. */\n  bulkActions?: (context: DataTableSelectionContext<T>) => React.ReactNode;\n\n  /** Panel revealed under a row by a leading disclosure button. One at a time. */\n  renderDetail?: (row: T) => React.ReactNode;\n  /** Trailing cell, revealed on row hover and on keyboard focus. */\n  rowActions?: (row: T, actions: DataTableRowActionContext) => React.ReactNode;\n\n  /**\n   * Fires once the removal animation has finished, with the ids that left.\n   * Delete them from your own data here — the table has already cleared them\n   * from the screen, so nothing flashes back in.\n   */\n  onDelete?: (ids: string[]) => void;\n\n  /** Rows per page. Omit to render every row. */\n  pageSize?: number;\n\n  loading?: boolean;\n  skeletonRows?: number;\n  emptyState?: React.ReactNode;\n\n  onRowClick?: (row: T) => void;\n\n  /**\n   * Arrow-key row cursor, Shift range selection and the ⌘A / ⌘C / Space\n   * shortcuts. The grid takes one Tab stop and moves a ring instead of focus.\n   */\n  keyboardNavigation?: boolean;\n  /** Copy the selected rows to the clipboard as TSV, ready to paste into a sheet. */\n  clipboard?: boolean;\n  /** Column ids to sum in a footer row that recounts as you filter. */\n  totals?: string[];\n  /** Drag a header's trailing edge to resize it; arrow keys do it too. */\n  resizableColumns?: boolean;\n  /** Keep the leading cells in place while the table scrolls sideways. */\n  pinFirstColumn?: boolean;\n\n  stickyHeader?: boolean;\n  /** Caps the scroll area, e.g. `360` or `\"60vh\"`. Pairs with `stickyHeader`. */\n  maxHeight?: number | string;\n  /** Set false to drop the reorder, disclosure and selection motion. */\n  animate?: boolean;\n\n  className?: string;\n}\n\n/* The house spring presets. Fluid repositions things that were already on\n * screen — rows finding a new sort order, a panel opening. Springs retarget\n * from the live value, so clicking a header again mid-flight redirects the\n * motion already running instead of restarting it. */\nconst SPRING_FLUID = { type: 'spring', stiffness: 300, damping: 30 } as const;\n/* Snappy is the micro-pop: the sort caret and the disclosure chevron. */\nconst SPRING_SNAPPY = { type: 'spring', stiffness: 500, damping: 28 } as const;\n/* Contextual icon swaps: scale, opacity, blur. Bounce stays 0. */\nconst SPRING_ICON = { type: 'spring', duration: 0.3, bounce: 0 } as const;\n/* Entrance is for something arriving over the content, i.e. the bulk bar. */\nconst SPRING_ENTRANCE = { type: 'spring', stiffness: 260, damping: 20 } as const;\n/* Press feedback is always 0.96; anything below 0.95 reads as a squash.\n * Named properties so this can sit on the same node as a color hover. */\nconst PRESS =\n  'transition-[transform,color,background-color,opacity,border-color] duration-150 ease-out active:scale-[0.96]';\n/* Stroke length is not a position or a scale, so it takes an ease. */\nconst DRAW = { duration: 0.18, ease: 'easeOut' } as const;\n/**\n * A row leaving. It accelerates away to the start edge rather than easing to a\n * stop, the way water pulls back off sand — and because rows leave on a\n * stagger, the set drains as one wave instead of blinking out together.\n */\nconst UNDERTOW = { duration: 0.42, ease: [0.32, 0, 0.67, 0] } as const;\n/** Gap between one row leaving and the next. Slow enough to read as a run. */\nconst WAVE_STEP = 0.06;\nconst INSTANT = { duration: 0 } as const;\n\nconst FOCUS =\n  'focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-neutral-950 dark:focus-visible:ring-neutral-300';\nconst EASE = 'ease-[cubic-bezier(0.2,0,0,1)]';\n\nconst DENSITY: Record<DataTableDensity, { head: string; cell: string; text: string; pad: string }> =\n  {\n    // 13px is off the type scale on purpose: at `text-xs` a dense table stops\n    // being readable, and at `text-sm` it stops being dense.\n    compact: { head: 'h-9', cell: 'py-1.5', text: 'text-[13px]', pad: 'px-3' },\n    default: { head: 'h-11', cell: 'py-2.5', text: 'text-sm', pad: 'px-4' },\n    relaxed: { head: 'h-12', cell: 'py-4', text: 'text-sm', pad: 'px-5' },\n  };\n\ninterface Surface {\n  frame: string;\n  head: string;\n  /** Under the header row. Lives on the cells so a sticky header keeps it. */\n  headRule: string;\n  /**\n   * Fill for a pinned header, where the variant's own is transparent. A sticky\n   * header floats over the rows, so a see-through one reads them right through.\n   */\n  headPinned?: string;\n  divider: string;\n  /**\n   * Opaque fill behind every row. Two rows swapping places during a re-sort\n   * pass over each other, and with no fill the text of both shows at once.\n   * Hover and selection tint the cells instead, so they layer on top of this.\n   */\n  rowFill: string;\n  stripe: string;\n  cell: string;\n}\n\nconst SURFACE: Record<DataTableVariant, Surface> = {\n  default: {\n    frame:\n      'rounded-2xl border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950',\n    head: 'bg-neutral-50 dark:bg-neutral-900',\n    headRule: 'border-b border-neutral-200 dark:border-neutral-800',\n    divider: 'border-t border-neutral-100 dark:border-neutral-900',\n    rowFill: 'bg-white dark:bg-neutral-950',\n    stripe: '',\n    cell: '',\n  },\n  bordered: {\n    frame:\n      'rounded-2xl border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950',\n    head: 'bg-neutral-50 dark:bg-neutral-900',\n    headRule: 'border-b border-neutral-200 dark:border-neutral-800',\n    divider: 'border-t border-neutral-200 dark:border-neutral-800',\n    rowFill: 'bg-white dark:bg-neutral-950',\n    stripe: '',\n    cell: 'border-s border-neutral-200 first:border-s-0 dark:border-neutral-800',\n  },\n  striped: {\n    frame:\n      'rounded-2xl border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950',\n    head: 'bg-white dark:bg-neutral-950',\n    headRule: 'border-b border-neutral-200 dark:border-neutral-800',\n    divider: '',\n    rowFill: 'bg-white dark:bg-neutral-950',\n    stripe: 'bg-neutral-50 dark:bg-neutral-900',\n    cell: '',\n  },\n  minimal: {\n    frame: 'bg-transparent',\n    head: 'bg-transparent',\n    headPinned: 'bg-white dark:bg-neutral-950',\n    headRule: 'border-b border-neutral-200 dark:border-neutral-800',\n    divider: 'border-t border-neutral-100 dark:border-neutral-900',\n    // No frame to sit on, so no fill either.\n    rowFill: 'bg-transparent',\n    stripe: '',\n    cell: '',\n  },\n  panel: {\n    frame:\n      'rounded-2xl bg-white shadow-[0_1px_2px_-1px_rgba(0,0,0,0.06),0_2px_4px_0_rgba(0,0,0,0.04)] ring-1 ring-neutral-950/8 dark:bg-neutral-950 dark:shadow-none dark:ring-white/10',\n    head: 'bg-neutral-50 dark:bg-neutral-900',\n    headRule: 'border-b border-neutral-200 dark:border-neutral-800',\n    divider: 'border-t border-neutral-100 dark:border-neutral-900',\n    rowFill: 'bg-white dark:bg-neutral-950',\n    stripe: '',\n    cell: '',\n  },\n};\n\nconst ALIGN: Record<DataTableAlign, string> = {\n  start: 'text-start',\n  center: 'text-center',\n  end: 'text-end',\n};\n\n/** Leading control cells are fixed width, so pin offsets need no measuring. */\nconst DETAIL_COL = 40;\nconst SELECT_COL = 48;\nconst MIN_COL = 72;\n\nconst HIDE_BELOW = {\n  sm: 'hidden sm:table-cell',\n  md: 'hidden md:table-cell',\n  lg: 'hidden lg:table-cell',\n} as const;\n\nfunction isEmpty(value: DataTableValue) {\n  return value === null || value === undefined || value === '';\n}\n\nfunction compare(a: DataTableValue, b: DataTableValue) {\n  if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();\n  if (typeof a === 'number' && typeof b === 'number') return a - b;\n  if (typeof a === 'boolean' && typeof b === 'boolean') return Number(a) - Number(b);\n  return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });\n}\n\nfunction readValue<T>(column: DataTableColumn<T> | undefined, row: T): DataTableValue {\n  if (!column) return undefined;\n  if (column.value) return column.value(row);\n  return (row as Record<string, unknown>)[column.id] as DataTableValue;\n}\n\nfunction printValue(value: DataTableValue) {\n  if (isEmpty(value)) return '';\n  // ISO rather than a locale format: a client component also renders on the\n  // server, and a locale mismatch there is a hydration error.\n  if (value instanceof Date) return value.toISOString().slice(0, 10);\n  return String(value);\n}\n\n/**\n * A footer sum that rolls to its new value. Written straight to the DOM node:\n * a spring firing setState 60 times a second would re-render the whole table.\n */\nfunction RollingTotal({\n  value,\n  format,\n  instant,\n}: {\n  value: number;\n  format: (sum: number) => string;\n  instant: boolean;\n}) {\n  const ref = React.useRef<HTMLSpanElement>(null);\n  const spring = useSpring(value, { stiffness: 260, damping: 30 });\n\n  React.useEffect(() => {\n    if (instant) spring.jump(value);\n    else spring.set(value);\n  }, [value, instant, spring]);\n\n  useMotionValueEvent(spring, 'change', (latest) => {\n    if (ref.current) ref.current.textContent = format(latest);\n  });\n\n  // The server renders the settled value, so no number ever pops in from zero.\n  return <span ref={ref}>{format(value)}</span>;\n}\n\n/** One value of a quick filter. Counts are live, so they read as a summary. */\nfunction FilterPill({\n  active,\n  onClick,\n  label,\n  count,\n  plain = false,\n  layoutId,\n  motionOn,\n}: {\n  active: boolean;\n  onClick: () => void;\n  label: React.ReactNode;\n  count: number;\n  /** Derived values arrive lowercase; explicit labels render untouched. */\n  plain?: boolean;\n  /** Shared across the group so the fill slides from one pill to the next. */\n  layoutId: string;\n  motionOn: boolean;\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-pressed={active}\n      onClick={onClick}\n      className={cn(\n        'relative flex h-7 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium',\n        PRESS,\n        plain && 'capitalize',\n        active\n          ? 'text-neutral-100 dark:text-neutral-900'\n          : 'text-neutral-500 hover:bg-neutral-900/[0.04] hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-100/[0.06] dark:hover:text-neutral-100',\n        FOCUS,\n      )}\n    >\n      {active &&\n        (motionOn ? (\n          <motion.span\n            layoutId={layoutId}\n            className=\"absolute inset-0 rounded-full bg-neutral-900 dark:bg-neutral-100\"\n            transition={SPRING_FLUID}\n          />\n        ) : (\n          <span className=\"absolute inset-0 rounded-full bg-neutral-900 dark:bg-neutral-100\" />\n        ))}\n      <span className=\"relative z-10\">{label}</span>\n      <span\n        className={cn(\n          'relative z-10 tabular-nums',\n          active ? 'opacity-60' : 'text-neutral-400 dark:text-neutral-600',\n        )}\n      >\n        {count}\n      </span>\n    </button>\n  );\n}\n\n/** A figure that rolls to its next value — pagination range, selected count. */\nfunction FlipDigits({ value, instant }: { value: string | number; instant: boolean }) {\n  return (\n    <span className=\"relative inline-flex overflow-hidden\">\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span\n          key={String(value)}\n          className=\"inline-block tabular-nums\"\n          initial={instant ? false : { y: 8, opacity: 0 }}\n          animate={{ y: 0, opacity: 1 }}\n          exit={instant ? { opacity: 0 } : { y: -8, opacity: 0 }}\n          transition={instant ? INSTANT : SPRING_SNAPPY}\n        >\n          {value}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nconst EMPTY_ITEM = {\n  hidden: { opacity: 0, y: 12, filter: 'blur(4px)' },\n  visible: { opacity: 1, y: 0, filter: 'blur(0px)' },\n};\n\n/** Staggered because an empty body appears once, not on every hover. */\nfunction EmptyHint({\n  filtered,\n  motionOn,\n  onClear,\n}: {\n  filtered: boolean;\n  motionOn: boolean;\n  onClear: () => void;\n}) {\n  return (\n    <motion.div\n      className=\"flex flex-col items-center gap-1 text-center\"\n      initial={motionOn ? 'hidden' : false}\n      animate=\"visible\"\n      variants={{\n        hidden: {},\n        visible: { transition: { staggerChildren: 0.1 } },\n      }}\n    >\n      <motion.div variants={EMPTY_ITEM} transition={motionOn ? SPRING_ICON : INSTANT}>\n        <IconDocument className=\"mb-2 size-7 text-neutral-300 dark:text-neutral-700\" />\n      </motion.div>\n      <motion.p\n        variants={EMPTY_ITEM}\n        transition={motionOn ? SPRING_ICON : INSTANT}\n        className=\"text-sm font-medium text-neutral-900 dark:text-neutral-100\"\n      >\n        {filtered ? 'No matching rows' : 'Nothing here yet'}\n      </motion.p>\n      <motion.p\n        variants={EMPTY_ITEM}\n        transition={motionOn ? SPRING_ICON : INSTANT}\n        className=\"text-sm text-neutral-500 dark:text-neutral-400\"\n      >\n        {filtered\n          ? 'Try different search terms or filters.'\n          : 'Rows appear here once there is data.'}\n      </motion.p>\n      {filtered && (\n        <motion.button\n          type=\"button\"\n          onClick={onClear}\n          variants={EMPTY_ITEM}\n          transition={motionOn ? SPRING_ICON : INSTANT}\n          className={cn(\n            'mt-3 rounded-xl border border-neutral-200 px-3 py-1.5 text-sm font-medium text-neutral-900 hover:bg-neutral-50 dark:border-neutral-800 dark:text-neutral-100 dark:hover:bg-neutral-900',\n            PRESS,\n            FOCUS,\n          )}\n        >\n          Clear filters\n        </motion.button>\n      )}\n    </motion.div>\n  );\n}\n\n/** Hand-rolled rather than the platform checkbox, because the tick draws. */\nfunction SelectBox({\n  checked,\n  indeterminate = false,\n  onChange,\n  onShiftPick,\n  label,\n  instant,\n}: {\n  checked: boolean;\n  indeterminate?: boolean;\n  onChange: (checked: boolean) => void;\n  /** Shift-click, which selects the range instead of one row. */\n  onShiftPick?: () => void;\n  label: string;\n  instant: boolean;\n}) {\n  const ref = React.useRef<HTMLInputElement>(null);\n\n  // Mixed state exists only on the DOM node, so it has to be written there.\n  React.useEffect(() => {\n    if (ref.current) ref.current.indeterminate = indeterminate;\n  }, [indeterminate]);\n\n  const filled = checked || indeterminate;\n\n  return (\n    <label className={cn('relative grid size-8 cursor-pointer place-items-center', PRESS)}>\n      <input\n        ref={ref}\n        type=\"checkbox\"\n        checked={checked}\n        aria-label={label}\n        onClick={(event) => {\n          if (!onShiftPick || !event.shiftKey) return;\n          // The change event would toggle one row; the range wants the whole run.\n          event.preventDefault();\n          onShiftPick();\n        }}\n        onChange={(event) => onChange(event.currentTarget.checked)}\n        className=\"peer absolute inset-0 size-full cursor-pointer appearance-none rounded-xl outline-hidden\"\n      />\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          'relative grid size-[18px] place-items-center overflow-hidden rounded-[5px] border',\n          'transition-[border-color] duration-150 ease-out',\n          filled\n            ? 'border-neutral-900 dark:border-neutral-100'\n            : 'border-neutral-300 bg-white hover:border-neutral-400 dark:border-neutral-700 dark:bg-neutral-950 dark:hover:border-neutral-500',\n          'peer-focus-visible:ring-1 peer-focus-visible:ring-neutral-950 dark:peer-focus-visible:ring-neutral-300',\n        )}\n      >\n        <motion.span\n          className=\"absolute inset-[1px] rounded-[4px] bg-neutral-900 dark:bg-neutral-100\"\n          initial={false}\n          animate={{ scale: filled ? 1 : 0.5, opacity: filled ? 1 : 0 }}\n          transition={instant ? INSTANT : SPRING_SNAPPY}\n        />\n        <span className=\"relative z-10 grid size-full place-items-center text-neutral-100 dark:text-neutral-900\">\n          {indeterminate ? (\n            <span className=\"block h-[2.2px] w-[9px] rounded-full bg-current\" />\n          ) : (\n            /* Iconly's tick, re-framed on its own bounds rather than inherited\n             * from Tick Square: in that icon the glyph fills a third of a\n             * 24-unit box because the square takes the rest, which left a\n             * 3px check rattling around inside an 18px control. On a 14-unit\n             * box drawn at full size the ink spans two thirds of the control,\n             * which is the proportion the eye reads as a checkbox.\n             *\n             * The geometry is centred on 7,7 and then dropped 0.2 — a check's\n             * long arm pulls the eye up, so bounding-box centre sits high. No\n             * translate hacks: the nudge lives in the points. */\n            <svg\n              viewBox=\"0 0 14 14\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={2.2}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              aria-hidden=\"true\"\n              className=\"block size-full\"\n            >\n              <motion.polyline\n                points=\"3.9 7.35 6.05 9.55 9.9 4.8\"\n                initial={false}\n                animate={{ pathLength: checked ? 1 : 0, opacity: checked ? 1 : 0 }}\n                transition={instant ? INSTANT : DRAW}\n              />\n            </svg>\n          )}\n        </span>\n      </span>\n    </label>\n  );\n}\n\nexport function DataTable<T>({\n  data,\n  columns,\n  rowId,\n  rowLabel,\n  variant = 'default',\n  density = 'default',\n  caption,\n  title,\n  toolbar,\n  searchable = false,\n  searchPlaceholder = 'Search',\n  searchText,\n  quickFilter,\n  defaultSort = null,\n  sort: sortProp,\n  onSortChange,\n  selectable = false,\n  defaultSelectedIds,\n  selectedIds,\n  onSelectedChange,\n  bulkActions,\n  renderDetail,\n  rowActions,\n  onDelete,\n  pageSize,\n  loading = false,\n  skeletonRows = 5,\n  emptyState,\n  onRowClick,\n  keyboardNavigation = true,\n  clipboard = true,\n  totals,\n  resizableColumns = false,\n  pinFirstColumn = false,\n  stickyHeader = false,\n  maxHeight,\n  animate = true,\n  className,\n}: DataTableProps<T>) {\n  const reduced = useReducedMotion();\n  const motionOn = animate && !reduced;\n  const surface = SURFACE[variant];\n  const scale = DENSITY[density];\n\n  const [query, setQuery] = React.useState('');\n  const [quickValue, setQuickValue] = React.useState<string | null>(null);\n  /** Index into the visible page. The ring, not focus, follows it. */\n  const [cursor, setCursor] = React.useState(0);\n  const [gridFocused, setGridFocused] = React.useState(false);\n  /** Where a Shift range started. */\n  const anchorRef = React.useRef<number | null>(null);\n  const [copied, setCopied] = React.useState(false);\n  /** Ids mid-sweep, in the order they leave. */\n  const [leaving, setLeaving] = React.useState<string[]>([]);\n  /** Ids whose sweep has finished; dropped from the body so the gap closes. */\n  const [gone, setGone] = React.useState<string[]>([]);\n  const [widths, setWidths] = React.useState<Record<string, number>>({});\n  const [resizing, setResizing] = React.useState<string | null>(null);\n  const [page, setPage] = React.useState(0);\n  const [expanded, setExpanded] = React.useState<string | null>(null);\n\n  const [innerSort, setInnerSort] = React.useState<DataTableSort | null>(defaultSort);\n  const sort = sortProp !== undefined ? sortProp : innerSort;\n\n  const [innerSelected, setInnerSelected] = React.useState<string[]>(defaultSelectedIds ?? []);\n  const selected = selectedIds !== undefined ? selectedIds : innerSelected;\n  const selectedSet = React.useMemo(() => new Set(selected), [selected]);\n\n  const setSelected = React.useCallback(\n    (ids: string[]) => {\n      if (selectedIds === undefined) setInnerSelected(ids);\n      onSelectedChange?.(ids);\n    },\n    [selectedIds, onSelectedChange],\n  );\n\n  const rows = React.useMemo(() => {\n    const all = Array.from(data);\n    // A row is pulled the instant its own sweep ends, not when the whole wave\n    // does, so the rows beneath it start closing the gap while the next one is\n    // still on its way out.\n    return gone.length > 0 ? all.filter((row) => !gone.includes(rowId(row))) : all;\n  }, [data, gone, rowId]);\n\n  const filtered = React.useMemo(() => {\n    const needle = query.trim().toLowerCase();\n    if (!needle) return rows;\n    return rows.filter((row) => {\n      const haystack = searchText\n        ? searchText(row)\n        : columns.map((column) => printValue(readValue(column, row))).join(' ');\n      return haystack.toLowerCase().includes(needle);\n    });\n  }, [rows, query, columns, searchText]);\n\n  const readQuickValue = React.useCallback(\n    (row: T) => {\n      if (!quickFilter) return '';\n      if (quickFilter.getValue) return quickFilter.getValue(row);\n      const column = columns.find((candidate) => candidate.id === quickFilter.columnId);\n      return printValue(readValue(column, row));\n    },\n    [quickFilter, columns],\n  );\n\n  const quickOptions = React.useMemo<DataTableQuickFilterOption[]>(() => {\n    if (!quickFilter) return [];\n    if (quickFilter.options) return quickFilter.options;\n    const seen: string[] = [];\n    for (const row of rows) {\n      const value = readQuickValue(row);\n      if (value && !seen.includes(value)) seen.push(value);\n    }\n    return seen.map((value) => ({ value }));\n  }, [quickFilter, rows, readQuickValue]);\n\n  // Live counts come from the searched set, so the pills always add up to the\n  // rows a click would actually show.\n  const quickCounts = React.useMemo(() => {\n    if (!quickFilter) return {} as Record<string, number>;\n    const counts: Record<string, number> = {};\n    for (const row of filtered) {\n      const value = readQuickValue(row);\n      counts[value] = (counts[value] ?? 0) + 1;\n    }\n    return counts;\n  }, [quickFilter, filtered, readQuickValue]);\n\n  const narrowed = React.useMemo(() => {\n    if (!quickFilter || quickValue === null) return filtered;\n    return filtered.filter((row) => readQuickValue(row) === quickValue);\n  }, [filtered, quickFilter, quickValue, readQuickValue]);\n\n  const sorted = React.useMemo(() => {\n    if (!sort) return narrowed;\n    const column = columns.find((candidate) => candidate.id === sort.columnId);\n    if (!column) return narrowed;\n    const direction = sort.direction === 'asc' ? 1 : -1;\n\n    // Sort is stable, so equal rows keep their source order. Blanks stay at the\n    // bottom in both directions; a column of empties is not a result.\n    return [...narrowed].sort((a, b) => {\n      const left = readValue(column, a);\n      const right = readValue(column, b);\n      const leftEmpty = isEmpty(left);\n      const rightEmpty = isEmpty(right);\n      if (leftEmpty || rightEmpty) return leftEmpty === rightEmpty ? 0 : leftEmpty ? 1 : -1;\n      return compare(left, right) * direction;\n    });\n  }, [narrowed, sort, columns]);\n\n  const pageCount = pageSize ? Math.max(1, Math.ceil(sorted.length / pageSize)) : 1;\n  // Clamped on the way out as well as in an effect, so a shrinking result set\n  // never renders a blank page for one frame.\n  const safePage = Math.min(page, pageCount - 1);\n  const visible = React.useMemo(\n    () => (pageSize ? sorted.slice(safePage * pageSize, safePage * pageSize + pageSize) : sorted),\n    [sorted, pageSize, safePage],\n  );\n\n  // A filtered result set is a different list, so page 3 of it means nothing.\n  // Reset from the handler rather than an effect: no extra render pass, and\n  // `safePage` already clamps whatever `page` holds.\n  const changeQuery = (next: string) => {\n    setQuery(next);\n    setPage(0);\n  };\n\n  const changeQuickValue = (next: string | null) => {\n    setQuickValue(next);\n    setPage(0);\n  };\n\n  const toggleSort = (column: DataTableColumn<T>) => {\n    const active = sort?.columnId === column.id;\n    // Third click clears, so a table can always be put back in source order.\n    const next: DataTableSort | null = !active\n      ? { columnId: column.id, direction: 'asc' }\n      : sort?.direction === 'asc'\n        ? { columnId: column.id, direction: 'desc' }\n        : null;\n    if (sortProp === undefined) setInnerSort(next);\n    onSortChange?.(next);\n  };\n\n  const pageIds = visible.map(rowId);\n  const selectedOnPage = pageIds.filter((id) => selectedSet.has(id));\n  const allOnPage = pageIds.length > 0 && selectedOnPage.length === pageIds.length;\n\n  const toggleAll = (checked: boolean) => {\n    if (checked) setSelected(Array.from(new Set([...selected, ...pageIds])));\n    else setSelected(selected.filter((id) => !pageIds.includes(id)));\n  };\n\n  const toggleRow = (id: string, checked: boolean) => {\n    if (checked) setSelected([...selected, id]);\n    else setSelected(selected.filter((candidate) => candidate !== id));\n  };\n\n  /**\n   * Shift-click, and Shift with the arrow keys, run from the last row you\n   * touched to this one — the behaviour every file manager and inbox has, and\n   * the reason nobody should have to click twelve checkboxes.\n   */\n  const selectRange = (from: number, to: number) => {\n    const [start, end] = from <= to ? [from, to] : [to, from];\n    const ids = visible.slice(start, end + 1).map(rowId);\n    setSelected(Array.from(new Set([...selected, ...ids])));\n  };\n\n  const selectRow = (index: number, event?: { shiftKey?: boolean }) => {\n    const id = pageIds[index];\n    if (id === undefined) return;\n    if (event?.shiftKey && anchorRef.current !== null) {\n      selectRange(anchorRef.current, index);\n      return;\n    }\n    anchorRef.current = index;\n    toggleRow(id, !selectedSet.has(id));\n  };\n\n  /**\n   * Marks rows as leaving in the order they appear, so the sweep runs down the\n   * table rather than firing at once. `onDelete` lands after the last one.\n   */\n  const requestRemove = React.useCallback(\n    (ids: string[]) => {\n      if (ids.length === 0) return;\n      const ordered = visible.map(rowId).filter((id) => ids.includes(id));\n      const rest = ids.filter((id) => !ordered.includes(id));\n      setLeaving([...ordered, ...rest]);\n    },\n    [visible, rowId],\n  );\n\n  // Two rows can finish in the same tick, so the running tally lives on a ref\n  // rather than in the state updater — an updater has to stay pure, and this\n  // one has to decide when the whole wave has drained.\n  const goneRef = React.useRef<string[]>([]);\n  const onRowLeft = React.useCallback(\n    (id: string) => {\n      if (!goneRef.current.includes(id)) goneRef.current = [...goneRef.current, id];\n      const next = goneRef.current;\n      if (leaving.length > 0 && leaving.every((candidate) => next.includes(candidate))) {\n        // Drained. Hand the ids over and reset in one batch, so the caller's own\n        // removal and our reset commit together and nothing flashes back in.\n        goneRef.current = [];\n        onDelete?.(leaving);\n        setSelected(selected.filter((candidate) => !leaving.includes(candidate)));\n        setLeaving([]);\n        setGone([]);\n        return;\n      }\n      setGone(next);\n    },\n    [leaving, onDelete, selected, setSelected],\n  );\n\n  const selectedRows = React.useMemo(\n    () => rows.filter((row) => selectedSet.has(rowId(row))),\n    [rows, selectedSet, rowId],\n  );\n\n  /** Sums follow the filters, so the footer always describes what you can see. */\n  const totalsById = React.useMemo(() => {\n    if (!totals || totals.length === 0) return {} as Record<string, number>;\n    const result: Record<string, number> = {};\n    for (const columnId of totals) {\n      const column = columns.find((candidate) => candidate.id === columnId);\n      if (!column) continue;\n      result[columnId] = sorted.reduce((sum, row) => {\n        const value = readValue(column, row);\n        return sum + (typeof value === 'number' ? value : 0);\n      }, 0);\n    }\n    return result;\n  }, [totals, columns, sorted]);\n\n  const copySelection = React.useCallback(async () => {\n    const chosen = selectedRows.length > 0 ? selectedRows : [];\n    if (chosen.length === 0) return;\n    const head = columns.map((column) =>\n      typeof column.header === 'string' ? column.header : column.id,\n    );\n    const body = chosen.map((row) =>\n      columns.map((column) => printValue(readValue(column, row))).join('\\t'),\n    );\n    try {\n      await navigator.clipboard.writeText([head.join('\\t'), ...body].join('\\n'));\n      setCopied(true);\n      window.setTimeout(() => setCopied(false), 1800);\n    } catch {\n      // Clipboard permission denied — the selection is unchanged, so the user\n      // can try the button again rather than be told about a failed shortcut.\n    }\n  }, [selectedRows, columns]);\n\n  // Selecting every row on a page rarely means \"these six\". Offer the rest.\n  const allMatchingSelected =\n    sorted.length > 0 && sorted.every((row) => selectedSet.has(rowId(row)));\n  const showSelectAllBanner =\n    selectable && allOnPage && sorted.length > pageIds.length && !allMatchingSelected;\n\n  const columnCount =\n    columns.length + (selectable ? 1 : 0) + (renderDetail ? 1 : 0) + (rowActions ? 1 : 0);\n\n  // A pinned header and a pinned column each need to know they are floating\n  // over content — one reads scrollTop, the other scrollLeft.\n  const scrollRef = React.useRef<HTMLDivElement>(null);\n  const [scrolled, setScrolled] = React.useState(false);\n  const [scrolledX, setScrolledX] = React.useState(false);\n  React.useEffect(() => {\n    const element = scrollRef.current;\n    if (!element || (!stickyHeader && !pinFirstColumn)) return;\n    const onScroll = () => {\n      setScrolled(element.scrollTop > 0);\n      setScrolledX(Math.abs(element.scrollLeft) > 0);\n    };\n    onScroll();\n    element.addEventListener('scroll', onScroll, { passive: true });\n    return () => element.removeEventListener('scroll', onScroll);\n  }, [stickyHeader, pinFirstColumn]);\n\n  // Column widths are measured once, then owned by the drag. Fixed layout only\n  // switches on after that first measurement, so the table never jumps.\n  const headRefs = React.useRef<Record<string, HTMLTableCellElement | null>>({});\n  const measured = resizableColumns && Object.keys(widths).length === columns.length;\n  React.useEffect(() => {\n    if (!resizableColumns) return;\n    // Measuring is a read from the DOM, the same shape as the ring below: take\n    // the browser's own column widths once, then let the drag own them.\n    const measure = () => {\n      const next: Record<string, number> = {};\n      for (const column of columns) {\n        const cell = headRefs.current[column.id];\n        if (cell)\n          next[column.id] = Math.max(MIN_COL, Math.round(cell.getBoundingClientRect().width));\n      }\n      if (Object.keys(next).length !== columns.length) return;\n      setWidths((current) => {\n        const same =\n          Object.keys(current).length === Object.keys(next).length &&\n          Object.keys(next).every((key) => current[key] === next[key]);\n        return same ? current : next;\n      });\n    };\n    const frame = requestAnimationFrame(measure);\n    return () => cancelAnimationFrame(frame);\n  }, [resizableColumns, columns, density]);\n\n  const startResize = (columnId: string, clientX: number) => {\n    const from = widths[columnId] ?? MIN_COL;\n    const rtl = typeof document !== 'undefined' && document.dir === 'rtl';\n    setResizing(columnId);\n    const onMove = (event: PointerEvent) => {\n      const delta = (event.clientX - clientX) * (rtl ? -1 : 1);\n      setWidths((current) => ({ ...current, [columnId]: Math.max(MIN_COL, from + delta) }));\n    };\n    const onUp = () => {\n      setResizing(null);\n      window.removeEventListener('pointermove', onMove);\n      window.removeEventListener('pointerup', onUp);\n    };\n    window.addEventListener('pointermove', onMove);\n    window.addEventListener('pointerup', onUp);\n  };\n\n  const nudgeWidth = (columnId: string, delta: number) => {\n    setWidths((current) => ({\n      ...current,\n      [columnId]: Math.max(MIN_COL, (current[columnId] ?? MIN_COL) + delta),\n    }));\n  };\n\n  /** Fixed offsets, because the leading cells are fixed widths. */\n  const pinStart = {\n    detail: 0,\n    select: renderDetail ? DETAIL_COL : 0,\n    first: (renderDetail ? DETAIL_COL : 0) + (selectable ? SELECT_COL : 0),\n  };\n  // An outer box-shadow rather than a pseudo-element: the pinned cell clips its\n  // own overflow to truncate long values, and that would clip the shadow too.\n  const pinnedShadow =\n    pinFirstColumn && scrolledX\n      ? 'shadow-[12px_0_10px_-10px_rgba(0,0,0,0.12)] dark:shadow-[12px_0_10px_-10px_rgba(0,0,0,0.55)]'\n      : '';\n\n  const rowRefs = React.useRef<(HTMLTableRowElement | null)[]>([]);\n\n  // Measuring is what makes a reorder animate; keeping it off every other\n  // render is what keeps hovering a row cheap.\n  const layoutToken = `${sort?.columnId ?? ''}:${sort?.direction ?? ''}:${safePage}:${query}:${quickValue ?? ''}`;\n\n  const gridId = React.useId();\n  const rowDomId = (index: number) => `${gridId}-row-${index}`;\n\n  /**\n   * One Tab stop for the whole grid, then arrows move a ring rather than focus\n   * — the pattern a keyboard-first tool uses, and the only way row shortcuts\n   * can exist without stealing keys from the controls inside a row.\n   */\n  const onGridKeyDown = (event: React.KeyboardEvent<HTMLTableElement>) => {\n    if (!keyboardNavigation || visible.length === 0) return;\n    const meta = event.metaKey || event.ctrlKey;\n\n    if (meta && event.key.toLowerCase() === 'c') {\n      if (!clipboard || selected.length === 0) return;\n      event.preventDefault();\n      void copySelection();\n      return;\n    }\n    if (meta && event.key.toLowerCase() === 'a' && selectable) {\n      event.preventDefault();\n      toggleAll(true);\n      return;\n    }\n\n    // Anything typed into a control inside a row belongs to that control.\n    const target = event.target as HTMLElement;\n    if (target !== event.currentTarget && target.closest('input,textarea,select')) return;\n\n    const move = (next: number) => {\n      const clamped = Math.max(0, Math.min(visible.length - 1, next));\n      event.preventDefault();\n      if (event.shiftKey && selectable) {\n        if (anchorRef.current === null) anchorRef.current = cursor;\n        selectRange(anchorRef.current, clamped);\n      }\n      setCursor(clamped);\n      rowRefs.current[clamped]?.scrollIntoView({ block: 'nearest' });\n    };\n\n    switch (event.key) {\n      case 'ArrowDown':\n        return move(cursor + 1);\n      case 'ArrowUp':\n        return move(cursor - 1);\n      case 'PageDown':\n        return move(cursor + 10);\n      case 'PageUp':\n        return move(cursor - 10);\n      case 'Home':\n        return move(0);\n      case 'End':\n        return move(visible.length - 1);\n      case ' ': {\n        if (!selectable) return;\n        event.preventDefault();\n        selectRow(cursor, event);\n        return;\n      }\n      case 'Enter': {\n        const row = visible[cursor];\n        if (!row || !onRowClick) return;\n        event.preventDefault();\n        onRowClick(row);\n        return;\n      }\n      case 'Escape': {\n        if (selected.length === 0) return;\n        event.preventDefault();\n        setSelected([]);\n        return;\n      }\n      case 'ArrowRight': {\n        if (!renderDetail) return;\n        event.preventDefault();\n        setExpanded(rowId(visible[cursor]));\n        return;\n      }\n      case 'ArrowLeft': {\n        if (!renderDetail) return;\n        event.preventDefault();\n        setExpanded(null);\n        return;\n      }\n      default:\n        return;\n    }\n  };\n\n  const showEmpty = !loading && visible.length === 0;\n  const rangeStart = sorted.length === 0 ? 0 : safePage * (pageSize ?? sorted.length) + 1;\n  const rangeEnd = pageSize ? Math.min(sorted.length, (safePage + 1) * pageSize) : sorted.length;\n\n  return (\n    <div className={cn('w-full', className)}>\n      <div className={cn('flex flex-col overflow-hidden', surface.frame)}>\n        {(searchable || title || toolbar || quickFilter) && (\n          <div\n            className={cn(\n              'flex flex-wrap items-center gap-x-4 gap-y-2 py-3',\n              scale.pad,\n              variant === 'minimal' && 'px-0 pt-0',\n            )}\n          >\n            {title && (\n              <h3 className=\"text-sm font-medium text-neutral-900 dark:text-neutral-100\">\n                {title}\n              </h3>\n            )}\n\n            {/* Controls live at the end of the toolbar: filter pills first,\n                then search, then whatever actions the caller parks here. */}\n            <div className=\"ms-auto flex min-w-0 flex-wrap items-center justify-end gap-2\">\n              {quickFilter && quickOptions.length > 0 && (\n                <LayoutGroup id={`${gridId}-filters`}>\n                  <div\n                    role=\"group\"\n                    aria-label={quickFilter.label ?? 'Filter rows'}\n                    className=\"flex flex-wrap items-center gap-1\"\n                  >\n                    <FilterPill\n                      active={quickValue === null}\n                      onClick={() => changeQuickValue(null)}\n                      label={quickFilter.allLabel ?? 'All'}\n                      count={filtered.length}\n                      layoutId={`${gridId}-pill`}\n                      motionOn={motionOn}\n                    />\n                    {quickOptions.map((option) => (\n                      <FilterPill\n                        key={option.value}\n                        active={quickValue === option.value}\n                        onClick={() =>\n                          changeQuickValue(quickValue === option.value ? null : option.value)\n                        }\n                        label={option.label ?? option.value}\n                        plain={option.label === undefined}\n                        count={quickCounts[option.value] ?? 0}\n                        layoutId={`${gridId}-pill`}\n                        motionOn={motionOn}\n                      />\n                    ))}\n                  </div>\n                </LayoutGroup>\n              )}\n\n              {searchable && (\n                <div className=\"group/search relative w-full transition-[width] duration-300 sm:w-56 sm:focus-within:w-72 ease-[cubic-bezier(0.2,0,0,1)]\">\n                  <IconSearch className=\"pointer-events-none absolute inset-y-0 start-2.5 my-auto size-4 text-neutral-400 transition-[color,opacity] duration-150 ease-out group-focus-within/search:text-neutral-700 dark:text-neutral-500 dark:group-focus-within/search:text-neutral-300\" />\n                  <input\n                    type=\"search\"\n                    value={query}\n                    onChange={(event) => changeQuery(event.currentTarget.value)}\n                    placeholder={searchPlaceholder}\n                    aria-label={searchPlaceholder}\n                    className={cn(\n                      'h-9 w-full rounded-xl border border-neutral-200 bg-white ps-8 pe-8 text-base text-neutral-900 placeholder:text-neutral-400 sm:text-sm dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-100 dark:placeholder:text-neutral-500',\n                      'transition-[border-color] duration-150 ease-out',\n                      'focus:border-neutral-400 dark:focus:border-neutral-500',\n                      '[&::-webkit-search-cancel-button]:hidden',\n                      FOCUS,\n                    )}\n                  />\n                  <AnimatePresence initial={false}>\n                    {query ? (\n                      <motion.button\n                        type=\"button\"\n                        onClick={() => changeQuery('')}\n                        aria-label=\"Clear search\"\n                        initial={\n                          motionOn ? { opacity: 0, scale: 0.25, filter: 'blur(4px)' } : false\n                        }\n                        animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}\n                        exit={\n                          motionOn\n                            ? { opacity: 0, scale: 0.25, filter: 'blur(4px)' }\n                            : { opacity: 0 }\n                        }\n                        transition={motionOn ? SPRING_ICON : INSTANT}\n                        className={cn(\n                          'absolute inset-y-0 end-1 my-auto grid size-7 place-items-center rounded-lg text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100',\n                          PRESS,\n                          FOCUS,\n                        )}\n                      >\n                        <IconCloseLine className=\"size-3.5\" />\n                      </motion.button>\n                    ) : null}\n                  </AnimatePresence>\n                </div>\n              )}\n\n              {toolbar}\n            </div>\n          </div>\n        )}\n\n        <AnimatePresence initial={false}>\n          {showSelectAllBanner && (\n            <motion.div\n              initial={{ height: 0, opacity: 0 }}\n              animate={{ height: 'auto', opacity: 1 }}\n              exit={{ height: 0, opacity: 0 }}\n              transition={motionOn ? SPRING_FLUID : INSTANT}\n              className=\"overflow-hidden border-t border-neutral-100 bg-neutral-50 dark:border-neutral-900 dark:bg-neutral-900/60\"\n            >\n              <p\n                className={cn(\n                  'flex flex-wrap items-center gap-x-1.5 gap-y-1 py-2 text-sm text-neutral-600 dark:text-neutral-400',\n                  scale.pad,\n                )}\n              >\n                <span className=\"tabular-nums\">\n                  All {pageIds.length} rows on this page are selected.\n                </span>\n                <button\n                  type=\"button\"\n                  onClick={() => setSelected(sorted.map(rowId))}\n                  className={cn(\n                    'rounded-lg font-medium text-neutral-900 underline underline-offset-2 transition-colors duration-100 ease-out hover:text-neutral-600 dark:text-neutral-100 dark:hover:text-neutral-400',\n                    FOCUS,\n                  )}\n                >\n                  Select all {sorted.length} that match\n                </button>\n              </p>\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        <div className=\"relative\">\n          <div\n            ref={scrollRef}\n            className=\"relative w-full overflow-auto overscroll-x-contain\"\n            style={maxHeight ? { maxHeight } : undefined}\n          >\n            <table\n              role={keyboardNavigation ? 'grid' : undefined}\n              aria-multiselectable={keyboardNavigation && selectable ? true : undefined}\n              tabIndex={keyboardNavigation && visible.length > 0 ? 0 : undefined}\n              aria-activedescendant={\n                keyboardNavigation && gridFocused && visible[cursor] ? rowDomId(cursor) : undefined\n              }\n              onKeyDown={onGridKeyDown}\n              onFocus={(event) => {\n                if (event.target === event.currentTarget) setGridFocused(true);\n              }}\n              onBlur={(event) => {\n                if (!event.currentTarget.contains(event.relatedTarget as Node))\n                  setGridFocused(false);\n              }}\n              className={cn(\n                'w-full border-collapse whitespace-nowrap',\n                scale.text,\n                'outline-hidden focus-visible:outline-hidden',\n              )}\n              style={\n                measured\n                  ? { tableLayout: 'fixed', width: 'max-content', minWidth: '100%' }\n                  : undefined\n              }\n              aria-busy={loading || undefined}\n            >\n              {caption && <caption className=\"sr-only\">{caption}</caption>}\n\n              <thead\n                className={cn(\n                  surface.head,\n                  stickyHeader && surface.headPinned,\n                  stickyHeader && 'sticky top-0 z-10',\n                  'transition-shadow duration-150 ease-out',\n                  stickyHeader && scrolled && 'shadow-[0_6px_10px_-8px_rgba(0,0,0,0.35)]',\n                )}\n              >\n                <tr className={scale.head}>\n                  {renderDetail && (\n                    <th\n                      scope=\"col\"\n                      className={cn('w-10', surface.headRule, pinFirstColumn && 'sticky z-1')}\n                      style={\n                        pinFirstColumn\n                          ? { insetInlineStart: pinStart.detail, width: DETAIL_COL }\n                          : undefined\n                      }\n                    />\n                  )}\n                  {selectable && (\n                    <th\n                      scope=\"col\"\n                      className={cn(\n                        'w-12 pe-0',\n                        scale.pad,\n                        surface.headRule,\n                        pinFirstColumn && 'sticky z-1',\n                      )}\n                      style={\n                        pinFirstColumn\n                          ? { insetInlineStart: pinStart.select, width: SELECT_COL }\n                          : undefined\n                      }\n                    >\n                      <SelectBox\n                        checked={allOnPage}\n                        indeterminate={selectedOnPage.length > 0 && !allOnPage}\n                        onChange={toggleAll}\n                        label=\"Select all rows on this page\"\n                        instant={!motionOn}\n                      />\n                    </th>\n                  )}\n                  {columns.map((column, columnIndex) => {\n                    const align = column.align ?? (column.numeric ? 'end' : 'start');\n                    const active = sort?.columnId === column.id;\n                    const pinned = pinFirstColumn && columnIndex === 0;\n                    // Not truncated: a header that can shrink to nothing contributes\n                    // no min-content width, so the table would clip its own labels\n                    // rather than scroll. Pass `headerClassName=\"truncate\"` to opt in.\n                    const label = <span>{column.header}</span>;\n\n                    return (\n                      <th\n                        key={column.id}\n                        scope=\"col\"\n                        aria-sort={\n                          column.sortable\n                            ? active\n                              ? sort?.direction === 'asc'\n                                ? 'ascending'\n                                : 'descending'\n                              : 'none'\n                            : undefined\n                        }\n                        ref={(node) => {\n                          headRefs.current[column.id] = node;\n                        }}\n                        style={{\n                          ...(widths[column.id]\n                            ? { width: widths[column.id] }\n                            : column.width\n                              ? { width: column.width }\n                              : null),\n                          ...(pinned ? { insetInlineStart: pinStart.first } : null),\n                        }}\n                        className={cn(\n                          'relative font-medium text-neutral-600 dark:text-neutral-400',\n                          scale.pad,\n                          ALIGN[align],\n                          surface.cell,\n                          surface.headRule,\n                          column.hideBelow && HIDE_BELOW[column.hideBelow],\n                          pinned && cn('sticky z-1', surface.head, pinnedShadow),\n                          column.headerClassName,\n                        )}\n                      >\n                        {column.sortable ? (\n                          <button\n                            type=\"button\"\n                            onClick={() => toggleSort(column)}\n                            className={cn(\n                              'group/sort -mx-1.5 inline-flex max-w-full items-center gap-1 rounded-xl px-1.5 py-1 font-medium hover:text-neutral-900 dark:hover:text-neutral-100',\n                              PRESS,\n                              align === 'end' && 'flex-row-reverse',\n                              active && 'text-neutral-900 dark:text-neutral-100',\n                              FOCUS,\n                            )}\n                          >\n                            {label}\n                            {/* Ghosted under the pointer so a sortable column\n                                advertises itself, solid once it is the sort,\n                                and the flip between asc and desc is a rotation\n                                rather than a second glyph. */}\n                            <motion.span\n                              aria-hidden=\"true\"\n                              className={cn(\n                                'grid size-3.5 shrink-0 place-items-center',\n                                'transition-[opacity,scale] duration-150',\n                                EASE,\n                                active\n                                  ? 'scale-100 opacity-100'\n                                  : 'scale-75 opacity-0 group-hover/sort:scale-100 group-hover/sort:opacity-40 group-focus-visible/sort:scale-100 group-focus-visible/sort:opacity-40',\n                              )}\n                              initial={false}\n                              animate={{ rotate: active && sort?.direction === 'asc' ? 180 : 0 }}\n                              transition={motionOn ? SPRING_SNAPPY : INSTANT}\n                            >\n                              <IconChevron className=\"size-3.5\" />\n                            </motion.span>\n                          </button>\n                        ) : (\n                          label\n                        )}\n\n                        {resizableColumns && (\n                          /* A splitter, not a button: arrow keys resize it, and\n                             screen readers announce the width they land on. */\n                          <span\n                            role=\"separator\"\n                            aria-orientation=\"vertical\"\n                            aria-label={`Resize ${\n                              typeof column.header === 'string' ? column.header : column.id\n                            } column`}\n                            aria-valuenow={Math.round(widths[column.id] ?? MIN_COL)}\n                            aria-valuemin={MIN_COL}\n                            tabIndex={0}\n                            onPointerDown={(event) => {\n                              event.preventDefault();\n                              startResize(column.id, event.clientX);\n                            }}\n                            onKeyDown={(event) => {\n                              const step = event.shiftKey ? 48 : 16;\n                              if (event.key === 'ArrowRight') {\n                                event.preventDefault();\n                                nudgeWidth(column.id, step);\n                              } else if (event.key === 'ArrowLeft') {\n                                event.preventDefault();\n                                nudgeWidth(column.id, -step);\n                              }\n                            }}\n                            className={cn(\n                              'absolute inset-y-0 end-0 z-1 w-2 translate-x-1/2 cursor-col-resize touch-none',\n                              \"before:absolute before:inset-y-1.5 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-neutral-900 before:opacity-0 before:transition-opacity before:duration-100 before:content-['']\",\n                              'hover:before:opacity-20 focus-visible:before:opacity-100 dark:before:bg-neutral-100',\n                              resizing === column.id && 'before:opacity-100',\n                              'focus-visible:outline-hidden',\n                            )}\n                          />\n                        )}\n                      </th>\n                    );\n                  })}\n                  {rowActions && (\n                    <th scope=\"col\" className={cn('w-12', surface.headRule)}>\n                      <span className=\"sr-only\">Actions</span>\n                    </th>\n                  )}\n                </tr>\n              </thead>\n\n              {loading ? (\n                <tbody>\n                  {Array.from({ length: skeletonRows }).map((_, index) => (\n                    <tr key={index} className={cn(index > 0 && surface.divider, surface.rowFill)}>\n                      {renderDetail && <td className=\"w-10\" />}\n                      {selectable && (\n                        <td className={cn('w-12 pe-0', scale.pad, scale.cell)}>\n                          <span className=\"ms-[7px] block size-[18px] rounded-[5px] bg-neutral-100 dark:bg-neutral-900\" />\n                        </td>\n                      )}\n                      {columns.map((column, columnIndex) => (\n                        <td\n                          key={column.id}\n                          className={cn(\n                            scale.pad,\n                            scale.cell,\n                            surface.cell,\n                            column.hideBelow && HIDE_BELOW[column.hideBelow],\n                          )}\n                        >\n                          <span\n                            className=\"block h-3 origin-left animate-pulse rounded-full bg-neutral-100 rtl:origin-right dark:bg-neutral-900\"\n                            style={{\n                              width: `${52 + ((index * 17 + column.id.length * 7) % 40)}%`,\n                              animationDelay: `${index * 80 + columnIndex * 40}ms`,\n                              animationDuration: '1.4s',\n                            }}\n                          />\n                        </td>\n                      ))}\n                      {rowActions && <td className=\"w-12\" />}\n                    </tr>\n                  ))}\n                </tbody>\n              ) : (\n                <tbody>\n                  {visible.map((row, index) => {\n                    const id = rowId(row);\n                    const isSelected = selectedSet.has(id);\n                    const isExpanded = expanded === id;\n                    const leavingAt = leaving.indexOf(id);\n                    const isLeaving = leavingAt !== -1;\n                    const name =\n                      rowLabel?.(row) || printValue(readValue(columns[0], row)) || 'this row';\n                    // Hover tints the cells rather than the row, so it paints over\n                    // the row's opaque fill instead of under it. Selection is the\n                    // checkbox — no extra rail or wash on the row.\n                    const tint =\n                      'transition-colors duration-100 ease-out group-hover/row:bg-neutral-900/[0.025] dark:group-hover/row:bg-neutral-100/[0.045]';\n\n                    return (\n                      <React.Fragment key={id}>\n                        <motion.tr\n                          id={rowDomId(index)}\n                          ref={(node) => {\n                            rowRefs.current[index] = node;\n                          }}\n                          aria-selected={selectable ? isSelected : undefined}\n                          aria-hidden={isLeaving || undefined}\n                          layout={motionOn ? 'position' : false}\n                          layoutDependency={layoutToken}\n                          initial={false}\n                          animate={isLeaving ? { x: '-100%', opacity: 0 } : { x: 0, opacity: 1 }}\n                          onAnimationComplete={() => {\n                            if (isLeaving) onRowLeft(id);\n                          }}\n                          transition={\n                            isLeaving\n                              ? motionOn\n                                ? { ...UNDERTOW, delay: leavingAt * WAVE_STEP }\n                                : INSTANT\n                              : SPRING_FLUID\n                          }\n                          data-selected={isSelected || undefined}\n                          onPointerDown={() => setCursor(index)}\n                          onClick={\n                            onRowClick\n                              ? (event) => {\n                                  // The first cell carries a real button for the\n                                  // keyboard; a pointer may land anywhere else.\n                                  const target = event.target as HTMLElement;\n                                  if (target.closest('a,button,input,select,textarea,label'))\n                                    return;\n                                  onRowClick(row);\n                                }\n                              : undefined\n                          }\n                          className={cn(\n                            'group/row',\n                            index > 0 && surface.divider,\n                            surface.rowFill,\n                            index % 2 === 1 && surface.stripe,\n                            onRowClick && 'cursor-pointer',\n                          )}\n                        >\n                          {renderDetail && (\n                            <td\n                              className={cn(\n                                'w-10 ps-2',\n                                scale.cell,\n                                tint,\n                                pinFirstColumn && cn('sticky z-1', surface.rowFill),\n                              )}\n                              style={\n                                pinFirstColumn\n                                  ? { insetInlineStart: pinStart.detail, width: DETAIL_COL }\n                                  : undefined\n                              }\n                            >\n                              <button\n                                type=\"button\"\n                                onClick={() => setExpanded(isExpanded ? null : id)}\n                                aria-expanded={isExpanded}\n                                aria-controls={isExpanded ? `${id}-detail` : undefined}\n                                aria-label={isExpanded ? `Hide ${name}` : `Show ${name}`}\n                                className={cn(\n                                  'grid size-8 place-items-center rounded-xl text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100',\n                                  PRESS,\n                                  FOCUS,\n                                )}\n                              >\n                                <motion.span\n                                  className=\"grid size-4 place-items-center\"\n                                  initial={false}\n                                  animate={{ rotate: isExpanded ? 0 : -90 }}\n                                  transition={motionOn ? SPRING_SNAPPY : INSTANT}\n                                >\n                                  <IconChevron className=\"size-4\" />\n                                </motion.span>\n                              </button>\n                            </td>\n                          )}\n\n                          {selectable && (\n                            <td\n                              className={cn(\n                                'w-12 pe-0',\n                                scale.pad,\n                                scale.cell,\n                                tint,\n                                pinFirstColumn && cn('sticky z-1', surface.rowFill),\n                              )}\n                              style={\n                                pinFirstColumn\n                                  ? { insetInlineStart: pinStart.select, width: SELECT_COL }\n                                  : undefined\n                              }\n                            >\n                              <SelectBox\n                                checked={isSelected}\n                                onChange={() => selectRow(index)}\n                                onShiftPick={() => {\n                                  setCursor(index);\n                                  selectRow(index, { shiftKey: true });\n                                }}\n                                label={`Select ${name}`}\n                                instant={!motionOn}\n                              />\n                            </td>\n                          )}\n\n                          {columns.map((column, columnIndex) => {\n                            const align = column.align ?? (column.numeric ? 'end' : 'start');\n                            const content = column.cell\n                              ? column.cell(row, index)\n                              : printValue(readValue(column, row));\n                            const activates = Boolean(onRowClick) && columnIndex === 0;\n\n                            return (\n                              <td\n                                key={column.id}\n                                style={\n                                  pinFirstColumn && columnIndex === 0\n                                    ? { insetInlineStart: pinStart.first }\n                                    : undefined\n                                }\n                                className={cn(\n                                  'text-neutral-700 dark:text-neutral-300',\n                                  tint,\n                                  scale.pad,\n                                  scale.cell,\n                                  ALIGN[align],\n                                  surface.cell,\n                                  column.numeric && 'tabular-nums',\n                                  column.hideBelow && HIDE_BELOW[column.hideBelow],\n                                  // Fixed layout cannot grow, so a long value has\n                                  // to end in an ellipsis rather than escape.\n                                  measured && 'truncate',\n                                  columnIndex === 0 &&\n                                    'font-medium text-neutral-900 dark:text-neutral-100',\n                                  pinFirstColumn &&\n                                    columnIndex === 0 &&\n                                    cn('sticky z-1', surface.rowFill, pinnedShadow),\n                                  column.className,\n                                )}\n                              >\n                                {activates ? (\n                                  <button\n                                    type=\"button\"\n                                    onClick={() => onRowClick?.(row)}\n                                    className={cn(\n                                      '-mx-1 flex max-w-[calc(100%+0.5rem)] items-center rounded-xl px-1 text-start',\n                                      FOCUS,\n                                    )}\n                                  >\n                                    {content}\n                                  </button>\n                                ) : (\n                                  content\n                                )}\n                              </td>\n                            );\n                          })}\n\n                          {rowActions && (\n                            <td className={cn('w-12 pe-2 text-end', scale.cell, tint)}>\n                              {/* Shown by default, and hidden until hover only\n                                  where hovering exists, so touch keeps it. */}\n                              <span className=\"inline-flex opacity-100 transition-opacity duration-100 ease-out group-focus-within/row:opacity-100 group-hover/row:opacity-100 [@media(hover:hover)]:opacity-0\">\n                                {rowActions(row, { remove: () => requestRemove([id]) })}\n                              </span>\n                            </td>\n                          )}\n                        </motion.tr>\n\n                        {renderDetail && (\n                          <AnimatePresence initial={false}>\n                            {isExpanded && (\n                              <motion.tr\n                                id={`${id}-detail`}\n                                initial=\"closed\"\n                                animate=\"open\"\n                                exit=\"closed\"\n                                className=\"bg-neutral-50 dark:bg-neutral-900\"\n                              >\n                                <td colSpan={columnCount} className=\"p-0\">\n                                  <motion.div\n                                    variants={{\n                                      closed: { height: 0, opacity: 0 },\n                                      open: { height: 'auto', opacity: 1 },\n                                    }}\n                                    transition={motionOn ? SPRING_FLUID : INSTANT}\n                                    className=\"overflow-hidden\"\n                                  >\n                                    <div className={cn(scale.pad, 'py-4')}>{renderDetail(row)}</div>\n                                  </motion.div>\n                                </td>\n                              </motion.tr>\n                            )}\n                          </AnimatePresence>\n                        )}\n                      </React.Fragment>\n                    );\n                  })}\n\n                  {showEmpty && (\n                    <tr>\n                      <td colSpan={columnCount} className=\"px-6 py-16\">\n                        {emptyState ?? (\n                          <EmptyHint\n                            filtered={Boolean(query || quickValue !== null)}\n                            motionOn={motionOn}\n                            onClear={() => {\n                              changeQuery('');\n                              changeQuickValue(null);\n                            }}\n                          />\n                        )}\n                      </td>\n                    </tr>\n                  )}\n                </tbody>\n              )}\n\n              {totals && totals.length > 0 && !loading && sorted.length > 0 && (\n                <tfoot>\n                  <tr\n                    className={cn(\n                      surface.rowFill,\n                      'border-t border-neutral-200 dark:border-neutral-800',\n                    )}\n                  >\n                    {renderDetail && <td className=\"w-10\" />}\n                    {selectable && <td className={cn('w-12 pe-0', scale.pad)} />}\n                    {columns.map((column, columnIndex) => {\n                      const align = column.align ?? (column.numeric ? 'end' : 'start');\n                      const sum = totalsById[column.id];\n                      return (\n                        <td\n                          key={column.id}\n                          className={cn(\n                            scale.pad,\n                            scale.cell,\n                            ALIGN[align],\n                            surface.cell,\n                            column.numeric && 'tabular-nums',\n                            column.hideBelow && HIDE_BELOW[column.hideBelow],\n                            'font-medium text-neutral-900 dark:text-neutral-100',\n                          )}\n                        >\n                          {sum !== undefined ? (\n                            <RollingTotal\n                              value={sum}\n                              format={\n                                column.formatTotal ??\n                                ((amount) => Math.round(amount).toLocaleString('en-US'))\n                              }\n                              instant={!motionOn}\n                            />\n                          ) : columnIndex === 0 ? (\n                            <span className=\"font-normal text-neutral-500 dark:text-neutral-400\">\n                              Total\n                            </span>\n                          ) : null}\n                        </td>\n                      );\n                    })}\n                    {rowActions && <td className=\"w-12\" />}\n                  </tr>\n                </tfoot>\n              )}\n            </table>\n          </div>\n\n          {/* Infrequent, and it changes what the whole toolbar means, so this\n              one earns a spring. */}\n          {selectable && bulkActions && (\n            <div className=\"pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-3\">\n              <AnimatePresence>\n                {selected.length > 0 && (\n                  <motion.div\n                    initial={{ opacity: 0, y: 12, scale: 0.98 }}\n                    animate={{ opacity: 1, y: 0, scale: 1 }}\n                    exit={{ opacity: 0, y: 8, scale: 0.98 }}\n                    transition={motionOn ? SPRING_ENTRANCE : INSTANT}\n                    className=\"pointer-events-auto flex max-w-full items-center gap-1 rounded-full bg-neutral-900 p-1 ps-3 whitespace-nowrap text-neutral-100 shadow-[0_8px_24px_rgba(0,0,0,0.18)] dark:bg-neutral-100 dark:text-neutral-900\"\n                  >\n                    <span className=\"pe-1 text-xs font-medium tabular-nums\">\n                      <FlipDigits value={selected.length} instant={!motionOn} /> selected\n                    </span>\n                    <span className=\"h-4 w-px bg-current opacity-20\" />\n                    {clipboard && (\n                      <button\n                        type=\"button\"\n                        onClick={() => void copySelection()}\n                        aria-label={`Copy ${selected.length} rows to the clipboard`}\n                        className={cn(\n                          'flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-sm font-medium hover:bg-white/10 focus-visible:ring-1 focus-visible:ring-current focus-visible:outline-hidden dark:hover:bg-black/10',\n                          PRESS,\n                        )}\n                      >\n                        {/* Contextual icon swap: scale, opacity and blur, never\n                            a visibility toggle. */}\n                        <span className=\"relative grid size-4 place-items-center\">\n                          <AnimatePresence initial={false} mode=\"popLayout\">\n                            <motion.span\n                              key={copied ? 'done' : 'copy'}\n                              initial={{ opacity: 0, scale: 0.25, filter: 'blur(4px)' }}\n                              animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}\n                              exit={{ opacity: 0, scale: 0.25, filter: 'blur(4px)' }}\n                              transition={motionOn ? SPRING_ICON : INSTANT}\n                              className=\"absolute inset-0 grid place-items-center\"\n                            >\n                              {copied ? (\n                                <IconTickSquare className=\"size-4\" />\n                              ) : (\n                                <IconCopy className=\"size-4\" />\n                              )}\n                            </motion.span>\n                          </AnimatePresence>\n                        </span>\n                        <span className=\"relative hidden h-[1.25em] overflow-hidden sm:inline\">\n                          <AnimatePresence mode=\"popLayout\" initial={false}>\n                            <motion.span\n                              key={copied ? 'copied' : 'copy'}\n                              className=\"inline-block\"\n                              initial={motionOn ? { y: 8, opacity: 0 } : false}\n                              animate={{ y: 0, opacity: 1 }}\n                              exit={motionOn ? { y: -8, opacity: 0 } : { opacity: 0 }}\n                              transition={motionOn ? SPRING_SNAPPY : INSTANT}\n                            >\n                              {copied ? 'Copied' : 'Copy'}\n                            </motion.span>\n                          </AnimatePresence>\n                        </span>\n                      </button>\n                    )}\n                    {bulkActions({\n                      ids: selected,\n                      rows: selectedRows,\n                      clear: () => setSelected([]),\n                      remove: () => requestRemove(selected),\n                    })}\n                    <button\n                      type=\"button\"\n                      onClick={() => setSelected([])}\n                      aria-label=\"Clear selection\"\n                      className={cn(\n                        'grid size-7 place-items-center rounded-full opacity-60 hover:opacity-100 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-current',\n                        PRESS,\n                      )}\n                    >\n                      <IconClose className=\"size-4\" />\n                    </button>\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </div>\n          )}\n        </div>\n\n        {pageSize && !loading && sorted.length > 0 && (\n          <nav\n            aria-label=\"Pagination\"\n            className={cn(\n              'flex items-center justify-between gap-3 border-t border-neutral-100 py-2.5 dark:border-neutral-900',\n              scale.pad,\n              variant === 'minimal' && 'px-0',\n            )}\n          >\n            <p className=\"text-xs text-neutral-500 dark:text-neutral-400\">\n              <FlipDigits\n                value={`${rangeStart}–${rangeEnd} of ${sorted.length}`}\n                instant={!motionOn}\n              />\n            </p>\n            <div className=\"flex items-center gap-1\">\n              <button\n                type=\"button\"\n                onClick={() => setPage(safePage - 1)}\n                disabled={safePage === 0}\n                aria-label=\"Previous page\"\n                className={cn(\n                  'group/page grid size-8 place-items-center rounded-xl text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 disabled:pointer-events-none disabled:opacity-40 dark:text-neutral-400 dark:hover:bg-neutral-900 dark:hover:text-neutral-100',\n                  PRESS,\n                  FOCUS,\n                )}\n              >\n                <IconChevron className=\"size-4 rotate-90 transition-transform duration-150 ease-out group-hover/page:-translate-x-px rtl:-rotate-90 rtl:group-hover/page:translate-x-px\" />\n              </button>\n              <button\n                type=\"button\"\n                onClick={() => setPage(safePage + 1)}\n                disabled={safePage >= pageCount - 1}\n                aria-label=\"Next page\"\n                className={cn(\n                  'group/page grid size-8 place-items-center rounded-xl text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 disabled:pointer-events-none disabled:opacity-40 dark:text-neutral-400 dark:hover:bg-neutral-900 dark:hover:text-neutral-100',\n                  PRESS,\n                  FOCUS,\n                )}\n              >\n                <IconChevron className=\"size-4 -rotate-90 transition-transform duration-150 ease-out group-hover/page:translate-x-px rtl:rotate-90 rtl:group-hover/page:-translate-x-px\" />\n              </button>\n            </div>\n          </nav>\n        )}\n      </div>\n\n      {/* One region, always rendered, so repeated counts announce reliably. */}\n      <div role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {loading\n          ? 'Loading rows'\n          : `${sorted.length} ${sorted.length === 1 ? 'row' : 'rows'}${\n              selected.length > 0 ? `, ${selected.length} selected` : ''\n            }`}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/data-table.tsx"
    }
  ],
  "type": "registry:component"
}
