shadimage

Table

A comprehensive table component with sorting, resizing, and selection

registry/preskok/ui/preskok-ui/table.tsx

"use client"

import { createContext, use, type ReactElement } from "react"
import { twJoin, twMerge } from "cn"
import { ChevronDownIcon } from "lucide-react"
import { composeRenderProps } from "react-aria-components/composeRenderProps"
import type {
  CellProps,
  ColumnProps,
  ColumnResizerProps,
  TableHeaderProps as HeaderProps,
  RowProps,
  TableBodyProps,
  TableProps as TablePrimitiveProps,
} from "react-aria-components/Table"
import {
  Button,
  Cell,
  Column,
  ColumnResizer as ColumnResizerPrimitive,
  ResizableTableContainer,
  Row,
  TableBody as TableBodyPrimitive,
  TableHeader as TableHeaderPrimitive,
  Table as TablePrimitive,
  useTableOptions,
} from "react-aria-components/Table"

import { cx } from "@/lib/primitive"

import { Checkbox } from "./checkbox"

interface TableProps extends Omit<TablePrimitiveProps, "className"> {
  allowResize?: boolean
  className?: string
  bleed?: boolean
  grid?: boolean
  striped?: boolean
  ref?: React.Ref<HTMLTableElement>
}

const TableContext = createContext<TableProps>({
  allowResize: false,
})

const DRAG_COLUMN_ID = "preskok-table-drag"
const SELECTION_COLUMN_ID = "preskok-table-selection"

type SyntheticColumn =
  | { id: typeof DRAG_COLUMN_ID; kind: "drag" }
  | { id: typeof SELECTION_COLUMN_ID; kind: "selection" }

const DRAG_COLUMN: SyntheticColumn = {
  id: DRAG_COLUMN_ID,
  kind: "drag",
}

const SELECTION_COLUMN: SyntheticColumn = {
  id: SELECTION_COLUMN_ID,
  kind: "selection",
}

const getSyntheticColumns = (
  allowsDragging: boolean,
  selectionBehavior: string | null
) => {
  const syntheticColumns: Array<SyntheticColumn> = []

  if (allowsDragging) {
    syntheticColumns.push(DRAG_COLUMN)
  }

  if (selectionBehavior === "toggle") {
    syntheticColumns.push(SELECTION_COLUMN)
  }

  return syntheticColumns
}

const isSyntheticColumn = <T extends object>(
  column: T | SyntheticColumn
): column is SyntheticColumn => {
  return (
    "kind" in column && (column.kind === "drag" || column.kind === "selection")
  )
}

const useTableContext = () => use(TableContext)

const Root = (props: TableProps) => {
  return (
    <TablePrimitive
      className="w-full min-w-full caption-bottom text-sm/6 outline-hidden [--table-selected-background:var(--color-secondary)]/50"
      {...props}
    />
  )
}

const Table = ({
  allowResize,
  className,
  bleed = false,
  grid = false,
  striped = false,
  ref,
  ...props
}: TableProps) => {
  return (
    <TableContext value={{ allowResize, bleed, grid, striped }}>
      <div className="flow-root">
        <div
          className={twMerge(
            "relative -mx-(--gutter) overflow-x-auto whitespace-nowrap [--gutter-y:--spacing(2)] has-data-[slot=table-resizable-container]:overflow-auto",
            className
          )}
        >
          <div
            className={twJoin(
              "inline-block min-w-full align-middle",
              !bleed && "sm:px-(--gutter)"
            )}
          >
            {allowResize ? (
              <ResizableTableContainer data-slot="table-resizable-container">
                <Root ref={ref} {...props} />
              </ResizableTableContainer>
            ) : (
              <Root {...props} ref={ref} />
            )}
          </div>
        </div>
      </div>
    </TableContext>
  )
}

const ColumnResizer = ({ className, ...props }: ColumnResizerProps) => (
  <ColumnResizerPrimitive
    {...props}
    className={cx(
      "&[data-resizable-direction=left]:cursor-e-resize &[data-resizable-direction=right]:cursor-w-resize [&[data-resizing]>div]:bg-primary absolute top-0 right-0 bottom-0 grid w-px touch-none place-content-center px-1 data-[resizable-direction=both]:cursor-ew-resize",
      className
    )}
  >
    <div className="h-full w-px bg-border py-(--gutter-y)" />
  </ColumnResizerPrimitive>
)

const TableBody = <T extends object>(props: TableBodyProps<T>) => (
  <TableBodyPrimitive data-slot="table-body" {...props} />
)

interface TableColumnProps extends ColumnProps {
  isResizable?: boolean
}

const TableColumn = ({
  isResizable = false,
  className,
  ...props
}: TableColumnProps) => {
  const { bleed, grid } = useTableContext()
  return (
    <Column
      data-slot="table-column"
      {...props}
      className={cx(
        [
          "text-muted-foreground text-left font-medium",
          "allows-sorting:cursor-default relative outline-hidden data-dragging:cursor-grabbing",
          "px-2 py-(--gutter-y)",
          "first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))",
          !bleed && "sm:first:pl-2 sm:last:pr-3",
          grid && "border-l first:border-l-0",
          isResizable && "truncate overflow-hidden",
        ],
        className
      )}
    >
      {(values) => (
        <div
          className={twJoin([
            "inline-flex items-center gap-2 **:data-[slot=icon]:shrink-0",
          ])}
        >
          {typeof props.children === "function"
            ? props.children(values)
            : props.children}
          {values.allowsSorting && (
            <span
              className={twJoin(
                "bg-secondary text-foreground grid size-[1.15rem] flex-none shrink-0 place-content-center rounded *:data-[slot=icon]:size-3.5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:transition-transform *:data-[slot=icon]:duration-200",
                values.isHovered ? "bg-secondary-foreground/10" : ""
              )}
            >
              <ChevronDownIcon
                data-slot="icon"
                className={
                  values.sortDirection === "ascending" ? "rotate-180" : ""
                }
              />
            </span>
          )}
          {isResizable && <ColumnResizer />}
        </div>
      )}
    </Column>
  )
}

interface TableHeaderProps<T extends object> extends HeaderProps<T> {
  ref?: React.Ref<HTMLTableSectionElement>
}

const TableHeader = <T extends object>({
  children,
  ref,
  columns,
  className,
  ...props
}: TableHeaderProps<T>) => {
  const { bleed } = useTableContext()
  const { selectionBehavior, selectionMode, allowsDragging } = useTableOptions()
  const syntheticColumns = getSyntheticColumns(
    allowsDragging,
    selectionBehavior
  )
  const dynamicColumns =
    columns && typeof children === "function"
      ? [...syntheticColumns, ...Array.from(columns)]
      : undefined

  const renderSyntheticColumn = (column: SyntheticColumn) => {
    if (column.kind === "drag") {
      return (
        <Column
          key={column.id}
          id={column.id}
          data-slot="table-column"
          width={32}
          minWidth={32}
          style={{ width: 32 }}
          className={twMerge(
            "px-2 py-(--gutter-y)",
            "first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))",
            !bleed && "sm:first:pl-2 sm:last:pr-3"
          )}
        />
      )
    }

    return (
      <Column
        key={column.id}
        id={column.id}
        width={32}
        minWidth={32}
        style={{ width: 32 }}
        data-slot="table-column"
        className={twMerge(
          "px-2 py-(--gutter-y)",
          "first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))",
          !bleed && "sm:first:pl-2 sm:last:pr-3"
        )}
      >
        {selectionMode === "multiple" && <Checkbox slot="selection" />}
      </Column>
    )
  }

  const renderColumn = (column: T | SyntheticColumn) => {
    if (isSyntheticColumn(column)) {
      return renderSyntheticColumn(column)
    }

    return (children as (item: T) => ReactElement)(column)
  }

  const staticChildren = (
    <>
      {syntheticColumns.map((column) => renderSyntheticColumn(column))}
      {children}
    </>
  )

  return (
    <TableHeaderPrimitive
      data-slot="table-header"
      className={cx("border-b", className)}
      ref={ref}
      {...props}
      columns={dynamicColumns}
    >
      {dynamicColumns ? renderColumn : staticChildren}
    </TableHeaderPrimitive>
  )
}

interface TableRowProps<T extends object> extends RowProps<T> {
  ref?: React.Ref<HTMLTableRowElement>
}

const TableRow = <T extends object>({
  children,
  className,
  columns,
  id,
  ref,
  ...props
}: TableRowProps<T>) => {
  const { selectionBehavior, allowsDragging } = useTableOptions()
  const { striped } = useTableContext()
  const syntheticColumns = getSyntheticColumns(
    allowsDragging,
    selectionBehavior
  )
  const dynamicColumns =
    columns && typeof children === "function"
      ? [...syntheticColumns, ...Array.from(columns)]
      : undefined

  const renderSyntheticCell = (column: SyntheticColumn) => {
    if (column.kind === "drag") {
      return (
        <TableCell key={column.id} className="cursor-grab">
          <Button
            slot="drag"
            className="grid place-content-center rounded-xs px-[calc(var(--gutter)/2)] outline-hidden focus-visible:ring focus-visible:ring-ring"
          >
            <svg
              aria-hidden
              data-slot="icon"
              xmlns="http://www.w3.org/2000/svg"
              width={16}
              height={16}
              viewBox="0 0 24 24"
              fill="none"
              stroke="currentColor"
              strokeWidth={2}
              strokeLinecap="round"
              strokeLinejoin="round"
              className="lucide lucide-grip-vertical-icon lucide-grip-vertical"
            >
              <circle cx={9} cy={12} r={1} />
              <circle cx={9} cy={5} r={1} />
              <circle cx={9} cy={19} r={1} />
              <circle cx={15} cy={12} r={1} />
              <circle cx={15} cy={5} r={1} />
              <circle cx={15} cy={19} r={1} />
            </svg>
          </Button>
        </TableCell>
      )
    }

    return (
      <TableCell key={column.id}>
        <Checkbox slot="selection" />
      </TableCell>
    )
  }

  const renderCell = (column: T | SyntheticColumn) => {
    if (isSyntheticColumn(column)) {
      return renderSyntheticCell(column)
    }

    return (children as (item: T) => ReactElement)(column)
  }

  const staticChildren = (
    <>
      {syntheticColumns.map((column) => renderSyntheticCell(column))}
      {children}
    </>
  )

  return (
    <Row
      ref={ref}
      data-slot="table-row"
      id={id}
      {...props}
      columns={dynamicColumns}
      className={composeRenderProps(
        className,
        (
          className,
          {
            isSelected,
            selectionMode,
            isFocusVisibleWithin,
            isDragging,
            isDisabled,
            isFocusVisible,
          }
        ) =>
          twMerge(
            "group text-muted-foreground relative cursor-default outline outline-transparent",
            isFocusVisible &&
              "bg-primary/5 outline-primary ring-ring/20 hover:bg-primary/10 ring-3",
            isDragging &&
              "bg-primary/10 text-foreground outline-primary cursor-grabbing",
            isSelected &&
              "text-foreground bg-(--table-selected-background) hover:bg-(--table-selected-background)/50",
            striped && "even:bg-muted",
            !striped && "border-b last:border-b-0",
            (props.href || props.onAction || selectionMode === "multiple") &&
              "hover:text-foreground hover:bg-(--table-selected-background)",
            (props.href || props.onAction || selectionMode === "multiple") &&
              isFocusVisibleWithin &&
              "selected:bg-(--table-selected-background)/50 text-foreground bg-(--table-selected-background)/50",
            isDisabled && "opacity-50",
            className
          )
      )}
    >
      {dynamicColumns ? renderCell : staticChildren}
    </Row>
  )
}

interface TableCellProps extends CellProps {
  ref?: React.Ref<HTMLTableCellElement>
}
const TableCell = ({ className, ref, ...props }: TableCellProps) => {
  const { allowResize, bleed, grid } = useTableContext()
  return (
    <Cell
      ref={ref}
      data-slot="table-cell"
      {...props}
      className={cx(
        twJoin(
          "group group-has-data-focus-visible-within:text-foreground px-2 py-(--gutter-y) align-middle outline-hidden first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))",
          grid && "border-l first:border-l-0",
          !bleed && "sm:first:pl-2 sm:last:pr-3",
          allowResize && "truncate overflow-hidden"
        ),
        className
      )}
    />
  )
}

export { Table, TableBody, TableCell, TableColumn, TableHeader, TableRow }
export type { TableColumnProps, TableProps, TableRowProps }
Where it goes
data-table
Interaction
dropdown
Use cases
dashboard

DependenciesBundle weight · light

icons
lucide-react