diff --git a/.changeset/table-features.md b/.changeset/table-features.md new file mode 100644 index 0000000000..d3a21fceb5 --- /dev/null +++ b/.changeset/table-features.md @@ -0,0 +1,10 @@ +--- +"@cloudflare/kumo": minor +--- + +feat(table): add layer variant, sort icon, and resize handle className prop + +- Add variant prop to Table with "default" and "layer" options for elevated styling +- Add Table.SortIcon component for column sort indicators +- Add className prop support to Table.ResizeHandle +- Update sticky column documentation comments diff --git a/packages/kumo-docs-astro/package.json b/packages/kumo-docs-astro/package.json index e441ac7a97..7a1b308bdd 100644 --- a/packages/kumo-docs-astro/package.json +++ b/packages/kumo-docs-astro/package.json @@ -24,6 +24,7 @@ "@cloudflare/kumo": "workspace:*", "@phosphor-icons/react": "catalog:", "@tailwindcss/typography": "^0.5.19", + "@tanstack/react-table": "^8.21.3", "@types/turndown": "5.0.6", "astro": "^5.18.1", "clsx": "catalog:", diff --git a/packages/kumo-docs-astro/src/components/SidebarNav.tsx b/packages/kumo-docs-astro/src/components/SidebarNav.tsx index acac10ad9e..25467428d3 100644 --- a/packages/kumo-docs-astro/src/components/SidebarNav.tsx +++ b/packages/kumo-docs-astro/src/components/SidebarNav.tsx @@ -95,6 +95,7 @@ const blockItems: NavItem[] = [ { label: "Page Header", href: "/blocks/page-header" }, { label: "Resource List", href: "/blocks/resource-list" }, { label: "Delete Resource", href: "/blocks/delete-resource" }, + { label: "Tanstack Table", href: "/blocks/tanstack-table" }, ]; // Build info injected via Vite define in astro.config.mjs diff --git a/packages/kumo-docs-astro/src/components/blocks/TanstackTable.tsx b/packages/kumo-docs-astro/src/components/blocks/TanstackTable.tsx new file mode 100644 index 0000000000..5595604735 --- /dev/null +++ b/packages/kumo-docs-astro/src/components/blocks/TanstackTable.tsx @@ -0,0 +1,512 @@ +import { + Badge, + Button, + cn, + DropdownMenu, + Pagination, + SkeletonLine, + Table, +} from "@cloudflare/kumo"; +import { MinusSquareIcon, PlusSquareIcon } from "@phosphor-icons/react"; +import { + flexRender, + type Table as TanstackTableType, + type Row, + type ColumnDef, +} from "@tanstack/react-table"; +import { Fragment, type ReactNode, useEffect, useMemo, useRef } from "react"; + +interface TanstackTableProps { + /** Additional CSS class names for the table */ + className?: string; + /** TanStack table instance from useReactTable hook */ + table: TanstackTableType; + /** Whether to show row selection checkboxes */ + showSelectionControl?: boolean; + /** Whether to show expand/collapse controls for rows with sub-rows */ + showExpandControl?: boolean; + /** Custom render function for expanded row content */ + customExpandChildren?: (row: Row) => ReactNode; + /** Whether to show loading skeleton state */ + isLoading?: boolean; +} + +const INITIAL_LOADING_ROW_COUNT = 5; + +// Column width constants for consistent sizing +const CONTROL_COLUMN_WIDTH = 40; // Width for expand/collapse and selection columns +const ACTION_COLUMN_WIDTH = 50; // Width for action column + +const FILLER_COLUMN_ID = "__filler"; +const ACTION_COLUMN_ID = "__actions"; + +/** + * Main table component that renders a TanStack table with sorting, selection, + * expansion, and column resizing capabilities. + * + * @example + * ```tsx + * const table = useReactTable({ + * data, + * columns, + * getCoreRowModel: getCoreRowModel(), + * getSortedRowModel: getSortedRowModel(), + * }); + * + * + * ``` + */ +function TanstackTableRoot({ + className, + table, + showSelectionControl, + showExpandControl, + customExpandChildren, + isLoading, +}: TanstackTableProps) { + // Use table state values that change to trigger re-renders instead of the table object itself + const sortingState = table.getState().sorting; + // Track which column is being resized to apply visual indicators. + // This is the column ID (string) or false if no column is resizing. + const resizingColumn = table.getState().columnSizingInfo.isResizingColumn; + const columnVisibility = table.getState().columnVisibility; + + const rowCount = table.getRowCount(); + const previousRowCount = useRef(INITIAL_LOADING_ROW_COUNT); + const paginationState = table.getState().pagination; + const expandingState = table.getState().expanded; + const selectionState = table.getState().rowSelection; + + useEffect(() => { + if (rowCount > 0) { + previousRowCount.current = rowCount; + } + }, [rowCount]); + + // Memoize body content to prevent unnecessary re-renders when parent updates + const bodyContent = useMemo(() => { + return table.getRowModel().rows.map((row) => ( + + + {showExpandControl && ( + { + if (!row.getCanExpand()) return; + row.toggleExpanded(); + }} + > + {row.getCanExpand() && + (row.getIsExpanded() ? ( + + ) : ( + + ))} + + )} + {showSelectionControl && + (row.getCanSelect() ? ( + + ) : ( + + ))} + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + + {row.getIsExpanded() && customExpandChildren && ( + + + {customExpandChildren(row)} + + + )} + + )); + // Dependencies intentionally include table state primitives (sortingState, resizingColumn, columnVisibility) + // to trigger re-renders on column resize/sort/visibility changes without re-creating the entire table object. + // The 'table' object reference is stable but we need these granular state dependencies for proper UI updates. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + table, + showExpandControl, + showSelectionControl, + customExpandChildren, + table.options.data, + sortingState, + resizingColumn, + columnVisibility, + paginationState, + expandingState, + selectionState, + ]); + + return ( + + + {showExpandControl && ( + + )} + {showSelectionControl && ( + + )} + {table + .getFlatHeaders() + .map((column) => + column.id === FILLER_COLUMN_ID ? ( + + ) : ( + + ), + )} + + + {table.getHeaderGroups().map((headerGroup) => ( + + {showExpandControl && } + {showSelectionControl && ( + + )} + {headerGroup.headers.map((header) => ( + + {header.column.getCanSort() ? ( + + ) : ( +
+ + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + +
+ )} + {header.column.getCanResize() && ( + { + e.stopPropagation(); + }} + onMouseDown={(e) => { + header.getResizeHandler()(e); + e.stopPropagation(); + }} + onTouchStart={(e) => { + header.getResizeHandler()(e); + e.stopPropagation(); + }} + className={"bg-inherit"} + /> + )} +
+ ))} +
+ ))} +
+ + {isLoading + ? Array.from({ + length: previousRowCount.current || INITIAL_LOADING_ROW_COUNT, + }).map((_, loadingRowIdx) => { + return ( + + {table.getAllFlatColumns().map((header) => { + return ( + + {header.id !== ACTION_COLUMN_ID && + header.id !== FILLER_COLUMN_ID && } + + ); + })} + + ); + }) + : bodyContent} + +
+ ); +} + +interface ColumnFilterProps { + /** TanStack table instance */ + table: TanstackTableType; +} + +/** + * Dropdown menu for toggling column visibility. + * Shows a badge with the count of hidden columns. + * + * @example + * ```tsx + * + * ``` + */ +function ColumnFilter({ table }: ColumnFilterProps) { + const hiddenColumnsCount = table + .getAllLeafColumns() + .filter((column) => column.getCanHide() && !column.getIsVisible()).length; + + const visibleColumns = table + .getAllLeafColumns() + .filter((column) => column.getCanHide() && column.getIsVisible()); + + return ( + + + Columns + {hiddenColumnsCount > 0 && ( + + {hiddenColumnsCount} + + )} + + } + /> + + {table + .getAllLeafColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + const isLastVisible = + visibleColumns.length === 1 && + visibleColumns[0]?.id === column.id; + + return ( + column.toggleVisibility()} + > + {typeof column.columnDef.header === "string" + ? column.columnDef.header + : column.id} + + ); + })} + + + ); +} + +interface ClientPaginationProps { + /** TanStack table instance */ + table: TanstackTableType; + /** Whether to show the page size selector dropdown */ + showPageSizeSelector?: boolean; + /** Available page size options for the selector */ + pageSizeOptions?: number[]; +} + +/** + * Client-side pagination controls for TanStack table. + * Converts TanStack's 0-indexed pagination to 1-indexed for the UI. + * + * @example + * ```tsx + * + * ``` + */ +function ClientPagination({ + table, + showPageSizeSelector = true, + pageSizeOptions = [10, 25, 50, 100], +}: ClientPaginationProps) { + const pageIndex = table.getState().pagination.pageIndex; + const pageSize = table.getState().pagination.pageSize; + // Use pre-pagination model to get filtered row count (excludes pagination) + const rowCount = table.getPrePaginationRowModel().rows.length; + + // TanStack uses 0-indexed pages, but Pagination component uses 1-indexed + const currentPage = pageIndex + 1; + + const handleSetPage = (newPage: number) => { + table.setPageIndex(newPage - 1); + }; + + const handlePageSizeChange = (newSize: number) => { + table.setPageSize(newSize); + }; + + return ( + + + {showPageSizeSelector && ( + <> + + + + )} + + + ); +} + +TanstackTableRoot.displayName = "TanstackTable"; +ColumnFilter.displayName = "TanstackTable.ColumnFilter"; +ClientPagination.displayName = "TanstackTable.ClientPagination"; + +export const TanstackTable = Object.assign(TanstackTableRoot, { + ColumnFilter, + ClientPagination, +}); + +/** + * Creates a filler column that expands to fill remaining table width. + * Use this as the last column to prevent columns from stretching unevenly. + * + * @example + * ```tsx + * const columns = [ + * { accessorKey: 'name', header: 'Name' }, + * { accessorKey: 'status', header: 'Status' }, + * createFillerColumn(), + * ]; + * ``` + */ +export function createFillerColumn(): ColumnDef { + return { + id: FILLER_COLUMN_ID, + header: "", + size: undefined, + enableHiding: false, + enableSorting: false, + enableResizing: false, + }; +} + +/** + * Creates an actions column for row-level actions (edit, delete, etc.). + * This column is fixed-width, non-sortable, and non-resizable. + * + * @param options - Configuration options + * @param options.cell - Render function for the action cell content + * @returns Column definition for actions + * + * @example + * ```tsx + * const columns = [ + * { accessorKey: 'name', header: 'Name' }, + * createActionsColumn({ + * cell: ({ row }) => ( + * + * + * + * + * + * edit(row.original)}> + * Edit + * + * + * + * ), + * }), + * ]; + * ``` + */ +export function createActionsColumn({ + cell, +}: Pick, "cell">): ColumnDef { + return { + id: ACTION_COLUMN_ID, + enableHiding: false, + enableSorting: false, + enableResizing: false, + header: "", + size: ACTION_COLUMN_WIDTH, + cell: cell, + }; +} diff --git a/packages/kumo-docs-astro/src/components/demos/TableDemo.tsx b/packages/kumo-docs-astro/src/components/demos/TableDemo.tsx index e52719aefc..9cb555da27 100644 --- a/packages/kumo-docs-astro/src/components/demos/TableDemo.tsx +++ b/packages/kumo-docs-astro/src/components/demos/TableDemo.tsx @@ -14,6 +14,8 @@ import { Trash, } from "@phosphor-icons/react"; +// Additional imports for resize and sort demo + // Sample data for demos const emailData = [ { @@ -138,8 +140,8 @@ export function TableWithCheckboxDemo() { export function TableWithCompactHeaderDemo() { return ( - - +
+ Subject From @@ -255,15 +257,13 @@ export function TableFixedLayoutDemo() { } /** - * Demonstrates a compact header combined with sticky columns. This combination - * may exhibit visual inconsistencies between the compact header background - * (`bg-kumo-elevated`) and the sticky cell background (`bg-kumo-base`). + * Demonstrates a layer header combined with sticky columns. */ export function TableCompactStickyDemo() { return ( -
- +
+ Subject From @@ -280,12 +280,8 @@ export function TableCompactStickyDemo() { {row.subject} - - {row.from} - - - {row.date} - + {row.from} + {row.date} {row.tags ? (
@@ -358,12 +354,8 @@ export function TableStickyColumnDemo() { {row.subject} - - {row.from} - - - {row.date} - + {row.from} + {row.date} {row.tags ? (
@@ -522,3 +514,61 @@ export function TableFullDemo() { ); } + +/** + * Demonstrates resize handles and sort icons in table headers. + * Visual only — no actual resize or sort logic. + */ +export function TableResizeAndSortDemo() { + return ( + +
+ + + + + + + + +
+ + Subject + + + +
+
+ +
+ + From + + + +
+
+ +
+ + Date + + + +
+
+
+
+ + {emailData.slice(0, 3).map((row) => ( + + {row.subject} + {row.from} + {row.date} + + ))} + +
+
+ ); +} diff --git a/packages/kumo-docs-astro/src/components/demos/TanstackTableDemo.tsx b/packages/kumo-docs-astro/src/components/demos/TanstackTableDemo.tsx new file mode 100644 index 0000000000..db26cf1b34 --- /dev/null +++ b/packages/kumo-docs-astro/src/components/demos/TanstackTableDemo.tsx @@ -0,0 +1,965 @@ +import { Badge, Button, DropdownMenu, LayerCard } from "@cloudflare/kumo"; +import { + createColumnHelper, + getCoreRowModel, + getExpandedRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, + type Row, + type RowSelectionState, +} from "@tanstack/react-table"; +import { useCallback, useMemo, useState } from "react"; +import { + createActionsColumn, + createFillerColumn, + TanstackTable, +} from "../blocks/TanstackTable"; +import { DotsThreeIcon } from "@phosphor-icons/react"; + +interface Resource { + id: string; + resourceName: string; + type: string; + status: "Active" | "Inactive" | "Pending" | "Error"; + size: string; + read: string; + write: string; +} + +interface ResourceWithSubrows extends Resource { + subRows?: Resource[]; +} + +const RESOURCE_DATA: Resource[] = [ + { + id: "1", + resourceName: "production-db-01", + type: "PostgreSQL", + status: "Active", + size: "256 GB", + read: "2.4 MB/s", + write: "1.1 MB/s", + }, + { + id: "2", + resourceName: "redis-cache-cluster", + type: "Redis", + status: "Active", + size: "64 GB", + read: "45.2 MB/s", + write: "38.7 MB/s", + }, + { + id: "3", + resourceName: "user-uploads-bucket", + type: "S3", + status: "Active", + size: "1.2 TB", + read: "8.5 MB/s", + write: "4.2 MB/s", + }, + { + id: "4", + resourceName: "analytics-warehouse", + type: "ClickHouse", + status: "Active", + size: "4.8 TB", + read: "156.3 MB/s", + write: "89.4 MB/s", + }, + { + id: "5", + resourceName: "search-index-primary", + type: "Elasticsearch", + status: "Inactive", + size: "512 GB", + read: "0 MB/s", + write: "0 MB/s", + }, + { + id: "6", + resourceName: "message-queue-main", + type: "RabbitMQ", + status: "Active", + size: "32 GB", + read: "12.8 MB/s", + write: "15.3 MB/s", + }, + { + id: "7", + resourceName: "logs-aggregator", + type: "Loki", + status: "Pending", + size: "128 GB", + read: "0 MB/s", + write: "0 MB/s", + }, + { + id: "8", + resourceName: "backup-storage-eu", + type: "S3", + status: "Active", + size: "8.5 TB", + read: "1.2 MB/s", + write: "0.8 MB/s", + }, + { + id: "9", + resourceName: "metrics-db", + type: "InfluxDB", + status: "Error", + size: "96 GB", + read: "0 MB/s", + write: "0 MB/s", + }, + { + id: "10", + resourceName: "auth-session-store", + type: "Redis", + status: "Active", + size: "16 GB", + read: "89.4 MB/s", + write: "67.2 MB/s", + }, + { + id: "11", + resourceName: "ml-model-storage", + type: "NFS", + status: "Active", + size: "320 GB", + read: "5.6 MB/s", + write: "2.1 MB/s", + }, + { + id: "12", + resourceName: "document-archive", + type: "MongoDB", + status: "Inactive", + size: "2.4 TB", + read: "0 MB/s", + write: "0 MB/s", + }, + { + id: "13", + resourceName: "cdn-origin-images", + type: "S3", + status: "Active", + size: "6.7 TB", + read: "234.5 MB/s", + write: "12.3 MB/s", + }, + { + id: "14", + resourceName: "payment-audit-log", + type: "PostgreSQL", + status: "Active", + size: "512 GB", + read: "3.2 MB/s", + write: "1.8 MB/s", + }, + { + id: "15", + resourceName: "feature-flags-db", + type: "DynamoDB", + status: "Active", + size: "8 GB", + read: "156.7 KB/s", + write: "45.2 KB/s", + }, + { + id: "16", + resourceName: "video-transcoding-queue", + type: "SQS", + status: "Active", + size: "4 GB", + read: "2.1 MB/s", + write: "3.4 MB/s", + }, + { + id: "17", + resourceName: "monitoring-alerts", + type: "Prometheus", + status: "Pending", + size: "64 GB", + read: "0 MB/s", + write: "0 MB/s", + }, + { + id: "18", + resourceName: "config-store", + type: "Consul", + status: "Active", + size: "2 GB", + read: "12.3 KB/s", + write: "8.7 KB/s", + }, + { + id: "19", + resourceName: "email-queue", + type: "RabbitMQ", + status: "Error", + size: "16 GB", + read: "0 MB/s", + write: "0 MB/s", + }, + { + id: "20", + resourceName: "api-gateway-cache", + type: "Redis", + status: "Active", + size: "48 GB", + read: "234.1 MB/s", + write: "198.5 MB/s", + }, + { + id: "21", + resourceName: "data-lake-raw", + type: "HDFS", + status: "Active", + size: "45 TB", + read: "78.9 MB/s", + write: "45.6 MB/s", + }, + { + id: "22", + resourceName: "time-series-metrics", + type: "TimescaleDB", + status: "Active", + size: "384 GB", + read: "34.5 MB/s", + write: "28.9 MB/s", + }, + { + id: "23", + resourceName: "service-registry", + type: "etcd", + status: "Active", + size: "1 GB", + read: "45.6 KB/s", + write: "23.4 KB/s", + }, + { + id: "24", + resourceName: "notification-events", + type: "Kafka", + status: "Active", + size: "256 GB", + read: "156.7 MB/s", + write: "178.2 MB/s", + }, + { + id: "25", + resourceName: "static-assets-us", + type: "S3", + status: "Active", + size: "890 GB", + read: "567.3 MB/s", + write: "23.4 MB/s", + }, + { + id: "26", + resourceName: "ai-training-data", + type: "NFS", + status: "Inactive", + size: "12 TB", + read: "0 MB/s", + write: "0 MB/s", + }, + { + id: "27", + resourceName: "webhook-retry-queue", + type: "SQS", + status: "Active", + size: "8 GB", + read: "1.8 MB/s", + write: "2.3 MB/s", + }, + { + id: "28", + resourceName: "audit-compliance-db", + type: "PostgreSQL", + status: "Active", + size: "768 GB", + read: "5.6 MB/s", + write: "2.4 MB/s", + }, + { + id: "29", + resourceName: "cdn-edge-logs", + type: "S3", + status: "Pending", + size: "45 GB", + read: "0 MB/s", + write: "0 MB/s", + }, + { + id: "30", + resourceName: "rate-limiter-store", + type: "Redis", + status: "Active", + size: "4 GB", + read: "1.2 GB/s", + write: "987.4 MB/s", + }, +]; + +// Data with subrows for expandable table demo +const RESOURCE_DATA_WITH_SUBROWS: ResourceWithSubrows[] = [ + { + id: "cluster-1", + resourceName: "production-cluster-us", + type: "Kubernetes", + status: "Active", + size: "2.4 TB", + read: "850 MB/s", + write: "420 MB/s", + subRows: [ + { + id: "cluster-1-worker-1", + resourceName: "prod-worker-01", + type: "Node", + status: "Active", + size: "256 GB", + read: "120 MB/s", + write: "60 MB/s", + }, + { + id: "cluster-1-worker-2", + resourceName: "prod-worker-02", + type: "Node", + status: "Active", + size: "256 GB", + read: "115 MB/s", + write: "58 MB/s", + }, + { + id: "cluster-1-worker-3", + resourceName: "prod-worker-03", + type: "Node", + status: "Active", + size: "256 GB", + read: "125 MB/s", + write: "62 MB/s", + }, + ], + }, + { + id: "pipeline-1", + resourceName: "analytics-pipeline", + type: "Apache Spark", + status: "Active", + size: "8.5 TB", + read: "2.1 GB/s", + write: "1.8 GB/s", + }, + { + id: "cluster-2", + resourceName: "search-cluster-eu", + type: "Elasticsearch", + status: "Active", + size: "1.2 TB", + read: "450 MB/s", + write: "120 MB/s", + subRows: [ + { + id: "cluster-2-master-1", + resourceName: "es-master-01", + type: "Node", + status: "Active", + size: "128 GB", + read: "80 MB/s", + write: "25 MB/s", + }, + { + id: "cluster-2-data-1", + resourceName: "es-data-01", + type: "Node", + status: "Active", + size: "512 GB", + read: "200 MB/s", + write: "60 MB/s", + }, + { + id: "cluster-2-data-2", + resourceName: "es-data-02", + type: "Node", + status: "Active", + size: "512 GB", + read: "195 MB/s", + write: "58 MB/s", + }, + ], + }, + { + id: "bus-1", + resourceName: "message-bus-primary", + type: "Apache Kafka", + status: "Active", + size: "4.2 TB", + read: "1.5 GB/s", + write: "1.2 GB/s", + }, + { + id: "cache-1", + resourceName: "cache-tier-global", + type: "Redis Cluster", + status: "Active", + size: "512 GB", + read: "8.5 GB/s", + write: "6.2 GB/s", + }, +]; + +export function TanstackBasicDemo() { + const data = useMemo(() => RESOURCE_DATA.slice(0, 5), []); + + const columns = useMemo(() => { + const columnHelper = createColumnHelper(); + + return [ + columnHelper.accessor("resourceName", { header: "Resource Name" }), + columnHelper.accessor("status", { + header: "Status", + cell: (row) => {row.getValue()}, + }), + ]; + }, []); + + const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + enableColumnResizing: false, + enableSorting: false, + getRowId: (row) => row.id, + }); + + return ( + + + + ); +} + +export function TanstackLoadingDemo() { + const data = useMemo(() => [], []); + + const columns = useMemo(() => { + const columnHelper = createColumnHelper(); + + return [ + columnHelper.accessor("resourceName", { header: "Resource Name" }), + columnHelper.accessor("status", { + header: "Status", + cell: (row) => {row.getValue()}, + }), + ]; + }, []); + + const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + enableColumnResizing: false, + enableSorting: false, + getRowId: (row) => row.id, + }); + + return ( + + + + ); +} + +export function TanstackResizableDemo() { + const data = useMemo(() => RESOURCE_DATA.slice(0, 5), []); + + const columns = useMemo(() => { + const columnHelper = createColumnHelper(); + + return [ + columnHelper.accessor("resourceName", { header: "Resource Name" }), + columnHelper.accessor("status", { + header: "Status", + cell: (row) => {row.getValue()}, + }), + columnHelper.accessor("size", { header: "Size" }), + ]; + }, []); + + const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + columnResizeMode: "onChange", + enableColumnResizing: true, + enableSorting: false, + getRowId: (row) => row.id, + }); + + return ( + + + + ); +} + +export function TanstackResizableFillerDemo() { + const data = useMemo(() => RESOURCE_DATA.slice(0, 5), []); + + const columns = useMemo(() => { + const columnHelper = createColumnHelper(); + + return [ + columnHelper.accessor("resourceName", { + header: "Resource Name", + minSize: 100, + size: 300, + cell: (props) => ( + {props.getValue()} + ), + }), + columnHelper.accessor("status", { + minSize: 100, + header: "Status", + cell: (row) => {row.getValue()}, + }), + columnHelper.accessor("size", { header: "Size", minSize: 100 }), + createFillerColumn(), + ]; + }, []); + + const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + columnResizeMode: "onChange", + enableColumnResizing: true, + enableSorting: false, + getRowId: (row) => row.id, + }); + + return ( + + + + ); +} + +export function TanstackColumnFilterDemo() { + const data = useMemo(() => RESOURCE_DATA.slice(0, 5), []); + + const columns = useMemo(() => { + const columnHelper = createColumnHelper(); + + return [ + columnHelper.accessor("resourceName", { + header: "Resource Name", + minSize: 100, + size: 300, + cell: (props) => ( + {props.getValue()} + ), + }), + columnHelper.accessor("status", { + minSize: 100, + header: "Status", + cell: (row) => {row.getValue()}, + }), + columnHelper.accessor("type", { header: "Type", minSize: 100 }), + columnHelper.accessor("size", { header: "Size", minSize: 100 }), + columnHelper.accessor("read", { header: "Read", minSize: 100 }), + columnHelper.accessor("write", { header: "Write", minSize: 100 }), + createFillerColumn(), + ]; + }, []); + + const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + columnResizeMode: "onChange", + enableColumnResizing: true, + enableSorting: false, + getRowId: (row) => row.id, + }); + + return ( +
+ + + + +
+ ); +} + +export function TanstackClientPaginationDemo() { + const data = useMemo(() => RESOURCE_DATA, []); + + const columns = useMemo(() => { + const columnHelper = createColumnHelper(); + + return [ + columnHelper.accessor("resourceName", { + header: "Resource Name", + minSize: 100, + size: 300, + cell: (props) => ( + {props.getValue()} + ), + }), + columnHelper.accessor("status", { + minSize: 100, + header: "Status", + cell: (row) => {row.getValue()}, + }), + columnHelper.accessor("type", { header: "Type", minSize: 100 }), + columnHelper.accessor("size", { header: "Size", minSize: 100 }), + columnHelper.accessor("read", { header: "Read", minSize: 100 }), + columnHelper.accessor("write", { header: "Write", minSize: 100 }), + createFillerColumn(), + ]; + }, []); + + const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + columnResizeMode: "onChange", + enableColumnResizing: true, + enableSorting: false, + getPaginationRowModel: getPaginationRowModel(), + initialState: { + pagination: { + pageIndex: 0, + pageSize: 5, + }, + }, + getRowId: (row) => row.id, + }); + + return ( +
+ + + + + +
+ ); +} + +export function TanstackSortingDemo() { + const data = useMemo(() => RESOURCE_DATA, []); + + const columns = useMemo(() => { + const columnHelper = createColumnHelper(); + + return [ + columnHelper.accessor("resourceName", { + header: "Resource Name", + minSize: 100, + size: 300, + cell: (props) => ( + {props.getValue()} + ), + }), + columnHelper.accessor("status", { + minSize: 100, + header: "Status", + cell: (row) => {row.getValue()}, + }), + columnHelper.accessor("type", { header: "Type", minSize: 100 }), + columnHelper.accessor("size", { header: "Size", minSize: 100 }), + columnHelper.accessor("read", { header: "Read", minSize: 100 }), + columnHelper.accessor("write", { header: "Write", minSize: 100 }), + createFillerColumn(), + ]; + }, []); + + const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + columnResizeMode: "onChange", + enableColumnResizing: true, + getSortedRowModel: getSortedRowModel(), + enableSorting: true, + getPaginationRowModel: getPaginationRowModel(), + initialState: { + pagination: { + pageIndex: 0, + pageSize: 5, + }, + }, + getRowId: (row) => row.id, + }); + + return ( +
+ + + + + +
+ ); +} + +export function TanstackActionsDemo() { + const data = useMemo(() => RESOURCE_DATA, []); + + const columns = useMemo(() => { + const columnHelper = createColumnHelper(); + + return [ + columnHelper.accessor("resourceName", { + header: "Resource Name", + minSize: 100, + size: 300, + cell: (props) => ( + {props.getValue()} + ), + }), + columnHelper.accessor("status", { + minSize: 100, + header: "Status", + cell: (row) => {row.getValue()}, + }), + columnHelper.accessor("type", { header: "Type", minSize: 100 }), + columnHelper.accessor("size", { header: "Size", minSize: 100 }), + columnHelper.accessor("read", { header: "Read", minSize: 100 }), + columnHelper.accessor("write", { header: "Write", minSize: 100 }), + createFillerColumn(), + createActionsColumn({ + cell: (row) => { + return ( + + + + + } + /> + + { + alert("Removing " + row.row.original.resourceName); + }} + > + Remove + + + + ); + }, + }), + ]; + }, []); + + const table = useReactTable({ + columns, + data, + getCoreRowModel: getCoreRowModel(), + columnResizeMode: "onChange", + enableColumnResizing: true, + getSortedRowModel: getSortedRowModel(), + enableSorting: true, + getPaginationRowModel: getPaginationRowModel(), + initialState: { + pagination: { + pageIndex: 0, + pageSize: 5, + }, + }, + getRowId: (row) => row.id, + }); + + return ( +
+ + + + + +
+ ); +} + +export function TanstackSubRowDemo() { + const columns = useMemo(() => { + const columnHelper = createColumnHelper(); + + return [ + columnHelper.accessor("resourceName", { + header: "Resource Name", + minSize: 100, + size: 300, + cell: (props) => ( + {props.getValue()} + ), + }), + columnHelper.accessor("status", { + minSize: 100, + header: "Status", + cell: (row) => {row.getValue()}, + }), + columnHelper.accessor("type", { header: "Type", minSize: 100 }), + columnHelper.accessor("size", { header: "Size", minSize: 100 }), + columnHelper.accessor("read", { header: "Read", minSize: 100 }), + columnHelper.accessor("write", { header: "Write", minSize: 100 }), + ]; + }, []); + + const table = useReactTable({ + columns, + data: RESOURCE_DATA_WITH_SUBROWS, + getCoreRowModel: getCoreRowModel(), + columnResizeMode: "onChange", + enableColumnResizing: false, + enableSorting: false, + enableExpanding: true, + getExpandedRowModel: getExpandedRowModel(), + getSubRows: (row) => row.subRows, + getRowId: (row) => row.id, + }); + + return ( +
+ + + + +
+ ); +} + +export function TanstackExpandCustomDemo() { + const columns = useMemo(() => { + const columnHelper = createColumnHelper(); + + return [ + columnHelper.accessor("resourceName", { + header: "Resource Name", + minSize: 100, + size: 300, + cell: (props) => ( + {props.getValue()} + ), + }), + columnHelper.accessor("status", { + minSize: 100, + header: "Status", + cell: (row) => {row.getValue()}, + }), + columnHelper.accessor("type", { header: "Type", minSize: 100 }), + columnHelper.accessor("size", { header: "Size", minSize: 100 }), + columnHelper.accessor("read", { header: "Read", minSize: 100 }), + columnHelper.accessor("write", { header: "Write", minSize: 100 }), + ]; + }, []); + + const table = useReactTable({ + columns, + data: RESOURCE_DATA_WITH_SUBROWS, + getCoreRowModel: getCoreRowModel(), + columnResizeMode: "onChange", + enableColumnResizing: false, + enableSorting: false, + enableExpanding: true, + getRowCanExpand: () => true, + getRowId: (row) => row.id, + }); + + const customRenderer = useCallback((row: Row) => { + return ( +
+ This is custom component for {row.original.resourceName} +
+ ); + }, []); + + return ( +
+ + + + +
+ ); +} + +export function TanstackSelectionDemo() { + const [selection, setSelection] = useState({}); + + const columns = useMemo(() => { + const columnHelper = createColumnHelper(); + + return [ + columnHelper.accessor("resourceName", { + header: "Resource Name", + minSize: 100, + size: 300, + cell: (props) => ( + {props.getValue()} + ), + }), + columnHelper.accessor("status", { + minSize: 100, + header: "Status", + cell: (row) => {row.getValue()}, + }), + columnHelper.accessor("type", { header: "Type", minSize: 100 }), + columnHelper.accessor("size", { header: "Size", minSize: 100 }), + columnHelper.accessor("read", { header: "Read", minSize: 100 }), + columnHelper.accessor("write", { header: "Write", minSize: 100 }), + ]; + }, []); + + const table = useReactTable({ + columns, + data: RESOURCE_DATA_WITH_SUBROWS, + getCoreRowModel: getCoreRowModel(), + columnResizeMode: "onChange", + enableColumnResizing: false, + enableSorting: false, + enableRowSelection: true, + getRowId: (row) => row.id, + onRowSelectionChange: setSelection, + state: { + rowSelection: selection, + }, + }); + + return ( +
+ + + + +
+ ); +} diff --git a/packages/kumo-docs-astro/src/pages/blocks/tanstack-table.mdx b/packages/kumo-docs-astro/src/pages/blocks/tanstack-table.mdx new file mode 100644 index 0000000000..52963372f0 --- /dev/null +++ b/packages/kumo-docs-astro/src/pages/blocks/tanstack-table.mdx @@ -0,0 +1,111 @@ + +--- +layout: ~/layouts/MdxDocLayout.astro +title: "TanStack Table" +description: "A powerful data table component built on TanStack Table with support for sorting, filtering, pagination, resizable columns, and expandable rows." +--- + +import ComponentExample from "~/components/docs/ComponentExample.astro"; +import ComponentSection from "~/components/docs/ComponentSection.astro"; +import CodeBlock from "~/components/docs/CodeBlock.astro"; +import PropsTable from "~/components/docs/PropsTable.astro"; +import { + TanstackBasicDemo, + TanstackLoadingDemo, + TanstackResizableDemo, + TanstackResizableFillerDemo, + TanstackColumnFilterDemo, + TanstackClientPaginationDemo, + TanstackSortingDemo, + TanstackActionsDemo, + TanstackSubRowDemo, + TanstackExpandCustomDemo, + TanstackSelectionDemo +} from "~/components/demos/TanstackTableDemo"; + +## Basic Usage + +Get started with a minimal data table using the TanStack Table boilerplate. This example shows the essential configuration including column definitions and row data rendering. + + + + + +## Loading State + +Display a loading skeleton while data is being fetched. Use the `LoadingTable` component to show placeholder rows during async data loading. + + + + + +## Resizable Columns + +Enable column resizing by setting `enableColumnResizing: true` in the table options. Users can drag column borders to adjust widths. Refer to the [TanStack column sizing guide](https://tanstack.com/table/v8/docs/guide/column-sizing) for advanced configuration options. + + + + + +For a smoother resizing experience, add a filler column that absorbs excess space. Configure `minSize` and `size` on columns to control their resizing behavior. + + + + + +## Column Filtering + +Add filter inputs to individual columns to let users narrow down data. See the [TanStack filtering guide](https://tanstack.com/table/v8/docs/guide/column-filtering) for filter function customization. + + + + + +## Client-side Pagination + +Split large datasets into pages with built-in pagination controls. Configure page size and navigation options. Learn more in the [TanStack pagination guide](https://tanstack.com/table/v8/docs/guide/pagination). + + + + + +## Sorting + +Enable click-to-sort on column headers. Supports multi-column sorting and custom sort functions. Check the [TanStack sorting guide](https://tanstack.com/table/v8/docs/guide/sorting) for implementation details. + + + + + +## Actions Column + +Add an actions column with buttons or dropdowns for row-level operations like edit, delete, or view details. + + + + + + +## Expandable Subrows + +Display hierarchical data with expandable rows. Click the chevron to reveal nested subrow information. + + + + + +## Custom Expand Component + +Replace the default subrow with a fully custom component when expanding a row. Useful for displaying detailed views, forms, or related data. + + + + + +## Selection + +Enable row selection with checkboxes for bulk operations. Access selected rows via the table state to perform actions like delete, export, or bulk edit on multiple items at once. See the [TanStack row selection guide](https://tanstack.com/table/v8/docs/guide/row-selection) for implementation details. + + + + \ No newline at end of file diff --git a/packages/kumo-docs-astro/src/pages/components/table.mdx b/packages/kumo-docs-astro/src/pages/components/table.mdx index e836975bc7..9a60930153 100644 --- a/packages/kumo-docs-astro/src/pages/components/table.mdx +++ b/packages/kumo-docs-astro/src/pages/components/table.mdx @@ -17,6 +17,7 @@ import { TableStickyColumnDemo, TableCompactStickyDemo, TableFullDemo, + TableResizeAndSortDemo, } from "~/components/demos/TableDemo"; {/* Hero Demo */} @@ -121,10 +122,11 @@ export default function Example() { -### Compact Header +### Layer Header

- Use `variant="compact"` on `Table.Header` for a more condensed header style. + Use `variant="layer"` on `Table` for a more condensed header style.{" "} + variant="compact" on Table.Header is deprecated.

@@ -159,13 +161,24 @@ export default function Example() { -### Compact Header with Sticky Column +### Layer Header with Sticky Column -

Combining `variant="compact"` on `Table.Header` with `sticky` columns.

+

Combining `variant="layer"` on `Table` with `sticky` columns.

+### Resize Handles and Sort Icons + +

+ Visual example showing Table.ResizeHandle and{" "} + Table.SortIcon in header cells. Resize handles appear on hover, + and sort icons indicate column sort state (ascending, descending, or none). +

+ + + + ### Full Example

@@ -191,7 +204,15 @@ export default function Example() { ### Table.Header -

Table header section. Renders ``. Set `sticky` to pin the header row to the top of the scroll container.

+

+ Table header section. Renders ``. Set `sticky` to pin the header row to + the top of the scroll container. +

+ +

+ Note: variant is deprecated. Use{" "} + variant="layer" on Table instead. +

### Table.Body @@ -227,6 +248,15 @@ export default function Example() { logic.

+### Table.SortIcon + +

+ Visual indicator for column sort state. Pass{" "} + direction="asc", direction="desc", or{" "} + direction={false} to show ascending, descending, or unsorted + states. +

+ {/* TanStack Table Integration */} diff --git a/packages/kumo/package.json b/packages/kumo/package.json index 240ac94134..5bef94f5e0 100644 --- a/packages/kumo/package.json +++ b/packages/kumo/package.json @@ -303,10 +303,6 @@ "types": "./dist/src/primitives/number-field.d.ts", "import": "./dist/primitives/number-field.js" }, - "./primitives/otp-field": { - "types": "./dist/src/primitives/otp-field.d.ts", - "import": "./dist/primitives/otp-field.js" - }, "./primitives/popover": { "types": "./dist/src/primitives/popover.d.ts", "import": "./dist/primitives/popover.js" @@ -371,6 +367,10 @@ "types": "./dist/src/primitives/tooltip.d.ts", "import": "./dist/primitives/tooltip.js" }, + "./primitives/otp-field": { + "types": "./dist/src/primitives/otp-field.d.ts", + "import": "./dist/primitives/otp-field.js" + }, "./registry": { "types": "./dist/src/registry/index.d.ts", "import": "./dist/registry.js" diff --git a/packages/kumo/src/components/table/table.tsx b/packages/kumo/src/components/table/table.tsx index f21ed0d7f5..31eef1c91d 100644 --- a/packages/kumo/src/components/table/table.tsx +++ b/packages/kumo/src/components/table/table.tsx @@ -1,6 +1,7 @@ import { forwardRef } from "react"; import { cn } from "../../utils"; -import { Checkbox, type CheckboxChangeEventDetails } from "../checkbox"; +import { Checkbox, CheckboxChangeEventDetails } from "../checkbox"; +import { ArrowDownIcon, CaretUpDownIcon } from "@phosphor-icons/react"; /** Table layout and row variant definitions mapping names to their Tailwind classes. */ export const KUMO_TABLE_VARIANTS = { @@ -49,7 +50,7 @@ export type KumoTableStickyColumn = keyof typeof KUMO_TABLE_VARIANTS.sticky; * - `z-1` — sticky body cells (``) * - `z-2` — sticky header cells (``) so they sit above sticky body cells * - * Header cells use `:has()` to detect if they're in a compact header (which has + * Header cells use `:has()` to detect if they're in a layer header (which has * `bg-kumo-elevated`) and adjust both the background and gradient fade colors. */ const stickyColumnClasses = ( @@ -73,8 +74,8 @@ const stickyColumnClasses = ( return cn(base, z, "bg-kumo-base", fadeBase, fadePosition, fade); } - // Header cells: use kumo-base by default, kumo-elevated when in compact header - // The compact header applies a data attribute we can target with :has() + // Header cells: use kumo-base by default, kumo-elevated when in layer header + // The layer header applies a data attribute we can target with :has() const bg = "bg-kumo-base group-data-[compact]/header:bg-kumo-elevated"; const fade = side === "right" @@ -123,8 +124,17 @@ const TableRoot = forwardRef< * @default "auto" */ layout?: KumoTableLayout; + variant?: "default" | "layer"; } ->(({ layout = "auto", ...props }, ref) => { +>(({ layout = "auto", variant, ...props }, ref) => { + const layerVariantClassName = cn( + "bg-kumo-elevated [&_tbody]:bg-kumo-base", + "[&_tbody]:rounded-lg [&_tbody]:ring-1 [&_tbody]:ring-kumo-line [&_tbody]:overflow-clip", + "[&_td]:overflow-clip", + "[&_tr:first-child_td:first-child]:rounded-tl-lg [&_tr:first-child_td:last-child]:rounded-tr-lg", + "[&_th]:py-2.5 [&_th]:font-medium [&_th]:bg-transparent [&_th]:border-b-0 [&_th]:text-sm", + ); + const className = cn( "isolate w-full", // isolate creates a stacking context so z-0/z-1/z-2 never leak out KUMO_TABLE_VARIANTS.layout[layout].classes, @@ -133,15 +143,26 @@ const TableRoot = forwardRef< "[&_th]:border-b [&_th]:border-kumo-fill [&_th]:p-3 [&_th]:font-semibold [&_th]:text-base", // Header styles "[&_th]:bg-kumo-base", // Header background color "text-base text-left text-kumo-default", + variant === "layer" && layerVariantClassName, props.className, ); - return ; + return ( +
+ ); }); const TableHeader = forwardRef< HTMLTableSectionElement, React.HTMLAttributes & { + /** + * @deprecated Use `variant="layer"` on `Table` instead. + */ variant?: "default" | "compact"; /** * Make the header row stick to the top of the scroll container. @@ -253,6 +274,7 @@ const TableResizeHandle = forwardRef< "absolute top-0 right-0", // Position the handle "m-0 bg-kumo-base p-0", // Override the stratus button styles "focus-visible:ring-2 focus-visible:ring-kumo-brand", // Consistent keyboard focus styling + props.className, )} > @@ -372,6 +394,26 @@ const TableCheckHead = forwardRef< }, ); +type TableSortDirection = false | "asc" | "desc"; + +const TableSortIcon = ({ direction }: { direction: TableSortDirection }) => { + if (!direction) + return ( + + ); + + return ( + + ); +}; + TableRoot.displayName = "Table"; TableBody.displayName = "Table.Body"; TableHead.displayName = "Table.Head"; @@ -382,6 +424,7 @@ TableHeader.displayName = "Table.Header"; TableResizeHandle.displayName = "Table.ResizeHandle"; TableCheckCell.displayName = "Table.CheckCell"; TableCheckHead.displayName = "Table.CheckHead"; +TableSortIcon.displayName = "Table.SortIcon"; /** * Table — semantic HTML table with styled rows, cells, and selection support. @@ -419,4 +462,5 @@ export const Table = Object.assign(TableRoot, { CheckHead: TableCheckHead, Footer: TableFooter, ResizeHandle: TableResizeHandle, + SortIcon: TableSortIcon, }); diff --git a/packages/kumo/src/primitives/index.ts b/packages/kumo/src/primitives/index.ts index 5ceca29713..fbf5f93429 100644 --- a/packages/kumo/src/primitives/index.ts +++ b/packages/kumo/src/primitives/index.ts @@ -36,7 +36,6 @@ export * from "@base-ui/react/menubar"; export * from "@base-ui/react/meter"; export * from "@base-ui/react/navigation-menu"; export * from "@base-ui/react/number-field"; -export * from "@base-ui/react/otp-field"; export * from "@base-ui/react/popover"; export * from "@base-ui/react/preview-card"; export * from "@base-ui/react/progress"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6155a68e02..bb9ddc0b1a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -270,6 +270,9 @@ importers: '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@4.1.17) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@types/turndown': specifier: 5.0.6 version: 5.0.6 @@ -2446,6 +2449,17 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -2947,6 +2961,7 @@ packages: basic-ftp@5.2.0: resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.1, please upgrade before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} @@ -8211,6 +8226,14 @@ snapshots: tailwindcss: 4.1.17 vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.2) + '@tanstack/react-table@8.21.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@tanstack/table-core@8.21.3': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0