From 91867cc6d08b0be45d70c116e10c40b3d4b7d3e4 Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Wed, 8 Jul 2026 08:48:39 -0500 Subject: [PATCH] docs: rewrite kitchen sink examples with createTableHook --- .../kitchen-sink-chakra-ui/src/columns.tsx | 106 + .../components/data-table/cell-components.tsx | 245 +++ .../data-table/data-table-column-header.tsx | 168 ++ .../data-table/data-table-filter-list.tsx | 345 +++ .../data-table/data-table-pagination.tsx | 75 + .../data-table/data-table-sort-list.tsx | 172 ++ .../data-table/data-table-view-options.tsx | 103 + .../data-table/header-components.tsx | 72 + .../src/components/data-table/shared.tsx | 480 +++++ .../src/hooks/features.ts | 72 + .../kitchen-sink-chakra-ui/src/hooks/table.ts | 69 + .../src/lib/data-table.ts | 36 +- .../react/kitchen-sink-chakra-ui/src/main.tsx | 1869 ++--------------- .../kitchen-sink-hero-ui/src/columns.tsx | 115 + .../components/data-table/cell-components.tsx | 165 ++ .../data-table/data-table-column-header.tsx | 141 ++ .../data-table/data-table-filter-list.tsx | 321 +++ .../data-table/data-table-pagination.tsx | 116 + .../data-table/data-table-sort-list.tsx | 151 ++ .../data-table/data-table-view-options.tsx | 98 + .../data-table/header-components.tsx | 56 + .../src/components/data-table/shared.tsx | 106 + .../src/hooks/features.ts | 72 + .../kitchen-sink-hero-ui/src/hooks/table.ts | 71 + .../src/lib/data-table.ts | 2 +- .../react/kitchen-sink-hero-ui/src/main.tsx | 1399 +----------- .../kitchen-sink-mantine/src/columns.tsx | 106 + .../components/data-table/cell-components.tsx | 244 +++ .../data-table/data-table-column-header.tsx | 152 ++ .../data-table/data-table-filter-list.tsx | 343 +++ .../data-table/data-table-pagination.tsx | 85 + .../data-table/data-table-sort-list.tsx | 191 ++ .../data-table/data-table-view-options.tsx | 122 ++ .../data-table/header-components.tsx | 74 + .../src/components/data-table/shared.tsx | 85 + .../src/hooks/features.ts | 72 + .../kitchen-sink-mantine/src/hooks/table.ts | 69 + .../src/lib/data-table.ts | 2 +- .../react/kitchen-sink-mantine/src/main.tsx | 1565 ++------------ .../kitchen-sink-material-ui/src/columns.tsx | 107 + .../components/data-table/cell-components.tsx | 249 +++ .../data-table/data-table-column-header.tsx | 255 +++ .../data-table/data-table-filter-list.tsx | 341 +++ .../data-table/data-table-pagination.tsx | 38 + .../data-table/data-table-sort-list.tsx | 182 ++ .../data-table/data-table-view-options.tsx | 130 ++ .../data-table/header-components.tsx | 57 + .../src/components/data-table/shared.tsx | 81 + .../src/hooks/features.ts | 72 + .../src/hooks/table.ts | 69 + .../src/lib/data-table.ts | 2 +- .../kitchen-sink-material-ui/src/main.tsx | 1685 ++------------- .../kitchen-sink-react-aria/src/columns.tsx | 120 ++ .../components/data-table/cell-components.tsx | 184 ++ .../data-table/data-table-column-header.tsx | 142 ++ .../data-table/data-table-filter-list.tsx | 319 +++ .../data-table/data-table-pagination.tsx | 104 + .../data-table/data-table-sort-list.tsx | 132 ++ .../data-table/data-table-view-options.tsx | 98 + .../data-table/header-components.tsx | 45 + .../src/components/data-table/shared.tsx | 132 ++ .../src/hooks/features.ts | 72 + .../src/hooks/table.ts | 71 + .../src/lib/data-table.ts | 2 +- .../kitchen-sink-react-aria/src/main.tsx | 1389 +----------- .../src/styles/globals.css | 8 + .../kitchen-sink-shadcn-base/src/columns.tsx | 108 + .../components/data-table/cell-components.tsx | 188 ++ .../data-table/data-table-column-header.tsx | 231 +- .../data-table/data-table-filter-list.tsx | 61 +- .../data-table/data-table-pagination.tsx | 16 +- .../data-table/data-table-sort-list.tsx | 25 +- .../data-table/data-table-view-options.tsx | 29 +- .../data-table/header-components.tsx | 50 + .../src/components/ui/table.tsx | 14 +- .../src/hooks/features.ts | 72 + .../src/hooks/table.ts | 69 + .../src/lib/data-table.ts | 25 +- .../kitchen-sink-shadcn-base/src/main.tsx | 658 ++---- .../kitchen-sink-shadcn-radix/src/columns.tsx | 108 + .../components/data-table/cell-components.tsx | 187 ++ .../data-table/data-table-column-header.tsx | 227 +- .../data-table/data-table-filter-list.tsx | 55 +- .../data-table/data-table-pagination.tsx | 16 +- .../data-table/data-table-sort-list.tsx | 25 +- .../data-table/data-table-view-options.tsx | 29 +- .../data-table/header-components.tsx | 49 + .../src/components/ui/table.tsx | 14 +- .../src/hooks/features.ts | 72 + .../src/hooks/table.ts | 69 + .../src/lib/data-table.ts | 2 +- .../kitchen-sink-shadcn-radix/src/main.tsx | 654 ++---- .../react/virtualized-columns/src/main.tsx | 76 +- packages/table-core/src/utils.ts | 3 +- test-results/.last-run.json | 2 +- 95 files changed, 10101 insertions(+), 8854 deletions(-) create mode 100644 examples/react/kitchen-sink-chakra-ui/src/columns.tsx create mode 100644 examples/react/kitchen-sink-chakra-ui/src/components/data-table/cell-components.tsx create mode 100644 examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-column-header.tsx create mode 100644 examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-filter-list.tsx create mode 100644 examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-pagination.tsx create mode 100644 examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-sort-list.tsx create mode 100644 examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-view-options.tsx create mode 100644 examples/react/kitchen-sink-chakra-ui/src/components/data-table/header-components.tsx create mode 100644 examples/react/kitchen-sink-chakra-ui/src/components/data-table/shared.tsx create mode 100644 examples/react/kitchen-sink-chakra-ui/src/hooks/features.ts create mode 100644 examples/react/kitchen-sink-chakra-ui/src/hooks/table.ts create mode 100644 examples/react/kitchen-sink-hero-ui/src/columns.tsx create mode 100644 examples/react/kitchen-sink-hero-ui/src/components/data-table/cell-components.tsx create mode 100644 examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-column-header.tsx create mode 100644 examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-filter-list.tsx create mode 100644 examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-pagination.tsx create mode 100644 examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-sort-list.tsx create mode 100644 examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-view-options.tsx create mode 100644 examples/react/kitchen-sink-hero-ui/src/components/data-table/header-components.tsx create mode 100644 examples/react/kitchen-sink-hero-ui/src/components/data-table/shared.tsx create mode 100644 examples/react/kitchen-sink-hero-ui/src/hooks/features.ts create mode 100644 examples/react/kitchen-sink-hero-ui/src/hooks/table.ts create mode 100644 examples/react/kitchen-sink-mantine/src/columns.tsx create mode 100644 examples/react/kitchen-sink-mantine/src/components/data-table/cell-components.tsx create mode 100644 examples/react/kitchen-sink-mantine/src/components/data-table/data-table-column-header.tsx create mode 100644 examples/react/kitchen-sink-mantine/src/components/data-table/data-table-filter-list.tsx create mode 100644 examples/react/kitchen-sink-mantine/src/components/data-table/data-table-pagination.tsx create mode 100644 examples/react/kitchen-sink-mantine/src/components/data-table/data-table-sort-list.tsx create mode 100644 examples/react/kitchen-sink-mantine/src/components/data-table/data-table-view-options.tsx create mode 100644 examples/react/kitchen-sink-mantine/src/components/data-table/header-components.tsx create mode 100644 examples/react/kitchen-sink-mantine/src/components/data-table/shared.tsx create mode 100644 examples/react/kitchen-sink-mantine/src/hooks/features.ts create mode 100644 examples/react/kitchen-sink-mantine/src/hooks/table.ts create mode 100644 examples/react/kitchen-sink-material-ui/src/columns.tsx create mode 100644 examples/react/kitchen-sink-material-ui/src/components/data-table/cell-components.tsx create mode 100644 examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-column-header.tsx create mode 100644 examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-filter-list.tsx create mode 100644 examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-pagination.tsx create mode 100644 examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-sort-list.tsx create mode 100644 examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-view-options.tsx create mode 100644 examples/react/kitchen-sink-material-ui/src/components/data-table/header-components.tsx create mode 100644 examples/react/kitchen-sink-material-ui/src/components/data-table/shared.tsx create mode 100644 examples/react/kitchen-sink-material-ui/src/hooks/features.ts create mode 100644 examples/react/kitchen-sink-material-ui/src/hooks/table.ts create mode 100644 examples/react/kitchen-sink-react-aria/src/columns.tsx create mode 100644 examples/react/kitchen-sink-react-aria/src/components/data-table/cell-components.tsx create mode 100644 examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-column-header.tsx create mode 100644 examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-filter-list.tsx create mode 100644 examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-pagination.tsx create mode 100644 examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-sort-list.tsx create mode 100644 examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-view-options.tsx create mode 100644 examples/react/kitchen-sink-react-aria/src/components/data-table/header-components.tsx create mode 100644 examples/react/kitchen-sink-react-aria/src/components/data-table/shared.tsx create mode 100644 examples/react/kitchen-sink-react-aria/src/hooks/features.ts create mode 100644 examples/react/kitchen-sink-react-aria/src/hooks/table.ts create mode 100644 examples/react/kitchen-sink-shadcn-base/src/columns.tsx create mode 100644 examples/react/kitchen-sink-shadcn-base/src/components/data-table/cell-components.tsx create mode 100644 examples/react/kitchen-sink-shadcn-base/src/components/data-table/header-components.tsx create mode 100644 examples/react/kitchen-sink-shadcn-base/src/hooks/features.ts create mode 100644 examples/react/kitchen-sink-shadcn-base/src/hooks/table.ts create mode 100644 examples/react/kitchen-sink-shadcn-radix/src/columns.tsx create mode 100644 examples/react/kitchen-sink-shadcn-radix/src/components/data-table/cell-components.tsx create mode 100644 examples/react/kitchen-sink-shadcn-radix/src/components/data-table/header-components.tsx create mode 100644 examples/react/kitchen-sink-shadcn-radix/src/hooks/features.ts create mode 100644 examples/react/kitchen-sink-shadcn-radix/src/hooks/table.ts diff --git a/examples/react/kitchen-sink-chakra-ui/src/columns.tsx b/examples/react/kitchen-sink-chakra-ui/src/columns.tsx new file mode 100644 index 0000000000..a15c7728da --- /dev/null +++ b/examples/react/kitchen-sink-chakra-ui/src/columns.tsx @@ -0,0 +1,106 @@ +import type { Person } from '@/lib/make-data' +import { createAppColumnHelper } from '@/hooks/table' +import { + AgeAggregatedCell, + JoinDateAggregatedCell, +} from '@/components/data-table/cell-components' +import { toSentenceCase } from '@/components/data-table/shared' +import { departments, statuses } from '@/lib/make-data' + +const columnHelper = createAppColumnHelper() + +export const columns = columnHelper.columns([ + columnHelper.display({ + id: 'select', + header: ({ header }) => , + cell: ({ cell }) => , + maxSize: 48, + enableSorting: false, + enableHiding: false, + enableResizing: false, + }), + columnHelper.accessor('firstName', { + id: 'firstName', + header: ({ header }) => , + cell: ({ cell }) => , + meta: { + label: 'First Name', + variant: 'text', + }, + }), + columnHelper.accessor((row) => row.lastName, { + id: 'lastName', + header: ({ header }) => , + cell: ({ cell }) => , + meta: { + label: 'Last Name', + variant: 'text', + }, + }), + columnHelper.accessor('age', { + id: 'age', + header: ({ header }) => , + cell: ({ cell }) => , + aggregationFn: 'mean', + aggregatedCell: () => , + meta: { + label: 'Age', + variant: 'number', + }, + }), + columnHelper.accessor('email', { + id: 'email', + header: ({ header }) => , + cell: ({ cell }) => , + meta: { + label: 'Email', + variant: 'text', + }, + }), + columnHelper.accessor('status', { + id: 'status', + header: ({ header }) => , + cell: ({ cell }) => , + aggregatedCell: () => null, + meta: { + label: 'Status', + variant: 'select', + options: statuses.map((status) => ({ + label: toSentenceCase(status), + value: status, + })), + }, + }), + columnHelper.accessor('department', { + id: 'department', + header: ({ header }) => , + cell: ({ cell }) => , + aggregatedCell: () => null, + meta: { + label: 'Department', + variant: 'multi-select', + options: departments.map((department) => ({ + label: toSentenceCase(department), + value: department, + })), + }, + }), + columnHelper.accessor('joinDate', { + id: 'joinDate', + header: ({ header }) => , + cell: ({ cell }) => , + aggregationFn: 'min', + aggregatedCell: () => , + meta: { + label: 'Join Date', + variant: 'date', + }, + }), + columnHelper.display({ + id: 'actions', + enableHiding: false, + cell: ({ cell }) => , + maxSize: 44, + enableResizing: false, + }), +]) diff --git a/examples/react/kitchen-sink-chakra-ui/src/components/data-table/cell-components.tsx b/examples/react/kitchen-sink-chakra-ui/src/components/data-table/cell-components.tsx new file mode 100644 index 0000000000..909f1d3afd --- /dev/null +++ b/examples/react/kitchen-sink-chakra-ui/src/components/data-table/cell-components.tsx @@ -0,0 +1,245 @@ +'use client' + +import { Badge, Box, Button, IconButton, Menu, Text } from '@chakra-ui/react' +import { + IconBriefcase, + IconBuildingStore, + IconCheck, + IconChevronDown, + IconChevronRight, + IconCode, + IconCreditCard, + IconDotsVertical, + IconUsersGroup, +} from '@tabler/icons-react' +import type { Person } from '@/lib/make-data' +import { useCellContext, useTableContext } from '@/hooks/table' +import { + CheckboxField, + DropdownMenu, + DropdownMenuItem, + EllipsisText, + formatDate, + toSentenceCase, +} from '@/components/data-table/shared' + +// --------------------------------------------------------------------------- +// Department helpers +// --------------------------------------------------------------------------- + +function DepartmentIcon({ department }: { department: Person['department'] }) { + const icons: Record = { + engineering: , + marketing: , + sales: , + hr: , + finance: , + } + + return icons[department] +} + +function DepartmentPill({ department }: { department: Person['department'] }) { + return ( + + + + + + {toSentenceCase(department)} + + + ) +} + +// --------------------------------------------------------------------------- +// Status Badge +// --------------------------------------------------------------------------- + +function StatusBadge({ status }: { status: Person['status'] }) { + const colorPalette: Record = { + active: 'green', + inactive: 'red', + pending: 'yellow', + } + + return ( + + + {toSentenceCase(status)} + + ) +} + +// --------------------------------------------------------------------------- +// Row Actions +// --------------------------------------------------------------------------- + +function RowActions({ person }: { person: Person }) { + return ( + + + + } + > + { + void navigator.clipboard.writeText(person.id) + }} + > + Copy ID + + + View details + View profile + + ) +} + +// --------------------------------------------------------------------------- +// Cell components (registered via createTableHook) +// --------------------------------------------------------------------------- + +export function SelectCell(): React.ReactNode { + const cell = useCellContext() + const table = useTableContext() + const row = cell.row + + return ( + + {() => ( + row.toggleSelected(checked)} + aria-label="Select row" + /> + )} + + ) +} + +export function TextCell(): React.ReactNode { + const cell = useCellContext() + return {String(cell.getValue())} +} + +export function AgeCell(): React.ReactNode { + const cell = useCellContext() + return {String(cell.getValue())} +} + +export function StatusCell(): React.ReactNode { + const cell = useCellContext() + const status = cell.getValue() + if (!status) return null + return +} + +export function DepartmentCell(): React.ReactNode { + const cell = useCellContext() + const department = cell.getValue() + if (!department) return null + return +} + +export function DateCell(): React.ReactNode { + const cell = useCellContext() + return <>{formatDate(cell.getValue())} +} + +export function GroupedCell(): React.ReactNode { + const cell = useCellContext() + const table = useTableContext() + const row = cell.row + + return ( + + {() => ( + + )} + + ) +} + +export function ActionsCell(): React.ReactNode { + const cell = useCellContext() + const person = cell.row.original as Person + return +} + +// --------------------------------------------------------------------------- +// Aggregated cell components (used directly in column defs, not registered) +// --------------------------------------------------------------------------- + +export function AgeAggregatedCell(): React.ReactNode { + const cell = useCellContext() + return ( + + Avg: {Math.round(Number(cell.getValue()) * 10) / 10} + + ) +} + +export function JoinDateAggregatedCell(): React.ReactNode { + const cell = useCellContext() + const earliest = cell.getValue() + return ( + + Earliest: {earliest ? formatDate(earliest) : '-'} + + ) +} diff --git a/examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-column-header.tsx b/examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-column-header.tsx new file mode 100644 index 0000000000..d38c2881a6 --- /dev/null +++ b/examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-column-header.tsx @@ -0,0 +1,168 @@ +'use client' + +import { Button, HStack, Menu, Text } from '@chakra-ui/react' +import { + IconArrowDown, + IconArrowUp, + IconCategory, + IconChevronDown, + IconEyeOff, + IconPinned, +} from '@tabler/icons-react' +import { useHeaderContext, useTableContext } from '@/hooks/table' +import { + DropdownMenu, + DropdownMenuItem, + SortIcon, +} from '@/components/data-table/shared' + +export function ColumnHeader(): React.ReactNode { + const header = useHeaderContext() + const table = useTableContext() + const column = header.column + + const title = column.columnDef.meta?.label ?? column.id + + const canSort = column.getCanSort() + const canHide = column.getCanHide() + const canPin = column.getCanPin() + const canGroup = column.getCanGroup() + + if (!canSort && !canHide && !canPin && !canGroup) { + return {title} + } + + return ( + ({ + sorting: s.sorting, + grouping: s.grouping, + columnPinning: s.columnPinning, + })} + > + {() => { + const sorted = canSort ? column.getIsSorted() : false + const pinned = canPin ? column.getIsPinned() : false + const grouped = canGroup ? column.getIsGrouped() : false + + return ( + + {canSort ? ( + + ) : ( + + {title} + + )} + + + + } + > + {canSort ? ( + <> + } + onSelect={() => column.toggleSorting(false)} + > + Asc + + } + onSelect={() => column.toggleSorting(true)} + > + Desc + + + ) : null} + {canGroup ? ( + } + onSelect={column.getToggleGroupingHandler()} + > + {grouped ? 'Ungroup' : 'Group by'} + + ) : null} + {canPin ? ( + <> + + } + onSelect={() => column.pin('left')} + > + Pin left + + } + onSelect={() => column.pin('right')} + > + Pin right + + {pinned ? ( + } + onSelect={() => column.pin(false)} + > + Unpin + + ) : null} + + ) : null} + {canHide ? ( + <> + + } + onSelect={() => column.toggleVisibility(false)} + > + Hide + + + ) : null} + + + ) + }} + + ) +} diff --git a/examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-filter-list.tsx b/examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-filter-list.tsx new file mode 100644 index 0000000000..79b90a5994 --- /dev/null +++ b/examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-filter-list.tsx @@ -0,0 +1,345 @@ +'use client' + +import * as React from 'react' +import { + Badge, + Box, + Button, + HStack, + IconButton, + Stack, + Text, +} from '@chakra-ui/react' +import { IconFilter, IconTrash } from '@tabler/icons-react' +import type { Column, RowData } from '@tanstack/react-table' +import type { + ExtendedColumnFilter, + FilterOperator, + JoinOperator, +} from '@/types' +import type { features } from '@/hooks/features' +import { getFilterOperators } from '@/lib/data-table' +import { useTableContext } from '@/hooks/table' +import { + FloatingPanel, + MultiSelectField, + SelectField, + TextInput, + toDateInputValue, +} from '@/components/data-table/shared' + +// --------------------------------------------------------------------------- +// Filter Value Input +// --------------------------------------------------------------------------- + +function FilterValueInput({ + column, + filter, + onFilterUpdate, +}: { + column: Column + filter: ExtendedColumnFilter + onFilterUpdate: ( + filterId: string, + patch: Partial, + ) => void +}): React.ReactNode { + if (!filter.filterId) return null + const variant = column.columnDef.meta?.variant ?? 'text' + const operator = filter.operator ?? 'includesString' + const disabled = operator === 'isEmpty' || operator === 'isNotEmpty' + + if (disabled) { + return No value required + } + + if (variant === 'select') { + const options = column.columnDef.meta?.options ?? [] + return ( + onFilterUpdate(filter.filterId!, { value })} + /> + ) + } + + if (variant === 'multi-select') { + const options = column.columnDef.meta?.options ?? [] + return ( + onFilterUpdate(filter.filterId!, { value })} + /> + ) + } + + if (variant === 'date') { + if (operator === 'inRange') { + const value = Array.isArray(filter.value) ? filter.value : [] + return ( + + + onFilterUpdate(filter.filterId!, { + value: [ + event.currentTarget.value + ? new Date(event.currentTarget.value).toISOString() + : undefined, + value[1], + ], + }) + } + /> + + onFilterUpdate(filter.filterId!, { + value: [ + value[0], + event.currentTarget.value + ? new Date(event.currentTarget.value).toISOString() + : undefined, + ], + }) + } + /> + + ) + } + + return ( + + onFilterUpdate(filter.filterId!, { + value: event.currentTarget.value + ? new Date(event.currentTarget.value).toISOString() + : undefined, + }) + } + /> + ) + } + + if (variant === 'number') { + return ( + + onFilterUpdate(filter.filterId!, { + value: + event.currentTarget.value === '' + ? '' + : Number(event.currentTarget.value), + }) + } + /> + ) + } + + return ( + + onFilterUpdate(filter.filterId!, { value: event.currentTarget.value }) + } + /> + ) +} + +// --------------------------------------------------------------------------- +// Filter List +// --------------------------------------------------------------------------- + +export function DataTableFilterList(): React.ReactNode { + const table = useTableContext() + const columnFilters = table.state.columnFilters as Array + + // Write through the raw controlled-state handler instead of + // `table.setColumnFilters`: the table API auto-removes filters with empty + // values, but a just-added filter row legitimately starts with `value: ''` + // while the user is still building it. + const setColumnFilters = (filters: Array) => { + table.options.onColumnFiltersChange?.(filters) + } + + const [opened, setOpened] = React.useState(false) + const filterableColumns = table + .getAllColumns() + .filter((column) => column.getCanFilter()) + const fieldOptions = filterableColumns.map((column) => ({ + value: column.id, + label: column.columnDef.meta?.label ?? column.id, + })) + + const updateFilter = ( + filterId: string, + patch: Partial, + ) => { + setColumnFilters( + columnFilters.map((filter) => + filter.filterId === filterId ? { ...filter, ...patch } : filter, + ), + ) + } + + const addFilter = () => { + if (filterableColumns.length === 0) return + const [column] = filterableColumns + setColumnFilters([ + ...columnFilters, + { + id: column.id, + filterId: crypto.randomUUID(), + value: '', + operator: 'includesString', + joinOperator: columnFilters[0]?.joinOperator ?? 'and', + }, + ] as any) + } + + return ( + + + Filter + {columnFilters.length ? ( + {columnFilters.length} + ) : null} + + } + > + + Filters + {columnFilters.map((filter, index) => { + const column = table.getColumn(filter.id) + if (!column || !filter.filterId) return null + const variant = column.columnDef.meta?.variant ?? 'text' + const operators = getFilterOperators(variant) + return ( + + {index === 0 ? ( + + Where + + ) : index === 1 ? ( + { + if (!joinOperator) return + setColumnFilters( + columnFilters.map((item) => ({ + ...item, + joinOperator: joinOperator as JoinOperator, + })), + ) + }} + width="90px" + /> + ) : ( + + {filter.joinOperator ?? 'and'} + + )} + { + const nextColumn = nextColumnId + ? table.getColumn(nextColumnId) + : undefined + if (nextColumn) { + updateFilter(filter.filterId!, { + id: nextColumn.id, + operator: getFilterOperators( + nextColumn.columnDef.meta?.variant ?? 'text', + )[0].value, + value: '', + }) + } + }} + width="190px" + /> + ({ + value: operator.value, + label: operator.label, + }))} + value={filter.operator ?? operators[0].value} + onChange={(operator) => { + if (!operator) return + updateFilter(filter.filterId!, { + operator: operator as FilterOperator, + value: '', + }) + }} + width="180px" + /> + + + + + setColumnFilters( + columnFilters.filter( + (item) => item.filterId !== filter.filterId, + ), + ) + } + > + + + + ) + })} + + + + + + + ) +} diff --git a/examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-pagination.tsx b/examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-pagination.tsx new file mode 100644 index 0000000000..16865395b9 --- /dev/null +++ b/examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-pagination.tsx @@ -0,0 +1,75 @@ +'use client' + +import { HStack, IconButton, Text } from '@chakra-ui/react' +import { + IconChevronLeft, + IconChevronRight, + IconChevronsLeft, + IconChevronsRight, +} from '@tabler/icons-react' +import { useTableContext } from '@/hooks/table' +import { SelectField } from '@/components/data-table/shared' + +export function DataTablePagination(): React.ReactNode { + const table = useTableContext() + const pageIndex = table.state.pagination.pageIndex + const pageSize = table.state.pagination.pageSize + + return ( + + + {table.getFilteredSelectedRowModel().rows.length.toLocaleString()} of{' '} + {table.getFilteredRowModel().rows.length.toLocaleString()} row(s) + selected. + + + Rows per page: + { + table.setPageSize(Number(value)) + table.setPageIndex(0) + }} + width="90px" + /> + table.setPageIndex(0)} + disabled={!table.getCanPreviousPage()} + > + + + table.previousPage()} + disabled={!table.getCanPreviousPage()} + > + + + + {pageIndex + 1} / {table.getPageCount()} + + table.nextPage()} + disabled={!table.getCanNextPage()} + > + + + table.setPageIndex(table.getPageCount() - 1)} + disabled={!table.getCanNextPage()} + > + + + + + ) +} diff --git a/examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-sort-list.tsx b/examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-sort-list.tsx new file mode 100644 index 0000000000..0375db7b7c --- /dev/null +++ b/examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-sort-list.tsx @@ -0,0 +1,172 @@ +'use client' + +import * as React from 'react' +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { arrayMove } from '@dnd-kit/sortable' +import { + Badge, + Button, + HStack, + IconButton, + Stack, + Text, +} from '@chakra-ui/react' +import { + IconArrowsSort, + IconGripVertical, + IconTrash, +} from '@tabler/icons-react' +import type { DragEndEvent } from '@dnd-kit/core' +import { useTableContext } from '@/hooks/table' +import { + FloatingPanel, + SelectField, + SortableContext, + SortableFrame, + verticalListSortingStrategy, +} from '@/components/data-table/shared' + +export function DataTableSortList(): React.ReactNode { + const table = useTableContext() + const sorting = table.state.sorting + + const [opened, setOpened] = React.useState(false) + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + ) + const sortableColumns = table + .getAllColumns() + .filter((column) => column.getCanSort()) + const columnOptions = sortableColumns.map((column) => ({ + value: column.id, + label: column.columnDef.meta?.label ?? column.id, + })) + + const updateSort = ( + index: number, + patch: Partial<{ id: string; desc: boolean }>, + ) => { + table.setSorting( + sorting.map((sort, sortIndex) => + sortIndex === index ? { ...sort, ...patch } : sort, + ), + ) + } + + const addSort = () => { + const nextColumn = sortableColumns.find( + (column) => !sorting.some((sort) => sort.id === column.id), + ) + if (nextColumn) + table.setSorting([...sorting, { id: nextColumn.id, desc: false }]) + } + + const onDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + + const oldIndex = sorting.findIndex((sort) => sort.id === active.id) + const newIndex = sorting.findIndex((sort) => sort.id === over.id) + if (oldIndex >= 0 && newIndex >= 0) { + table.setSorting(arrayMove(sorting, oldIndex, newIndex)) + } + } + + return ( + + + Sort + {sorting.length ? ( + {sorting.length} + ) : null} + + } + > + + + {sorting.length ? 'Sort by' : 'No sorting applied'} + + + sort.id)} + strategy={verticalListSortingStrategy} + > + + {sorting.map((sort, index) => ( + + + + { + if (value) updateSort(index, { id: value }) + }} + flex={1} + /> + + updateSort(index, { desc: value === 'desc' }) + } + width="110px" + /> + + table.setSorting( + sorting.filter((_, sortIndex) => sortIndex !== index), + ) + } + > + + + + + ))} + + + + + + + + + + ) +} diff --git a/examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-view-options.tsx b/examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-view-options.tsx new file mode 100644 index 0000000000..ede147b7be --- /dev/null +++ b/examples/react/kitchen-sink-chakra-ui/src/components/data-table/data-table-view-options.tsx @@ -0,0 +1,103 @@ +'use client' + +import * as React from 'react' +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { arrayMove } from '@dnd-kit/sortable' +import { Button, HStack, Stack } from '@chakra-ui/react' +import { IconGripVertical, IconSettings } from '@tabler/icons-react' +import type { DragEndEvent } from '@dnd-kit/core' +import { useTableContext } from '@/hooks/table' +import { + CheckboxField, + FloatingPanel, + SortableContext, + SortableFrame, + TextInput, + verticalListSortingStrategy, +} from '@/components/data-table/shared' + +export function DataTableViewOptions(): React.ReactNode { + const table = useTableContext() + const columnOrder = table.state.columnOrder + + const [opened, setOpened] = React.useState(false) + const [query, setQuery] = React.useState('') + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + ) + const columns = table + .getAllColumns() + .filter((column) => typeof column.accessorFn !== 'undefined') + .sort((a, b) => columnOrder.indexOf(a.id) - columnOrder.indexOf(b.id)) + .filter((column) => + (column.columnDef.meta?.label ?? column.id) + .toLowerCase() + .includes(query.toLowerCase()), + ) + + const onDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + + const oldIndex = columnOrder.indexOf(String(active.id)) + const newIndex = columnOrder.indexOf(String(over.id)) + if (oldIndex >= 0 && newIndex >= 0) { + table.setColumnOrder(arrayMove(columnOrder, oldIndex, newIndex)) + } + } + + return ( + + + View + + } + > + + setQuery(event.currentTarget.value)} + /> + + column.id)} + strategy={verticalListSortingStrategy} + > + + {columns.map((column) => ( + + + + column.toggleVisibility(checked) + } + /> + + + + ))} + + + + + + ) +} diff --git a/examples/react/kitchen-sink-chakra-ui/src/components/data-table/header-components.tsx b/examples/react/kitchen-sink-chakra-ui/src/components/data-table/header-components.tsx new file mode 100644 index 0000000000..e77cdc7717 --- /dev/null +++ b/examples/react/kitchen-sink-chakra-ui/src/components/data-table/header-components.tsx @@ -0,0 +1,72 @@ +'use client' + +import * as React from 'react' +import { Box } from '@chakra-ui/react' +import { useHeaderContext, useTableContext } from '@/hooks/table' +import { CheckboxField } from '@/components/data-table/shared' + +export function SelectAllHeader(): React.ReactNode { + const table = useTableContext() + + return ( + + {() => ( + + table.toggleAllPageRowsSelected(checked) + } + aria-label="Select all" + /> + )} + + ) +} + +export function ResizeHandle(): React.ReactNode { + const header = useHeaderContext() + const table = useTableContext() + const [hovered, setHovered] = React.useState(false) + + if (!header.column.getCanResize()) return null + + return ( + + {() => { + const isResizing = header.column.getIsResizing() + return ( + // Sits just inside the cell's right edge so it isn't hidden behind + // the neighbouring column. Idle it shows a faint divider; it + // brightens on hover and turns solid while actively resizing. + header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + position: 'absolute', + top: 0, + right: 0, + width: 5, + height: '100%', + cursor: 'col-resize', + userSelect: 'none', + touchAction: 'none', + background: + isResizing || hovered + ? 'var(--chakra-colors-blue-solid)' + : 'var(--chakra-colors-border)', + opacity: isResizing || hovered ? 1 : 0.4, + transition: 'background 100ms, opacity 100ms', + }} + /> + ) + }} + + ) +} diff --git a/examples/react/kitchen-sink-chakra-ui/src/components/data-table/shared.tsx b/examples/react/kitchen-sink-chakra-ui/src/components/data-table/shared.tsx new file mode 100644 index 0000000000..2bbdb59d5a --- /dev/null +++ b/examples/react/kitchen-sink-chakra-ui/src/components/data-table/shared.tsx @@ -0,0 +1,480 @@ +'use client' + +import * as React from 'react' +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { + Box, + Checkbox, + Input, + Menu, + NativeSelect, + Popover, + Portal, + Text, +} from '@chakra-ui/react' +import { IconArrowDown, IconArrowUp, IconArrowsSort } from '@tabler/icons-react' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type Option = { + value: string + label: string +} + +// --------------------------------------------------------------------------- +// Utility functions +// --------------------------------------------------------------------------- + +export function toSentenceCase(value: string) { + return value + .replace(/[-_]/g, ' ') + .replace(/\w\S*/g, (word) => word[0].toUpperCase() + word.slice(1)) +} + +export function formatDate(value: string) { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(new Date(value)) +} + +export function toDateInputValue(value: unknown) { + if (!value) return '' + const date = new Date(String(value)) + return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10) +} + +// --------------------------------------------------------------------------- +// Text Input +// --------------------------------------------------------------------------- + +export type TextInputProps = Omit< + React.ComponentProps, + 'onChange' +> & { + label?: string + icon?: React.ReactNode + onChange?: React.ChangeEventHandler +} + +export function TextInput({ + label, + icon, + ref: _ref, + ...props +}: TextInputProps) { + return ( + + {label ? ( + + {label} + + ) : null} + + {icon ? ( + + {icon} + + ) : null} + + + + ) +} + +// --------------------------------------------------------------------------- +// Select Field +// --------------------------------------------------------------------------- + +export type SelectFieldProps = { + 'aria-label'?: string + label?: string + options: Array + value?: string | null + onChange?: (value: string | null) => void + width?: string | number + flex?: string | number +} + +export function SelectField({ + 'aria-label': ariaLabel, + label, + options, + value, + onChange, + width, + flex, +}: SelectFieldProps) { + const normalizedOptions = options.map((item) => + typeof item === 'string' ? { value: item, label: item } : item, + ) + return ( + + {label ? ( + + {label} + + ) : null} + + onChange?.(event.currentTarget.value || null)} + > + {normalizedOptions.map((option) => ( + + ))} + + + + + ) +} + +// --------------------------------------------------------------------------- +// Multi-Select Field +// --------------------------------------------------------------------------- + +export type MultiSelectFieldProps = { + label?: string + options: Array + value?: Array + onChange?: (value: Array) => void + flex?: string | number +} + +export function MultiSelectField({ + label, + options, + value = [], + onChange, + flex, +}: MultiSelectFieldProps) { + const normalizedOptions = options.map((item) => + typeof item === 'string' ? { value: item, label: item } : item, + ) + return ( + + {label ? ( + + {label} + + ) : null} + + + ) +} + +// --------------------------------------------------------------------------- +// Checkbox Field +// --------------------------------------------------------------------------- + +export type CheckboxFieldProps = Omit< + React.ComponentProps<'input'>, + 'checked' | 'onChange' | 'type' +> & { + label?: string + checked?: boolean + indeterminate?: boolean + onCheckedChange?: (checked: boolean) => void +} + +export function CheckboxField({ + label, + checked, + indeterminate, + onCheckedChange, + ...props +}: CheckboxFieldProps) { + return ( + onCheckedChange?.(details.checked === true)} + > + + + {label ? {label} : null} + + ) +} + +// --------------------------------------------------------------------------- +// Chakra v3 dropdown / floating panel wrappers +// --------------------------------------------------------------------------- + +export function useCloseOnOutsidePointerDown( + open: boolean, + refs: + | React.RefObject + | Array>, + onClose: () => void, +) { + React.useEffect(() => { + if (!open) return + + const refList = Array.isArray(refs) ? refs : [refs] + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Node + // The floating content (menu/popover) is rendered through a Portal, so + // it lives outside the trigger's ref subtree. Check every registered ref + // before treating a pointer-down as "outside" — otherwise clicking a + // menu item would close the panel before its click handler runs. + const inside = refList.some((ref) => ref.current?.contains(target)) + if (!inside) { + onClose() + } + } + + document.addEventListener('pointerdown', onPointerDown, true) + return () => + document.removeEventListener('pointerdown', onPointerDown, true) + }, [open, onClose, refs]) +} + +export const DropdownCloseContext = React.createContext<() => void>( + () => undefined, +) + +export function DropdownMenu({ + trigger, + children, + width = '180px', +}: { + trigger: React.ReactNode + children: React.ReactNode + width?: string | number +}) { + const [open, setOpen] = React.useState(false) + const rootRef = React.useRef(null) + const contentRef = React.useRef(null) + const close = React.useCallback(() => setOpen(false), []) + + useCloseOnOutsidePointerDown(open, [rootRef, contentRef], close) + + return ( + + + setOpen(details.open)} + positioning={{ placement: 'bottom-end', offset: { mainAxis: 6 } }} + > + {trigger} + {/* Portal the content so it isn't clipped by a cell's + `overflow: hidden` (row actions live inside such a cell). */} + + + + {children} + + + + + + + ) +} + +export function DropdownMenuItem({ + value, + icon, + children, + onSelect, + disabled, + colorPalette, +}: { + value: string + icon?: React.ReactNode + children: React.ReactNode + onSelect?: () => void + disabled?: boolean + colorPalette?: string +}) { + const close = React.useContext(DropdownCloseContext) + const hasHandledSelectionRef = React.useRef(false) + const handleSelection = React.useCallback(() => { + if (hasHandledSelectionRef.current) return + + hasHandledSelectionRef.current = true + onSelect?.() + close() + queueMicrotask(() => { + hasHandledSelectionRef.current = false + }) + }, [close, onSelect]) + + return ( + + {icon} + {children} + + ) +} + +export function FloatingPanel({ + open, + onOpenChange, + width = '320px', + trigger, + children, +}: { + open: boolean + onOpenChange: (open: boolean) => void + width?: string | number + trigger: React.ReactNode + children: React.ReactNode +}) { + const rootRef = React.useRef(null) + const contentRef = React.useRef(null) + const close = React.useCallback(() => onOpenChange(false), [onOpenChange]) + + useCloseOnOutsidePointerDown(open, [rootRef, contentRef], close) + + return ( + + onOpenChange(details.open)} + positioning={{ placement: 'bottom-end', offset: { mainAxis: 6 } }} + > + {trigger} + + + + {children} + + + + + + ) +} + +// --------------------------------------------------------------------------- +// Sortable Frame (dnd-kit) +// --------------------------------------------------------------------------- + +export function SortableFrame({ + id, + children, +}: { + id: string + children: React.ReactNode +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }) + + return ( + + {children} + + ) +} + +// --------------------------------------------------------------------------- +// Sort Icon +// --------------------------------------------------------------------------- + +export function SortIcon({ + direction, +}: { + direction: 'asc' | 'desc' | undefined +}) { + if (direction === 'asc') return + if (direction === 'desc') return + return +} + +// --------------------------------------------------------------------------- +// Ellipsis Text +// --------------------------------------------------------------------------- + +export function EllipsisText({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +// --------------------------------------------------------------------------- +// Re-export dnd-kit helpers used by multiple extracted components +// --------------------------------------------------------------------------- + +export { SortableContext, verticalListSortingStrategy } diff --git a/examples/react/kitchen-sink-chakra-ui/src/hooks/features.ts b/examples/react/kitchen-sink-chakra-ui/src/hooks/features.ts new file mode 100644 index 0000000000..730a2273ba --- /dev/null +++ b/examples/react/kitchen-sink-chakra-ui/src/hooks/features.ts @@ -0,0 +1,72 @@ +import { + aggregationFns, + columnFacetingFeature, + columnFilteringFeature, + columnGroupingFeature, + columnOrderingFeature, + columnPinningFeature, + columnResizingFeature, + columnSizingFeature, + columnVisibilityFeature, + createExpandedRowModel, + createFacetedRowModel, + createFacetedUniqueValues, + createFilteredRowModel, + createGroupedRowModel, + createPaginatedRowModel, + createSortedRowModel, + filterFns, + globalFilteringFeature, + metaHelper, + rowExpandingFeature, + rowPaginationFeature, + rowSelectionFeature, + rowSortingFeature, + sortFns, + tableFeatures, +} from '@tanstack/react-table' +import { rankItem } from '@tanstack/match-sorter-utils' + +export interface MyColumnMeta { + label?: string + variant?: 'text' | 'number' | 'date' | 'boolean' | 'select' | 'multi-select' + options?: Array<{ label: string; value: string; count?: number }> +} + +const fuzzyFilterFn = ( + row: { getValue: (id: string) => unknown }, + columnId: string, + value: unknown, + addMeta?: (meta: object) => void, +) => { + const itemRank = rankItem(row.getValue(columnId), value as string) + addMeta?.({ itemRank }) + return itemRank.passed +} + +export const features = tableFeatures({ + rowSortingFeature, + rowPaginationFeature, + rowSelectionFeature, + rowExpandingFeature, + columnFilteringFeature, + columnFacetingFeature, + columnOrderingFeature, + columnVisibilityFeature, + columnSizingFeature, + columnResizingFeature, + columnPinningFeature, + columnGroupingFeature, + globalFilteringFeature, + columnMeta: metaHelper(), + filteredRowModel: createFilteredRowModel(), + facetedRowModel: createFacetedRowModel(), + facetedUniqueValues: createFacetedUniqueValues(), + paginatedRowModel: createPaginatedRowModel(), + sortedRowModel: createSortedRowModel(), + groupedRowModel: createGroupedRowModel(), + expandedRowModel: createExpandedRowModel(), + filterFns: { ...filterFns, fuzzy: fuzzyFilterFn }, + sortFns, + aggregationFns, +}) diff --git a/examples/react/kitchen-sink-chakra-ui/src/hooks/table.ts b/examples/react/kitchen-sink-chakra-ui/src/hooks/table.ts new file mode 100644 index 0000000000..15040d3641 --- /dev/null +++ b/examples/react/kitchen-sink-chakra-ui/src/hooks/table.ts @@ -0,0 +1,69 @@ +import { createTableHook } from '@tanstack/react-table' +import { features } from '@/hooks/features' +import { dynamicFilterFn } from '@/lib/data-table' + +import { DataTablePagination } from '@/components/data-table/data-table-pagination' +import { DataTableFilterList } from '@/components/data-table/data-table-filter-list' +import { DataTableSortList } from '@/components/data-table/data-table-sort-list' +import { DataTableViewOptions } from '@/components/data-table/data-table-view-options' + +import { + ActionsCell, + AgeCell, + DateCell, + DepartmentCell, + GroupedCell, + SelectCell, + StatusCell, + TextCell, +} from '@/components/data-table/cell-components' + +import { ColumnHeader } from '@/components/data-table/data-table-column-header' +import { + ResizeHandle, + SelectAllHeader, +} from '@/components/data-table/header-components' + +export const { + createAppColumnHelper, + useAppTable, + useTableContext, + useCellContext, + useHeaderContext, +} = createTableHook({ + features, + defaultColumn: { + size: 120, + minSize: 60, + maxSize: 800, + filterFn: dynamicFilterFn, + }, + globalFilterFn: 'fuzzy', + getRowId: (row: any) => row.id, + enableRowSelection: true, + columnResizeMode: 'onChange' as const, + + tableComponents: { + Pagination: DataTablePagination, + FilterList: DataTableFilterList, + SortList: DataTableSortList, + ViewOptions: DataTableViewOptions, + }, + + cellComponents: { + SelectCell, + TextCell, + AgeCell, + StatusCell, + DepartmentCell, + DateCell, + GroupedCell, + ActionsCell, + }, + + headerComponents: { + ColumnHeader, + SelectAllHeader, + ResizeHandle, + }, +}) diff --git a/examples/react/kitchen-sink-chakra-ui/src/lib/data-table.ts b/examples/react/kitchen-sink-chakra-ui/src/lib/data-table.ts index acb58f259c..d0b89514da 100644 --- a/examples/react/kitchen-sink-chakra-ui/src/lib/data-table.ts +++ b/examples/react/kitchen-sink-chakra-ui/src/lib/data-table.ts @@ -14,19 +14,35 @@ import type { FilterOperator, JoinOperator, } from '@/types' -import type { FilterFn } from '@tanstack/react-table' -import type { features } from '../main' +import type { RankingInfo } from '@tanstack/match-sorter-utils' +import type { + FilterFn, + Row, + RowData, + TableFeatures, +} from '@tanstack/react-table' +import type { features } from '@/hooks/features' -export const fuzzyFilter: FilterFn = ( - row, - columnId, - value, - addMeta, -) => { - const itemRank = rankItem(row.getValue(columnId), value) +declare module '@tanstack/react-table' { + interface FilterFns { + fuzzy: FilterFn + } + interface FilterMeta { + itemRank?: RankingInfo + } +} +export function fuzzyFilter< + TFeatures extends TableFeatures, + TData extends RowData, +>( + row: Row, + columnId: string, + value: string, + addMeta?: (meta: { itemRank: RankingInfo }) => void, +): boolean { + const itemRank = rankItem(row.getValue(columnId), value) addMeta?.({ itemRank }) - return itemRank.passed } diff --git a/examples/react/kitchen-sink-chakra-ui/src/main.tsx b/examples/react/kitchen-sink-chakra-ui/src/main.tsx index ec81e98388..d9385911ec 100644 --- a/examples/react/kitchen-sink-chakra-ui/src/main.tsx +++ b/examples/react/kitchen-sink-chakra-ui/src/main.tsx @@ -5,564 +5,48 @@ import { TanStackDevtools } from '@tanstack/react-devtools' import * as ReactDOM from 'react-dom/client' import { useDebouncedCallback } from '@tanstack/react-pacer/debouncer' import { - DndContext, - PointerSensor, - closestCenter, - useSensor, - useSensors, -} from '@dnd-kit/core' -import { - SortableContext, - arrayMove, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable' -import { CSS } from '@dnd-kit/utilities' -import { - Badge, Box, Button, ChakraProvider, - Checkbox, Container, HStack, IconButton, - Input, - Menu, - NativeSelect, - Popover, Stack, Table, - Text, defaultSystem, } from '@chakra-ui/react' import { ThemeProvider, useTheme } from 'next-themes' import { - IconArrowDown, - IconArrowUp, - IconArrowsSort, - IconBriefcase, - IconBuildingStore, - IconCategory, - IconCheck, - IconChevronDown, - IconChevronLeft, - IconChevronRight, - IconChevronsLeft, - IconChevronsRight, - IconCode, - IconCreditCard, IconDeviceDesktop, - IconDotsVertical, - IconEyeOff, - IconFilter, - IconGripVertical, IconMoon, - IconPinned, IconSearch, - IconSettings, IconSun, - IconTrash, - IconUsersGroup, } from '@tabler/icons-react' -import { - aggregationFns, - columnFacetingFeature, - columnFilteringFeature, - columnGroupingFeature, - columnOrderingFeature, - columnPinningFeature, - columnResizingFeature, - columnSizingFeature, - columnVisibilityFeature, - createColumnHelper, - createExpandedRowModel, - createFacetedRowModel, - createFacetedUniqueValues, - createFilteredRowModel, - createGroupedRowModel, - createPaginatedRowModel, - createSortedRowModel, - filterFns, - globalFilteringFeature, - metaHelper, - rowExpandingFeature, - rowPaginationFeature, - rowSelectionFeature, - rowSortingFeature, - sortFns, - tableFeatures, - useTable, -} from '@tanstack/react-table' import { tableDevtoolsPlugin, useTanStackTableDevtools, } from '@tanstack/react-table-devtools' -import { rankItem } from '@tanstack/match-sorter-utils' +import type { Column } from '@tanstack/react-table' +import type { ExtendedColumnFilter } from '@/types' import type { Person } from '@/lib/make-data' -import type { DragEndEvent } from '@dnd-kit/core' -import type { - Column, - ColumnPinningState, - ColumnSizingState, - ExpandedState, - GroupingState, - Header, - ReactTable, - SortingState, -} from '@tanstack/react-table' -import type { - ExtendedColumnFilter, - FilterOperator, - JoinOperator, -} from '@/types' +import type { features } from '@/hooks/features' -import { dynamicFilterFn, getFilterOperators } from '@/lib/data-table' -import { departments, makeData, statuses } from '@/lib/make-data' +import { makeData } from '@/lib/make-data' +import { useAppTable } from '@/hooks/table' +import { columns } from '@/columns' +import { + DropdownMenu, + DropdownMenuItem, + TextInput, +} from '@/components/data-table/shared' import './styles/globals.css' -type Option = { - value: string - label: string -} - -type TextInputProps = Omit, 'onChange'> & { - label?: string - icon?: React.ReactNode - onChange?: React.ChangeEventHandler -} - -function TextInput({ label, icon, ...props }: TextInputProps) { - return ( - - {label ? ( - - {label} - - ) : null} - - {icon ? ( - - {icon} - - ) : null} - - - - ) -} - -type SelectFieldProps = { - 'aria-label'?: string - label?: string - options: Array - value?: string | null - onChange?: (value: string | null) => void - width?: string | number - flex?: string | number -} - -function SelectField({ - 'aria-label': ariaLabel, - label, - options, - value, - onChange, - width, - flex, -}: SelectFieldProps) { - const normalizedOptions = options.map((item) => - typeof item === 'string' ? { value: item, label: item } : item, - ) - return ( - - {label ? ( - - {label} - - ) : null} - - onChange?.(event.currentTarget.value || null)} - > - {normalizedOptions.map((option) => ( - - ))} - - - - - ) -} - -type MultiSelectFieldProps = { - label?: string - options: Array - value?: Array - onChange?: (value: Array) => void - flex?: string | number -} - -function MultiSelectField({ - label, - options, - value = [], - onChange, - flex, -}: MultiSelectFieldProps) { - const normalizedOptions = options.map((item) => - typeof item === 'string' ? { value: item, label: item } : item, - ) - return ( - - {label ? ( - - {label} - - ) : null} - - - ) -} - -type CheckboxFieldProps = Omit< - React.ComponentProps<'input'>, - 'checked' | 'onChange' | 'type' -> & { - label?: string - checked?: boolean - indeterminate?: boolean - onCheckedChange?: (checked: boolean) => void -} - -function CheckboxField({ - label, - checked, - indeterminate, - onCheckedChange, - ...props -}: CheckboxFieldProps) { - return ( - onCheckedChange?.(details.checked === true)} - > - - - {label ? {label} : null} - - ) -} - -function ProgressBar({ value }: { value: number }) { - return ( - - - - ) -} - -function useCloseOnOutsidePointerDown( - open: boolean, - ref: React.RefObject, - onClose: () => void, -) { - React.useEffect(() => { - if (!open) return - - const onPointerDown = (event: PointerEvent) => { - if (!ref.current?.contains(event.target as Node)) { - onClose() - } - } - - document.addEventListener('pointerdown', onPointerDown, true) - return () => - document.removeEventListener('pointerdown', onPointerDown, true) - }, [open, onClose, ref]) -} - -const DropdownCloseContext = React.createContext<() => void>(() => undefined) - -function DropdownMenu({ - trigger, - children, - width = '180px', -}: { - trigger: React.ReactNode - children: React.ReactNode - width?: string | number -}) { - const [open, setOpen] = React.useState(false) - const rootRef = React.useRef(null) - const close = React.useCallback(() => setOpen(false), []) - - useCloseOnOutsidePointerDown(open, rootRef, close) - - return ( - - - setOpen(details.open)} - positioning={{ placement: 'bottom-end', offset: { mainAxis: 6 } }} - > - {trigger} - - {children} - - - - - ) -} - -function DropdownMenuItem({ - value, - icon, - children, - onSelect, - disabled, - colorPalette, -}: { - value: string - icon?: React.ReactNode - children: React.ReactNode - onSelect?: () => void - disabled?: boolean - colorPalette?: string -}) { - const close = React.useContext(DropdownCloseContext) - const hasHandledSelectionRef = React.useRef(false) - const handleSelection = React.useCallback(() => { - if (hasHandledSelectionRef.current) return - - hasHandledSelectionRef.current = true - onSelect?.() - close() - queueMicrotask(() => { - hasHandledSelectionRef.current = false - }) - }, [close, onSelect]) - - return ( - - {icon} - {children} - - ) -} - -function FloatingPanel({ - open, - onOpenChange, - width = '320px', - trigger, - children, -}: { - open: boolean - onOpenChange: (open: boolean) => void - width?: string | number - trigger: React.ReactNode - children: React.ReactNode -}) { - const rootRef = React.useRef(null) - const close = React.useCallback(() => onOpenChange(false), [onOpenChange]) - - useCloseOnOutsidePointerDown(open, rootRef, close) - - return ( - - onOpenChange(details.open)} - positioning={{ placement: 'bottom-end', offset: { mainAxis: 6 } }} - > - {trigger} - - - {children} - - - - - ) -} - -function ChakraExampleProvider({ children }: { children: React.ReactNode }) { - return ( - - - {children} - - - ) -} - -interface MyColumnMeta { - label?: string - variant?: 'text' | 'number' | 'date' | 'boolean' | 'select' | 'multi-select' - options?: Array<{ label: string; value: string; count?: number }> -} - -// Local fuzzy filter implementation for the filterFns registry slot. -// Defined here to avoid a circular type dependency with data-table.ts. -const fuzzyFilterFn = ( - row: { getValue: (id: string) => unknown }, - columnId: string, - value: unknown, - addMeta?: (meta: object) => void, -) => { - const itemRank = rankItem(row.getValue(columnId), value as string) - addMeta?.({ itemRank }) - return itemRank.passed -} - -export const features = tableFeatures({ - rowSortingFeature, - rowPaginationFeature, - rowSelectionFeature, - rowExpandingFeature, - columnFilteringFeature, - columnFacetingFeature, - columnOrderingFeature, - columnVisibilityFeature, - columnSizingFeature, - columnResizingFeature, - columnPinningFeature, - columnGroupingFeature, - globalFilteringFeature, - columnMeta: metaHelper(), - filteredRowModel: createFilteredRowModel(), - facetedRowModel: createFacetedRowModel(), - facetedUniqueValues: createFacetedUniqueValues(), - paginatedRowModel: createPaginatedRowModel(), - sortedRowModel: createSortedRowModel(), - groupedRowModel: createGroupedRowModel(), - expandedRowModel: createExpandedRowModel(), - filterFns: { ...filterFns, fuzzy: fuzzyFilterFn }, - sortFns, - aggregationFns, -}) - -const columnHelper = createColumnHelper() -type AppTable = ReactTable -type AppColumn = Column - -function SortableFrame({ - id, - children, -}: { - id: string - children: React.ReactNode -}) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id }) - - return ( - - {children} - - ) -} - -function toSentenceCase(value: string) { - return value - .replace(/[-_]/g, ' ') - .replace(/\w\S*/g, (word) => word[0].toUpperCase() + word.slice(1)) -} - -function formatDate(value: string) { - return new Intl.DateTimeFormat('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }).format(new Date(value)) -} - -function toDateInputValue(value: unknown) { - if (!value) return '' - const date = new Date(String(value)) - return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10) -} - -function getAriaSort(sortDirection: false | 'asc' | 'desc') { - if (sortDirection === 'asc') return 'ascending' - if (sortDirection === 'desc') return 'descending' - return 'none' -} - -const SortingContext = React.createContext([]) - -function getSortDirection(sorting: SortingState, columnId: string) { - const sort = sorting.find((sort) => sort.id === columnId) - return sort ? (sort.desc ? 'desc' : 'asc') : undefined -} +// --------------------------------------------------------------------------- +// Pinning styles (local to main - uses Chakra CSS vars) +// --------------------------------------------------------------------------- function getCommonPinningStyles( - column: AppColumn, + column: Column, isSelected = false, ): React.CSSProperties { const isPinned = column.getIsPinned() @@ -595,853 +79,43 @@ function getCommonPinningStyles( } } -function DepartmentIcon({ department }: { department: Person['department'] }) { - const icons: Record = { - engineering: , - marketing: , - sales: , - hr: , - finance: , - } - - return icons[department] -} - -function DepartmentPill({ department }: { department: Person['department'] }) { - return ( - - - - - - {toSentenceCase(department)} - - - ) -} - -function EllipsisText({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ) -} - -function StatusBadge({ status }: { status: Person['status'] }) { - const colorPalette: Record = { - active: 'green', - inactive: 'red', - pending: 'yellow', - } - - return ( - - - {toSentenceCase(status)} - - ) -} - -function RowActions({ person }: { person: Person }) { - return ( - - - - } - > - { - void navigator.clipboard.writeText(person.id) - }} - > - Copy ID - - - View details - View profile - - ) -} - -function SortIcon({ direction }: { direction: 'asc' | 'desc' | undefined }) { - if (direction === 'asc') return - if (direction === 'desc') return - return -} - -function ColumnHeaderMenu({ - column, - title, -}: { - column: AppColumn - title: string -}) { - const canSort = column.getCanSort() - const canHide = column.getCanHide() - const canPin = column.getCanPin() - const canGroup = column.getCanGroup() - const sorting = React.useContext(SortingContext) - const direction = canSort ? getSortDirection(sorting, column.id) : undefined - const pinned = canPin ? column.getIsPinned() : false - const grouped = canGroup ? column.getIsGrouped() : false - - if (!canSort && !canHide && !canPin && !canGroup) { - return {title} - } - - return ( - - {canSort ? ( - - ) : ( - {title} - )} - - - - } - > - {canSort ? ( - <> - } - onSelect={() => column.toggleSorting(false)} - > - Asc - - } - onSelect={() => column.toggleSorting(true)} - > - Desc - - - ) : null} - {canGroup ? ( - } - onSelect={column.getToggleGroupingHandler()} - > - {grouped ? 'Ungroup' : 'Group by'} - - ) : null} - {canPin ? ( - <> - - } - onSelect={() => column.pin('left')} - > - Pin left - - } - onSelect={() => column.pin('right')} - > - Pin right - - {pinned ? ( - } - onSelect={() => column.pin(false)} - > - Unpin - - ) : null} - - ) : null} - {canHide ? ( - <> - - } - onSelect={() => column.toggleVisibility(false)} - > - Hide - - - ) : null} - - - ) -} - -function ViewOptionsPopover({ - table, - columnOrder, - onColumnOrderChange, -}: { - table: AppTable - columnOrder: Array - onColumnOrderChange: React.Dispatch>> -}) { - const [opened, setOpened] = React.useState(false) - const [query, setQuery] = React.useState('') - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), - ) - const columns = table - .getAllColumns() - .filter((column) => typeof column.accessorFn !== 'undefined') - .sort((a, b) => columnOrder.indexOf(a.id) - columnOrder.indexOf(b.id)) - .filter((column) => - (column.columnDef.meta?.label ?? column.id) - .toLowerCase() - .includes(query.toLowerCase()), - ) - - const onDragEnd = (event: DragEndEvent) => { - const { active, over } = event - if (!over || active.id === over.id) return - - onColumnOrderChange((current) => { - const oldIndex = current.indexOf(String(active.id)) - const newIndex = current.indexOf(String(over.id)) - return oldIndex >= 0 && newIndex >= 0 - ? arrayMove(current, oldIndex, newIndex) - : current - }) - } - - return ( - - - View - - } - > - - setQuery(event.currentTarget.value)} - /> - - column.id)} - strategy={verticalListSortingStrategy} - > - - {columns.map((column) => ( - - - - column.toggleVisibility(checked) - } - /> - - - - ))} - - - - - - ) -} - -function SortListPopover({ - table, - sorting, - onSortingChange, -}: { - table: AppTable - sorting: SortingState - onSortingChange: React.Dispatch> -}) { - const [opened, setOpened] = React.useState(false) - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), - ) - const sortableColumns = table - .getAllColumns() - .filter((column) => column.getCanSort()) - const columnOptions = sortableColumns.map((column) => ({ - value: column.id, - label: column.columnDef.meta?.label ?? column.id, - })) - - const updateSort = (index: number, patch: Partial) => { - onSortingChange((current) => - current.map((sort, sortIndex) => - sortIndex === index ? { ...sort, ...patch } : sort, - ), - ) - } - - const addSort = () => { - const nextColumn = sortableColumns.find( - (column) => !sorting.some((sort) => sort.id === column.id), - ) - if (nextColumn) - onSortingChange((current) => [ - ...current, - { id: nextColumn.id, desc: false }, - ]) - } - - const onDragEnd = (event: DragEndEvent) => { - const { active, over } = event - if (!over || active.id === over.id) return - - onSortingChange((current) => { - const oldIndex = current.findIndex((sort) => sort.id === active.id) - const newIndex = current.findIndex((sort) => sort.id === over.id) - return oldIndex >= 0 && newIndex >= 0 - ? arrayMove(current, oldIndex, newIndex) - : current - }) - } - - return ( - - - Sort - {sorting.length ? ( - {sorting.length} - ) : null} - - } - > - - - {sorting.length ? 'Sort by' : 'No sorting applied'} - - - sort.id)} - strategy={verticalListSortingStrategy} - > - - {sorting.map((sort, index) => ( - - - - { - if (value) updateSort(index, { id: value }) - }} - flex={1} - /> - - updateSort(index, { desc: value === 'desc' }) - } - width="110px" - /> - - onSortingChange((current) => - current.filter((_, sortIndex) => sortIndex !== index), - ) - } - > - - - - - ))} - - - - - - - - - - ) -} +// --------------------------------------------------------------------------- +// Debounced Text Input +// --------------------------------------------------------------------------- -function FilterValueInput({ - column, - filter, - onFilterUpdate, +function DebouncedTextInput({ + value: initialValue, + onChange, + debounce = 300, + ...props }: { - column: AppColumn - filter: ExtendedColumnFilter - onFilterUpdate: ( - filterId: string, - patch: Partial, - ) => void -}) { - if (!filter.filterId) return null - const variant = column.columnDef.meta?.variant ?? 'text' - const operator = filter.operator ?? 'includesString' - const disabled = operator === 'isEmpty' || operator === 'isNotEmpty' - - if (disabled) { - return No value required - } - - if (variant === 'select') { - const options = column.columnDef.meta?.options ?? [] - return ( - onFilterUpdate(filter.filterId!, { value })} - /> - ) - } - - if (variant === 'multi-select') { - const options = column.columnDef.meta?.options ?? [] - return ( - onFilterUpdate(filter.filterId!, { value })} - /> - ) - } - - if (variant === 'date') { - if (operator === 'inRange') { - const value = Array.isArray(filter.value) ? filter.value : [] - return ( - - - onFilterUpdate(filter.filterId!, { - value: [ - event.currentTarget.value - ? new Date(event.currentTarget.value).toISOString() - : undefined, - value[1], - ], - }) - } - /> - - onFilterUpdate(filter.filterId!, { - value: [ - value[0], - event.currentTarget.value - ? new Date(event.currentTarget.value).toISOString() - : undefined, - ], - }) - } - /> - - ) - } + value: string | number + onChange: (value: string | number) => void + debounce?: number +} & Omit, 'onChange'>) { + const [value, setValue] = React.useState(initialValue) - return ( - - onFilterUpdate(filter.filterId!, { - value: event.currentTarget.value - ? new Date(event.currentTarget.value).toISOString() - : undefined, - }) - } - /> - ) - } + React.useEffect(() => { + setValue(initialValue) + }, [initialValue]) - if (variant === 'number') { - return ( - - onFilterUpdate(filter.filterId!, { - value: - event.currentTarget.value === '' - ? '' - : Number(event.currentTarget.value), - }) - } - /> - ) - } + const debouncedOnChange = useDebouncedCallback(onChange, { wait: debounce }) return ( - onFilterUpdate(filter.filterId!, { value: event.currentTarget.value }) - } + {...props} + value={value} + onChange={(event) => { + setValue(event.currentTarget.value) + debouncedOnChange(event.currentTarget.value) + }} /> ) } -function FilterListPopover({ - table, - columnFilters, - onColumnFiltersChange, -}: { - table: AppTable - columnFilters: Array - onColumnFiltersChange: React.Dispatch< - React.SetStateAction> - > -}) { - const [opened, setOpened] = React.useState(false) - const filterableColumns = table - .getAllColumns() - .filter((column) => column.getCanFilter()) - const fieldOptions = filterableColumns.map((column) => ({ - value: column.id, - label: column.columnDef.meta?.label ?? column.id, - })) - - const updateFilter = ( - filterId: string, - patch: Partial, - ) => { - onColumnFiltersChange((current) => - current.map((filter) => - filter.filterId === filterId ? { ...filter, ...patch } : filter, - ), - ) - } - - const addFilter = () => { - if (filterableColumns.length === 0) return - const [column] = filterableColumns - onColumnFiltersChange((current) => [ - ...current, - { - id: column.id, - filterId: crypto.randomUUID(), - value: '', - operator: 'includesString', - joinOperator: current[0]?.joinOperator ?? 'and', - }, - ]) - } - - return ( - - - Filter - {columnFilters.length ? ( - {columnFilters.length} - ) : null} - - } - > - - Filters - {columnFilters.map((filter, index) => { - const column = table.getColumn(filter.id) - if (!column || !filter.filterId) return null - const variant = column.columnDef.meta?.variant ?? 'text' - const operators = getFilterOperators(variant) - return ( - - {index === 0 ? ( - - Where - - ) : index === 1 ? ( - { - if (!joinOperator) return - onColumnFiltersChange((current) => - current.map((item) => ({ - ...item, - joinOperator: joinOperator as JoinOperator, - })), - ) - }} - width="90px" - /> - ) : ( - - {filter.joinOperator ?? 'and'} - - )} - { - const nextColumn = nextColumnId - ? table.getColumn(nextColumnId) - : undefined - if (nextColumn) { - updateFilter(filter.filterId!, { - id: nextColumn.id, - operator: getFilterOperators( - nextColumn.columnDef.meta?.variant ?? 'text', - )[0].value, - value: '', - }) - } - }} - width="190px" - /> - ({ - value: operator.value, - label: operator.label, - }))} - value={filter.operator ?? operators[0].value} - onChange={(operator) => { - if (!operator) return - updateFilter(filter.filterId!, { - operator: operator as FilterOperator, - value: '', - }) - }} - width="180px" - /> - - - - - onColumnFiltersChange((current) => - current.filter((item) => item.filterId !== filter.filterId), - ) - } - > - - - - ) - })} - - - - - - - ) -} - -function Pagination({ table }: { table: AppTable }) { - const pageIndex = table.state.pagination.pageIndex - const pageSize = table.state.pagination.pageSize - - return ( - - - {table.getFilteredSelectedRowModel().rows.length.toLocaleString()} of{' '} - {table.getFilteredRowModel().rows.length.toLocaleString()} row(s) - selected. - - - Rows per page: - { - table.setPageSize(Number(value)) - table.setPageIndex(0) - }} - width="90px" - /> - table.setPageIndex(0)} - disabled={!table.getCanPreviousPage()} - > - - - table.previousPage()} - disabled={!table.getCanPreviousPage()} - > - - - - {pageIndex + 1} / {table.getPageCount()} - - table.nextPage()} - disabled={!table.getCanNextPage()} - > - - - table.setPageIndex(table.getPageCount() - 1)} - disabled={!table.getCanNextPage()} - > - - - - - ) -} +// --------------------------------------------------------------------------- +// Mode Menu +// --------------------------------------------------------------------------- function ModeMenu() { const { resolvedTheme, setTheme, theme } = useTheme() @@ -1486,262 +160,67 @@ function ModeMenu() { ) } -function DebouncedTextInput({ - value: initialValue, - onChange, - debounce = 300, - ...props -}: { - value: string | number - onChange: (value: string | number) => void - debounce?: number -} & Omit, 'onChange'>) { - const [value, setValue] = React.useState(initialValue) - - React.useEffect(() => { - setValue(initialValue) - }, [initialValue]) - - const debouncedOnChange = useDebouncedCallback(onChange, { wait: debounce }) +// --------------------------------------------------------------------------- +// Chakra Example Provider +// --------------------------------------------------------------------------- +function ChakraExampleProvider({ children }: { children: React.ReactNode }) { return ( - { - setValue(event.currentTarget.value) - debouncedOnChange(event.currentTarget.value) - }} - /> + + + {children} + + ) } +// --------------------------------------------------------------------------- +// App +// --------------------------------------------------------------------------- + function App() { - const [rowSelection, setRowSelection] = React.useState({}) - const [sorting, setSorting] = React.useState([]) const [columnFilters, setColumnFilters] = React.useState< Array >([]) - const [columnVisibility, setColumnVisibility] = React.useState({}) - const [columnSizing, setColumnSizing] = React.useState({}) const [globalFilter, setGlobalFilter] = React.useState('') - const [columnPinning, setColumnPinning] = React.useState({ - left: ['select'], - right: ['actions'], - }) - const [grouping, setGrouping] = React.useState([]) - const [expanded, setExpanded] = React.useState({}) const [data, setData] = React.useState(() => makeData(1_000)) - const columns = React.useMemo( - () => - columnHelper.columns([ - columnHelper.display({ - id: 'select', - header: ({ table }) => ( - - table.toggleAllPageRowsSelected(checked) - } - aria-label="Select all" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(checked)} - aria-label="Select row" - /> - ), - maxSize: 48, - enableSorting: false, - enableHiding: false, - enableResizing: false, - }), - columnHelper.accessor('firstName', { - id: 'firstName', - header: ({ column }) => ( - - ), - cell: (info) => ( - {String(info.getValue())} - ), - meta: { label: 'First Name', variant: 'text' }, - }), - columnHelper.accessor((row) => row.lastName, { - id: 'lastName', - header: ({ column }) => ( - - ), - cell: (info) => ( - {String(info.getValue())} - ), - meta: { label: 'Last Name', variant: 'text' }, - }), - columnHelper.accessor('age', { - id: 'age', - header: ({ column }) => ( - - ), - cell: (info) => {String(info.getValue())}, - aggregationFn: 'mean', - aggregatedCell: ({ getValue }) => ( - - Avg: {Math.round(Number(getValue()) * 10) / 10} - - ), - meta: { label: 'Age', variant: 'number' }, - }), - columnHelper.accessor('email', { - id: 'email', - header: ({ column }) => ( - - ), - cell: (info) => ( - {info.cell.getValue()} - ), - meta: { label: 'Email', variant: 'text' }, - }), - columnHelper.accessor('status', { - id: 'status', - header: ({ column }) => ( - - ), - cell: (info) => { - const status = info.getValue() - return status ? : null - }, - aggregatedCell: () => null, - meta: { - label: 'Status', - variant: 'select', - options: statuses.map((status) => ({ - label: toSentenceCase(status), - value: status, - })), - }, - }), - columnHelper.accessor('department', { - id: 'department', - header: ({ column }) => ( - - ), - cell: (info) => { - const department = info.getValue() - return department ? ( - - ) : null - }, - aggregatedCell: () => null, - meta: { - label: 'Department', - variant: 'multi-select', - options: departments.map((department) => ({ - label: toSentenceCase(department), - value: department, - })), - }, - }), - columnHelper.accessor('joinDate', { - id: 'joinDate', - header: ({ column }) => ( - - ), - cell: (info) => formatDate(info.getValue()), - aggregationFn: 'min', - aggregatedCell: ({ getValue }) => { - const earliest = getValue() - return ( - - Earliest: {earliest ? formatDate(earliest) : '-'} - - ) - }, - meta: { label: 'Join Date', variant: 'date' }, - }), - columnHelper.display({ - id: 'actions', - enableHiding: false, - cell: ({ row }) => , - maxSize: 44, - enableResizing: false, - }), - ]), - [], - ) - - const [columnOrder, setColumnOrder] = React.useState>(() => - columns.map((column) => column.id ?? ''), - ) + const refreshData = () => setData(makeData(1_000)) + const stressTest = () => setData(makeData(1_000_000)) - const table = useTable( + const table = useAppTable( { - key: 'kitchen-sink-chakra-ui', // needed for devtools - features, + key: 'kitchen-sink-chakra-ui', columns, data, - defaultColumn: { - minSize: 60, - maxSize: 800, - filterFn: dynamicFilterFn, - }, - globalFilterFn: 'fuzzy', + debugTable: true, state: { - rowSelection, - sorting, - columnVisibility, - columnOrder, - columnSizing, columnFilters, globalFilter, - columnPinning, - grouping, - expanded, }, - onSortingChange: setSorting, - onColumnVisibilityChange: setColumnVisibility, - onColumnOrderChange: setColumnOrder, - onColumnSizingChange: setColumnSizing, onColumnFiltersChange: setColumnFilters, onGlobalFilterChange: setGlobalFilter, - onColumnPinningChange: setColumnPinning, - onGroupingChange: setGrouping, - onExpandedChange: setExpanded, - getRowId: (row) => row.id, - enableRowSelection: true, - onRowSelectionChange: setRowSelection, - columnResizeMode: 'onChange', - debugTable: true, + initialState: { + columnPinning: { left: ['select'], right: ['actions'] }, + columnOrder: columns.map((c) => c.id ?? ''), + }, }, - (state) => state, // default selector + (state) => state, ) useTanStackTableDevtools(table) - const columnSizeVars = React.useMemo(() => { - const headers = table.getFlatHeaders() - const colSizes: Record = {} - for (const header of headers) { - colSizes[`--header-${header.id}-size`] = header.getSize() - colSizes[`--col-${header.column.id}-size`] = header.column.getSize() - } - return colSizes - }, [table.state.columnSizing]) - - const refreshData = () => setData(makeData(1_000)) - const stressTest = () => setData(makeData(1_000_000)) - return ( - - - - - + + + + + - ) : cell.column.id === 'progress' ? ( - - - {String(cell.getValue())}% - - - - ) : ( - + )} - + ))} ) @@ -1892,70 +366,17 @@ function App() { - + - + ) } -function ResizableHeaderCell({ - header, - table, -}: { - header: Header - table: { - FlexRender: React.ComponentType<{ - header: Header - }> - } -}) { - const sorting = React.useContext(SortingContext) - const sortDirection = getSortDirection(sorting, header.column.id) - - return ( - - - {header.isPlaceholder ? null : } - {header.column.getCanResize() ? ( - header.column.resetSize()} - onMouseDown={header.getResizeHandler()} - onTouchStart={header.getResizeHandler()} - style={{ - position: 'absolute', - top: 0, - right: -6, - width: 6, - height: '100%', - cursor: 'col-resize', - touchAction: 'none', - background: header.column.getIsResizing() - ? 'var(--chakra-colors-blue-solid)' - : 'transparent', - }} - /> - ) : null} - - - ) -} +// --------------------------------------------------------------------------- +// Root +// --------------------------------------------------------------------------- function Root() { return ( diff --git a/examples/react/kitchen-sink-hero-ui/src/columns.tsx b/examples/react/kitchen-sink-hero-ui/src/columns.tsx new file mode 100644 index 0000000000..41173147e5 --- /dev/null +++ b/examples/react/kitchen-sink-hero-ui/src/columns.tsx @@ -0,0 +1,115 @@ +import type { Person } from '@/lib/make-data' +import { createAppColumnHelper } from '@/hooks/table' +import { + AgeAggregatedCell, + JoinDateAggregatedCell, +} from '@/components/data-table/cell-components' +import { toSentenceCase } from '@/components/data-table/shared' +import { departments, statuses } from '@/lib/make-data' + +const columnHelper = createAppColumnHelper() + +export const columns = columnHelper.columns([ + columnHelper.display({ + id: 'select', + header: ({ header }) => , + cell: ({ cell }) => , + maxSize: 48, + enableSorting: false, + enableHiding: false, + enableResizing: false, + }), + columnHelper.accessor('firstName', { + id: 'firstName', + header: ({ header }) => , + cell: ({ cell }) => , + meta: { + label: 'First Name', + variant: 'text', + }, + }), + columnHelper.accessor((row) => row.lastName, { + id: 'lastName', + header: ({ header }) => , + cell: ({ cell }) => , + meta: { + label: 'Last Name', + variant: 'text', + }, + }), + columnHelper.accessor('age', { + id: 'age', + header: ({ header }) => , + cell: ({ cell }) => , + aggregationFn: 'mean', + aggregatedCell: () => , + meta: { + label: 'Age', + variant: 'number', + }, + }), + columnHelper.accessor('email', { + id: 'email', + header: ({ header }) => , + cell: ({ cell }) => , + meta: { + label: 'Email', + variant: 'text', + }, + }), + columnHelper.accessor('status', { + id: 'status', + header: ({ header }) => , + cell: ({ cell }) => , + aggregatedCell: () => null, + meta: { + label: 'Status', + variant: 'select', + options: statuses.map((status) => ({ + label: toSentenceCase(status), + value: status, + })), + }, + }), + columnHelper.accessor('department', { + id: 'department', + header: ({ header }) => , + cell: ({ cell }) => , + aggregatedCell: () => null, + meta: { + label: 'Department', + variant: 'multi-select', + options: departments.map((department) => ({ + label: toSentenceCase(department), + value: department, + })), + }, + }), + columnHelper.accessor('joinDate', { + id: 'joinDate', + header: ({ header }) => , + cell: ({ cell }) => , + aggregationFn: 'min', + aggregatedCell: () => , + meta: { + label: 'Join Date', + variant: 'date', + }, + }), + columnHelper.accessor((row) => row.age, { + id: 'progress', + header: ({ header }) => , + cell: ({ cell }) => , + meta: { + label: 'Profile Progress', + variant: 'number', + }, + }), + columnHelper.display({ + id: 'actions', + enableHiding: false, + cell: ({ cell }) => , + maxSize: 60, + enableResizing: false, + }), +]) diff --git a/examples/react/kitchen-sink-hero-ui/src/components/data-table/cell-components.tsx b/examples/react/kitchen-sink-hero-ui/src/components/data-table/cell-components.tsx new file mode 100644 index 0000000000..ac8add88b7 --- /dev/null +++ b/examples/react/kitchen-sink-hero-ui/src/components/data-table/cell-components.tsx @@ -0,0 +1,165 @@ +'use client' + +import { Button, Checkbox, Chip, Dropdown, ProgressBar } from '@heroui/react' +import type { Person } from '@/lib/make-data' +import { useCellContext, useTableContext } from '@/hooks/table' +import { formatDate, toSentenceCase } from '@/components/data-table/shared' + +function EllipsisText({ children }: { children: React.ReactNode }) { + return {children} +} + +export function SelectCell(): React.ReactNode { + const cell = useCellContext() + const table = useTableContext() + const row = cell.row + + return ( + s.rowSelection}> + {() => ( + row.toggleSelected(selected)} + aria-label="Select row" + > + + + + + + + )} + + ) +} + +export function TextCell(): React.ReactNode { + const cell = useCellContext() + return {String(cell.getValue())} +} + +export function AgeCell(): React.ReactNode { + const cell = useCellContext() + return {String(cell.getValue())} +} + +export function StatusCell(): React.ReactNode { + const cell = useCellContext() + const status = cell.getValue() + if (!status) return null + + const color: Record = { + active: 'success', + inactive: 'danger', + pending: 'warning', + } + + return ( + + {toSentenceCase(status)} + + ) +} + +export function DepartmentCell(): React.ReactNode { + const cell = useCellContext() + const department = cell.getValue() + if (!department) return null + + return ( + + + {department.slice(0, 2).toUpperCase()} + + {toSentenceCase(department)} + + ) +} + +export function DateCell(): React.ReactNode { + const cell = useCellContext() + return <>{formatDate(cell.getValue())} +} + +export function ProgressCell(): React.ReactNode { + const cell = useCellContext() + const value = Math.min(100, Math.max(0, Number(cell.getValue()))) + return ( + + + + + + ) +} + +export function GroupedCell(): React.ReactNode { + const cell = useCellContext() + const table = useTableContext() + const row = cell.row + + return ( + s.expanded}> + {() => ( + + )} + + ) +} + +export function ActionsCell(): React.ReactNode { + const cell = useCellContext() + const person = cell.row.original as Person + + return ( + + {'•••'} + + + { + void navigator.clipboard.writeText(person.id) + }} + > + Copy ID + + View details + View profile + + + + ) +} + +export function AgeAggregatedCell(): React.ReactNode { + const cell = useCellContext() + return ( + + Avg: {Math.round(Number(cell.getValue()) * 10) / 10} + + ) +} + +export function JoinDateAggregatedCell(): React.ReactNode { + const cell = useCellContext() + const earliest = cell.getValue() + return ( + + Earliest: {earliest ? formatDate(earliest) : '-'} + + ) +} diff --git a/examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-column-header.tsx b/examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-column-header.tsx new file mode 100644 index 0000000000..d9e58596c3 --- /dev/null +++ b/examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-column-header.tsx @@ -0,0 +1,141 @@ +'use client' + +import { Button, Dropdown } from '@heroui/react' +import { useHeaderContext, useTableContext } from '@/hooks/table' + +function SortIcon({ direction }: { direction: 'asc' | 'desc' | undefined }) { + if (direction === 'asc') return + if (direction === 'desc') return + return ( + + ) +} + +export function ColumnHeader({ title }: { title?: string }): React.ReactNode { + const header = useHeaderContext() + const table = useTableContext() + const column = header.column + + const displayTitle = column.columnDef.meta?.label ?? title ?? column.id + + const canSort = column.getCanSort() + const canHide = column.getCanHide() + const canPin = column.getCanPin() + const canGroup = column.getCanGroup() + + if (!canSort && !canHide && !canPin && !canGroup) { + return {displayTitle} + } + + return ( +
+ ({ + sorting: s.sorting, + grouping: s.grouping, + columnPinning: s.columnPinning, + })} + > + {() => { + const sorted = canSort ? column.getIsSorted() : false + const pinned = canPin ? column.getIsPinned() : false + const grouped = canGroup ? column.getIsGrouped() : false + const direction = + sorted === 'asc' ? 'asc' : sorted === 'desc' ? 'desc' : undefined + + return ( + <> + {canSort ? ( + + ) : ( + {displayTitle} + )} + + + {'▾'} + + + + {canSort ? ( + <> + column.toggleSorting(false)} + > + Asc + + column.toggleSorting(true)} + > + Desc + + + ) : null} + {canGroup ? ( + + {grouped ? 'Ungroup' : 'Group by'} + + ) : null} + {canPin ? ( + <> + column.pin('left')} + > + Pin left + + column.pin('right')} + > + Pin right + + {pinned ? ( + column.pin(false)} + > + Unpin + + ) : null} + + ) : null} + {canHide ? ( + column.toggleVisibility(false)} + > + Hide + + ) : null} + + + + + ) + }} + +
+ ) +} diff --git a/examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-filter-list.tsx b/examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-filter-list.tsx new file mode 100644 index 0000000000..985dfc3ec6 --- /dev/null +++ b/examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-filter-list.tsx @@ -0,0 +1,321 @@ +'use client' + +import * as React from 'react' +import { Button, Input, Popover } from '@heroui/react' +import type { ExtendedColumnFilter } from '@/types' +import { useTableContext } from '@/hooks/table' +import { getFilterOperators } from '@/lib/data-table' +import { HeroSelect, toDateInputValue } from '@/components/data-table/shared' + +function FilterValueInput({ + column, + filter, + onFilterUpdate, +}: { + column: any + filter: ExtendedColumnFilter + onFilterUpdate: ( + filterId: string, + patch: Partial, + ) => void +}) { + if (!filter.filterId) return null + const variant = column.columnDef.meta?.variant ?? 'text' + const operator = filter.operator ?? 'includesString' + const disabled = operator === 'isEmpty' || operator === 'isNotEmpty' + + if (disabled) + return
No value required
+ + if (variant === 'select') { + const options: Array<{ label: string; value: string }> = + column.columnDef.meta?.options ?? [] + return ( + onFilterUpdate(filter.filterId!, { value })} + /> + ) + } + + if (variant === 'multi-select') { + const options: Array<{ label: string; value: string }> = + column.columnDef.meta?.options ?? [] + const values = Array.isArray(filter.value) + ? filter.value.map(String) + : typeof filter.value === 'string' && filter.value + ? [filter.value] + : [] + return ( + + ) + } + + if (variant === 'date') { + if (operator === 'inRange') { + const value = Array.isArray(filter.value) ? filter.value : [] + return ( +
+ + onFilterUpdate(filter.filterId!, { + value: [ + event.currentTarget.value + ? new Date(event.currentTarget.value).toISOString() + : undefined, + value[1], + ], + }) + } + /> + + onFilterUpdate(filter.filterId!, { + value: [ + value[0], + event.currentTarget.value + ? new Date(event.currentTarget.value).toISOString() + : undefined, + ], + }) + } + /> +
+ ) + } + + return ( + + onFilterUpdate(filter.filterId!, { + value: event.currentTarget.value + ? new Date(event.currentTarget.value).toISOString() + : undefined, + }) + } + /> + ) + } + + if (variant === 'number') { + return ( + + onFilterUpdate(filter.filterId!, { + value: + event.currentTarget.value === '' + ? '' + : Number(event.currentTarget.value), + }) + } + /> + ) + } + + return ( + + onFilterUpdate(filter.filterId!, { value: event.currentTarget.value }) + } + /> + ) +} + +export function DataTableFilterList(): React.ReactNode { + const table = useTableContext() + const columnFilters = table.state.columnFilters as Array + + // Write through the raw controlled-state handler instead of + // `table.setColumnFilters`: the table API auto-removes filters with empty + // values, but a just-added filter row legitimately starts with `value: ''` + // while the user is still building it. + const setColumnFilters = (filters: Array) => { + table.options.onColumnFiltersChange?.(filters) + } + + const filterableColumns = table + .getAllColumns() + .filter((column: any) => column.getCanFilter()) + const fieldOptions = filterableColumns.map((column: any) => ({ + value: column.id as string, + label: (column.columnDef.meta?.label ?? column.id) as string, + })) + + const updateFilter = ( + filterId: string, + patch: Partial, + ) => { + setColumnFilters( + columnFilters.map((filter: ExtendedColumnFilter) => + filter.filterId === filterId ? { ...filter, ...patch } : filter, + ) as any, + ) + } + + const addFilter = () => { + const [column] = filterableColumns + if (!column) return + setColumnFilters([ + ...columnFilters, + { + id: column.id, + filterId: crypto.randomUUID(), + value: '', + operator: 'includesString', + joinOperator: columnFilters[0]?.joinOperator ?? 'and', + }, + ] as any) + } + + return ( + + + + +
Filters
+ {columnFilters.map((filter: ExtendedColumnFilter, index: number) => { + const column = table.getColumn(filter.id) + if (!column || !filter.filterId) return null + const variant = column.columnDef.meta?.variant ?? 'text' + const operators = getFilterOperators(variant) + return ( +
+ {index === 0 ? ( +
Where
+ ) : index === 1 ? ( + + setColumnFilters( + columnFilters.map((item: ExtendedColumnFilter) => ({ + ...item, + joinOperator: joinOperator as 'and' | 'or', + })) as any, + ) + } + /> + ) : ( +
+ {filter.joinOperator ?? 'and'} +
+ )} + { + const nextColumn = table.getColumn(nextColumnId) + if (nextColumn) { + updateFilter(filter.filterId!, { + id: nextColumn.id, + operator: getFilterOperators( + nextColumn.columnDef.meta?.variant ?? 'text', + )[0].value, + value: '', + }) + } + }} + /> + ({ + value: op.value, + label: op.label, + }))} + onChange={(operator) => + updateFilter(filter.filterId!, { + operator: operator as ExtendedColumnFilter['operator'], + value: '', + }) + } + /> + + +
+ ) + })} +
+ + +
+
+
+
+ ) +} diff --git a/examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-pagination.tsx b/examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-pagination.tsx new file mode 100644 index 0000000000..38dec3aa58 --- /dev/null +++ b/examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-pagination.tsx @@ -0,0 +1,116 @@ +'use client' + +import { Button, Pagination as HeroPagination } from '@heroui/react' +import { useTableContext } from '@/hooks/table' +import { HeroSelect } from '@/components/data-table/shared' + +function getPageItems(pageIndex: number, pageCount: number) { + const currentPage = pageIndex + 1 + const pages = new Set([ + 1, + pageCount, + currentPage - 1, + currentPage, + currentPage + 1, + ]) + + return Array.from(pages) + .filter((page) => page >= 1 && page <= pageCount) + .sort((a, b) => a - b) + .reduce>((items, page) => { + const previous = items[items.length - 1] + if (typeof previous === 'number' && page - previous > 1) { + items.push('ellipsis') + } + items.push(page) + return items + }, []) +} + +export function DataTablePagination(): React.ReactNode { + const table = useTableContext() + const pageIndex = table.state.pagination.pageIndex + const pageSize = table.state.pagination.pageSize + const pageItems = getPageItems(pageIndex, table.getPageCount()) + + return ( +
+
+ {table.getFilteredSelectedRowModel().rows.length.toLocaleString()} of{' '} + {table.getFilteredRowModel().rows.length.toLocaleString()} row(s) + selected. +
+
+ Rows per page: + ({ + value, + label: value, + }))} + onChange={(value) => { + table.setPageSize(Number(value)) + table.setPageIndex(0) + }} + /> + + + + + table.previousPage()} + > + + Prev + + + {pageItems.map((page, index) => + page === 'ellipsis' ? ( + + + + ) : ( + + table.setPageIndex(page - 1)} + > + {page} + + + ), + )} + + table.nextPage()} + > + Next + + + + + + +
+
+ ) +} diff --git a/examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-sort-list.tsx b/examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-sort-list.tsx new file mode 100644 index 0000000000..d9ce9dc795 --- /dev/null +++ b/examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-sort-list.tsx @@ -0,0 +1,151 @@ +'use client' + +import * as React from 'react' +import { Button, Popover } from '@heroui/react' +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { + SortableContext, + arrayMove, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import type { DragEndEvent } from '@dnd-kit/core' +import type { ColumnSort } from '@tanstack/react-table' +import { useTableContext } from '@/hooks/table' +import { HeroSelect, SortableFrame } from '@/components/data-table/shared' + +export function DataTableSortList(): React.ReactNode { + const table = useTableContext() + const sorting = table.state.sorting + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + ) + + const sortableColumns = table + .getAllColumns() + .filter((column: any) => column.getCanSort()) + const columnOptions = sortableColumns.map((column: any) => ({ + value: column.id as string, + label: (column.columnDef.meta?.label ?? column.id) as string, + })) + + const updateSort = (index: number, patch: Partial) => { + table.setSorting( + sorting.map((sort: ColumnSort, sortIndex: number) => + sortIndex === index ? { ...sort, ...patch } : sort, + ), + ) + } + + const addSort = () => { + const nextColumn = sortableColumns.find( + (column: any) => + !sorting.some((sort: ColumnSort) => sort.id === column.id), + ) + if (nextColumn) { + table.setSorting([...sorting, { id: nextColumn.id, desc: false }]) + } + } + + const onDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + const oldIndex = sorting.findIndex( + (sort: ColumnSort) => sort.id === active.id, + ) + const newIndex = sorting.findIndex( + (sort: ColumnSort) => sort.id === over.id, + ) + if (oldIndex >= 0 && newIndex >= 0) { + table.setSorting(arrayMove([...sorting], oldIndex, newIndex)) + } + } + + return ( + + + + +
+ {sorting.length ? 'Sort by' : 'No sorting applied'} +
+ + sort.id)} + strategy={verticalListSortingStrategy} + > +
+ {sorting.map((sort: ColumnSort, index: number) => ( + +
+ {'≡'} + updateSort(index, { id: value })} + /> + + updateSort(index, { desc: value === 'desc' }) + } + /> + +
+
+ ))} +
+
+
+
+ + +
+
+
+
+ ) +} diff --git a/examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-view-options.tsx b/examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-view-options.tsx new file mode 100644 index 0000000000..e1139e3970 --- /dev/null +++ b/examples/react/kitchen-sink-hero-ui/src/components/data-table/data-table-view-options.tsx @@ -0,0 +1,98 @@ +'use client' + +import * as React from 'react' +import { Button, Checkbox, Input, Popover } from '@heroui/react' +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { + SortableContext, + arrayMove, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import type { DragEndEvent } from '@dnd-kit/core' +import { useTableContext } from '@/hooks/table' +import { SortableFrame } from '@/components/data-table/shared' + +export function DataTableViewOptions(): React.ReactNode { + const table = useTableContext() + const columnOrder = table.state.columnOrder + const [query, setQuery] = React.useState('') + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + ) + + const columns = table + .getAllColumns() + .filter((column: any) => typeof column.accessorFn !== 'undefined') + .sort( + (a: any, b: any) => columnOrder.indexOf(a.id) - columnOrder.indexOf(b.id), + ) + .filter((column: any) => + ((column.columnDef.meta?.label ?? column.id) as string) + .toLowerCase() + .includes(query.toLowerCase()), + ) + + const onDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + const current = columnOrder + const oldIndex = current.indexOf(String(active.id)) + const newIndex = current.indexOf(String(over.id)) + if (oldIndex >= 0 && newIndex >= 0) { + table.setColumnOrder(arrayMove([...current], oldIndex, newIndex)) + } + } + + return ( + + + + + setQuery(event.currentTarget.value)} + /> + + column.id as string)} + strategy={verticalListSortingStrategy} + > +
+ {columns.map((column: any) => ( + +
+ + column.toggleVisibility(selected) + } + > + {column.columnDef.meta?.label ?? column.id} + + {'≡'} +
+
+ ))} +
+
+
+
+
+
+ ) +} diff --git a/examples/react/kitchen-sink-hero-ui/src/components/data-table/header-components.tsx b/examples/react/kitchen-sink-hero-ui/src/components/data-table/header-components.tsx new file mode 100644 index 0000000000..1e1c1f4674 --- /dev/null +++ b/examples/react/kitchen-sink-hero-ui/src/components/data-table/header-components.tsx @@ -0,0 +1,56 @@ +'use client' + +import { Checkbox, cn } from '@heroui/react' +import { useHeaderContext, useTableContext } from '@/hooks/table' + +export function SelectAllHeader(): React.ReactNode { + const table = useTableContext() + + return ( + s.rowSelection}> + {() => ( + + table.toggleAllPageRowsSelected(selected) + } + aria-label="Select all" + > + + + + + + + )} + + ) +} + +export function ResizeHandle(): React.ReactNode { + const header = useHeaderContext() + const table = useTableContext() + + if (!header.column.getCanResize()) return null + + return ( + s.columnSizing}> + {() => ( +
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + className={cn( + 'absolute right-[-6px] top-0 h-full w-1.5 cursor-col-resize touch-none', + header.column.getIsResizing() && 'bg-primary', + )} + /> + )} + + ) +} diff --git a/examples/react/kitchen-sink-hero-ui/src/components/data-table/shared.tsx b/examples/react/kitchen-sink-hero-ui/src/components/data-table/shared.tsx new file mode 100644 index 0000000000..f1d118eafc --- /dev/null +++ b/examples/react/kitchen-sink-hero-ui/src/components/data-table/shared.tsx @@ -0,0 +1,106 @@ +'use client' + +import * as React from 'react' +import { Label, ListBox, ListBoxItem, Select } from '@heroui/react' +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import type { Key } from '@heroui/react' + +export function toSentenceCase(value: string) { + return value + .replace(/[-_]/g, ' ') + .replace(/\w\S*/g, (word) => word[0].toUpperCase() + word.slice(1)) +} + +export function formatDate(value: unknown) { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(new Date(String(value))) +} + +export function toDateInputValue(value: unknown) { + if (!value) return '' + const date = new Date(String(value)) + return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10) +} + +export function HeroSelect({ + label, + value, + options, + className, + showLabel = true, + onChange, +}: { + label: string + value: string | null + options: Array<{ value: string; label: string }> + className?: string + showLabel?: boolean + onChange: (value: string) => void +}) { + return ( + + ) +} + +export function SortableFrame({ + id, + children, +}: { + id: string + children: React.ReactNode +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }) + + return ( +
+ {children} +
+ ) +} diff --git a/examples/react/kitchen-sink-hero-ui/src/hooks/features.ts b/examples/react/kitchen-sink-hero-ui/src/hooks/features.ts new file mode 100644 index 0000000000..730a2273ba --- /dev/null +++ b/examples/react/kitchen-sink-hero-ui/src/hooks/features.ts @@ -0,0 +1,72 @@ +import { + aggregationFns, + columnFacetingFeature, + columnFilteringFeature, + columnGroupingFeature, + columnOrderingFeature, + columnPinningFeature, + columnResizingFeature, + columnSizingFeature, + columnVisibilityFeature, + createExpandedRowModel, + createFacetedRowModel, + createFacetedUniqueValues, + createFilteredRowModel, + createGroupedRowModel, + createPaginatedRowModel, + createSortedRowModel, + filterFns, + globalFilteringFeature, + metaHelper, + rowExpandingFeature, + rowPaginationFeature, + rowSelectionFeature, + rowSortingFeature, + sortFns, + tableFeatures, +} from '@tanstack/react-table' +import { rankItem } from '@tanstack/match-sorter-utils' + +export interface MyColumnMeta { + label?: string + variant?: 'text' | 'number' | 'date' | 'boolean' | 'select' | 'multi-select' + options?: Array<{ label: string; value: string; count?: number }> +} + +const fuzzyFilterFn = ( + row: { getValue: (id: string) => unknown }, + columnId: string, + value: unknown, + addMeta?: (meta: object) => void, +) => { + const itemRank = rankItem(row.getValue(columnId), value as string) + addMeta?.({ itemRank }) + return itemRank.passed +} + +export const features = tableFeatures({ + rowSortingFeature, + rowPaginationFeature, + rowSelectionFeature, + rowExpandingFeature, + columnFilteringFeature, + columnFacetingFeature, + columnOrderingFeature, + columnVisibilityFeature, + columnSizingFeature, + columnResizingFeature, + columnPinningFeature, + columnGroupingFeature, + globalFilteringFeature, + columnMeta: metaHelper(), + filteredRowModel: createFilteredRowModel(), + facetedRowModel: createFacetedRowModel(), + facetedUniqueValues: createFacetedUniqueValues(), + paginatedRowModel: createPaginatedRowModel(), + sortedRowModel: createSortedRowModel(), + groupedRowModel: createGroupedRowModel(), + expandedRowModel: createExpandedRowModel(), + filterFns: { ...filterFns, fuzzy: fuzzyFilterFn }, + sortFns, + aggregationFns, +}) diff --git a/examples/react/kitchen-sink-hero-ui/src/hooks/table.ts b/examples/react/kitchen-sink-hero-ui/src/hooks/table.ts new file mode 100644 index 0000000000..8d9435a9da --- /dev/null +++ b/examples/react/kitchen-sink-hero-ui/src/hooks/table.ts @@ -0,0 +1,71 @@ +import { createTableHook } from '@tanstack/react-table' +import { features } from '@/hooks/features' +import { dynamicFilterFn } from '@/lib/data-table' + +import { DataTablePagination } from '@/components/data-table/data-table-pagination' +import { DataTableFilterList } from '@/components/data-table/data-table-filter-list' +import { DataTableSortList } from '@/components/data-table/data-table-sort-list' +import { DataTableViewOptions } from '@/components/data-table/data-table-view-options' + +import { + ActionsCell, + AgeCell, + DateCell, + DepartmentCell, + GroupedCell, + ProgressCell, + SelectCell, + StatusCell, + TextCell, +} from '@/components/data-table/cell-components' + +import { ColumnHeader } from '@/components/data-table/data-table-column-header' +import { + ResizeHandle, + SelectAllHeader, +} from '@/components/data-table/header-components' + +export const { + createAppColumnHelper, + useAppTable, + useTableContext, + useCellContext, + useHeaderContext, +} = createTableHook({ + features, + defaultColumn: { + size: 120, + minSize: 60, + maxSize: 800, + filterFn: dynamicFilterFn, + }, + globalFilterFn: 'fuzzy', + getRowId: (row: any) => row.id, + enableRowSelection: true, + columnResizeMode: 'onChange' as const, + + tableComponents: { + Pagination: DataTablePagination, + FilterList: DataTableFilterList, + SortList: DataTableSortList, + ViewOptions: DataTableViewOptions, + }, + + cellComponents: { + SelectCell, + TextCell, + AgeCell, + StatusCell, + DepartmentCell, + DateCell, + ProgressCell, + GroupedCell, + ActionsCell, + }, + + headerComponents: { + ColumnHeader, + SelectAllHeader, + ResizeHandle, + }, +}) diff --git a/examples/react/kitchen-sink-hero-ui/src/lib/data-table.ts b/examples/react/kitchen-sink-hero-ui/src/lib/data-table.ts index acb58f259c..c319644f33 100644 --- a/examples/react/kitchen-sink-hero-ui/src/lib/data-table.ts +++ b/examples/react/kitchen-sink-hero-ui/src/lib/data-table.ts @@ -15,7 +15,7 @@ import type { JoinOperator, } from '@/types' import type { FilterFn } from '@tanstack/react-table' -import type { features } from '../main' +import type { features } from '@/hooks/features' export const fuzzyFilter: FilterFn = ( row, diff --git a/examples/react/kitchen-sink-hero-ui/src/main.tsx b/examples/react/kitchen-sink-hero-ui/src/main.tsx index 6e2c44305a..4676ffc00e 100644 --- a/examples/react/kitchen-sink-hero-ui/src/main.tsx +++ b/examples/react/kitchen-sink-hero-ui/src/main.tsx @@ -4,234 +4,37 @@ import * as React from 'react' import { TanStackDevtools } from '@tanstack/react-devtools' import * as ReactDOM from 'react-dom/client' import { useDebouncedCallback } from '@tanstack/react-pacer/debouncer' -import { - DndContext, - PointerSensor, - closestCenter, - useSensor, - useSensors, -} from '@dnd-kit/core' -import { - SortableContext, - arrayMove, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable' -import { CSS } from '@dnd-kit/utilities' import { Button, - Checkbox, - Chip, - Dropdown, - Pagination as HeroPagination, - Table as HeroTable, Input, - Label, - ListBox, - ListBoxItem, - Popover, - ProgressBar, - Select, Surface, Switch, + Table as HeroTable, Tooltip, cn, useTheme, } from '@heroui/react' -import { - aggregationFns, - columnFacetingFeature, - columnFilteringFeature, - columnGroupingFeature, - columnOrderingFeature, - columnPinningFeature, - columnResizingFeature, - columnSizingFeature, - columnVisibilityFeature, - createColumnHelper, - createExpandedRowModel, - createFacetedRowModel, - createFacetedUniqueValues, - createFilteredRowModel, - createGroupedRowModel, - createPaginatedRowModel, - createSortedRowModel, - filterFns, - globalFilteringFeature, - metaHelper, - rowExpandingFeature, - rowPaginationFeature, - rowSelectionFeature, - rowSortingFeature, - sortFns, - tableFeatures, - useTable, -} from '@tanstack/react-table' import { tableDevtoolsPlugin, useTanStackTableDevtools, } from '@tanstack/react-table-devtools' -import { rankItem } from '@tanstack/match-sorter-utils' -import type { DragEndEvent } from '@dnd-kit/core' -import type { Key } from '@heroui/react' -import type { Person } from '@/lib/make-data' -import type { - Column, - ColumnPinningState, - ColumnSizingState, - ExpandedState, - GroupingState, - Header, - ReactTable, - SortingState, -} from '@tanstack/react-table' +import type { Column } from '@tanstack/react-table' import type { ExtendedColumnFilter } from '@/types' - -import { dynamicFilterFn, getFilterOperators } from '@/lib/data-table' -import { departments, makeData, statuses } from '@/lib/make-data' +import type { Person } from '@/lib/make-data' +import type { features } from '@/hooks/features' +import { makeData } from '@/lib/make-data' +import { useAppTable } from '@/hooks/table' +import { columns } from '@/columns' import './styles/globals.css' -interface MyColumnMeta { - label?: string - variant?: 'text' | 'number' | 'date' | 'boolean' | 'select' | 'multi-select' - options?: Array<{ label: string; value: string; count?: number }> -} - -// Local fuzzy filter implementation for the filterFns registry slot. -// Defined here to avoid a circular type dependency with data-table.ts. -const fuzzyFilterFn = ( - row: { getValue: (id: string) => unknown }, - columnId: string, - value: unknown, - addMeta?: (meta: object) => void, -) => { - const itemRank = rankItem(row.getValue(columnId), value as string) - addMeta?.({ itemRank }) - return itemRank.passed -} - -export const features = tableFeatures({ - rowSortingFeature, - rowPaginationFeature, - rowSelectionFeature, - rowExpandingFeature, - columnFilteringFeature, - columnFacetingFeature, - columnOrderingFeature, - columnVisibilityFeature, - columnSizingFeature, - columnResizingFeature, - columnPinningFeature, - columnGroupingFeature, - globalFilteringFeature, - columnMeta: metaHelper(), - filteredRowModel: createFilteredRowModel(), - facetedRowModel: createFacetedRowModel(), - facetedUniqueValues: createFacetedUniqueValues(), - paginatedRowModel: createPaginatedRowModel(), - sortedRowModel: createSortedRowModel(), - groupedRowModel: createGroupedRowModel(), - expandedRowModel: createExpandedRowModel(), - filterFns: { ...filterFns, fuzzy: fuzzyFilterFn }, - sortFns, - aggregationFns, -}) - -const columnHelper = createColumnHelper() -type AppTable = ReactTable -type AppColumn = Column - -function getPageItems(pageIndex: number, pageCount: number) { - const currentPage = pageIndex + 1 - const pages = new Set([ - 1, - pageCount, - currentPage - 1, - currentPage, - currentPage + 1, - ]) - - return Array.from(pages) - .filter((page) => page >= 1 && page <= pageCount) - .sort((a, b) => a - b) - .reduce>((items, page) => { - const previous = items[items.length - 1] - if (typeof previous === 'number' && page - previous > 1) { - items.push('ellipsis') - } - items.push(page) - return items - }, []) -} - -function SortableFrame({ - id, - children, -}: { - id: string - children: React.ReactNode -}) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id }) - - return ( -
- {children} -
- ) -} - -function toSentenceCase(value: string) { - return value - .replace(/[-_]/g, ' ') - .replace(/\w\S*/g, (word) => word[0].toUpperCase() + word.slice(1)) -} - -function formatDate(value: unknown) { - return new Intl.DateTimeFormat('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }).format(new Date(String(value))) -} - -function toDateInputValue(value: unknown) { - if (!value) return '' - const date = new Date(String(value)) - return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10) -} - function getAriaSort(sortDirection: false | 'asc' | 'desc') { if (sortDirection === 'asc') return 'ascending' if (sortDirection === 'desc') return 'descending' return 'none' } -const SortingContext = React.createContext([]) - -function getSortDirection(sorting: SortingState, columnId: string) { - const sort = sorting.find((item) => item.id === columnId) - return sort ? (sort.desc ? 'desc' : 'asc') : undefined -} - function getCommonPinningStyles( - column: AppColumn, + column: Column, isSelected = false, ): React.CSSProperties { const isPinned = column.getIsPinned() @@ -264,833 +67,6 @@ function getCommonPinningStyles( } } -function DepartmentPill({ department }: { department: Person['department'] }) { - return ( - - - {department.slice(0, 2).toUpperCase()} - - {toSentenceCase(department)} - - ) -} - -function EllipsisText({ children }: { children: React.ReactNode }) { - return {children} -} - -function StatusBadge({ status }: { status: Person['status'] }) { - const color: Record = { - active: 'success', - inactive: 'danger', - pending: 'warning', - } - - return ( - - {toSentenceCase(status)} - - ) -} - -function RowActions({ person }: { person: Person }) { - return ( - - ••• - - - { - void navigator.clipboard.writeText(person.id) - }} - > - Copy ID - - View details - View profile - - - - ) -} - -function SortIcon({ direction }: { direction: 'asc' | 'desc' | undefined }) { - if (direction === 'asc') return - if (direction === 'desc') return - return ( - - ) -} - -function ColumnHeaderMenu({ - column, - title, -}: { - column: AppColumn - title: string -}) { - const canSort = column.getCanSort() - const canHide = column.getCanHide() - const canPin = column.getCanPin() - const canGroup = column.getCanGroup() - const sorting = React.useContext(SortingContext) - const direction = canSort ? getSortDirection(sorting, column.id) : undefined - const pinned = canPin ? column.getIsPinned() : false - const grouped = canGroup ? column.getIsGrouped() : false - - if (!canSort && !canHide && !canPin && !canGroup) { - return {title} - } - - return ( -
- {canSort ? ( - - ) : ( - {title} - )} - - - ▾ - - - - {canSort ? ( - <> - column.toggleSorting(false)} - > - Asc - - column.toggleSorting(true)} - > - Desc - - - ) : null} - {canGroup ? ( - - {grouped ? 'Ungroup' : 'Group by'} - - ) : null} - {canPin ? ( - <> - column.pin('left')} - > - Pin left - - column.pin('right')} - > - Pin right - - {pinned ? ( - column.pin(false)}> - Unpin - - ) : null} - - ) : null} - {canHide ? ( - column.toggleVisibility(false)} - > - Hide - - ) : null} - - - -
- ) -} - -function HeroSelect({ - label, - value, - options, - className, - showLabel = true, - onChange, -}: { - label: string - value: string | null - options: Array<{ value: string; label: string }> - className?: string - showLabel?: boolean - onChange: (value: string) => void -}) { - return ( - - ) -} - -function ViewOptionsPopover({ - table, - columnOrder, - onColumnOrderChange, -}: { - table: AppTable - columnOrder: Array - onColumnOrderChange: React.Dispatch>> -}) { - const [query, setQuery] = React.useState('') - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), - ) - const columns = table - .getAllColumns() - .filter((column) => typeof column.accessorFn !== 'undefined') - .sort((a, b) => columnOrder.indexOf(a.id) - columnOrder.indexOf(b.id)) - .filter((column) => - (column.columnDef.meta?.label ?? column.id) - .toLowerCase() - .includes(query.toLowerCase()), - ) - - const onDragEnd = (event: DragEndEvent) => { - const { active, over } = event - if (!over || active.id === over.id) return - - onColumnOrderChange((current) => { - const oldIndex = current.indexOf(String(active.id)) - const newIndex = current.indexOf(String(over.id)) - return oldIndex >= 0 && newIndex >= 0 - ? arrayMove(current, oldIndex, newIndex) - : current - }) - } - - return ( - - - - - setQuery(event.currentTarget.value)} - /> - - column.id)} - strategy={verticalListSortingStrategy} - > -
- {columns.map((column) => ( - -
- - column.toggleVisibility(selected) - } - > - {column.columnDef.meta?.label ?? column.id} - - -
-
- ))} -
-
-
-
-
-
- ) -} - -function SortListPopover({ - table, - sorting, - onSortingChange, -}: { - table: AppTable - sorting: SortingState - onSortingChange: React.Dispatch> -}) { - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), - ) - const sortableColumns = table - .getAllColumns() - .filter((column) => column.getCanSort()) - const columnOptions = sortableColumns.map((column) => ({ - value: column.id, - label: column.columnDef.meta?.label ?? column.id, - })) - - const updateSort = (index: number, patch: Partial) => { - onSortingChange((current) => - current.map((sort, sortIndex) => - sortIndex === index ? { ...sort, ...patch } : sort, - ), - ) - } - - const addSort = () => { - const nextColumn = sortableColumns.find( - (column) => !sorting.some((sort) => sort.id === column.id), - ) - if (nextColumn) { - onSortingChange((current) => [ - ...current, - { id: nextColumn.id, desc: false }, - ]) - } - } - - const onDragEnd = (event: DragEndEvent) => { - const { active, over } = event - if (!over || active.id === over.id) return - - onSortingChange((current) => { - const oldIndex = current.findIndex((sort) => sort.id === active.id) - const newIndex = current.findIndex((sort) => sort.id === over.id) - return oldIndex >= 0 && newIndex >= 0 - ? arrayMove(current, oldIndex, newIndex) - : current - }) - } - - return ( - - - - -
- {sorting.length ? 'Sort by' : 'No sorting applied'} -
- - sort.id)} - strategy={verticalListSortingStrategy} - > -
- {sorting.map((sort, index) => ( - -
- - updateSort(index, { id: value })} - /> - - updateSort(index, { desc: value === 'desc' }) - } - /> - -
-
- ))} -
-
-
-
- - -
-
-
-
- ) -} - -function FilterValueInput({ - column, - filter, - onFilterUpdate, -}: { - column: AppColumn - filter: ExtendedColumnFilter - onFilterUpdate: ( - filterId: string, - patch: Partial, - ) => void -}) { - if (!filter.filterId) return null - const variant = column.columnDef.meta?.variant ?? 'text' - const operator = filter.operator ?? 'includesString' - const disabled = operator === 'isEmpty' || operator === 'isNotEmpty' - - if (disabled) - return
No value required
- - if (variant === 'select') { - const options = column.columnDef.meta?.options ?? [] - return ( - onFilterUpdate(filter.filterId!, { value })} - /> - ) - } - - if (variant === 'multi-select') { - const options = column.columnDef.meta?.options ?? [] - const values = Array.isArray(filter.value) - ? filter.value.map(String) - : typeof filter.value === 'string' && filter.value - ? [filter.value] - : [] - return ( - - ) - } - - if (variant === 'date') { - if (operator === 'inRange') { - const value = Array.isArray(filter.value) ? filter.value : [] - return ( -
- - onFilterUpdate(filter.filterId!, { - value: [ - event.currentTarget.value - ? new Date(event.currentTarget.value).toISOString() - : undefined, - value[1], - ], - }) - } - /> - - onFilterUpdate(filter.filterId!, { - value: [ - value[0], - event.currentTarget.value - ? new Date(event.currentTarget.value).toISOString() - : undefined, - ], - }) - } - /> -
- ) - } - - return ( - - onFilterUpdate(filter.filterId!, { - value: event.currentTarget.value - ? new Date(event.currentTarget.value).toISOString() - : undefined, - }) - } - /> - ) - } - - if (variant === 'number') { - return ( - - onFilterUpdate(filter.filterId!, { - value: - event.currentTarget.value === '' - ? '' - : Number(event.currentTarget.value), - }) - } - /> - ) - } - - return ( - - onFilterUpdate(filter.filterId!, { value: event.currentTarget.value }) - } - /> - ) -} - -function FilterListPopover({ - table, - columnFilters, - onColumnFiltersChange, -}: { - table: AppTable - columnFilters: Array - onColumnFiltersChange: React.Dispatch< - React.SetStateAction> - > -}) { - const filterableColumns = table - .getAllColumns() - .filter((column) => column.getCanFilter()) - const fieldOptions = filterableColumns.map((column) => ({ - value: column.id, - label: column.columnDef.meta?.label ?? column.id, - })) - - const updateFilter = ( - filterId: string, - patch: Partial, - ) => { - onColumnFiltersChange((current) => - current.map((filter) => - filter.filterId === filterId ? { ...filter, ...patch } : filter, - ), - ) - } - - const addFilter = () => { - const [column] = filterableColumns - if (!column) return - onColumnFiltersChange((current) => [ - ...current, - { - id: column.id, - filterId: crypto.randomUUID(), - value: '', - operator: 'includesString', - joinOperator: current[0]?.joinOperator ?? 'and', - }, - ]) - } - - return ( - - - - -
Filters
- {columnFilters.map((filter, index) => { - const column = table.getColumn(filter.id) - if (!column || !filter.filterId) return null - const variant = column.columnDef.meta?.variant ?? 'text' - const operators = getFilterOperators(variant) - return ( -
- {index === 0 ? ( -
Where
- ) : index === 1 ? ( - - onColumnFiltersChange((current) => - current.map((item) => ({ - ...item, - joinOperator: joinOperator as 'and' | 'or', - })), - ) - } - /> - ) : ( -
- {filter.joinOperator ?? 'and'} -
- )} - { - const nextColumn = table.getColumn(nextColumnId) - if (nextColumn) { - updateFilter(filter.filterId!, { - id: nextColumn.id, - operator: getFilterOperators( - nextColumn.columnDef.meta?.variant ?? 'text', - )[0].value, - value: '', - }) - } - }} - /> - ({ - value: operator.value, - label: operator.label, - }))} - onChange={(operator) => - updateFilter(filter.filterId!, { - operator: operator as ExtendedColumnFilter['operator'], - value: '', - }) - } - /> - - -
- ) - })} -
- - -
-
-
-
- ) -} - -function Pagination({ table }: { table: AppTable }) { - const pageIndex = table.state.pagination.pageIndex - const pageSize = table.state.pagination.pageSize - const pageItems = getPageItems(pageIndex, table.getPageCount()) - - return ( -
-
- {table.getFilteredSelectedRowModel().rows.length.toLocaleString()} of{' '} - {table.getFilteredRowModel().rows.length.toLocaleString()} row(s) - selected. -
-
- Rows per page: - ({ - value, - label: value, - }))} - onChange={(value) => { - table.setPageSize(Number(value)) - table.setPageIndex(0) - }} - /> - - - - - table.previousPage()} - > - - Prev - - - {pageItems.map((page, index) => - page === 'ellipsis' ? ( - - - - ) : ( - - table.setPageIndex(page - 1)} - > - {page} - - - ), - )} - - table.nextPage()} - > - Next - - - - - - -
-
- ) -} - function ModeSwitch() { const { theme, setTheme } = useTheme('system') @@ -1102,9 +78,11 @@ function ModeSwitch() { isSelected={theme === 'dark'} onChange={(selected) => setTheme(selected ? 'dark' : 'light')} > - - - + + + + + Theme @@ -1142,246 +120,39 @@ function DebouncedTextInput({ } function App() { - const [rowSelection, setRowSelection] = React.useState({}) - const [sorting, setSorting] = React.useState([]) const [columnFilters, setColumnFilters] = React.useState< Array >([]) - const [columnVisibility, setColumnVisibility] = React.useState({}) - const [columnSizing, setColumnSizing] = React.useState({}) const [globalFilter, setGlobalFilter] = React.useState('') - const [columnPinning, setColumnPinning] = React.useState({ - left: ['select'], - right: ['actions'], - }) - const [grouping, setGrouping] = React.useState([]) - const [expanded, setExpanded] = React.useState({}) const [data, setData] = React.useState(() => makeData(1_000)) - const columns = React.useMemo( - () => - columnHelper.columns([ - columnHelper.display({ - id: 'select', - header: ({ table }) => ( - table.toggleAllPageRowsSelected(selected)} - aria-label="Select all" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(selected)} - aria-label="Select row" - /> - ), - maxSize: 48, - enableSorting: false, - enableHiding: false, - enableResizing: false, - }), - columnHelper.accessor('firstName', { - id: 'firstName', - header: ({ column }) => ( - - ), - cell: (info) => ( - {String(info.getValue())} - ), - meta: { label: 'First Name', variant: 'text' }, - }), - columnHelper.accessor((row) => row.lastName, { - id: 'lastName', - header: ({ column }) => ( - - ), - cell: (info) => ( - {String(info.getValue())} - ), - meta: { label: 'Last Name', variant: 'text' }, - }), - columnHelper.accessor('age', { - id: 'age', - header: ({ column }) => ( - - ), - cell: (info) => ( - {String(info.getValue())} - ), - aggregationFn: 'mean', - aggregatedCell: ({ getValue }) => ( - - Avg: {Math.round(Number(getValue()) * 10) / 10} - - ), - meta: { label: 'Age', variant: 'number' }, - }), - columnHelper.accessor('email', { - id: 'email', - header: ({ column }) => ( - - ), - cell: (info) => ( - {info.cell.getValue()} - ), - meta: { label: 'Email', variant: 'text' }, - }), - columnHelper.accessor('status', { - id: 'status', - header: ({ column }) => ( - - ), - cell: (info) => { - const status = info.getValue() - return status ? : null - }, - aggregatedCell: () => null, - meta: { - label: 'Status', - variant: 'select', - options: statuses.map((status) => ({ - label: toSentenceCase(status), - value: status, - })), - }, - }), - columnHelper.accessor('department', { - id: 'department', - header: ({ column }) => ( - - ), - cell: (info) => { - const department = info.getValue() - return department ? ( - - ) : null - }, - aggregatedCell: () => null, - meta: { - label: 'Department', - variant: 'multi-select', - options: departments.map((department) => ({ - label: toSentenceCase(department), - value: department, - })), - }, - }), - columnHelper.accessor('joinDate', { - id: 'joinDate', - header: ({ column }) => ( - - ), - cell: (info) => formatDate(info.getValue()), - aggregationFn: 'min', - aggregatedCell: ({ getValue }) => { - const earliest = getValue() - return ( - - Earliest: {earliest ? formatDate(earliest) : '-'} - - ) - }, - meta: { label: 'Join Date', variant: 'date' }, - }), - columnHelper.accessor((row) => row.age, { - id: 'progress', - header: ({ column }) => ( - - ), - cell: (info) => { - const value = Math.min(100, Math.max(0, Number(info.getValue()))) - return ( - - - - - - ) - }, - meta: { label: 'Profile Progress', variant: 'number' }, - }), - columnHelper.display({ - id: 'actions', - enableHiding: false, - cell: ({ row }) => , - maxSize: 60, - enableResizing: false, - }), - ]), - [], - ) - - const [columnOrder, setColumnOrder] = React.useState>(() => - columns.map((column) => column.id ?? ''), - ) - - const table = useTable( + const table = useAppTable( { - key: 'kitchen-sink-hero-ui', // needed for devtools - features, + key: 'kitchen-sink-hero-ui', columns, data, - defaultColumn: { - minSize: 60, - maxSize: 800, - filterFn: dynamicFilterFn, - }, - globalFilterFn: 'fuzzy', + debugTable: true, state: { - rowSelection, - sorting, - columnVisibility, - columnOrder, - columnSizing, columnFilters, globalFilter, - columnPinning, - grouping, - expanded, }, - onSortingChange: setSorting, - onColumnVisibilityChange: setColumnVisibility, - onColumnOrderChange: setColumnOrder, - onColumnSizingChange: setColumnSizing, onColumnFiltersChange: setColumnFilters, onGlobalFilterChange: setGlobalFilter, - onColumnPinningChange: setColumnPinning, - onGroupingChange: setGrouping, - onExpandedChange: setExpanded, - getRowId: (row) => row.id, - enableRowSelection: true, - onRowSelectionChange: setRowSelection, - columnResizeMode: 'onChange', - debugTable: true, + initialState: { + columnPinning: { left: ['select'], right: ['actions'] }, + columnOrder: columns.map((c) => c.id ?? ''), + }, }, - (state) => state, // default selector + (state) => state, ) useTanStackTableDevtools(table) - const columnSizeVars = React.useMemo(() => { - const headers = table.getFlatHeaders() - const colSizes: Record = {} - for (const header of headers) { - colSizes[`--header-${header.id}-size`] = header.getSize() - colSizes[`--col-${header.column.id}-size`] = header.column.getSize() - } - return colSizes - }, [table.state.columnSizing]) - const refreshData = () => setData(makeData(1_000)) const stressTest = () => setData(makeData(1_000_000)) return ( - +
@@ -1416,32 +187,18 @@ function App() { onChange={(value) => setGlobalFilter(String(value))} placeholder="Search all columns..." /> - - - + + +
@@ -1449,11 +206,30 @@ function App() { .getHeaderGroups()[0] ?.headers.filter((header) => header.column.getIsVisible()) .map((header) => ( - + id={header.id} + allowsSorting={header.column.getCanSort()} + isRowHeader={header.column.id === 'firstName'} + aria-sort={getAriaSort(header.column.getIsSorted())} + className={cn( + header.column.id === 'select' && 'text-center', + )} + style={{ + width: header.getSize(), + padding: 8, + ...getCommonPinningStyles(header.column), + }} + > + + {(h) => ( +
+ {h.isPlaceholder ? null : } + +
+ )} +
+ ))}
@@ -1474,29 +250,19 @@ function App() { cell.column.id === 'select' && 'text-center', )} style={{ - width: `calc(var(--col-${cell.column.id}-size) * 1px)`, + width: cell.column.getSize(), ...getCommonPinningStyles(cell.column, selected), }} > - {cell.getIsGrouped() ? ( - - ) : ( - - )} + + {(c) => + c.getIsGrouped() ? ( + + ) : ( + + ) + } + ))} @@ -1505,56 +271,11 @@ function App() {
- +
- - ) -} - -function ResizableHeaderCell({ - header, - table, -}: { - header: Header - table: { - FlexRender: React.ComponentType<{ - header: Header - }> - } -}) { - const sorting = React.useContext(SortingContext) - const sortDirection = getSortDirection(sorting, header.column.id) - - return ( - -
- {header.isPlaceholder ? null : } - {header.column.getCanResize() ? ( -
header.column.resetSize()} - onMouseDown={header.getResizeHandler()} - onTouchStart={header.getResizeHandler()} - className={cn( - 'absolute right-[-6px] top-0 h-full w-1.5 cursor-col-resize touch-none', - header.column.getIsResizing() && 'bg-primary', - )} - /> - ) : null} -
- + ) } diff --git a/examples/react/kitchen-sink-mantine/src/columns.tsx b/examples/react/kitchen-sink-mantine/src/columns.tsx new file mode 100644 index 0000000000..a15c7728da --- /dev/null +++ b/examples/react/kitchen-sink-mantine/src/columns.tsx @@ -0,0 +1,106 @@ +import type { Person } from '@/lib/make-data' +import { createAppColumnHelper } from '@/hooks/table' +import { + AgeAggregatedCell, + JoinDateAggregatedCell, +} from '@/components/data-table/cell-components' +import { toSentenceCase } from '@/components/data-table/shared' +import { departments, statuses } from '@/lib/make-data' + +const columnHelper = createAppColumnHelper() + +export const columns = columnHelper.columns([ + columnHelper.display({ + id: 'select', + header: ({ header }) => , + cell: ({ cell }) => , + maxSize: 48, + enableSorting: false, + enableHiding: false, + enableResizing: false, + }), + columnHelper.accessor('firstName', { + id: 'firstName', + header: ({ header }) => , + cell: ({ cell }) => , + meta: { + label: 'First Name', + variant: 'text', + }, + }), + columnHelper.accessor((row) => row.lastName, { + id: 'lastName', + header: ({ header }) => , + cell: ({ cell }) => , + meta: { + label: 'Last Name', + variant: 'text', + }, + }), + columnHelper.accessor('age', { + id: 'age', + header: ({ header }) => , + cell: ({ cell }) => , + aggregationFn: 'mean', + aggregatedCell: () => , + meta: { + label: 'Age', + variant: 'number', + }, + }), + columnHelper.accessor('email', { + id: 'email', + header: ({ header }) => , + cell: ({ cell }) => , + meta: { + label: 'Email', + variant: 'text', + }, + }), + columnHelper.accessor('status', { + id: 'status', + header: ({ header }) => , + cell: ({ cell }) => , + aggregatedCell: () => null, + meta: { + label: 'Status', + variant: 'select', + options: statuses.map((status) => ({ + label: toSentenceCase(status), + value: status, + })), + }, + }), + columnHelper.accessor('department', { + id: 'department', + header: ({ header }) => , + cell: ({ cell }) => , + aggregatedCell: () => null, + meta: { + label: 'Department', + variant: 'multi-select', + options: departments.map((department) => ({ + label: toSentenceCase(department), + value: department, + })), + }, + }), + columnHelper.accessor('joinDate', { + id: 'joinDate', + header: ({ header }) => , + cell: ({ cell }) => , + aggregationFn: 'min', + aggregatedCell: () => , + meta: { + label: 'Join Date', + variant: 'date', + }, + }), + columnHelper.display({ + id: 'actions', + enableHiding: false, + cell: ({ cell }) => , + maxSize: 44, + enableResizing: false, + }), +]) diff --git a/examples/react/kitchen-sink-mantine/src/components/data-table/cell-components.tsx b/examples/react/kitchen-sink-mantine/src/components/data-table/cell-components.tsx new file mode 100644 index 0000000000..e5ce0438fb --- /dev/null +++ b/examples/react/kitchen-sink-mantine/src/components/data-table/cell-components.tsx @@ -0,0 +1,244 @@ +'use client' + +import { + ActionIcon, + Badge, + Box, + Button, + Checkbox, + Menu, + Text, +} from '@mantine/core' +import { + IconBriefcase, + IconBuildingStore, + IconCheck, + IconChevronDown, + IconChevronRight, + IconCode, + IconCreditCard, + IconDotsVertical, + IconUsersGroup, +} from '@tabler/icons-react' +import { Subscribe } from '@tanstack/react-table' +import type { Person } from '@/lib/make-data' +import { useCellContext, useTableContext } from '@/hooks/table' +import { formatDate, toSentenceCase } from '@/components/data-table/shared' + +export function SelectCell(): React.ReactNode { + const cell = useCellContext() + const table = useTableContext() + const row = cell.row + + return ( + + {() => ( + row.toggleSelected(event.currentTarget.checked)} + aria-label="Select row" + /> + )} + + ) +} + +export function TextCell(): React.ReactNode { + const cell = useCellContext() + return ( + + {String(cell.getValue())} + + ) +} + +export function AgeCell(): React.ReactNode { + const cell = useCellContext() + return {String(cell.getValue())} +} + +function DepartmentIcon({ department }: { department: Person['department'] }) { + const icons: Record = { + engineering: , + marketing: , + sales: , + hr: , + finance: , + } + + return icons[department] +} + +function DepartmentPill({ department }: { department: Person['department'] }) { + return ( + + + + + + {toSentenceCase(department)} + + + ) +} + +function StatusBadge({ status }: { status: Person['status'] }) { + const color: Record = { + active: 'green', + inactive: 'red', + pending: 'yellow', + } + + return ( + } + > + {toSentenceCase(status)} + + ) +} + +export function StatusCell(): React.ReactNode { + const cell = useCellContext() + const status = cell.getValue() + if (!status) return null + + return +} + +export function DepartmentCell(): React.ReactNode { + const cell = useCellContext() + const department = cell.getValue() + if (!department) return null + + return +} + +export function DateCell(): React.ReactNode { + const cell = useCellContext() + return <>{formatDate(cell.getValue())} +} + +export function GroupedCell(): React.ReactNode { + const cell = useCellContext() + const table = useTableContext() + const row = cell.row + + return ( + + {() => ( + + )} + + ) +} + +export function ActionsCell(): React.ReactNode { + const cell = useCellContext() + const person = cell.row.original as Person + + return ( + + + + + + + + { + void navigator.clipboard.writeText(person.id) + }} + > + Copy ID + + + View details + View profile + + + ) +} + +export function AgeAggregatedCell(): React.ReactNode { + const cell = useCellContext() + return ( + + Avg: {Math.round(Number(cell.getValue()) * 10) / 10} + + ) +} + +export function JoinDateAggregatedCell(): React.ReactNode { + const cell = useCellContext() + const earliest = cell.getValue() + return ( + + Earliest: {earliest ? formatDate(earliest) : '-'} + + ) +} diff --git a/examples/react/kitchen-sink-mantine/src/components/data-table/data-table-column-header.tsx b/examples/react/kitchen-sink-mantine/src/components/data-table/data-table-column-header.tsx new file mode 100644 index 0000000000..5dee2570ea --- /dev/null +++ b/examples/react/kitchen-sink-mantine/src/components/data-table/data-table-column-header.tsx @@ -0,0 +1,152 @@ +'use client' + +import { ActionIcon, Group, Menu, Text, UnstyledButton } from '@mantine/core' +import { + IconArrowDown, + IconArrowUp, + IconCategory, + IconChevronDown, + IconEyeOff, + IconPinned, +} from '@tabler/icons-react' +import { Subscribe } from '@tanstack/react-table' +import { useHeaderContext, useTableContext } from '@/hooks/table' +import { SortIcon } from '@/components/data-table/shared' + +export function ColumnHeader({ title }: { title?: string }): React.ReactNode { + const header = useHeaderContext() + const table = useTableContext() + const column = header.column + + const displayTitle = column.columnDef.meta?.label ?? title ?? column.id + + const canSort = column.getCanSort() + const canHide = column.getCanHide() + const canPin = column.getCanPin() + const canGroup = column.getCanGroup() + + if (!canSort && !canHide && !canPin && !canGroup) { + return {displayTitle} + } + + return ( + ({ + sorting: s.sorting, + grouping: s.grouping, + columnPinning: s.columnPinning, + })} + > + {() => { + const sorted = canSort ? column.getIsSorted() : false + const pinned = canPin ? column.getIsPinned() : false + const grouped = canGroup ? column.getIsGrouped() : false + + return ( + + {canSort ? ( + + + + {displayTitle} + + + + + ) : ( + {displayTitle} + )} + + + + + + + + {canSort ? ( + <> + } + onClick={() => column.toggleSorting(false)} + > + Asc + + } + onClick={() => column.toggleSorting(true)} + > + Desc + + + ) : null} + {canGroup ? ( + } + onClick={column.getToggleGroupingHandler()} + > + {grouped ? 'Ungroup' : 'Group by'} + + ) : null} + {canPin ? ( + <> + + } + onClick={() => column.pin('left')} + > + Pin left + + } + onClick={() => column.pin('right')} + > + Pin right + + {pinned ? ( + } + onClick={() => column.pin(false)} + > + Unpin + + ) : null} + + ) : null} + {canHide ? ( + <> + + } + onClick={() => column.toggleVisibility(false)} + > + Hide + + + ) : null} + + + + ) + }} + + ) +} diff --git a/examples/react/kitchen-sink-mantine/src/components/data-table/data-table-filter-list.tsx b/examples/react/kitchen-sink-mantine/src/components/data-table/data-table-filter-list.tsx new file mode 100644 index 0000000000..2020e78f2e --- /dev/null +++ b/examples/react/kitchen-sink-mantine/src/components/data-table/data-table-filter-list.tsx @@ -0,0 +1,343 @@ +'use client' + +import * as React from 'react' +import { + ActionIcon, + Badge, + Box, + Button, + Group, + MultiSelect, + Popover, + Select, + Stack, + Text, + TextInput, +} from '@mantine/core' +import { IconFilter, IconTrash } from '@tabler/icons-react' +import type { Column, RowData } from '@tanstack/react-table' +import type { ExtendedColumnFilter } from '@/types' +import type { features } from '@/hooks/features' + +type AppColumn = Column +import { useTableContext } from '@/hooks/table' +import { getFilterOperators } from '@/lib/data-table' +import { toDateInputValue } from '@/components/data-table/shared' + +function FilterValueInput({ + column, + filter, + onFilterUpdate, +}: { + column: Column + filter: ExtendedColumnFilter + onFilterUpdate: ( + filterId: string, + patch: Partial, + ) => void +}): React.ReactNode { + if (!filter.filterId) return null + const variant = column.columnDef.meta?.variant ?? 'text' + const operator = filter.operator ?? 'includesString' + const disabled = operator === 'isEmpty' || operator === 'isNotEmpty' + + if (disabled) { + return No value required + } + + if (variant === 'select') { + const options = column.columnDef.meta?.options ?? [] + return ( + { + if (!joinOperator) return + const updatedFilters = columnFilters.map((item) => ({ + ...item, + joinOperator, + })) + setColumnFilters(updatedFilters) + }} + w={90} + /> + ) : ( + + {filter.joinOperator ?? 'and'} + + )} + ({ + value: operator.value, + label: operator.label, + }))} + value={filter.operator ?? operators[0].value} + onChange={(operator) => { + if (!operator) return + updateFilter(filter.filterId!, { + operator, + value: '', + }) + }} + w={180} + /> + + + + { + const newFilters = columnFilters.filter( + (item) => item.filterId !== filter.filterId, + ) + setColumnFilters(newFilters) + }} + > + + + + ) + })} + + + + + + + + ) +} diff --git a/examples/react/kitchen-sink-mantine/src/components/data-table/data-table-pagination.tsx b/examples/react/kitchen-sink-mantine/src/components/data-table/data-table-pagination.tsx new file mode 100644 index 0000000000..32a7d72cb2 --- /dev/null +++ b/examples/react/kitchen-sink-mantine/src/components/data-table/data-table-pagination.tsx @@ -0,0 +1,85 @@ +'use client' + +import { + ActionIcon, + Group, + Pagination as MantinePagination, + Select, + Text, +} from '@mantine/core' +import { + IconChevronLeft, + IconChevronRight, + IconChevronsLeft, + IconChevronsRight, +} from '@tabler/icons-react' +import { useTableContext } from '@/hooks/table' + +export function DataTablePagination(): React.ReactNode { + const table = useTableContext() + const pageIndex = table.state.pagination.pageIndex + const pageSize = table.state.pagination.pageSize + + return ( + + + {table.getFilteredSelectedRowModel().rows.length.toLocaleString()} of{' '} + {table.getFilteredRowModel().rows.length.toLocaleString()} row(s) + selected. + + + Rows per page: + { + if (value) updateSort(index, { id: value }) + }} + style={{ flex: 1 }} + /> + { - if (value) updateSort(index, { id: value }) - }} - style={{ flex: 1 }} - /> - onFilterUpdate(filter.filterId!, { value })} - /> - ) - } - - if (variant === 'multi-select') { - const options = column.columnDef.meta?.options ?? [] - return ( - onFilterUpdate(filter.filterId!, { value })} - /> - ) - } - - if (variant === 'date') { - if (operator === 'inRange') { - const value = Array.isArray(filter.value) ? filter.value : [] - return ( - - - onFilterUpdate(filter.filterId!, { - value: [ - event.currentTarget.value - ? new Date(event.currentTarget.value).toISOString() - : undefined, - value[1], - ], - }) - } - /> - - onFilterUpdate(filter.filterId!, { - value: [ - value[0], - event.currentTarget.value - ? new Date(event.currentTarget.value).toISOString() - : undefined, - ], - }) - } - /> - - ) - } - - return ( - - onFilterUpdate(filter.filterId!, { - value: event.currentTarget.value - ? new Date(event.currentTarget.value).toISOString() - : undefined, - }) - } - /> - ) - } - - if (variant === 'number') { - return ( - - onFilterUpdate(filter.filterId!, { - value: - event.currentTarget.value === '' - ? '' - : Number(event.currentTarget.value), - }) - } - /> - ) - } - - return ( - - onFilterUpdate(filter.filterId!, { value: event.currentTarget.value }) - } - /> - ) -} - -function FilterListPopover({ - table, - columnFilters, - onColumnFiltersChange, -}: { - table: AppTable - columnFilters: Array - onColumnFiltersChange: React.Dispatch< - React.SetStateAction> - > -}) { - const [opened, setOpened] = React.useState(false) - const filterableColumns = table - .getAllColumns() - .filter((column) => column.getCanFilter()) - const fieldOptions = filterableColumns.map((column) => ({ - value: column.id, - label: column.columnDef.meta?.label ?? column.id, - })) - - const updateFilter = ( - filterId: string, - patch: Partial, - ) => { - onColumnFiltersChange((current) => - current.map((filter) => - filter.filterId === filterId ? { ...filter, ...patch } : filter, - ), - ) - } - - const addFilter = () => { - if (filterableColumns.length === 0) return - const [column] = filterableColumns - onColumnFiltersChange((current) => [ - ...current, - { - id: column.id, - filterId: crypto.randomUUID(), - value: '', - operator: 'includesString', - joinOperator: current[0]?.joinOperator ?? 'and', - }, - ]) - } - - return ( - - - - - - - Filters - {columnFilters.map((filter, index) => { - const column = table.getColumn(filter.id) - if (!column || !filter.filterId) return null - const variant = column.columnDef.meta?.variant ?? 'text' - const operators = getFilterOperators(variant) - return ( - - {index === 0 ? ( - - Where - - ) : index === 1 ? ( - { - const nextColumn = nextColumnId - ? table.getColumn(nextColumnId) - : undefined - if (nextColumn) { - updateFilter(filter.filterId!, { - id: nextColumn.id, - operator: getFilterOperators( - nextColumn.columnDef.meta?.variant ?? 'text', - )[0].value, - value: '', - }) - } - }} - w={190} - /> - { - table.setPageSize(Number(value)) - table.setPageIndex(0) - }} - w={90} - /> - table.setPageIndex(0)} - disabled={!table.getCanPreviousPage()} - > - - - table.previousPage()} - disabled={!table.getCanPreviousPage()} - > - - - table.setPageIndex(page - 1)} - withEdges={false} - siblings={1} - boundaries={1} - /> - table.nextPage()} - disabled={!table.getCanNextPage()} - > - - - table.setPageIndex(table.getPageCount() - 1)} - disabled={!table.getCanNextPage()} - > - - - - - ) -} - function ModeMenu() { const { colorScheme, setColorScheme } = useMantineColorScheme() const computedColorScheme = useComputedColorScheme('light') @@ -1216,229 +166,39 @@ function DebouncedTextInput({ } function App() { - const [rowSelection, setRowSelection] = React.useState({}) - const [sorting, setSorting] = React.useState([]) const [columnFilters, setColumnFilters] = React.useState< Array >([]) - const [columnVisibility, setColumnVisibility] = React.useState({}) - const [columnSizing, setColumnSizing] = React.useState({}) const [globalFilter, setGlobalFilter] = React.useState('') - const [columnPinning, setColumnPinning] = React.useState({ - left: ['select'], - right: ['actions'], - }) - const [grouping, setGrouping] = React.useState([]) - const [expanded, setExpanded] = React.useState({}) const [data, setData] = React.useState(() => makeData(1_000)) - const columns = React.useMemo( - () => - columnHelper.columns([ - columnHelper.display({ - id: 'select', - header: ({ table }) => ( - - table.toggleAllPageRowsSelected(event.currentTarget.checked) - } - aria-label="Select all" - /> - ), - cell: ({ row }) => ( - - row.toggleSelected(event.currentTarget.checked) - } - aria-label="Select row" - /> - ), - maxSize: 48, - enableSorting: false, - enableHiding: false, - enableResizing: false, - }), - columnHelper.accessor('firstName', { - id: 'firstName', - header: ({ column }) => ( - - ), - cell: (info) => ( - {String(info.getValue())} - ), - meta: { label: 'First Name', variant: 'text' }, - }), - columnHelper.accessor((row) => row.lastName, { - id: 'lastName', - header: ({ column }) => ( - - ), - cell: (info) => ( - {String(info.getValue())} - ), - meta: { label: 'Last Name', variant: 'text' }, - }), - columnHelper.accessor('age', { - id: 'age', - header: ({ column }) => ( - - ), - cell: (info) => {String(info.getValue())}, - aggregationFn: 'mean', - aggregatedCell: ({ getValue }) => ( - - Avg: {Math.round(Number(getValue()) * 10) / 10} - - ), - meta: { label: 'Age', variant: 'number' }, - }), - columnHelper.accessor('email', { - id: 'email', - header: ({ column }) => ( - - ), - cell: (info) => ( - {info.cell.getValue()} - ), - meta: { label: 'Email', variant: 'text' }, - }), - columnHelper.accessor('status', { - id: 'status', - header: ({ column }) => ( - - ), - cell: (info) => { - const status = info.getValue() - return status ? : null - }, - aggregatedCell: () => null, - meta: { - label: 'Status', - variant: 'select', - options: statuses.map((status) => ({ - label: toSentenceCase(status), - value: status, - })), - }, - }), - columnHelper.accessor('department', { - id: 'department', - header: ({ column }) => ( - - ), - cell: (info) => { - const department = info.getValue() - return department ? ( - - ) : null - }, - aggregatedCell: () => null, - meta: { - label: 'Department', - variant: 'multi-select', - options: departments.map((department) => ({ - label: toSentenceCase(department), - value: department, - })), - }, - }), - columnHelper.accessor('joinDate', { - id: 'joinDate', - header: ({ column }) => ( - - ), - cell: (info) => formatDate(info.getValue()), - aggregationFn: 'min', - aggregatedCell: ({ getValue }) => { - const earliest = getValue() - return ( - - Earliest: {earliest ? formatDate(earliest) : '-'} - - ) - }, - meta: { label: 'Join Date', variant: 'date' }, - }), - columnHelper.display({ - id: 'actions', - enableHiding: false, - cell: ({ row }) => , - maxSize: 44, - enableResizing: false, - }), - ]), - [], - ) - - const [columnOrder, setColumnOrder] = React.useState>(() => - columns.map((column) => column.id ?? ''), - ) + const refreshData = () => setData(makeData(1_000)) + const stressTest = () => setData(makeData(1_000_000)) - const table = useTable( + const table = useAppTable( { - key: 'kitchen-sink-mantine', // needed for devtools - features, + key: 'kitchen-sink-mantine', columns, data, - defaultColumn: { - minSize: 60, - maxSize: 800, - filterFn: dynamicFilterFn, - }, - globalFilterFn: 'fuzzy', + debugTable: true, state: { - rowSelection, - sorting, - columnVisibility, - columnOrder, - columnSizing, columnFilters, globalFilter, - columnPinning, - grouping, - expanded, }, - onSortingChange: setSorting, - onColumnVisibilityChange: setColumnVisibility, - onColumnOrderChange: setColumnOrder, - onColumnSizingChange: setColumnSizing, onColumnFiltersChange: setColumnFilters, onGlobalFilterChange: setGlobalFilter, - onColumnPinningChange: setColumnPinning, - onGroupingChange: setGrouping, - onExpandedChange: setExpanded, - getRowId: (row) => row.id, - enableRowSelection: true, - onRowSelectionChange: setRowSelection, - columnResizeMode: 'onChange', - debugTable: true, + initialState: { + columnPinning: { left: ['select'], right: ['actions'] }, + columnOrder: columns.map((c) => c.id ?? ''), + }, }, - (state) => state, // default selector + (state) => state, ) useTanStackTableDevtools(table) - const columnSizeVars = React.useMemo(() => { - const headers = table.getFlatHeaders() - const colSizes: Record = {} - for (const header of headers) { - colSizes[`--header-${header.id}-size`] = header.getSize() - colSizes[`--col-${header.column.id}-size`] = header.column.getSize() - } - return colSizes - }, [table.state.columnSizing]) - - const refreshData = () => setData(makeData(1_000)) - const stressTest = () => setData(makeData(1_000_000)) - return ( - + @@ -1473,25 +233,13 @@ function App() { leftSection={} w={{ base: '100%', md: 360 }} /> - - - + + + - + - - {table.getVisibleLeafColumns().map((column) => ( - + {table + .getHeaderGroups() + .map((headerGroup: AppHeaderGroup) => ( + + {headerGroup.headers + .filter((header: AppHeader) => + header.column.getIsVisible(), + ) + .map((header: AppHeader) => ( + + {(h) => ( + + + {h.isPlaceholder ? null : } + + + + )} + + ))} + + ))} + + + {table.getRowModel().rows.map((row: AppRow) => ( + - ))} - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers - .filter((header) => header.column.getIsVisible()) - .map((header) => ( - - ))} + > + {row.getVisibleCells().map((cell: AppCell) => ( + + {(c) => ( + + {c.getIsGrouped() ? ( + + ) : ( + + )} + + )} + + ))} ))} - - - {table.getRowModel().rows.map((row) => { - const selected = row.getIsSelected() - return ( - - {row.getVisibleCells().map((cell) => ( - - {cell.getIsGrouped() ? ( - - ) : cell.column.id === 'progress' ? ( - - - {String(cell.getValue())}% - - - - ) : ( - - )} - - ))} - - ) - })} - + - - ) -} - -function ResizableHeaderCell({ - header, - table, -}: { - header: Header - table: { - FlexRender: React.ComponentType<{ - header: Header - }> - } -}) { - const sorting = React.useContext(SortingContext) - const sortDirection = getSortDirection(sorting, header.column.id) - - return ( - - - {header.isPlaceholder ? null : } - {header.column.getCanResize() ? ( - header.column.resetSize()} - onMouseDown={header.getResizeHandler()} - onTouchStart={header.getResizeHandler()} - style={{ - position: 'absolute', - top: 0, - right: -6, - width: 6, - height: '100%', - cursor: 'col-resize', - touchAction: 'none', - background: header.column.getIsResizing() - ? 'var(--mantine-primary-color-filled)' - : 'transparent', - }} - /> - ) : null} - - - ) -} - -function Root() { - return ( - - - - + ) } @@ -1674,6 +366,9 @@ if (!rootElement) throw new Error('Failed to find the root element') ReactDOM.createRoot(rootElement).render( - + + + + , ) diff --git a/examples/react/kitchen-sink-material-ui/src/columns.tsx b/examples/react/kitchen-sink-material-ui/src/columns.tsx new file mode 100644 index 0000000000..30cb826199 --- /dev/null +++ b/examples/react/kitchen-sink-material-ui/src/columns.tsx @@ -0,0 +1,107 @@ +import type { Person } from '@/lib/make-data' +import { createAppColumnHelper } from '@/hooks/table' +import { + AgeAggregatedCell, + JoinDateAggregatedCell, +} from '@/components/data-table/cell-components' +import { toSentenceCase } from '@/components/data-table/shared' +import { departments, statuses } from '@/lib/make-data' + +const columnHelper = createAppColumnHelper() + +export const columns = columnHelper.columns([ + columnHelper.display({ + id: 'select', + header: ({ header }) => , + cell: ({ cell }) => , + maxSize: 48, + enableSorting: false, + enableHiding: false, + enableResizing: false, + }), + columnHelper.accessor('firstName', { + id: 'firstName', + header: ({ header }) => , + cell: ({ cell }) => , + meta: { + label: 'First Name', + variant: 'text', + }, + }), + columnHelper.accessor((row) => row.lastName, { + id: 'lastName', + header: ({ header }) => , + cell: ({ cell }) => , + meta: { + label: 'Last Name', + variant: 'text', + }, + }), + columnHelper.accessor('age', { + id: 'age', + header: ({ header }) => , + cell: ({ cell }) => , + aggregationFn: 'mean', + aggregatedCell: () => , + meta: { + label: 'Age', + variant: 'number', + }, + }), + columnHelper.accessor('email', { + id: 'email', + header: ({ header }) => , + cell: ({ cell }) => , + size: 200, + meta: { + label: 'Email', + variant: 'text', + }, + }), + columnHelper.accessor('status', { + id: 'status', + header: ({ header }) => , + cell: ({ cell }) => , + aggregatedCell: () => null, + meta: { + label: 'Status', + variant: 'select', + options: statuses.map((status) => ({ + label: toSentenceCase(status), + value: status, + })), + }, + }), + columnHelper.accessor('department', { + id: 'department', + header: ({ header }) => , + cell: ({ cell }) => , + aggregatedCell: () => null, + meta: { + label: 'Department', + variant: 'multi-select', + options: departments.map((department) => ({ + label: toSentenceCase(department), + value: department, + })), + }, + }), + columnHelper.accessor('joinDate', { + id: 'joinDate', + header: ({ header }) => , + cell: ({ cell }) => , + aggregationFn: 'min', + aggregatedCell: () => , + meta: { + label: 'Join Date', + variant: 'date', + }, + }), + columnHelper.display({ + id: 'actions', + enableHiding: false, + cell: ({ cell }) => , + maxSize: 44, + enableResizing: false, + }), +]) diff --git a/examples/react/kitchen-sink-material-ui/src/components/data-table/cell-components.tsx b/examples/react/kitchen-sink-material-ui/src/components/data-table/cell-components.tsx new file mode 100644 index 0000000000..2b7dad35f3 --- /dev/null +++ b/examples/react/kitchen-sink-material-ui/src/components/data-table/cell-components.tsx @@ -0,0 +1,249 @@ +import * as React from 'react' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Checkbox from '@mui/material/Checkbox' +import Chip from '@mui/material/Chip' +import Divider from '@mui/material/Divider' +import IconButton from '@mui/material/IconButton' +import ListItemText from '@mui/material/ListItemText' +import Menu from '@mui/material/Menu' +import MenuItem from '@mui/material/MenuItem' +import Typography from '@mui/material/Typography' +import CheckIcon from '@mui/icons-material/Check' +import CodeIcon from '@mui/icons-material/Code' +import CreditCardIcon from '@mui/icons-material/CreditCard' +import ExpandLessIcon from '@mui/icons-material/ExpandLess' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import GroupIcon from '@mui/icons-material/Group' +import MoreVertIcon from '@mui/icons-material/MoreVert' +import ShoppingCartIcon from '@mui/icons-material/ShoppingCart' +import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt' +import { Subscribe } from '@tanstack/react-table' +import type { Person } from '@/lib/make-data' +import { useCellContext, useTableContext } from '@/hooks/table' +import { + EllipsisText, + formatDate, + toSentenceCase, +} from '@/components/data-table/shared' + +function DepartmentIcon({ + department, +}: { + department: Person['department'] +}): React.ReactNode { + const icons: Record = { + engineering: , + marketing: , + sales: , + hr: , + finance: , + } + + return icons[department] +} + +function DepartmentChip({ + department, +}: { + department: Person['department'] +}): React.ReactNode { + return ( + + + + + + {toSentenceCase(department)} + + + ) +} + +function StatusChip({ status }: { status: Person['status'] }): React.ReactNode { + const color: Record = { + active: 'success', + inactive: 'error', + pending: 'warning', + } + + return ( + } + label={toSentenceCase(status)} + color={color[status]} + variant="outlined" + size="small" + /> + ) +} + +export function SelectCell(): React.ReactNode { + const cell = useCellContext() + const table = useTableContext() + const row = cell.row + + return ( + + {() => ( + row.toggleSelected(checked)} + slotProps={{ input: { 'aria-label': 'Select row' } }} + size="small" + /> + )} + + ) +} + +export function TextCell(): React.ReactNode { + const cell = useCellContext() + return {String(cell.getValue())} +} + +export function AgeCell(): React.ReactNode { + const cell = useCellContext() + return {String(cell.getValue())} +} + +export function StatusCell(): React.ReactNode { + const cell = useCellContext() + const status = cell.getValue() + if (!status) return null + return +} + +export function DepartmentCell(): React.ReactNode { + const cell = useCellContext() + const department = cell.getValue() + if (!department) return null + return +} + +export function DateCell(): React.ReactNode { + const cell = useCellContext() + return <>{formatDate(cell.getValue())} +} + +export function GroupedCell(): React.ReactNode { + const cell = useCellContext() + const table = useTableContext() + const row = cell.row + + return ( + + {() => ( + + )} + + ) +} + +export function ActionsCell(): React.ReactNode { + const cell = useCellContext() + const person = cell.row.original as Person + const [anchorEl, setAnchorEl] = React.useState(null) + const open = Boolean(anchorEl) + + return ( + <> + setAnchorEl(event.currentTarget)} + > + + + setAnchorEl(null)} + > + { + void navigator.clipboard.writeText(person.id) + setAnchorEl(null) + }} + > + Copy ID + + + setAnchorEl(null)}>View details + setAnchorEl(null)}>View profile + + + ) +} + +export function AgeAggregatedCell(): React.ReactNode { + const cell = useCellContext() + return ( + + Avg: {Math.round(Number(cell.getValue()) * 10) / 10} + + ) +} + +export function JoinDateAggregatedCell(): React.ReactNode { + const cell = useCellContext() + const earliest = cell.getValue() + return ( + + Earliest: {earliest ? formatDate(earliest) : '—'} + + ) +} diff --git a/examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-column-header.tsx b/examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-column-header.tsx new file mode 100644 index 0000000000..483e27975a --- /dev/null +++ b/examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-column-header.tsx @@ -0,0 +1,255 @@ +import * as React from 'react' +import Box from '@mui/material/Box' +import Divider from '@mui/material/Divider' +import IconButton from '@mui/material/IconButton' +import ListItemIcon from '@mui/material/ListItemIcon' +import ListItemText from '@mui/material/ListItemText' +import Menu from '@mui/material/Menu' +import MenuItem from '@mui/material/MenuItem' +import Stack from '@mui/material/Stack' +import TableSortLabel from '@mui/material/TableSortLabel' +import Typography from '@mui/material/Typography' +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward' +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown' +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward' +import GroupIcon from '@mui/icons-material/Group' +import PushPinIcon from '@mui/icons-material/PushPin' +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff' +import { Subscribe } from '@tanstack/react-table' +import { useHeaderContext, useTableContext } from '@/hooks/table' + +export function ColumnHeader({ title }: { title?: string }): React.ReactNode { + const header = useHeaderContext() + const table = useTableContext() + const column = header.column + + const displayTitle = column.columnDef.meta?.label ?? title ?? column.id + + const canSort = column.getCanSort() + const canHide = column.getCanHide() + const canPin = column.getCanPin() + const canGroup = column.getCanGroup() + + if (!canSort && !canHide && !canPin && !canGroup) { + return {displayTitle} + } + + return ( + ({ + sorting: s.sorting, + grouping: s.grouping, + columnPinning: s.columnPinning, + })} + > + {() => { + const sorted = canSort ? column.getIsSorted() : false + const direction = + sorted === 'asc' ? 'asc' : sorted === 'desc' ? 'desc' : undefined + const isSorted = !!direction + const pinned = canPin ? column.getIsPinned() : false + const grouped = canGroup ? column.getIsGrouped() : false + + return ( + column.toggleSorting(false)} + onSortDesc={() => column.toggleSorting(true)} + onToggleGrouping={column.getToggleGroupingHandler()} + onPinLeft={() => column.pin('left')} + onPinRight={() => column.pin('right')} + onUnpin={() => column.pin(false)} + onHide={() => column.toggleVisibility(false)} + /> + ) + }} + + ) +} + +function ColumnHeaderMenu({ + displayTitle, + canSort, + canHide, + canPin, + canGroup, + direction, + isSorted, + pinned, + grouped, + onToggleSorting, + onSortAsc, + onSortDesc, + onToggleGrouping, + onPinLeft, + onPinRight, + onUnpin, + onHide, +}: { + displayTitle: string + canSort: boolean + canHide: boolean + canPin: boolean + canGroup: boolean + direction: 'asc' | 'desc' | undefined + isSorted: boolean + pinned: false | 'left' | 'right' + grouped: boolean + onToggleSorting: ((event: unknown) => void) | undefined + onSortAsc: () => void + onSortDesc: () => void + onToggleGrouping: () => void + onPinLeft: () => void + onPinRight: () => void + onUnpin: () => void + onHide: () => void +}): React.ReactNode { + const [anchorEl, setAnchorEl] = React.useState(null) + + return ( + + {canSort ? ( + + {displayTitle} + + ) : ( + {displayTitle} + )} + setAnchorEl(event.currentTarget)} + > + + + setAnchorEl(null)} + > + {canSort && ( + + { + onSortAsc() + setAnchorEl(null) + }} + > + + + + Asc + + { + onSortDesc() + setAnchorEl(null) + }} + > + + + + Desc + + + )} + {canGroup && ( + { + onToggleGrouping() + setAnchorEl(null) + }} + > + + + + {grouped ? 'Ungroup' : 'Group by'} + + )} + {canPin && ( + + + { + onPinLeft() + setAnchorEl(null) + }} + > + + + + Pin left + + { + onPinRight() + setAnchorEl(null) + }} + > + + + + Pin right + + {pinned ? ( + { + onUnpin() + setAnchorEl(null) + }} + > + + + + Unpin + + ) : null} + + )} + {canHide && ( + + + { + onHide() + setAnchorEl(null) + }} + > + + + + Hide + + + )} + + + ) +} diff --git a/examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-filter-list.tsx b/examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-filter-list.tsx new file mode 100644 index 0000000000..8d48f4c691 --- /dev/null +++ b/examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-filter-list.tsx @@ -0,0 +1,341 @@ +import * as React from 'react' +import Autocomplete from '@mui/material/Autocomplete' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Chip from '@mui/material/Chip' +import FormControl from '@mui/material/FormControl' +import IconButton from '@mui/material/IconButton' +import InputLabel from '@mui/material/InputLabel' +import MenuItem from '@mui/material/MenuItem' +import Popover from '@mui/material/Popover' +import Select from '@mui/material/Select' +import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import DeleteIcon from '@mui/icons-material/Delete' +import FilterListIcon from '@mui/icons-material/FilterList' +import type { Column, RowData } from '@tanstack/react-table' +import type { ExtendedColumnFilter } from '@/types' +import type { features } from '@/hooks/features' +import { useTableContext } from '@/hooks/table' +import { getFilterOperators } from '@/lib/data-table' +import { toDateInputValue } from '@/components/data-table/shared' + +function FilterValueInput({ + column, + filter, + onFilterUpdate, +}: { + column: Column + filter: ExtendedColumnFilter + onFilterUpdate: ( + filterId: string, + patch: Partial, + ) => void +}): React.ReactNode { + if (!filter.filterId) return null + const variant = column.columnDef.meta?.variant ?? 'text' + const operator = filter.operator ?? 'includesString' + const disabled = operator === 'isEmpty' || operator === 'isNotEmpty' + + if (disabled) { + return No value required + } + + if (variant === 'select' || variant === 'multi-select') { + const options = column.columnDef.meta?.options ?? [] + const multiple = variant === 'multi-select' + const value = multiple + ? options.filter( + (option) => + Array.isArray(filter.value) && filter.value.includes(option.value), + ) + : (options.find((option) => option.value === filter.value) ?? null) + + return ( + option.label} + onChange={(_, nextValue) => { + onFilterUpdate(filter.filterId!, { + value: Array.isArray(nextValue) + ? nextValue.map((option) => option.value) + : nextValue?.value, + }) + }} + renderInput={(params) => } + /> + ) + } + + if (variant === 'date') { + if (operator === 'inRange') { + const value = Array.isArray(filter.value) ? filter.value : [] + return ( + + + onFilterUpdate(filter.filterId!, { + value: [ + event.target.value + ? new Date(event.target.value).toISOString() + : undefined, + value[1], + ], + }) + } + slotProps={{ inputLabel: { shrink: true } }} + /> + + onFilterUpdate(filter.filterId!, { + value: [ + value[0], + event.target.value + ? new Date(event.target.value).toISOString() + : undefined, + ], + }) + } + slotProps={{ inputLabel: { shrink: true } }} + /> + + ) + } + + return ( + + onFilterUpdate(filter.filterId!, { + value: event.target.value + ? new Date(event.target.value).toISOString() + : undefined, + }) + } + slotProps={{ inputLabel: { shrink: true } }} + /> + ) + } + + if (variant === 'number') { + return ( + + onFilterUpdate(filter.filterId!, { + value: event.target.value === '' ? '' : Number(event.target.value), + }) + } + /> + ) + } + + return ( + + onFilterUpdate(filter.filterId!, { value: event.target.value }) + } + /> + ) +} + +export function DataTableFilterList(): React.ReactNode { + const table = useTableContext() + const columnFilters = table.state.columnFilters as Array + const [anchorEl, setAnchorEl] = React.useState(null) + const filterableColumns = React.useMemo( + () => table.getAllColumns().filter((column) => column.getCanFilter()), + [table], + ) + + // Write through the raw controlled-state handler instead of + // `table.setColumnFilters`: the table API auto-removes filters with empty + // values, but a just-added filter row legitimately starts with `value: ''` + // while the user is still building it. + const setColumnFilters = (filters: Array) => { + table.options.onColumnFiltersChange?.(filters) + } + + const updateFilter = ( + filterId: string, + patch: Partial, + ) => { + const newFilters = columnFilters.map((filter) => + filter.filterId === filterId ? { ...filter, ...patch } : filter, + ) + setColumnFilters(newFilters) + } + + const addFilter = () => { + if (filterableColumns.length === 0) return + const [column] = filterableColumns + setColumnFilters([ + ...columnFilters, + { + id: column.id, + filterId: crypto.randomUUID(), + value: '', + operator: 'includesString', + joinOperator: columnFilters[0]?.joinOperator ?? 'and', + }, + ]) + } + + return ( + <> + + setAnchorEl(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + > + + Filters + {columnFilters.map((filter, index) => { + const column = table.getColumn(filter.id) + if (!column || !filter.filterId) return null + const variant = column.columnDef.meta?.variant ?? 'text' + const operators = getFilterOperators(variant) + return ( + + {index === 0 ? ( + Where + ) : index === 1 ? ( + + + + ) : ( + + {filter.joinOperator ?? 'and'} + + )} + + option.columnDef.meta?.label ?? option.id + } + onChange={(_, nextColumn) => { + if (nextColumn) { + updateFilter(filter.filterId!, { + id: nextColumn.id, + operator: getFilterOperators( + nextColumn.columnDef.meta?.variant ?? 'text', + )[0].value, + value: '', + }) + } + }} + renderInput={(params) => ( + + )} + /> + + Operator + + + + + + + setColumnFilters( + columnFilters.filter( + (item) => item.filterId !== filter.filterId, + ), + ) + } + > + + + + ) + })} + + + + + + + + ) +} diff --git a/examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-pagination.tsx b/examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-pagination.tsx new file mode 100644 index 0000000000..3b93ba9dce --- /dev/null +++ b/examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-pagination.tsx @@ -0,0 +1,38 @@ +import Stack from '@mui/material/Stack' +import TablePagination from '@mui/material/TablePagination' +import Typography from '@mui/material/Typography' +import { useTableContext } from '@/hooks/table' + +export function DataTablePagination(): React.ReactNode { + const table = useTableContext() + + return ( + + + {table.getFilteredSelectedRowModel().rows.length.toLocaleString()} of{' '} + {table.getFilteredRowModel().rows.length.toLocaleString()} row(s) + selected. + + + table.setPageIndex(page)} + onRowsPerPageChange={(event) => { + table.setPageSize(Number(event.target.value)) + table.setPageIndex(0) + }} + /> + + + ) +} diff --git a/examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-sort-list.tsx b/examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-sort-list.tsx new file mode 100644 index 0000000000..4d095c95cb --- /dev/null +++ b/examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-sort-list.tsx @@ -0,0 +1,182 @@ +import * as React from 'react' +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { + SortableContext, + arrayMove, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import Autocomplete from '@mui/material/Autocomplete' +import Button from '@mui/material/Button' +import Chip from '@mui/material/Chip' +import FormControl from '@mui/material/FormControl' +import IconButton from '@mui/material/IconButton' +import InputLabel from '@mui/material/InputLabel' +import MenuItem from '@mui/material/MenuItem' +import Popover from '@mui/material/Popover' +import Select from '@mui/material/Select' +import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import DeleteIcon from '@mui/icons-material/Delete' +import DragIndicatorIcon from '@mui/icons-material/DragIndicator' +import SortIcon from '@mui/icons-material/Sort' +import type { DragEndEvent } from '@dnd-kit/core' +import type { SortingState } from '@tanstack/react-table' +import { useTableContext } from '@/hooks/table' +import { SortableFrame } from '@/components/data-table/shared' + +export function DataTableSortList(): React.ReactNode { + const table = useTableContext() + const sorting = table.state.sorting + const [anchorEl, setAnchorEl] = React.useState(null) + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + ) + const sortableColumns = React.useMemo( + () => table.getAllColumns().filter((column) => column.getCanSort()), + [table], + ) + + const updateSort = (index: number, patch: Partial) => { + const newSorting = sorting.map((sort, sortIndex) => + sortIndex === index ? { ...sort, ...patch } : sort, + ) + table.setSorting(newSorting) + } + + const addSort = () => { + const nextColumn = sortableColumns.find( + (column) => !sorting.some((sort) => sort.id === column.id), + ) + if (nextColumn) { + table.setSorting([...sorting, { id: nextColumn.id, desc: false }]) + } + } + + const onDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + + const oldIndex = sorting.findIndex((sort) => sort.id === active.id) + const newIndex = sorting.findIndex((sort) => sort.id === over.id) + if (oldIndex >= 0 && newIndex >= 0) { + table.setSorting(arrayMove(sorting, oldIndex, newIndex)) + } + } + + return ( + <> + + setAnchorEl(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + > + + + {sorting.length ? 'Sort by' : 'No sorting applied'} + + + sort.id)} + strategy={verticalListSortingStrategy} + > + + {sorting.map((sort, index) => ( + + + + column.id === sort.id, + ) ?? null + } + getOptionLabel={(column) => + column.columnDef.meta?.label ?? column.id + } + onChange={(_, column) => { + if (column) updateSort(index, { id: column.id }) + }} + renderInput={(params) => ( + + )} + /> + + Direction + + + + table.setSorting( + sorting.filter((_, i) => i !== index), + ) + } + > + + + + + ))} + + + + + + + + + + + ) +} diff --git a/examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-view-options.tsx b/examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-view-options.tsx new file mode 100644 index 0000000000..191e5411b0 --- /dev/null +++ b/examples/react/kitchen-sink-material-ui/src/components/data-table/data-table-view-options.tsx @@ -0,0 +1,130 @@ +import * as React from 'react' +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { + SortableContext, + arrayMove, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import Button from '@mui/material/Button' +import Checkbox from '@mui/material/Checkbox' +import List from '@mui/material/List' +import ListItem from '@mui/material/ListItem' +import ListItemButton from '@mui/material/ListItemButton' +import ListItemIcon from '@mui/material/ListItemIcon' +import ListItemText from '@mui/material/ListItemText' +import Popover from '@mui/material/Popover' +import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' +import DragIndicatorIcon from '@mui/icons-material/DragIndicator' +import SettingsIcon from '@mui/icons-material/Settings' +import type { DragEndEvent } from '@dnd-kit/core' +import { useTableContext } from '@/hooks/table' +import { SortableFrame } from '@/components/data-table/shared' + +export function DataTableViewOptions(): React.ReactNode { + const table = useTableContext() + const columnOrder = table.state.columnOrder + const [anchorEl, setAnchorEl] = React.useState(null) + const [query, setQuery] = React.useState('') + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + ) + + const columns = table + .getAllColumns() + .filter((column) => typeof column.accessorFn !== 'undefined') + .sort((a, b) => columnOrder.indexOf(a.id) - columnOrder.indexOf(b.id)) + .filter((column) => + (column.columnDef.meta?.label ?? column.id) + .toLowerCase() + .includes(query.toLowerCase()), + ) + + const onDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + + const oldIndex = columnOrder.indexOf(String(active.id)) + const newIndex = columnOrder.indexOf(String(over.id)) + if (oldIndex >= 0 && newIndex >= 0) { + table.setColumnOrder(arrayMove(columnOrder, oldIndex, newIndex)) + } + } + + return ( + <> + + setAnchorEl(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + > + + setQuery(event.target.value)} + /> + + column.id)} + strategy={verticalListSortingStrategy} + > + + {columns.map((column) => ( + + + } + > + + column.toggleVisibility(!column.getIsVisible()) + } + > + + + + + + + + ))} + + + + + + + ) +} diff --git a/examples/react/kitchen-sink-material-ui/src/components/data-table/header-components.tsx b/examples/react/kitchen-sink-material-ui/src/components/data-table/header-components.tsx new file mode 100644 index 0000000000..27713aaf8d --- /dev/null +++ b/examples/react/kitchen-sink-material-ui/src/components/data-table/header-components.tsx @@ -0,0 +1,57 @@ +import Box from '@mui/material/Box' +import Checkbox from '@mui/material/Checkbox' +import { Subscribe } from '@tanstack/react-table' +import { useHeaderContext, useTableContext } from '@/hooks/table' + +export function SelectAllHeader(): React.ReactNode { + const table = useTableContext() + + return ( + + {() => ( + table.toggleAllPageRowsSelected(checked)} + slotProps={{ input: { 'aria-label': 'Select all' } }} + size="small" + /> + )} + + ) +} + +export function ResizeHandle(): React.ReactNode { + const header = useHeaderContext() + const table = useTableContext() + + if (!header.column.getCanResize()) return null + + return ( + + {() => ( + header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + sx={{ + position: 'absolute', + top: 0, + right: -6, + width: 6, + height: '100%', + cursor: 'col-resize', + touchAction: 'none', + bgcolor: header.column.getIsResizing() + ? 'primary.main' + : 'transparent', + '&:hover': { bgcolor: 'primary.main' }, + }} + /> + )} + + ) +} diff --git a/examples/react/kitchen-sink-material-ui/src/components/data-table/shared.tsx b/examples/react/kitchen-sink-material-ui/src/components/data-table/shared.tsx new file mode 100644 index 0000000000..6f9faaf5ae --- /dev/null +++ b/examples/react/kitchen-sink-material-ui/src/components/data-table/shared.tsx @@ -0,0 +1,81 @@ +import * as React from 'react' +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import Box from '@mui/material/Box' + +export function toSentenceCase(value: string): string { + return value + .replace(/[-_]/g, ' ') + .replace(/\w\S*/g, (word) => word[0].toUpperCase() + word.slice(1)) +} + +export function formatDate(value: string): string { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(new Date(value)) +} + +export function toDateInputValue(value: unknown): string { + if (!value) return '' + const date = new Date(String(value)) + return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10) +} + +export function SortableFrame({ + id, + children, +}: { + id: string + children: React.ReactNode +}): React.ReactNode { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }) + + return ( + + {children} + + ) +} + +export function EllipsisText({ + children, +}: { + children: React.ReactNode +}): React.ReactNode { + return ( + + {children} + + ) +} diff --git a/examples/react/kitchen-sink-material-ui/src/hooks/features.ts b/examples/react/kitchen-sink-material-ui/src/hooks/features.ts new file mode 100644 index 0000000000..730a2273ba --- /dev/null +++ b/examples/react/kitchen-sink-material-ui/src/hooks/features.ts @@ -0,0 +1,72 @@ +import { + aggregationFns, + columnFacetingFeature, + columnFilteringFeature, + columnGroupingFeature, + columnOrderingFeature, + columnPinningFeature, + columnResizingFeature, + columnSizingFeature, + columnVisibilityFeature, + createExpandedRowModel, + createFacetedRowModel, + createFacetedUniqueValues, + createFilteredRowModel, + createGroupedRowModel, + createPaginatedRowModel, + createSortedRowModel, + filterFns, + globalFilteringFeature, + metaHelper, + rowExpandingFeature, + rowPaginationFeature, + rowSelectionFeature, + rowSortingFeature, + sortFns, + tableFeatures, +} from '@tanstack/react-table' +import { rankItem } from '@tanstack/match-sorter-utils' + +export interface MyColumnMeta { + label?: string + variant?: 'text' | 'number' | 'date' | 'boolean' | 'select' | 'multi-select' + options?: Array<{ label: string; value: string; count?: number }> +} + +const fuzzyFilterFn = ( + row: { getValue: (id: string) => unknown }, + columnId: string, + value: unknown, + addMeta?: (meta: object) => void, +) => { + const itemRank = rankItem(row.getValue(columnId), value as string) + addMeta?.({ itemRank }) + return itemRank.passed +} + +export const features = tableFeatures({ + rowSortingFeature, + rowPaginationFeature, + rowSelectionFeature, + rowExpandingFeature, + columnFilteringFeature, + columnFacetingFeature, + columnOrderingFeature, + columnVisibilityFeature, + columnSizingFeature, + columnResizingFeature, + columnPinningFeature, + columnGroupingFeature, + globalFilteringFeature, + columnMeta: metaHelper(), + filteredRowModel: createFilteredRowModel(), + facetedRowModel: createFacetedRowModel(), + facetedUniqueValues: createFacetedUniqueValues(), + paginatedRowModel: createPaginatedRowModel(), + sortedRowModel: createSortedRowModel(), + groupedRowModel: createGroupedRowModel(), + expandedRowModel: createExpandedRowModel(), + filterFns: { ...filterFns, fuzzy: fuzzyFilterFn }, + sortFns, + aggregationFns, +}) diff --git a/examples/react/kitchen-sink-material-ui/src/hooks/table.ts b/examples/react/kitchen-sink-material-ui/src/hooks/table.ts new file mode 100644 index 0000000000..15040d3641 --- /dev/null +++ b/examples/react/kitchen-sink-material-ui/src/hooks/table.ts @@ -0,0 +1,69 @@ +import { createTableHook } from '@tanstack/react-table' +import { features } from '@/hooks/features' +import { dynamicFilterFn } from '@/lib/data-table' + +import { DataTablePagination } from '@/components/data-table/data-table-pagination' +import { DataTableFilterList } from '@/components/data-table/data-table-filter-list' +import { DataTableSortList } from '@/components/data-table/data-table-sort-list' +import { DataTableViewOptions } from '@/components/data-table/data-table-view-options' + +import { + ActionsCell, + AgeCell, + DateCell, + DepartmentCell, + GroupedCell, + SelectCell, + StatusCell, + TextCell, +} from '@/components/data-table/cell-components' + +import { ColumnHeader } from '@/components/data-table/data-table-column-header' +import { + ResizeHandle, + SelectAllHeader, +} from '@/components/data-table/header-components' + +export const { + createAppColumnHelper, + useAppTable, + useTableContext, + useCellContext, + useHeaderContext, +} = createTableHook({ + features, + defaultColumn: { + size: 120, + minSize: 60, + maxSize: 800, + filterFn: dynamicFilterFn, + }, + globalFilterFn: 'fuzzy', + getRowId: (row: any) => row.id, + enableRowSelection: true, + columnResizeMode: 'onChange' as const, + + tableComponents: { + Pagination: DataTablePagination, + FilterList: DataTableFilterList, + SortList: DataTableSortList, + ViewOptions: DataTableViewOptions, + }, + + cellComponents: { + SelectCell, + TextCell, + AgeCell, + StatusCell, + DepartmentCell, + DateCell, + GroupedCell, + ActionsCell, + }, + + headerComponents: { + ColumnHeader, + SelectAllHeader, + ResizeHandle, + }, +}) diff --git a/examples/react/kitchen-sink-material-ui/src/lib/data-table.ts b/examples/react/kitchen-sink-material-ui/src/lib/data-table.ts index acb58f259c..c319644f33 100644 --- a/examples/react/kitchen-sink-material-ui/src/lib/data-table.ts +++ b/examples/react/kitchen-sink-material-ui/src/lib/data-table.ts @@ -15,7 +15,7 @@ import type { JoinOperator, } from '@/types' import type { FilterFn } from '@tanstack/react-table' -import type { features } from '../main' +import type { features } from '@/hooks/features' export const fuzzyFilter: FilterFn = ( row, diff --git a/examples/react/kitchen-sink-material-ui/src/main.tsx b/examples/react/kitchen-sink-material-ui/src/main.tsx index 36b6f3805b..9b7c5612e0 100644 --- a/examples/react/kitchen-sink-material-ui/src/main.tsx +++ b/examples/react/kitchen-sink-material-ui/src/main.tsx @@ -4,252 +4,53 @@ import * as React from 'react' import * as ReactDOM from 'react-dom/client' import { useDebouncedCallback } from '@tanstack/react-pacer/debouncer' import { - DndContext, - PointerSensor, - closestCenter, - useSensor, - useSensors, -} from '@dnd-kit/core' -import { - SortableContext, - arrayMove, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable' -import { CSS } from '@dnd-kit/utilities' -import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward' -import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown' -import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward' -import CheckIcon from '@mui/icons-material/Check' -import CodeIcon from '@mui/icons-material/Code' -import CreditCardIcon from '@mui/icons-material/CreditCard' -import DarkModeIcon from '@mui/icons-material/DarkMode' -import DeleteIcon from '@mui/icons-material/Delete' -import DragIndicatorIcon from '@mui/icons-material/DragIndicator' -import ExpandLessIcon from '@mui/icons-material/ExpandLess' -import ExpandMoreIcon from '@mui/icons-material/ExpandMore' -import FilterListIcon from '@mui/icons-material/FilterList' -import GroupIcon from '@mui/icons-material/Group' -import LightModeIcon from '@mui/icons-material/LightMode' -import MoreVertIcon from '@mui/icons-material/MoreVert' -import PushPinIcon from '@mui/icons-material/PushPin' -import SearchIcon from '@mui/icons-material/Search' -import SettingsIcon from '@mui/icons-material/Settings' -import ShoppingCartIcon from '@mui/icons-material/ShoppingCart' -import SortIcon from '@mui/icons-material/Sort' -import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt' -import VisibilityOffIcon from '@mui/icons-material/VisibilityOff' -import { - Autocomplete, Box, Button, - Checkbox, - Chip, Container, CssBaseline, - Divider, - FormControl, IconButton, InputAdornment, - InputLabel, - LinearProgress, - List, - ListItem, - ListItemButton, - ListItemIcon, - ListItemText, Menu, MenuItem, - Table as MuiTable, Paper, - Popover, - Select, Stack, + Table as MuiTable, TableBody, TableCell, TableContainer, TableHead, - TablePagination, TableRow, - TableSortLabel, TextField, ThemeProvider, Toolbar, Tooltip, - Typography, createTheme, useMediaQuery, } from '@mui/material' -import { - aggregationFns, - columnFacetingFeature, - columnFilteringFeature, - columnGroupingFeature, - columnOrderingFeature, - columnPinningFeature, - columnResizingFeature, - columnSizingFeature, - columnVisibilityFeature, - createColumnHelper, - createExpandedRowModel, - createFacetedRowModel, - createFacetedUniqueValues, - createFilteredRowModel, - createGroupedRowModel, - createPaginatedRowModel, - createSortedRowModel, - filterFns, - globalFilteringFeature, - metaHelper, - rowExpandingFeature, - rowPaginationFeature, - rowSelectionFeature, - rowSortingFeature, - sortFns, - tableFeatures, - useTable, -} from '@tanstack/react-table' +import DarkModeIcon from '@mui/icons-material/DarkMode' +import LightModeIcon from '@mui/icons-material/LightMode' +import SearchIcon from '@mui/icons-material/Search' import { useTanStackTableDevtools } from '@tanstack/react-table-devtools' -import { rankItem } from '@tanstack/match-sorter-utils' -import type { Person } from '@/lib/make-data' -import type { DragEndEvent } from '@dnd-kit/core' -import type { - Column, - ColumnPinningState, - ColumnSizingState, - ExpandedState, - GroupingState, - Header, - ReactTable, - SortingState, -} from '@tanstack/react-table' +import type { Column } from '@tanstack/react-table' import type { ExtendedColumnFilter } from '@/types' - -import { dynamicFilterFn, getFilterOperators } from '@/lib/data-table' -import { departments, makeData, statuses } from '@/lib/make-data' +import type { Person } from '@/lib/make-data' +import type { features } from '@/hooks/features' +import { makeData } from '@/lib/make-data' +import { useAppTable } from '@/hooks/table' +import { columns } from '@/columns' +import { toSentenceCase } from '@/components/data-table/shared' import './styles/globals.css' -interface MyColumnMeta { - label?: string - variant?: 'text' | 'number' | 'date' | 'boolean' | 'select' | 'multi-select' - options?: Array<{ label: string; value: string; count?: number }> -} - -// Local fuzzy filter implementation for the filterFns registry slot. -// Defined here to avoid a circular type dependency with data-table.ts. -const fuzzyFilterFn = ( - row: { getValue: (id: string) => unknown }, - columnId: string, - value: unknown, - addMeta?: (meta: object) => void, -) => { - const itemRank = rankItem(row.getValue(columnId), value as string) - addMeta?.({ itemRank }) - return itemRank.passed -} - -export const features = tableFeatures({ - rowSortingFeature, - rowPaginationFeature, - rowSelectionFeature, - rowExpandingFeature, - columnFilteringFeature, - columnFacetingFeature, - columnOrderingFeature, - columnVisibilityFeature, - columnSizingFeature, - columnResizingFeature, - columnPinningFeature, - columnGroupingFeature, - globalFilteringFeature, - columnMeta: metaHelper(), - filteredRowModel: createFilteredRowModel(), - facetedRowModel: createFacetedRowModel(), - facetedUniqueValues: createFacetedUniqueValues(), - paginatedRowModel: createPaginatedRowModel(), - sortedRowModel: createSortedRowModel(), - groupedRowModel: createGroupedRowModel(), - expandedRowModel: createExpandedRowModel(), - filterFns: { ...filterFns, fuzzy: fuzzyFilterFn }, - sortFns, - aggregationFns, -}) - -const columnHelper = createColumnHelper() -type AppTable = ReactTable -type AppColumn = Column - -function SortableFrame({ - id, - children, -}: { - id: string - children: React.ReactNode -}) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id }) - - return ( - - {children} - - ) -} - -function toSentenceCase(value: string) { - return value - .replace(/[-_]/g, ' ') - .replace(/\w\S*/g, (word) => word[0].toUpperCase() + word.slice(1)) -} - -function formatDate(value: string) { - return new Intl.DateTimeFormat('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }).format(new Date(value)) -} - -function toDateInputValue(value: unknown) { - if (!value) return '' - const date = new Date(String(value)) - return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10) -} - -function getAriaSort(sortDirection: false | 'asc' | 'desc') { +function getAriaSort( + sortDirection: false | 'asc' | 'desc', +): 'ascending' | 'descending' | 'none' { if (sortDirection === 'asc') return 'ascending' if (sortDirection === 'desc') return 'descending' return 'none' } -const SortingContext = React.createContext([]) - -function getSortDirection(sorting: SortingState, columnId: string) { - const sort = sorting.find((sort) => sort.id === columnId) - return sort ? (sort.desc ? 'desc' : 'asc') : undefined -} - function getCommonPinningStyles( - column: AppColumn, + column: Column, isSelected = false, ): React.CSSProperties { const isPinned = column.getIsPinned() @@ -269,962 +70,11 @@ function getCommonPinningStyles( position: isPinned ? 'sticky' : 'relative', background: isSelected ? 'rgba(var(--mui-palette-primary-mainChannel) / var(--mui-palette-action-selectedOpacity))' - : isPinned - ? 'var(--mui-palette-background-paper)' - : undefined, + : 'var(--mui-palette-background-paper)', zIndex: isPinned ? 2 : 0, } } -function DepartmentIcon({ department }: { department: Person['department'] }) { - const icons: Record = { - engineering: , - marketing: , - sales: , - hr: , - finance: , - } - - return icons[department] -} - -function DepartmentChip({ department }: { department: Person['department'] }) { - return ( - - - - - - {toSentenceCase(department)} - - - ) -} - -function EllipsisText({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ) -} - -function StatusChip({ status }: { status: Person['status'] }) { - const color: Record = { - active: 'success', - inactive: 'error', - pending: 'warning', - } - - return ( - } - label={toSentenceCase(status)} - color={color[status]} - variant="outlined" - size="small" - /> - ) -} - -function RowActions({ person }: { person: Person }) { - const [anchorEl, setAnchorEl] = React.useState(null) - const open = Boolean(anchorEl) - - return ( - <> - setAnchorEl(event.currentTarget)} - > - - - setAnchorEl(null)} - > - { - void navigator.clipboard.writeText(person.id) - setAnchorEl(null) - }} - > - Copy ID - - - setAnchorEl(null)}>View details - setAnchorEl(null)}>View profile - - - ) -} - -function ColumnHeaderMenu({ - column, - title, -}: { - column: AppColumn - title: string -}) { - const [anchorEl, setAnchorEl] = React.useState(null) - const canSort = column.getCanSort() - const canHide = column.getCanHide() - const canPin = column.getCanPin() - const canGroup = column.getCanGroup() - const sorting = React.useContext(SortingContext) - const direction = canSort ? getSortDirection(sorting, column.id) : undefined - const isSorted = !!direction - const pinned = canPin ? column.getIsPinned() : false - const grouped = canGroup ? column.getIsGrouped() : false - - if (!canSort && !canHide && !canPin && !canGroup) { - return {title} - } - - return ( - - {canSort ? ( - - {title} - - ) : ( - {title} - )} - setAnchorEl(event.currentTarget)} - > - - - setAnchorEl(null)} - > - {canSort && ( - - column.toggleSorting(false)}> - - - - Asc - - column.toggleSorting(true)}> - - - - Desc - - - )} - {canGroup && ( - - - - - {grouped ? 'Ungroup' : 'Group by'} - - )} - {canPin && ( - - - column.pin('left')} - > - - - - Pin left - - column.pin('right')} - > - - - - Pin right - - {pinned ? ( - column.pin(false)}> - - - - Unpin - - ) : null} - - )} - {canHide && ( - - - column.toggleVisibility(false)}> - - - - Hide - - - )} - - - ) -} - -function ViewOptionsPopover({ - table, - columnOrder, - onColumnOrderChange, -}: { - table: AppTable - columnOrder: Array - onColumnOrderChange: React.Dispatch>> -}) { - const [anchorEl, setAnchorEl] = React.useState(null) - const [query, setQuery] = React.useState('') - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), - ) - const columns = table - .getAllColumns() - .filter((column) => typeof column.accessorFn !== 'undefined') - .sort((a, b) => columnOrder.indexOf(a.id) - columnOrder.indexOf(b.id)) - .filter((column) => - (column.columnDef.meta?.label ?? column.id) - .toLowerCase() - .includes(query.toLowerCase()), - ) - - const onDragEnd = (event: DragEndEvent) => { - const { active, over } = event - if (!over || active.id === over.id) return - - onColumnOrderChange((current) => { - const oldIndex = current.indexOf(String(active.id)) - const newIndex = current.indexOf(String(over.id)) - return oldIndex >= 0 && newIndex >= 0 - ? arrayMove(current, oldIndex, newIndex) - : current - }) - } - - return ( - <> - - setAnchorEl(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} - transformOrigin={{ vertical: 'top', horizontal: 'right' }} - > - - setQuery(event.target.value)} - /> - - column.id)} - strategy={verticalListSortingStrategy} - > - - {columns.map((column) => ( - - - } - > - - column.toggleVisibility(!column.getIsVisible()) - } - > - - - - - - - - ))} - - - - - - - ) -} - -function SortListPopover({ - table, - sorting, - onSortingChange, -}: { - table: AppTable - sorting: SortingState - onSortingChange: React.Dispatch> -}) { - const [anchorEl, setAnchorEl] = React.useState(null) - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), - ) - const sortableColumns = table - .getAllColumns() - .filter((column) => column.getCanSort()) - - const updateSort = (index: number, patch: Partial) => { - onSortingChange((current) => - current.map((sort, sortIndex) => - sortIndex === index ? { ...sort, ...patch } : sort, - ), - ) - } - - const addSort = () => { - const nextColumn = sortableColumns.find( - (column) => !sorting.some((sort) => sort.id === column.id), - ) - if (nextColumn) - onSortingChange((current) => [ - ...current, - { id: nextColumn.id, desc: false }, - ]) - } - - const onDragEnd = (event: DragEndEvent) => { - const { active, over } = event - if (!over || active.id === over.id) return - - onSortingChange((current) => { - const oldIndex = current.findIndex((sort) => sort.id === active.id) - const newIndex = current.findIndex((sort) => sort.id === over.id) - return oldIndex >= 0 && newIndex >= 0 - ? arrayMove(current, oldIndex, newIndex) - : current - }) - } - - return ( - <> - - setAnchorEl(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} - > - - - {sorting.length ? 'Sort by' : 'No sorting applied'} - - - sort.id)} - strategy={verticalListSortingStrategy} - > - - {sorting.map((sort, index) => ( - - - - column.id === sort.id, - ) ?? null - } - getOptionLabel={(column) => - column.columnDef.meta?.label ?? column.id - } - onChange={(_, column) => { - if (column) updateSort(index, { id: column.id }) - }} - renderInput={(params) => ( - - )} - /> - - Direction - - - - onSortingChange((current) => - current.filter( - (_, sortIndex) => sortIndex !== index, - ), - ) - } - > - - - - - ))} - - - - - - - - - - - ) -} - -function FilterValueInput({ - column, - filter, - onFilterUpdate, -}: { - column: AppColumn - filter: ExtendedColumnFilter - onFilterUpdate: ( - filterId: string, - patch: Partial, - ) => void -}) { - if (!filter.filterId) return null - const variant = column.columnDef.meta?.variant ?? 'text' - const operator = filter.operator ?? 'includesString' - const disabled = operator === 'isEmpty' || operator === 'isNotEmpty' - - if (disabled) { - return No value required - } - - if (variant === 'select' || variant === 'multi-select') { - const options = column.columnDef.meta?.options ?? [] - const multiple = variant === 'multi-select' - const value = multiple - ? options.filter( - (option) => - Array.isArray(filter.value) && filter.value.includes(option.value), - ) - : (options.find((option) => option.value === filter.value) ?? null) - - return ( - option.label} - onChange={(_, nextValue) => { - onFilterUpdate(filter.filterId!, { - value: Array.isArray(nextValue) - ? nextValue.map((option) => option.value) - : nextValue?.value, - }) - }} - renderInput={(params) => } - /> - ) - } - - if (variant === 'date') { - if (operator === 'inRange') { - const value = Array.isArray(filter.value) ? filter.value : [] - return ( - - - onFilterUpdate(filter.filterId!, { - value: [ - event.target.value - ? new Date(event.target.value).toISOString() - : undefined, - value[1], - ], - }) - } - slotProps={{ inputLabel: { shrink: true } }} - /> - - onFilterUpdate(filter.filterId!, { - value: [ - value[0], - event.target.value - ? new Date(event.target.value).toISOString() - : undefined, - ], - }) - } - slotProps={{ inputLabel: { shrink: true } }} - /> - - ) - } - - return ( - - onFilterUpdate(filter.filterId!, { - value: event.target.value - ? new Date(event.target.value).toISOString() - : undefined, - }) - } - slotProps={{ inputLabel: { shrink: true } }} - /> - ) - } - - if (variant === 'number') { - return ( - - onFilterUpdate(filter.filterId!, { - value: event.target.value === '' ? '' : Number(event.target.value), - }) - } - /> - ) - } - - return ( - - onFilterUpdate(filter.filterId!, { value: event.target.value }) - } - /> - ) -} - -function FilterListPopover({ - table, - columnFilters, - onColumnFiltersChange, -}: { - table: AppTable - columnFilters: Array - onColumnFiltersChange: React.Dispatch< - React.SetStateAction> - > -}) { - const [anchorEl, setAnchorEl] = React.useState(null) - const filterableColumns = table - .getAllColumns() - .filter((column) => column.getCanFilter()) - - const updateFilter = ( - filterId: string, - patch: Partial, - ) => { - onColumnFiltersChange((current) => - current.map((filter) => - filter.filterId === filterId ? { ...filter, ...patch } : filter, - ), - ) - } - - const addFilter = () => { - if (filterableColumns.length === 0) return - const [column] = filterableColumns - onColumnFiltersChange((current) => [ - ...current, - { - id: column.id, - filterId: crypto.randomUUID(), - value: '', - operator: 'includesString', - joinOperator: current[0]?.joinOperator ?? 'and', - }, - ]) - } - - return ( - <> - - setAnchorEl(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} - > - - Filters - {columnFilters.map((filter, index) => { - const column = table.getColumn(filter.id) - if (!column || !filter.filterId) return null - const variant = column.columnDef.meta?.variant ?? 'text' - const operators = getFilterOperators(variant) - return ( - - {index === 0 ? ( - Where - ) : index === 1 ? ( - - - - ) : ( - - {filter.joinOperator ?? 'and'} - - )} - - option.columnDef.meta?.label ?? option.id - } - onChange={(_, nextColumn) => { - if (nextColumn) { - updateFilter(filter.filterId!, { - id: nextColumn.id, - operator: getFilterOperators( - nextColumn.columnDef.meta?.variant ?? 'text', - )[0].value, - value: '', - }) - } - }} - renderInput={(params) => ( - - )} - /> - - Operator - - - - - - - onColumnFiltersChange((current) => - current.filter( - (item) => item.filterId !== filter.filterId, - ), - ) - } - > - - - - ) - })} - - - - - - - - ) -} - -function Pagination({ table }: { table: AppTable }) { - return ( - - - {table.getFilteredSelectedRowModel().rows.length.toLocaleString()} of{' '} - {table.getFilteredRowModel().rows.length.toLocaleString()} row(s) - selected. - - - table.setPageIndex(page)} - onRowsPerPageChange={(event) => { - table.setPageSize(Number(event.target.value)) - table.setPageIndex(0) - }} - /> - - - ) -} - -function ModeMenu({ - mode, - setMode, -}: { - mode: 'light' | 'dark' | 'system' - setMode: React.Dispatch> -}) { - const [anchorEl, setAnchorEl] = React.useState(null) - - return ( - <> - - setAnchorEl(event.currentTarget)}> - {mode === 'dark' ? : } - - - setAnchorEl(null)} - > - {(['light', 'dark', 'system'] as const).map((themeMode) => ( - { - setMode(themeMode) - setAnchorEl(null) - }} - > - {toSentenceCase(themeMode)} - - ))} - - - ) -} - -function DebouncedTextField({ - value: initialValue, - onChange, - debounce = 300, - ...props -}: { - value: string | number - onChange: (value: string | number) => void - debounce?: number -} & Omit, 'onChange'>) { - const [value, setValue] = React.useState(initialValue) - - React.useEffect(() => { - setValue(initialValue) - }, [initialValue]) - - const debouncedOnChange = useDebouncedCallback(onChange, { wait: debounce }) - - return ( - { - setValue(event.target.value) - debouncedOnChange(event.target.value) - }} - /> - ) -} - function App({ mode, setMode, @@ -1232,231 +82,39 @@ function App({ mode: 'light' | 'dark' | 'system' setMode: React.Dispatch> }) { - const [rowSelection, setRowSelection] = React.useState({}) - const [sorting, setSorting] = React.useState([]) const [columnFilters, setColumnFilters] = React.useState< Array >([]) - const [columnVisibility, setColumnVisibility] = React.useState({}) - const [columnSizing, setColumnSizing] = React.useState({}) const [globalFilter, setGlobalFilter] = React.useState('') - const [columnPinning, setColumnPinning] = React.useState({ - left: ['select'], - right: ['actions'], - }) - const [grouping, setGrouping] = React.useState([]) - const [expanded, setExpanded] = React.useState({}) const [data, setData] = React.useState(() => makeData(1_000)) - const columns = React.useMemo( - () => - columnHelper.columns([ - columnHelper.display({ - id: 'select', - header: ({ table }) => ( - - table.toggleAllPageRowsSelected(checked) - } - slotProps={{ input: { 'aria-label': 'Select all' } }} - size="small" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(checked)} - slotProps={{ input: { 'aria-label': 'Select row' } }} - size="small" - /> - ), - maxSize: 48, - enableSorting: false, - enableHiding: false, - enableResizing: false, - }), - columnHelper.accessor('firstName', { - id: 'firstName', - header: ({ column }) => ( - - ), - cell: (info) => ( - {String(info.getValue())} - ), - meta: { label: 'First Name', variant: 'text' }, - }), - columnHelper.accessor((row) => row.lastName, { - id: 'lastName', - header: ({ column }) => ( - - ), - cell: (info) => ( - {String(info.getValue())} - ), - meta: { label: 'Last Name', variant: 'text' }, - }), - columnHelper.accessor('age', { - id: 'age', - header: ({ column }) => ( - - ), - cell: (info) => ( - {String(info.getValue())} - ), - aggregationFn: 'mean', - aggregatedCell: ({ getValue }) => ( - - Avg: {Math.round(Number(getValue()) * 10) / 10} - - ), - meta: { label: 'Age', variant: 'number' }, - }), - columnHelper.accessor('email', { - id: 'email', - header: ({ column }) => ( - - ), - cell: (info) => ( - {info.cell.getValue()} - ), - meta: { label: 'Email', variant: 'text' }, - }), - columnHelper.accessor('status', { - id: 'status', - header: ({ column }) => ( - - ), - cell: (info) => { - const status = info.getValue() - return status ? : null - }, - aggregatedCell: () => null, - meta: { - label: 'Status', - variant: 'select', - options: statuses.map((status) => ({ - label: toSentenceCase(status), - value: status, - })), - }, - }), - columnHelper.accessor('department', { - id: 'department', - header: ({ column }) => ( - - ), - cell: (info) => { - const department = info.getValue() - return department ? ( - - ) : null - }, - aggregatedCell: () => null, - meta: { - label: 'Department', - variant: 'multi-select', - options: departments.map((department) => ({ - label: toSentenceCase(department), - value: department, - })), - }, - }), - columnHelper.accessor('joinDate', { - id: 'joinDate', - header: ({ column }) => ( - - ), - cell: (info) => formatDate(info.getValue()), - aggregationFn: 'min', - aggregatedCell: ({ getValue }) => { - const earliest = getValue() - return ( - - Earliest: {earliest ? formatDate(earliest) : '—'} - - ) - }, - meta: { label: 'Join Date', variant: 'date' }, - }), - columnHelper.display({ - id: 'actions', - enableHiding: false, - cell: ({ row }) => , - maxSize: 44, - enableResizing: false, - }), - ]), - [], - ) - - const [columnOrder, setColumnOrder] = React.useState>(() => - columns.map((column) => column.id ?? ''), - ) + const refreshData = () => setData(makeData(1_000)) + const stressTest = () => setData(makeData(1_000_000)) - const table = useTable( + const table = useAppTable( { - key: 'kitchen-sink-material-ui', // needed for devtools - features, + key: 'kitchen-sink-material-ui', columns, data, - defaultColumn: { - minSize: 60, - maxSize: 800, - filterFn: dynamicFilterFn, - }, - globalFilterFn: 'fuzzy', + debugTable: true, state: { - rowSelection, - sorting, - columnVisibility, - columnOrder, - columnSizing, columnFilters, globalFilter, - columnPinning, - grouping, - expanded, }, - onSortingChange: setSorting, - onColumnVisibilityChange: setColumnVisibility, - onColumnOrderChange: setColumnOrder, - onColumnSizingChange: setColumnSizing, onColumnFiltersChange: setColumnFilters, onGlobalFilterChange: setGlobalFilter, - onColumnPinningChange: setColumnPinning, - onGroupingChange: setGrouping, - onExpandedChange: setExpanded, - getRowId: (row) => row.id, - enableRowSelection: true, - onRowSelectionChange: setRowSelection, - columnResizeMode: 'onChange', - debugTable: true, + initialState: { + columnPinning: { left: ['select'], right: ['actions'] }, + columnOrder: columns.map((c) => c.id ?? ''), + }, }, - (state) => state, // default selector + (state) => state, ) useTanStackTableDevtools(table) - const columnSizeVars = React.useMemo(() => { - const headers = table.getFlatHeaders() - const colSizes: Record = {} - for (const header of headers) { - colSizes[`--header-${header.id}-size`] = header.getSize() - colSizes[`--col-${header.column.id}-size`] = header.column.getSize() - } - return colSizes - }, [table.state.columnSizing]) - - const refreshData = () => setData(makeData(1_000)) - const stressTest = () => setData(makeData(1_000_000)) - return ( - + @@ -1506,21 +164,9 @@ function App({ }, }} /> - - - + + + @@ -1530,158 +176,198 @@ function App({ size="small" sx={{ width: '100%', - tableLayout: 'fixed', - ...columnSizeVars, + display: 'grid', }} > - + {table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers .filter((header) => header.column.getIsVisible()) .map((header) => ( - + + {(h) => { + const sortDirection = h.column.getCanSort() + ? h.column.getIsSorted() + : false + + return ( + + + {h.isPlaceholder ? null : } + + + + ) + }} + ))} ))} - + {table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( - - {cell.getIsGrouped() ? ( - - ) : cell.column.id === 'progress' ? ( - - - {String(cell.getValue())}% - - - - ) : ( - + {c.getIsGrouped() ? ( + + ) : ( + + )} + )} - + ))} ))} - + - + ) } -function ResizableHeaderCell({ - header, - table, +function DebouncedTextField({ + value: initialValue, + onChange, + debounce = 300, + ...props }: { - header: Header - table: { - FlexRender: React.ComponentType<{ - header: Header - }> - } -}) { - const sorting = React.useContext(SortingContext) - const sortDirection = getSortDirection(sorting, header.column.id) + value: string | number + onChange: (value: string | number) => void + debounce?: number +} & Omit, 'onChange'>) { + const [value, setValue] = React.useState(initialValue) + + React.useEffect(() => { + setValue(initialValue) + }, [initialValue]) + + const debouncedOnChange = useDebouncedCallback(onChange, { wait: debounce }) return ( - { + setValue(event.target.value) + debouncedOnChange(event.target.value) }} - > - + ) +} + +function ModeMenu({ + mode, + setMode, +}: { + mode: 'light' | 'dark' | 'system' + setMode: React.Dispatch> +}): React.ReactNode { + const [anchorEl, setAnchorEl] = React.useState(null) + + return ( + <> + + setAnchorEl(event.currentTarget)}> + {mode === 'dark' ? : } + + + setAnchorEl(null)} > - {header.isPlaceholder ? null : } - {header.column.getCanResize() ? ( - header.column.resetSize()} - onMouseDown={header.getResizeHandler()} - onTouchStart={header.getResizeHandler()} - sx={{ - position: 'absolute', - top: 0, - right: -6, - width: 6, - height: '100%', - cursor: 'col-resize', - touchAction: 'none', - bgcolor: header.column.getIsResizing() - ? 'primary.main' - : 'transparent', - '&:hover': { bgcolor: 'primary.main' }, + {(['light', 'dark', 'system'] as const).map((themeMode) => ( + { + setMode(themeMode) + setAnchorEl(null) }} - /> - ) : null} - - + > + {toSentenceCase(themeMode)} + + ))} + + ) } @@ -1693,6 +379,9 @@ function Root() { const theme = React.useMemo( () => createTheme({ + // Generate the `--mui-*` CSS variables that the sticky-pinning styles + // reference; without this they resolve to nothing (transparent cells). + cssVariables: true, palette: { mode: resolvedMode, }, diff --git a/examples/react/kitchen-sink-react-aria/src/columns.tsx b/examples/react/kitchen-sink-react-aria/src/columns.tsx new file mode 100644 index 0000000000..9dd24c9bdd --- /dev/null +++ b/examples/react/kitchen-sink-react-aria/src/columns.tsx @@ -0,0 +1,120 @@ +import type { Person } from '@/lib/make-data' +import { createAppColumnHelper } from '@/hooks/table' +import { + AgeAggregatedCell, + JoinDateAggregatedCell, +} from '@/components/data-table/cell-components' +import { toSentenceCase } from '@/components/data-table/shared' +import { departments, statuses } from '@/lib/make-data' + +const columnHelper = createAppColumnHelper() + +export const columns = columnHelper.columns([ + columnHelper.display({ + id: 'select', + header: ({ header }) => , + cell: ({ cell }) => , + size: 64, + minSize: 64, + maxSize: 64, + enableSorting: false, + enableHiding: false, + enableResizing: false, + }), + columnHelper.accessor('firstName', { + id: 'firstName', + header: ({ header }) => , + cell: ({ cell }) => , + meta: { + label: 'First Name', + variant: 'text', + }, + }), + columnHelper.accessor((row) => row.lastName, { + id: 'lastName', + header: ({ header }) => , + cell: ({ cell }) => , + meta: { + label: 'Last Name', + variant: 'text', + }, + }), + columnHelper.accessor('age', { + id: 'age', + header: ({ header }) => , + cell: ({ cell }) => , + aggregationFn: 'mean', + aggregatedCell: () => , + meta: { + label: 'Age', + variant: 'number', + }, + }), + columnHelper.accessor('email', { + id: 'email', + header: ({ header }) => , + cell: ({ cell }) => , + size: 200, + meta: { + label: 'Email', + variant: 'text', + }, + }), + columnHelper.accessor('status', { + id: 'status', + header: ({ header }) => , + cell: ({ cell }) => , + aggregatedCell: () => null, + meta: { + label: 'Status', + variant: 'select', + options: statuses.map((status) => ({ + label: toSentenceCase(status), + value: status, + })), + }, + }), + columnHelper.accessor('department', { + id: 'department', + header: ({ header }) => , + cell: ({ cell }) => , + aggregatedCell: () => null, + meta: { + label: 'Department', + variant: 'multi-select', + options: departments.map((department) => ({ + label: toSentenceCase(department), + value: department, + })), + }, + }), + columnHelper.accessor('joinDate', { + id: 'joinDate', + header: ({ header }) => , + cell: ({ cell }) => , + aggregationFn: 'min', + aggregatedCell: () => , + meta: { + label: 'Join Date', + variant: 'date', + }, + }), + columnHelper.accessor((row) => row.age, { + id: 'progress', + header: ({ header }) => , + cell: ({ cell }) => , + meta: { + label: 'Profile Progress', + variant: 'number', + }, + }), + columnHelper.display({ + id: 'actions', + enableHiding: false, + cell: ({ cell }) => , + size: 72, + minSize: 72, + maxSize: 72, + enableResizing: false, + }), +]) diff --git a/examples/react/kitchen-sink-react-aria/src/components/data-table/cell-components.tsx b/examples/react/kitchen-sink-react-aria/src/components/data-table/cell-components.tsx new file mode 100644 index 0000000000..678191fc63 --- /dev/null +++ b/examples/react/kitchen-sink-react-aria/src/components/data-table/cell-components.tsx @@ -0,0 +1,184 @@ +import { + Button, + Menu, + MenuItem, + MenuTrigger, + Popover, + ProgressBar, +} from 'react-aria-components' +import type { Person } from '@/lib/make-data' +import { useCellContext, useTableContext } from '@/hooks/table' +import { + SelectionCheckbox, + toSentenceCase, +} from '@/components/data-table/shared' + +function EllipsisText({ children }: { children: React.ReactNode }) { + return {children} +} + +function StatusBadge({ status }: { status: Person['status'] }) { + const className: Record = { + active: 'status-badge status-active', + inactive: 'status-badge status-inactive', + pending: 'status-badge status-pending', + } + return {toSentenceCase(status)} +} + +function DepartmentPill({ department }: { department: Person['department'] }) { + return ( + + + {department.slice(0, 2).toUpperCase()} + + {toSentenceCase(department)} + + ) +} + +function formatDate(value: unknown) { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(new Date(String(value))) +} + +export function SelectCell(): React.ReactNode { + const cell = useCellContext() + const table = useTableContext() + const row = cell.row + + return ( + + {() => ( + row.toggleSelected(selected)} + /> + )} + + ) +} + +export function TextCell(): React.ReactNode { + const cell = useCellContext() + return {String(cell.getValue())} +} + +export function AgeCell(): React.ReactNode { + const cell = useCellContext() + return {String(cell.getValue())} +} + +export function StatusCell(): React.ReactNode { + const cell = useCellContext() + const status = cell.getValue() + if (!status) return null + return +} + +export function DepartmentCell(): React.ReactNode { + const cell = useCellContext() + const department = cell.getValue() + if (!department) return null + return +} + +export function DateCell(): React.ReactNode { + const cell = useCellContext() + return <>{formatDate(cell.getValue())} +} + +export function ProgressCell(): React.ReactNode { + const cell = useCellContext() + const value = Math.min(100, Math.max(0, Number(cell.getValue()))) + return ( + + {({ percentage }) => ( +
+
+
+ )} + + ) +} + +export function GroupedCell(): React.ReactNode { + const cell = useCellContext() + const table = useTableContext() + const row = cell.row + + return ( + + {() => ( + + )} + + ) +} + +export function ActionsCell(): React.ReactNode { + const cell = useCellContext() + const person = cell.row.original as Person + + return ( + + + + + { + void navigator.clipboard.writeText(person.id) + }} + > + Copy ID + + View details + View profile + + + + ) +} + +export function AgeAggregatedCell(): React.ReactNode { + const cell = useCellContext() + return ( + + Avg: {Math.round(Number(cell.getValue()) * 10) / 10} + + ) +} + +export function JoinDateAggregatedCell(): React.ReactNode { + const cell = useCellContext() + const earliest = cell.getValue() + return ( + + Earliest: {earliest ? formatDate(earliest) : '-'} + + ) +} diff --git a/examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-column-header.tsx b/examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-column-header.tsx new file mode 100644 index 0000000000..b3e00d0c41 --- /dev/null +++ b/examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-column-header.tsx @@ -0,0 +1,142 @@ +import { + Button, + Menu, + MenuItem, + MenuTrigger, + Popover, +} from 'react-aria-components' +import { useHeaderContext, useTableContext } from '@/hooks/table' + +function SortIcon({ direction }: { direction: 'asc' | 'desc' | undefined }) { + if (direction === 'asc') return + if (direction === 'desc') return + return ( + + ) +} + +export function ColumnHeader({ title }: { title?: string }): React.ReactNode { + const header = useHeaderContext() + const table = useTableContext() + const column = header.column + + const displayTitle = column.columnDef.meta?.label ?? title ?? column.id + + const canSort = column.getCanSort() + const canHide = column.getCanHide() + const canPin = column.getCanPin() + const canGroup = column.getCanGroup() + + if (!canSort && !canHide && !canPin && !canGroup) { + return {displayTitle} + } + + return ( +
+ ({ + sorting: s.sorting, + grouping: s.grouping, + columnPinning: s.columnPinning, + })} + > + {() => { + const sorted = canSort ? column.getIsSorted() : false + const pinned = canPin ? column.getIsPinned() : false + const grouped = canGroup ? column.getIsGrouped() : false + const direction = + sorted === 'desc' ? 'desc' : sorted === 'asc' ? 'asc' : undefined + + return ( + <> + {canSort ? ( + + ) : ( + {displayTitle} + )} + + + + + {canSort ? ( + <> + column.toggleSorting(false)} + > + Asc + + column.toggleSorting(true)} + > + Desc + + + ) : null} + {canGroup ? ( + + {grouped ? 'Ungroup' : 'Group by'} + + ) : null} + {canPin ? ( + <> + column.pin('left')} + > + Pin left + + column.pin('right')} + > + Pin right + + {pinned ? ( + column.pin(false)} + > + Unpin + + ) : null} + + ) : null} + {canHide ? ( + column.toggleVisibility(false)} + > + Hide + + ) : null} + + + + + ) + }} + +
+ ) +} diff --git a/examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-filter-list.tsx b/examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-filter-list.tsx new file mode 100644 index 0000000000..49d67ab39c --- /dev/null +++ b/examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-filter-list.tsx @@ -0,0 +1,319 @@ +import { + Button, + Dialog, + DialogTrigger, + Input, + Popover, +} from 'react-aria-components' +import type { Column, RowData } from '@tanstack/react-table' +import type { features } from '@/hooks/features' +import type { ExtendedColumnFilter } from '@/types' +import { getFilterOperators } from '@/lib/data-table' +import { useTableContext } from '@/hooks/table' +import { AriaSelect } from '@/components/data-table/shared' + +function toDateInputValue(value: unknown) { + if (!value) return '' + const date = new Date(String(value)) + return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10) +} + +function FilterValueInput({ + column, + filter, + onFilterUpdate, +}: { + column: Column + filter: ExtendedColumnFilter + onFilterUpdate: ( + filterId: string, + patch: Partial, + ) => void +}): React.ReactNode { + if (!filter.filterId) return null + const variant = column.columnDef.meta?.variant ?? 'text' + const operator = filter.operator ?? 'includesString' + const disabled = operator === 'isEmpty' || operator === 'isNotEmpty' + + if (disabled) + return
No value required
+ + if (variant === 'select') { + const options = column.columnDef.meta?.options ?? [] + return ( + onFilterUpdate(filter.filterId!, { value })} + /> + ) + } + + if (variant === 'multi-select') { + const options = column.columnDef.meta?.options ?? [] + const values = Array.isArray(filter.value) + ? filter.value.map(String) + : typeof filter.value === 'string' && filter.value + ? [filter.value] + : [] + return ( + + ) + } + + if (variant === 'date') { + if (operator === 'inRange') { + const value = Array.isArray(filter.value) ? filter.value : [] + return ( +
+ + onFilterUpdate(filter.filterId!, { + value: [ + event.currentTarget.value + ? new Date(event.currentTarget.value).toISOString() + : undefined, + value[1], + ], + }) + } + /> + + onFilterUpdate(filter.filterId!, { + value: [ + value[0], + event.currentTarget.value + ? new Date(event.currentTarget.value).toISOString() + : undefined, + ], + }) + } + /> +
+ ) + } + + return ( + + onFilterUpdate(filter.filterId!, { + value: event.currentTarget.value + ? new Date(event.currentTarget.value).toISOString() + : undefined, + }) + } + /> + ) + } + + if (variant === 'number') { + return ( + + onFilterUpdate(filter.filterId!, { + value: + event.currentTarget.value === '' + ? '' + : Number(event.currentTarget.value), + }) + } + /> + ) + } + + return ( + + onFilterUpdate(filter.filterId!, { value: event.currentTarget.value }) + } + /> + ) +} + +export function DataTableFilterList(): React.ReactNode { + const table = useTableContext() + const columnFilters = table.state.columnFilters as Array + + // Write through the raw controlled-state handler instead of + // `table.setColumnFilters`: the table API auto-removes filters with empty + // values, but a just-added filter row legitimately starts with `value: ''` + // while the user is still building it. + const setColumnFilters = (filters: Array) => { + table.options.onColumnFiltersChange?.(filters) + } + + const filterableColumns = table + .getAllColumns() + .filter((column) => column.getCanFilter()) + const fieldOptions = filterableColumns.map((column) => ({ + value: column.id, + label: column.columnDef.meta?.label ?? column.id, + })) + + const updateFilter = ( + filterId: string, + patch: Partial, + ) => { + setColumnFilters( + columnFilters.map((filter) => + filter.filterId === filterId ? { ...filter, ...patch } : filter, + ) as any, + ) + } + + const addFilter = () => { + const [column] = filterableColumns + if (!column) return + setColumnFilters([ + ...columnFilters, + { + id: column.id, + filterId: crypto.randomUUID(), + value: '', + operator: 'includesString', + joinOperator: columnFilters[0]?.joinOperator ?? 'and', + } as ExtendedColumnFilter, + ] as any) + } + + return ( + + + + +
Filters
+ {columnFilters.map((filter, index) => { + const column = table.getColumn(filter.id) + if (!column || !filter.filterId) return null + const variant = column.columnDef.meta?.variant ?? 'text' + const operators = getFilterOperators(variant) + return ( +
+ {index === 0 ? ( +
Where
+ ) : index === 1 ? ( + + setColumnFilters( + columnFilters.map((item) => ({ + ...item, + joinOperator: joinOperator as 'and' | 'or', + })) as any, + ) + } + /> + ) : ( +
+ {filter.joinOperator ?? 'and'} +
+ )} + { + const nextColumn = table.getColumn(nextColumnId) + if (nextColumn) { + updateFilter(filter.filterId!, { + id: nextColumn.id, + operator: getFilterOperators( + nextColumn.columnDef.meta?.variant ?? 'text', + )[0].value, + value: '', + }) + } + }} + /> + ({ + value: operator.value, + label: operator.label, + }))} + onChange={(operator) => + updateFilter(filter.filterId!, { + operator: operator as ExtendedColumnFilter['operator'], + value: '', + }) + } + /> + + +
+ ) + })} +
+ + +
+
+
+
+ ) +} diff --git a/examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-pagination.tsx b/examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-pagination.tsx new file mode 100644 index 0000000000..08adf44c54 --- /dev/null +++ b/examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-pagination.tsx @@ -0,0 +1,104 @@ +import { Button } from 'react-aria-components' +import { useTableContext } from '@/hooks/table' +import { AriaSelect, cx } from '@/components/data-table/shared' + +function getPageItems(pageIndex: number, pageCount: number) { + const currentPage = pageIndex + 1 + const pages = new Set([ + 1, + pageCount, + currentPage - 1, + currentPage, + currentPage + 1, + ]) + + return Array.from(pages) + .filter((page) => page >= 1 && page <= pageCount) + .sort((a, b) => a - b) + .reduce>((items, page) => { + const previous = items[items.length - 1] + if (typeof previous === 'number' && page - previous > 1) { + items.push('ellipsis') + } + items.push(page) + return items + }, []) +} + +export function DataTablePagination(): React.ReactNode { + const table = useTableContext() + const pageIndex = table.state.pagination.pageIndex + const pageSize = table.state.pagination.pageSize + const pageItems = getPageItems(pageIndex, table.getPageCount()) + + return ( +
+
+ {table.getFilteredSelectedRowModel().rows.length.toLocaleString()} of{' '} + {table.getFilteredRowModel().rows.length.toLocaleString()} row(s) + selected. +
+
+ Rows per page: + ({ + value, + label: value, + }))} + onChange={(value) => { + table.setPageSize(Number(value)) + table.setPageIndex(0) + }} + /> + + + +
+
+ ) +} diff --git a/examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-sort-list.tsx b/examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-sort-list.tsx new file mode 100644 index 0000000000..f5d842a5d4 --- /dev/null +++ b/examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-sort-list.tsx @@ -0,0 +1,132 @@ +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { + SortableContext, + arrayMove, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { Button, Dialog, DialogTrigger, Popover } from 'react-aria-components' +import type { DragEndEvent } from '@dnd-kit/core' +import { useTableContext } from '@/hooks/table' +import { AriaSelect, SortableFrame } from '@/components/data-table/shared' + +export function DataTableSortList(): React.ReactNode { + const table = useTableContext() + const sorting = table.state.sorting + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + ) + const sortableColumns = table + .getAllColumns() + .filter((column) => column.getCanSort()) + const columnOptions = sortableColumns.map((column) => ({ + value: column.id, + label: column.columnDef.meta?.label ?? column.id, + })) + + const updateSort = ( + index: number, + patch: Partial<(typeof sorting)[number]>, + ) => { + table.setSorting( + sorting.map((sort, sortIndex) => + sortIndex === index ? { ...sort, ...patch } : sort, + ), + ) + } + + const addSort = () => { + const nextColumn = sortableColumns.find( + (column) => !sorting.some((sort) => sort.id === column.id), + ) + if (nextColumn) { + table.setSorting([...sorting, { id: nextColumn.id, desc: false }]) + } + } + + const onDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + const oldIndex = sorting.findIndex((sort) => sort.id === active.id) + const newIndex = sorting.findIndex((sort) => sort.id === over.id) + if (oldIndex >= 0 && newIndex >= 0) { + table.setSorting(arrayMove(sorting, oldIndex, newIndex)) + } + } + + return ( + + + + +
+ {sorting.length ? 'Sort by' : 'No sorting applied'} +
+ + sort.id)} + strategy={verticalListSortingStrategy} + > +
+ {sorting.map((sort, index) => ( + +
+ + updateSort(index, { id: value })} + /> + + updateSort(index, { desc: value === 'desc' }) + } + /> + +
+
+ ))} +
+
+
+
+ + +
+
+
+
+ ) +} diff --git a/examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-view-options.tsx b/examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-view-options.tsx new file mode 100644 index 0000000000..7fe82cca20 --- /dev/null +++ b/examples/react/kitchen-sink-react-aria/src/components/data-table/data-table-view-options.tsx @@ -0,0 +1,98 @@ +import * as React from 'react' +import { + DndContext, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { + SortableContext, + arrayMove, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { + Button, + Checkbox, + Dialog, + DialogTrigger, + Input, + Popover, +} from 'react-aria-components' +import type { DragEndEvent } from '@dnd-kit/core' +import { useTableContext } from '@/hooks/table' +import { SortableFrame } from '@/components/data-table/shared' + +export function DataTableViewOptions(): React.ReactNode { + const table = useTableContext() + const columnOrder = table.state.columnOrder + + const [query, setQuery] = React.useState('') + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + ) + const columns = table + .getAllColumns() + .filter((column) => typeof column.accessorFn !== 'undefined') + .sort((a, b) => columnOrder.indexOf(a.id) - columnOrder.indexOf(b.id)) + .filter((column) => + (column.columnDef.meta?.label ?? column.id) + .toLowerCase() + .includes(query.toLowerCase()), + ) + + const onDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + + const oldIndex = columnOrder.indexOf(String(active.id)) + const newIndex = columnOrder.indexOf(String(over.id)) + if (oldIndex >= 0 && newIndex >= 0) { + table.setColumnOrder(arrayMove(columnOrder, oldIndex, newIndex)) + } + } + + return ( + + + + + setQuery(event.currentTarget.value)} + /> + + column.id)} + strategy={verticalListSortingStrategy} + > +
+ {columns.map((column) => ( + +
+ + column.toggleVisibility(selected) + } + > + {column.columnDef.meta?.label ?? column.id} + + +
+
+ ))} +
+
+
+
+
+
+ ) +} diff --git a/examples/react/kitchen-sink-react-aria/src/components/data-table/header-components.tsx b/examples/react/kitchen-sink-react-aria/src/components/data-table/header-components.tsx new file mode 100644 index 0000000000..9c240bfa3b --- /dev/null +++ b/examples/react/kitchen-sink-react-aria/src/components/data-table/header-components.tsx @@ -0,0 +1,45 @@ +import { useHeaderContext, useTableContext } from '@/hooks/table' +import { SelectionCheckbox, cx } from '@/components/data-table/shared' + +export function SelectAllHeader(): React.ReactNode { + const table = useTableContext() + + return ( + + {() => ( + table.toggleAllPageRowsSelected(selected)} + /> + )} + + ) +} + +export function ResizeHandle(): React.ReactNode { + const header = useHeaderContext() + const table = useTableContext() + + if (!header.column.getCanResize()) return null + + return ( + + {() => ( +
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + className={cx( + 'column-resizer', + header.column.getIsResizing() && 'bg-primary', + )} + /> + )} + + ) +} diff --git a/examples/react/kitchen-sink-react-aria/src/components/data-table/shared.tsx b/examples/react/kitchen-sink-react-aria/src/components/data-table/shared.tsx new file mode 100644 index 0000000000..6dfcc77c24 --- /dev/null +++ b/examples/react/kitchen-sink-react-aria/src/components/data-table/shared.tsx @@ -0,0 +1,132 @@ +import * as React from 'react' +import { + Button, + Label, + ListBox, + ListBoxItem, + Popover, + Select, + SelectValue, +} from 'react-aria-components' +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import type { Key } from 'react-aria-components' + +export function cx(...classes: Array) { + return classes.filter(Boolean).join(' ') +} + +export function toSentenceCase(value: string) { + return value + .replace(/[-_]/g, ' ') + .replace(/\w\S*/g, (word) => word[0].toUpperCase() + word.slice(1)) +} + +export function SelectionCheckbox({ + ariaLabel, + isSelected, + isIndeterminate, + onChange, +}: { + ariaLabel: string + isSelected: boolean + isIndeterminate?: boolean + onChange: (selected: boolean) => void +}) { + const ref = React.useRef(null) + + React.useEffect(() => { + if (ref.current) ref.current.indeterminate = !!isIndeterminate + }, [isIndeterminate]) + + return ( + onChange(event.currentTarget.checked)} + /> + ) +} + +export function AriaSelect({ + label, + value, + options, + className, + showLabel = true, + onChange, +}: { + label: string + value: string | null + options: Array<{ value: string; label: string }> + className?: string + showLabel?: boolean + onChange: (value: string) => void +}) { + return ( + + ) +} + +export function SortableFrame({ + id, + children, +}: { + id: string + children: React.ReactNode +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }) + + return ( +
+ {children} +
+ ) +} diff --git a/examples/react/kitchen-sink-react-aria/src/hooks/features.ts b/examples/react/kitchen-sink-react-aria/src/hooks/features.ts new file mode 100644 index 0000000000..730a2273ba --- /dev/null +++ b/examples/react/kitchen-sink-react-aria/src/hooks/features.ts @@ -0,0 +1,72 @@ +import { + aggregationFns, + columnFacetingFeature, + columnFilteringFeature, + columnGroupingFeature, + columnOrderingFeature, + columnPinningFeature, + columnResizingFeature, + columnSizingFeature, + columnVisibilityFeature, + createExpandedRowModel, + createFacetedRowModel, + createFacetedUniqueValues, + createFilteredRowModel, + createGroupedRowModel, + createPaginatedRowModel, + createSortedRowModel, + filterFns, + globalFilteringFeature, + metaHelper, + rowExpandingFeature, + rowPaginationFeature, + rowSelectionFeature, + rowSortingFeature, + sortFns, + tableFeatures, +} from '@tanstack/react-table' +import { rankItem } from '@tanstack/match-sorter-utils' + +export interface MyColumnMeta { + label?: string + variant?: 'text' | 'number' | 'date' | 'boolean' | 'select' | 'multi-select' + options?: Array<{ label: string; value: string; count?: number }> +} + +const fuzzyFilterFn = ( + row: { getValue: (id: string) => unknown }, + columnId: string, + value: unknown, + addMeta?: (meta: object) => void, +) => { + const itemRank = rankItem(row.getValue(columnId), value as string) + addMeta?.({ itemRank }) + return itemRank.passed +} + +export const features = tableFeatures({ + rowSortingFeature, + rowPaginationFeature, + rowSelectionFeature, + rowExpandingFeature, + columnFilteringFeature, + columnFacetingFeature, + columnOrderingFeature, + columnVisibilityFeature, + columnSizingFeature, + columnResizingFeature, + columnPinningFeature, + columnGroupingFeature, + globalFilteringFeature, + columnMeta: metaHelper(), + filteredRowModel: createFilteredRowModel(), + facetedRowModel: createFacetedRowModel(), + facetedUniqueValues: createFacetedUniqueValues(), + paginatedRowModel: createPaginatedRowModel(), + sortedRowModel: createSortedRowModel(), + groupedRowModel: createGroupedRowModel(), + expandedRowModel: createExpandedRowModel(), + filterFns: { ...filterFns, fuzzy: fuzzyFilterFn }, + sortFns, + aggregationFns, +}) diff --git a/examples/react/kitchen-sink-react-aria/src/hooks/table.ts b/examples/react/kitchen-sink-react-aria/src/hooks/table.ts new file mode 100644 index 0000000000..d4dd8511ba --- /dev/null +++ b/examples/react/kitchen-sink-react-aria/src/hooks/table.ts @@ -0,0 +1,71 @@ +import { createTableHook } from '@tanstack/react-table' +import { features } from '@/hooks/features' +import { dynamicFilterFn } from '@/lib/data-table' + +import { DataTablePagination } from '@/components/data-table/data-table-pagination' +import { DataTableFilterList } from '@/components/data-table/data-table-filter-list' +import { DataTableSortList } from '@/components/data-table/data-table-sort-list' +import { DataTableViewOptions } from '@/components/data-table/data-table-view-options' + +import { + ActionsCell, + AgeCell, + DateCell, + DepartmentCell, + GroupedCell, + ProgressCell, + SelectCell, + StatusCell, + TextCell, +} from '@/components/data-table/cell-components' + +import { ColumnHeader } from '@/components/data-table/data-table-column-header' +import { + ResizeHandle, + SelectAllHeader, +} from '@/components/data-table/header-components' + +export const { + createAppColumnHelper, + useAppTable, + useTableContext, + useCellContext, + useHeaderContext, +} = createTableHook({ + features, + defaultColumn: { + size: 150, + minSize: 60, + maxSize: 800, + filterFn: dynamicFilterFn, + }, + globalFilterFn: 'fuzzy', + getRowId: (row: any) => row.id, + enableRowSelection: true, + columnResizeMode: 'onChange' as const, + + tableComponents: { + Pagination: DataTablePagination, + FilterList: DataTableFilterList, + SortList: DataTableSortList, + ViewOptions: DataTableViewOptions, + }, + + cellComponents: { + SelectCell, + TextCell, + AgeCell, + StatusCell, + DepartmentCell, + DateCell, + ProgressCell, + GroupedCell, + ActionsCell, + }, + + headerComponents: { + ColumnHeader, + SelectAllHeader, + ResizeHandle, + }, +}) diff --git a/examples/react/kitchen-sink-react-aria/src/lib/data-table.ts b/examples/react/kitchen-sink-react-aria/src/lib/data-table.ts index acb58f259c..c319644f33 100644 --- a/examples/react/kitchen-sink-react-aria/src/lib/data-table.ts +++ b/examples/react/kitchen-sink-react-aria/src/lib/data-table.ts @@ -15,7 +15,7 @@ import type { JoinOperator, } from '@/types' import type { FilterFn } from '@tanstack/react-table' -import type { features } from '../main' +import type { features } from '@/hooks/features' export const fuzzyFilter: FilterFn = ( row, diff --git a/examples/react/kitchen-sink-react-aria/src/main.tsx b/examples/react/kitchen-sink-react-aria/src/main.tsx index 1c07ecc2cc..a705d7a473 100644 --- a/examples/react/kitchen-sink-react-aria/src/main.tsx +++ b/examples/react/kitchen-sink-react-aria/src/main.tsx @@ -4,245 +4,41 @@ import * as React from 'react' import { TanStackDevtools } from '@tanstack/react-devtools' import * as ReactDOM from 'react-dom/client' import { useDebouncedCallback } from '@tanstack/react-pacer/debouncer' -import { - DndContext, - PointerSensor, - closestCenter, - useSensor, - useSensors, -} from '@dnd-kit/core' -import { - SortableContext, - arrayMove, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable' -import { CSS } from '@dnd-kit/utilities' import { Column as AriaColumn, Table as AriaTable, Button, Cell, - Checkbox, - Dialog, - DialogTrigger, Input, - Label, - ListBox, - ListBoxItem, - Menu, - MenuItem, - MenuTrigger, - Popover, - ProgressBar, Row, - Select, - SelectValue, Switch, TableBody, TableHeader, Tooltip, TooltipTrigger, } from 'react-aria-components' -import { - aggregationFns, - columnFacetingFeature, - columnFilteringFeature, - columnGroupingFeature, - columnOrderingFeature, - columnPinningFeature, - columnResizingFeature, - columnSizingFeature, - columnVisibilityFeature, - createColumnHelper, - createExpandedRowModel, - createFacetedRowModel, - createFacetedUniqueValues, - createFilteredRowModel, - createGroupedRowModel, - createPaginatedRowModel, - createSortedRowModel, - filterFns, - globalFilteringFeature, - metaHelper, - rowExpandingFeature, - rowPaginationFeature, - rowSelectionFeature, - rowSortingFeature, - sortFns, - tableFeatures, - useTable, -} from '@tanstack/react-table' import { tableDevtoolsPlugin, useTanStackTableDevtools, } from '@tanstack/react-table-devtools' -import { rankItem } from '@tanstack/match-sorter-utils' -import type { DragEndEvent } from '@dnd-kit/core' -import type { Key } from 'react-aria-components' -import type { Person } from '@/lib/make-data' -import type { - Column, - ColumnPinningState, - ColumnSizingState, - ExpandedState, - GroupingState, - Header, - ReactTable, - RowSelectionState, - SortingState, -} from '@tanstack/react-table' +import type { Column } from '@tanstack/react-table' import type { ExtendedColumnFilter } from '@/types' - -import { dynamicFilterFn, getFilterOperators } from '@/lib/data-table' -import { departments, makeData, statuses } from '@/lib/make-data' +import type { Person } from '@/lib/make-data' +import type { features } from '@/hooks/features' +import { makeData } from '@/lib/make-data' +import { useAppTable } from '@/hooks/table' +import { columns } from '@/columns' +import { cx } from '@/components/data-table/shared' import './styles/globals.css' -interface MyColumnMeta { - label?: string - variant?: 'text' | 'number' | 'date' | 'boolean' | 'select' | 'multi-select' - options?: Array<{ label: string; value: string; count?: number }> -} - -// Local fuzzy filter implementation for the filterFns registry slot. -// Defined here to avoid a circular type dependency with data-table.ts. -const fuzzyFilterFn = ( - row: { getValue: (id: string) => unknown }, - columnId: string, - value: unknown, - addMeta?: (meta: object) => void, -) => { - const itemRank = rankItem(row.getValue(columnId), value as string) - addMeta?.({ itemRank }) - return itemRank.passed -} - -export const features = tableFeatures({ - rowSortingFeature, - rowPaginationFeature, - rowSelectionFeature, - rowExpandingFeature, - columnFilteringFeature, - columnFacetingFeature, - columnOrderingFeature, - columnVisibilityFeature, - columnSizingFeature, - columnResizingFeature, - columnPinningFeature, - columnGroupingFeature, - globalFilteringFeature, - columnMeta: metaHelper(), - filteredRowModel: createFilteredRowModel(), - facetedRowModel: createFacetedRowModel(), - facetedUniqueValues: createFacetedUniqueValues(), - paginatedRowModel: createPaginatedRowModel(), - sortedRowModel: createSortedRowModel(), - groupedRowModel: createGroupedRowModel(), - expandedRowModel: createExpandedRowModel(), - filterFns: { ...filterFns, fuzzy: fuzzyFilterFn }, - sortFns, - aggregationFns, -}) - -const columnHelper = createColumnHelper() -type AppTable = ReactTable -type AppColumn = Column - -function cx(...classes: Array) { - return classes.filter(Boolean).join(' ') -} - -function getPageItems(pageIndex: number, pageCount: number) { - const currentPage = pageIndex + 1 - const pages = new Set([ - 1, - pageCount, - currentPage - 1, - currentPage, - currentPage + 1, - ]) - - return Array.from(pages) - .filter((page) => page >= 1 && page <= pageCount) - .sort((a, b) => a - b) - .reduce>((items, page) => { - const previous = items[items.length - 1] - if (typeof previous === 'number' && page - previous > 1) { - items.push('ellipsis') - } - items.push(page) - return items - }, []) -} - -function SortableFrame({ - id, - children, -}: { - id: string - children: React.ReactNode -}) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id }) - - return ( -
- {children} -
- ) -} - -function toSentenceCase(value: string) { - return value - .replace(/[-_]/g, ' ') - .replace(/\w\S*/g, (word) => word[0].toUpperCase() + word.slice(1)) -} - -function formatDate(value: unknown) { - return new Intl.DateTimeFormat('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }).format(new Date(String(value))) -} - -function toDateInputValue(value: unknown) { - if (!value) return '' - const date = new Date(String(value)) - return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10) -} - function getAriaSort(sortDirection: false | 'asc' | 'desc') { if (sortDirection === 'asc') return 'ascending' if (sortDirection === 'desc') return 'descending' return 'none' } -const SortingContext = React.createContext([]) - -function getSortDirection(sorting: SortingState, columnId: string) { - const sort = sorting.find((item) => item.id === columnId) - return sort ? (sort.desc ? 'desc' : 'asc') : undefined -} - function getCommonPinningStyles( - column: AppColumn, + column: Column, isSelected = false, ): React.CSSProperties { const isPinned = column.getIsPinned() @@ -273,820 +69,6 @@ function getCommonPinningStyles( } } -function DepartmentPill({ department }: { department: Person['department'] }) { - return ( - - - {department.slice(0, 2).toUpperCase()} - - {toSentenceCase(department)} - - ) -} - -function EllipsisText({ children }: { children: React.ReactNode }) { - return {children} -} - -function StatusBadge({ status }: { status: Person['status'] }) { - const className: Record = { - active: 'status-badge status-active', - inactive: 'status-badge status-inactive', - pending: 'status-badge status-pending', - } - - return {toSentenceCase(status)} -} - -function SelectionCheckbox({ - ariaLabel, - isSelected, - isIndeterminate, - onChange, -}: { - ariaLabel: string - isSelected: boolean - isIndeterminate?: boolean - onChange: (selected: boolean) => void -}) { - const ref = React.useRef(null) - - React.useEffect(() => { - if (ref.current) ref.current.indeterminate = !!isIndeterminate - }, [isIndeterminate]) - - return ( - onChange(event.currentTarget.checked)} - /> - ) -} - -function RowActions({ person }: { person: Person }) { - return ( - - - - - { - void navigator.clipboard.writeText(person.id) - }} - > - Copy ID - - View details - View profile - - - - ) -} - -function SortIcon({ direction }: { direction: 'asc' | 'desc' | undefined }) { - if (direction === 'asc') return - if (direction === 'desc') return - return ( - - ) -} - -function ColumnHeaderMenu({ - column, - title, -}: { - column: AppColumn - title: string -}) { - const canSort = column.getCanSort() - const canHide = column.getCanHide() - const canPin = column.getCanPin() - const canGroup = column.getCanGroup() - const sorting = React.useContext(SortingContext) - const direction = canSort ? getSortDirection(sorting, column.id) : undefined - const pinned = canPin ? column.getIsPinned() : false - const grouped = canGroup ? column.getIsGrouped() : false - - if (!canSort && !canHide && !canPin && !canGroup) { - return {title} - } - - return ( -
- {canSort ? ( - - ) : ( - {title} - )} - - - - - {canSort ? ( - <> - column.toggleSorting(false)}> - Asc - - column.toggleSorting(true)}> - Desc - - - ) : null} - {canGroup ? ( - - {grouped ? 'Ungroup' : 'Group by'} - - ) : null} - {canPin ? ( - <> - column.pin('left')} - > - Pin left - - column.pin('right')} - > - Pin right - - {pinned ? ( - column.pin(false)}> - Unpin - - ) : null} - - ) : null} - {canHide ? ( - column.toggleVisibility(false)} - > - Hide - - ) : null} - - - -
- ) -} - -function AriaSelect({ - label, - value, - options, - className, - showLabel = true, - onChange, -}: { - label: string - value: string | null - options: Array<{ value: string; label: string }> - className?: string - showLabel?: boolean - onChange: (value: string) => void -}) { - return ( - - ) -} - -function ViewOptionsPopover({ - table, - columnOrder, - onColumnOrderChange, -}: { - table: AppTable - columnOrder: Array - onColumnOrderChange: React.Dispatch>> -}) { - const [query, setQuery] = React.useState('') - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), - ) - const columns = table - .getAllColumns() - .filter((column) => typeof column.accessorFn !== 'undefined') - .sort((a, b) => columnOrder.indexOf(a.id) - columnOrder.indexOf(b.id)) - .filter((column) => - (column.columnDef.meta?.label ?? column.id) - .toLowerCase() - .includes(query.toLowerCase()), - ) - - const onDragEnd = (event: DragEndEvent) => { - const { active, over } = event - if (!over || active.id === over.id) return - - onColumnOrderChange((current) => { - const oldIndex = current.indexOf(String(active.id)) - const newIndex = current.indexOf(String(over.id)) - return oldIndex >= 0 && newIndex >= 0 - ? arrayMove(current, oldIndex, newIndex) - : current - }) - } - - return ( - - - - - setQuery(event.currentTarget.value)} - /> - - column.id)} - strategy={verticalListSortingStrategy} - > -
- {columns.map((column) => ( - -
- - column.toggleVisibility(selected) - } - > - {column.columnDef.meta?.label ?? column.id} - - -
-
- ))} -
-
-
-
-
-
- ) -} - -function SortListPopover({ - table, - sorting, - onSortingChange, -}: { - table: AppTable - sorting: SortingState - onSortingChange: React.Dispatch> -}) { - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), - ) - const sortableColumns = table - .getAllColumns() - .filter((column) => column.getCanSort()) - const columnOptions = sortableColumns.map((column) => ({ - value: column.id, - label: column.columnDef.meta?.label ?? column.id, - })) - - const updateSort = (index: number, patch: Partial) => { - onSortingChange((current) => - current.map((sort, sortIndex) => - sortIndex === index ? { ...sort, ...patch } : sort, - ), - ) - } - - const addSort = () => { - const nextColumn = sortableColumns.find( - (column) => !sorting.some((sort) => sort.id === column.id), - ) - if (nextColumn) { - onSortingChange((current) => [ - ...current, - { id: nextColumn.id, desc: false }, - ]) - } - } - - const onDragEnd = (event: DragEndEvent) => { - const { active, over } = event - if (!over || active.id === over.id) return - - onSortingChange((current) => { - const oldIndex = current.findIndex((sort) => sort.id === active.id) - const newIndex = current.findIndex((sort) => sort.id === over.id) - return oldIndex >= 0 && newIndex >= 0 - ? arrayMove(current, oldIndex, newIndex) - : current - }) - } - - return ( - - - - -
- {sorting.length ? 'Sort by' : 'No sorting applied'} -
- - sort.id)} - strategy={verticalListSortingStrategy} - > -
- {sorting.map((sort, index) => ( - -
- - updateSort(index, { id: value })} - /> - - updateSort(index, { desc: value === 'desc' }) - } - /> - -
-
- ))} -
-
-
-
- - -
-
-
-
- ) -} - -function FilterValueInput({ - column, - filter, - onFilterUpdate, -}: { - column: AppColumn - filter: ExtendedColumnFilter - onFilterUpdate: ( - filterId: string, - patch: Partial, - ) => void -}) { - if (!filter.filterId) return null - const variant = column.columnDef.meta?.variant ?? 'text' - const operator = filter.operator ?? 'includesString' - const disabled = operator === 'isEmpty' || operator === 'isNotEmpty' - - if (disabled) - return
No value required
- - if (variant === 'select') { - const options = column.columnDef.meta?.options ?? [] - return ( - onFilterUpdate(filter.filterId!, { value })} - /> - ) - } - - if (variant === 'multi-select') { - const options = column.columnDef.meta?.options ?? [] - const values = Array.isArray(filter.value) - ? filter.value.map(String) - : typeof filter.value === 'string' && filter.value - ? [filter.value] - : [] - return ( - - ) - } - - if (variant === 'date') { - if (operator === 'inRange') { - const value = Array.isArray(filter.value) ? filter.value : [] - return ( -
- - onFilterUpdate(filter.filterId!, { - value: [ - event.currentTarget.value - ? new Date(event.currentTarget.value).toISOString() - : undefined, - value[1], - ], - }) - } - /> - - onFilterUpdate(filter.filterId!, { - value: [ - value[0], - event.currentTarget.value - ? new Date(event.currentTarget.value).toISOString() - : undefined, - ], - }) - } - /> -
- ) - } - - return ( - - onFilterUpdate(filter.filterId!, { - value: event.currentTarget.value - ? new Date(event.currentTarget.value).toISOString() - : undefined, - }) - } - /> - ) - } - - if (variant === 'number') { - return ( - - onFilterUpdate(filter.filterId!, { - value: - event.currentTarget.value === '' - ? '' - : Number(event.currentTarget.value), - }) - } - /> - ) - } - - return ( - - onFilterUpdate(filter.filterId!, { value: event.currentTarget.value }) - } - /> - ) -} - -function FilterListPopover({ - table, - columnFilters, - onColumnFiltersChange, -}: { - table: AppTable - columnFilters: Array - onColumnFiltersChange: React.Dispatch< - React.SetStateAction> - > -}) { - const filterableColumns = table - .getAllColumns() - .filter((column) => column.getCanFilter()) - const fieldOptions = filterableColumns.map((column) => ({ - value: column.id, - label: column.columnDef.meta?.label ?? column.id, - })) - - const updateFilter = ( - filterId: string, - patch: Partial, - ) => { - onColumnFiltersChange((current) => - current.map((filter) => - filter.filterId === filterId ? { ...filter, ...patch } : filter, - ), - ) - } - - const addFilter = () => { - const [column] = filterableColumns - if (!column) return - onColumnFiltersChange((current) => [ - ...current, - { - id: column.id, - filterId: crypto.randomUUID(), - value: '', - operator: 'includesString', - joinOperator: current[0]?.joinOperator ?? 'and', - }, - ]) - } - - return ( - - - - -
Filters
- {columnFilters.map((filter, index) => { - const column = table.getColumn(filter.id) - if (!column || !filter.filterId) return null - const variant = column.columnDef.meta?.variant ?? 'text' - const operators = getFilterOperators(variant) - return ( -
- {index === 0 ? ( -
Where
- ) : index === 1 ? ( - - onColumnFiltersChange((current) => - current.map((item) => ({ - ...item, - joinOperator: joinOperator as 'and' | 'or', - })), - ) - } - /> - ) : ( -
- {filter.joinOperator ?? 'and'} -
- )} - { - const nextColumn = table.getColumn(nextColumnId) - if (nextColumn) { - updateFilter(filter.filterId!, { - id: nextColumn.id, - operator: getFilterOperators( - nextColumn.columnDef.meta?.variant ?? 'text', - )[0].value, - value: '', - }) - } - }} - /> - ({ - value: operator.value, - label: operator.label, - }))} - onChange={(operator) => - updateFilter(filter.filterId!, { - operator: operator as ExtendedColumnFilter['operator'], - value: '', - }) - } - /> - - -
- ) - })} -
- - -
-
-
-
- ) -} - -function Pagination({ table }: { table: AppTable }) { - const pageIndex = table.state.pagination.pageIndex - const pageSize = table.state.pagination.pageSize - const pageItems = getPageItems(pageIndex, table.getPageCount()) - - return ( -
-
- {table.getFilteredSelectedRowModel().rows.length.toLocaleString()} of{' '} - {table.getFilteredRowModel().rows.length.toLocaleString()} row(s) - selected. -
-
- Rows per page: - ({ - value, - label: value, - }))} - onChange={(value) => { - table.setPageSize(Number(value)) - table.setPageIndex(0) - }} - /> - - - -
-
- ) -} - function ModeSwitch() { const [isDark, setIsDark] = React.useState(() => document.documentElement.classList.contains('dark'), @@ -1143,238 +125,41 @@ function DebouncedTextInput({ } function App() { - const [rowSelection, setRowSelection] = React.useState({}) - const [sorting, setSorting] = React.useState([]) const [columnFilters, setColumnFilters] = React.useState< Array >([]) - const [columnVisibility, setColumnVisibility] = React.useState({}) - const [columnSizing, setColumnSizing] = React.useState({}) const [globalFilter, setGlobalFilter] = React.useState('') - const [columnPinning, setColumnPinning] = React.useState({ - left: ['select'], - right: ['actions'], - }) - const [grouping, setGrouping] = React.useState([]) - const [expanded, setExpanded] = React.useState({}) const [data, setData] = React.useState(() => makeData(1_000)) - const columns = React.useMemo( - () => - columnHelper.columns([ - columnHelper.display({ - id: 'select', - header: ({ table }) => ( - table.toggleAllPageRowsSelected(selected)} - /> - ), - cell: ({ row }) => ( - row.toggleSelected(selected)} - /> - ), - size: 64, - minSize: 64, - maxSize: 64, - enableSorting: false, - enableHiding: false, - enableResizing: false, - }), - columnHelper.accessor('firstName', { - id: 'firstName', - header: ({ column }) => ( - - ), - cell: (info) => ( - {String(info.getValue())} - ), - meta: { label: 'First Name', variant: 'text' }, - }), - columnHelper.accessor((row) => row.lastName, { - id: 'lastName', - header: ({ column }) => ( - - ), - cell: (info) => ( - {String(info.getValue())} - ), - meta: { label: 'Last Name', variant: 'text' }, - }), - columnHelper.accessor('age', { - id: 'age', - header: ({ column }) => ( - - ), - cell: (info) => ( - {String(info.getValue())} - ), - aggregationFn: 'mean', - aggregatedCell: ({ getValue }) => ( - - Avg: {Math.round(Number(getValue()) * 10) / 10} - - ), - meta: { label: 'Age', variant: 'number' }, - }), - columnHelper.accessor('email', { - id: 'email', - header: ({ column }) => ( - - ), - cell: (info) => ( - {info.cell.getValue()} - ), - meta: { label: 'Email', variant: 'text' }, - }), - columnHelper.accessor('status', { - id: 'status', - header: ({ column }) => ( - - ), - cell: (info) => { - const status = info.getValue() - return status ? : null - }, - aggregatedCell: () => null, - meta: { - label: 'Status', - variant: 'select', - options: statuses.map((status) => ({ - label: toSentenceCase(status), - value: status, - })), - }, - }), - columnHelper.accessor('department', { - id: 'department', - header: ({ column }) => ( - - ), - cell: (info) => { - const department = info.getValue() - return department ? ( - - ) : null - }, - aggregatedCell: () => null, - meta: { - label: 'Department', - variant: 'multi-select', - options: departments.map((department) => ({ - label: toSentenceCase(department), - value: department, - })), - }, - }), - columnHelper.accessor('joinDate', { - id: 'joinDate', - header: ({ column }) => ( - - ), - cell: (info) => formatDate(info.getValue()), - aggregationFn: 'min', - aggregatedCell: ({ getValue }) => { - const earliest = getValue() - return ( - - Earliest: {earliest ? formatDate(earliest) : '-'} - - ) - }, - meta: { label: 'Join Date', variant: 'date' }, - }), - columnHelper.accessor((row) => row.age, { - id: 'progress', - header: ({ column }) => ( - - ), - cell: (info) => { - const value = Math.min(100, Math.max(0, Number(info.getValue()))) - return ( - - {({ percentage }) => ( -
-
-
- )} - - ) - }, - meta: { label: 'Profile Progress', variant: 'number' }, - }), - columnHelper.display({ - id: 'actions', - enableHiding: false, - cell: ({ row }) => , - size: 72, - minSize: 72, - maxSize: 72, - enableResizing: false, - }), - ]), - [], - ) - - const [columnOrder, setColumnOrder] = React.useState>(() => - columns.map((column) => column.id ?? ''), - ) + const refreshData = () => setData(makeData(1_000)) + const stressTest = () => setData(makeData(1_000_000)) - const table = useTable( + const table = useAppTable( { - key: 'kitchen-sink-react-aria', // needed for devtools - features, + key: 'kitchen-sink-react-aria', columns, data, - defaultColumn: { - minSize: 60, - maxSize: 800, - filterFn: dynamicFilterFn, - }, - globalFilterFn: 'fuzzy', + debugTable: true, state: { - rowSelection, - sorting, - columnVisibility, - columnOrder, - columnSizing, columnFilters, globalFilter, - columnPinning, - grouping, - expanded, }, - onSortingChange: setSorting, - onColumnVisibilityChange: setColumnVisibility, - onColumnOrderChange: setColumnOrder, - onColumnSizingChange: setColumnSizing, onColumnFiltersChange: setColumnFilters, onGlobalFilterChange: setGlobalFilter, - onColumnPinningChange: setColumnPinning, - onGroupingChange: setGrouping, - onExpandedChange: setExpanded, - getRowId: (row) => row.id, - enableRowSelection: true, - onRowSelectionChange: setRowSelection, - columnResizeMode: 'onChange', - debugTable: true, + initialState: { + columnPinning: { left: ['select'], right: ['actions'] }, + columnOrder: columns.map((c) => c.id ?? ''), + }, }, - (state) => state, // default selector + (state) => state, // default state selector ) useTanStackTableDevtools(table) + // Keep tableLayout: 'fixed' + CSS-variable column widths because + // React Aria Components render real / +
+ + {{ + asc: ' 🔼', + desc: ' 🔽', + }[header.column.getIsSorted() as string] ?? null} +
+
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + className={`resizer ${ + header.column.getIsResizing() ? 'isResizing' : '' + }`} + /> + + )} ) } @@ -408,15 +410,17 @@ interface TableBodyCellProps { function TableBodyCell({ cell, table }: TableBodyCellProps) { return ( -
+ {() => ( + + )} ) } diff --git a/packages/table-core/src/utils.ts b/packages/table-core/src/utils.ts index c77e876b31..81cd79b4f7 100755 --- a/packages/table-core/src/utils.ts +++ b/packages/table-core/src/utils.ts @@ -254,8 +254,7 @@ export function tableMemo< let debugCache: boolean | undefined if (process.env.NODE_ENV === 'development') { - const { debugCache: _debugCache, debugAll } = table.options - debugCache = _debugCache + const { debugAll } = table.options const { parentName } = getFunctionNameInfo(fnName, '.') const debugByParent = diff --git a/test-results/.last-run.json b/test-results/.last-run.json index f740f7c700..cbcc1fbac1 100644 --- a/test-results/.last-run.json +++ b/test-results/.last-run.json @@ -1,4 +1,4 @@ { "status": "passed", "failedTests": [] -} +} \ No newline at end of file
/ elements. + // Setting display: grid/flex on semantic table elements breaks + // native table layout and accessibility semantics. const columnSizeVars = React.useMemo(() => { const headers = table.getFlatHeaders() const colSizes: Record = {} @@ -1385,11 +170,8 @@ function App() { return colSizes }, [table.state.columnSizing]) - const refreshData = () => setData(makeData(1_000)) - const stressTest = () => setData(makeData(1_000_000)) - return ( - +
@@ -1420,21 +202,9 @@ function App() { />
- - - + + +
@@ -1454,11 +224,31 @@ function App() { .getHeaderGroups()[0] ?.headers.filter((header) => header.column.getIsVisible()) .map((header) => ( - + id={header.id} + allowsSorting={header.column.getCanSort()} + isRowHeader={header.column.id === 'firstName'} + aria-sort={getAriaSort(header.column.getIsSorted())} + className={cx( + 'header-cell', + header.column.id === 'select' && 'select-cell', + header.column.id === 'actions' && 'action-cell', + )} + style={{ + width: `calc(var(--header-${header.id}-size) * 1px)`, + ...getCommonPinningStyles(header.column), + }} + > + + {(h) => ( +
+ {h.isPlaceholder ? null : } + +
+ )} +
+ ))} @@ -1484,24 +274,15 @@ function App() { ...getCommonPinningStyles(cell.column, selected), }} > - {cell.getIsGrouped() ? ( - - ) : ( - - )} + + {(c) => + c.getIsGrouped() ? ( + + ) : ( + + ) + } + ))} @@ -1510,59 +291,11 @@ function App() { - +
-
- ) -} - -function ResizableHeaderCell({ - header, - table, -}: { - header: Header - table: { - FlexRender: React.ComponentType<{ - header: Header - }> - } -}) { - const sorting = React.useContext(SortingContext) - const sortDirection = getSortDirection(sorting, header.column.id) - - return ( - -
- {header.isPlaceholder ? null : } - {header.column.getCanResize() ? ( -
header.column.resetSize()} - onMouseDown={header.getResizeHandler()} - onTouchStart={header.getResizeHandler()} - className={cx( - 'column-resizer', - header.column.getIsResizing() && 'bg-primary', - )} - /> - ) : null} -
- + ) } diff --git a/examples/react/kitchen-sink-react-aria/src/styles/globals.css b/examples/react/kitchen-sink-react-aria/src/styles/globals.css index 8b1c37df43..d19f7f770f 100644 --- a/examples/react/kitchen-sink-react-aria/src/styles/globals.css +++ b/examples/react/kitchen-sink-react-aria/src/styles/globals.css @@ -249,6 +249,14 @@ body { padding: 0.25rem 0.5rem; } +.header-cell .icon-button { + min-width: 1.5rem; + min-height: 1.5rem; + padding: 0.125rem 0.25rem; + border: 0; + background: transparent; +} + .header-sort-button, .group-toggle-button { min-height: 1.75rem; diff --git a/examples/react/kitchen-sink-shadcn-base/src/columns.tsx b/examples/react/kitchen-sink-shadcn-base/src/columns.tsx new file mode 100644 index 0000000000..45775cd127 --- /dev/null +++ b/examples/react/kitchen-sink-shadcn-base/src/columns.tsx @@ -0,0 +1,108 @@ +import type { Person } from '@/lib/make-data' +import { createAppColumnHelper } from '@/hooks/table' +import { + AgeAggregatedCell, + JoinDateAggregatedCell, +} from '@/components/data-table/cell-components' +import { toSentenceCase } from '@/lib/utils' +import { departments, statuses } from '@/lib/make-data' + +const columnHelper = createAppColumnHelper() + +export const columns = columnHelper.columns([ + columnHelper.display({ + id: 'select', + header: ({ header }) => , + cell: ({ cell }) => , + maxSize: 40, + enableSorting: false, + enableHiding: false, + enableResizing: false, + }), + columnHelper.accessor('firstName', { + id: 'firstName', + header: ({ header }) => , + cell: ({ cell }) => , + meta: { + label: 'First Name', + variant: 'text', + }, + }), + columnHelper.accessor((row) => row.lastName, { + id: 'lastName', + header: ({ header }) => , + cell: ({ cell }) => , + meta: { + label: 'Last Name', + variant: 'text', + }, + }), + columnHelper.accessor('age', { + id: 'age', + header: ({ header }) => , + cell: ({ cell }) => , + aggregationFn: 'mean', + aggregatedCell: () => , + size: 80, + meta: { + label: 'Age', + variant: 'number', + }, + }), + columnHelper.accessor('email', { + id: 'email', + header: ({ header }) => , + cell: ({ cell }) => , + size: 200, + meta: { + label: 'Email', + variant: 'text', + }, + }), + columnHelper.accessor('status', { + id: 'status', + header: ({ header }) => , + cell: ({ cell }) => , + aggregatedCell: () => null, + meta: { + label: 'Status', + variant: 'select', + options: statuses.map((status) => ({ + label: toSentenceCase(status), + value: status, + })), + }, + }), + columnHelper.accessor('department', { + id: 'department', + header: ({ header }) => , + cell: ({ cell }) => , + aggregatedCell: () => null, + meta: { + label: 'Department', + variant: 'multi-select', + options: departments.map((department) => ({ + label: toSentenceCase(department), + value: department, + })), + }, + }), + columnHelper.accessor('joinDate', { + id: 'joinDate', + header: ({ header }) => , + cell: ({ cell }) => , + aggregationFn: 'min', + aggregatedCell: () => , + meta: { + label: 'Join Date', + variant: 'date', + }, + }), + columnHelper.display({ + id: 'actions', + enableHiding: false, + cell: ({ cell }) => , + maxSize: 40, + enableResizing: false, + }), +]) diff --git a/examples/react/kitchen-sink-shadcn-base/src/components/data-table/cell-components.tsx b/examples/react/kitchen-sink-shadcn-base/src/components/data-table/cell-components.tsx new file mode 100644 index 0000000000..f57ac9faef --- /dev/null +++ b/examples/react/kitchen-sink-shadcn-base/src/components/data-table/cell-components.tsx @@ -0,0 +1,188 @@ +'use client' + +import { + CheckCircle, + ChevronDown, + ChevronRight, + Clock, + Code, + CreditCard, + Megaphone, + MoreHorizontal, + ShoppingCart, + Users, + XCircle, +} from 'lucide-react' +import { Subscribe } from '@tanstack/react-table' +import type { Person } from '@/lib/make-data' +import { useCellContext, useTableContext } from '@/hooks/table' +import { Checkbox } from '@/components/ui/checkbox' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { formatDate, toSentenceCase } from '@/lib/utils' + +export function SelectCell(): React.ReactNode { + const cell = useCellContext() + const table = useTableContext() + const row = cell.row + + return ( + + {() => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + )} + + ) +} + +export function TextCell(): React.ReactNode { + const cell = useCellContext() + return <>{String(cell.getValue())} +} + +export function AgeCell(): React.ReactNode { + const cell = useCellContext() + return {String(cell.getValue())} +} + +export function StatusCell(): React.ReactNode { + const cell = useCellContext() + const status = cell.getValue() + if (!status) return null + + const icons: Record = { + active: , + inactive: , + pending: , + } + + return ( + + {icons[status]} + {toSentenceCase(status)} + + ) +} + +export function DepartmentCell(): React.ReactNode { + const cell = useCellContext() + const department = cell.getValue() + if (!department) return null + + const icons: Record = { + engineering: , + marketing: , + sales: , + hr: , + finance: , + } + + return ( + + {icons[department]} + {toSentenceCase(department)} + + ) +} + +export function DateCell(): React.ReactNode { + const cell = useCellContext() + return <>{formatDate(cell.getValue())} +} + +export function GroupedCell(): React.ReactNode { + const cell = useCellContext() + const table = useTableContext() + const row = cell.row + + return ( + + {() => ( + + )} + + ) +} + +export function ActionsCell(): React.ReactNode { + const cell = useCellContext() + const person = cell.row.original as Person + + return ( + + } + > + Open menu + + + + Actions + navigator.clipboard.writeText(person.id)} + > + Copy ID + + + View details + View profile + + + ) +} + +export function AgeAggregatedCell(): React.ReactNode { + const cell = useCellContext() + return ( + + Avg: {Math.round(Number(cell.getValue()) * 10) / 10} + + ) +} + +export function JoinDateAggregatedCell(): React.ReactNode { + const cell = useCellContext() + const earliest = cell.getValue() + return ( + + Earliest: {earliest ? formatDate(earliest) : '—'} + + ) +} diff --git a/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-column-header.tsx b/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-column-header.tsx index 4586b08953..df9d429811 100644 --- a/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-column-header.tsx +++ b/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-column-header.tsx @@ -10,8 +10,8 @@ import { PinOff, Ungroup, } from 'lucide-react' -import type { CellData, Column, RowData } from '@tanstack/react-table' -import type { features } from '@/main' +import { Subscribe } from '@tanstack/react-table' +import { useHeaderContext, useTableContext } from '@/hooks/table' import { Button } from '@/components/ui/button' import { @@ -23,130 +23,141 @@ import { } from '@/components/ui/dropdown-menu' import { cn } from '@/lib/utils' -/** - * Per-column header dropdown that consolidates the v9 column actions: - * sort asc/desc, group by, pin left/right, unpin, and hide. Items are - * conditionally rendered based on `column.getCan*()` so it's safe to use - * even when some features are not registered. - * - * Inspired by the shadcn data-table docs `DataTableColumnHeader` component - * (https://ui.shadcn.com/docs/components/radix/data-table) but extended to - * cover grouping and pinning since the kitchen-sink uses the full v9 surface. - */ -interface DataTableColumnHeaderProps< - TData extends RowData, - TValue extends CellData = CellData, -> extends React.HTMLAttributes { - column: Column - title: string -} +export function ColumnHeader({ + title, + className, +}: { + title?: string + className?: string +}): React.ReactNode { + const header = useHeaderContext() + const table = useTableContext() + const column = header.column + + const displayTitle = column.columnDef.meta?.label ?? title ?? column.id -export function DataTableColumnHeader< - TData extends RowData, - TValue extends CellData = CellData, ->({ column, title, className }: DataTableColumnHeaderProps) { const canSort = column.getCanSort() const canHide = column.getCanHide() const canPin = column.getCanPin() const canGroup = column.getCanGroup() - // No actions available — render the title plain. if (!canSort && !canHide && !canPin && !canGroup) { - return
{title}
+ return
{displayTitle}
} - const sorted = canSort ? column.getIsSorted() : false - const pinned = canPin ? column.getIsPinned() : false - const grouped = canGroup ? column.getIsGrouped() : false - return (
- - - } - > - {title} - {sorted === 'desc' ? ( - - ) : sorted === 'asc' ? ( - - ) : canSort ? ( - - ) : null} - - - {canSort && ( - <> - column.toggleSorting(false)}> - - Asc - - column.toggleSorting(true)}> - - Desc - - - )} - {canGroup && ( - <> - {canSort ? : null} - - {grouped ? ( + ({ + sorting: s.sorting, + grouping: s.grouping, + columnPinning: s.columnPinning, + })} + > + {() => { + const sorted = canSort ? column.getIsSorted() : false + const pinned = canPin ? column.getIsPinned() : false + const grouped = canGroup ? column.getIsGrouped() : false + + return ( + + + } + > + {displayTitle} + {sorted === 'desc' ? ( + + ) : sorted === 'asc' ? ( + + ) : canSort ? ( + + ) : null} + + + {canSort && ( <> - - Ungroup + column.toggleSorting(false)} + > + + Asc + + column.toggleSorting(true)} + > + + Desc + - ) : ( + )} + {canGroup && ( <> - - Group by + {canSort ? : null} + + {grouped ? ( + <> + + Ungroup + + ) : ( + <> + + Group by + + )} + )} - - - )} - {canPin && ( - <> - {canSort || canGroup ? : null} - column.pin('left')} - disabled={pinned === 'left'} - > - - Pin left - - column.pin('right')} - disabled={pinned === 'right'} - > - - Pin right - - {pinned ? ( - column.pin(false)}> - - Unpin - - ) : null} - - )} - {canHide && ( - <> - - column.toggleVisibility(false)}> - - Hide - - - )} - - + {canPin && ( + <> + {canSort || canGroup ? : null} + column.pin('left')} + disabled={pinned === 'left'} + > + + Pin left + + column.pin('right')} + disabled={pinned === 'right'} + > + + Pin right + + {pinned ? ( + column.pin(false)}> + + Unpin + + ) : null} + + )} + {canHide && ( + <> + + column.toggleVisibility(false)} + > + + Hide + + + )} + + + ) + }} +
) } diff --git a/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-filter-list.tsx b/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-filter-list.tsx index 0cff657242..1900ede680 100644 --- a/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-filter-list.tsx +++ b/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-filter-list.tsx @@ -9,14 +9,13 @@ import { ListFilter, Trash2, } from 'lucide-react' -import type { ExtendedColumnFilter, FilterOperator } from '@/types' import type { - CellData, - Column, - ReactTable, - RowData, -} from '@tanstack/react-table' -import type { features } from '@/main' + ExtendedColumnFilter, + FilterOperator, + JoinOperator, +} from '@/types' +import type { Column, RowData } from '@tanstack/react-table' +import type { features } from '@/hooks/features' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -55,11 +54,12 @@ import { CommandItem, CommandList, } from '@/components/ui/command' +import { useTableContext } from '@/hooks/table' -function getColumnOptions({ +function getColumnOptions({ column, }: { - column: Column + column: Column }): Array<{ label: string; value: string; count?: number }> { const customOptions = column.columnDef.meta?.options @@ -74,17 +74,18 @@ function getColumnOptions({ })) } -interface DataTableFilterListProps { - table: ReactTable - columnFilters: Array - onColumnFiltersChange: (filters: Array) => void -} +export function DataTableFilterList(): React.ReactNode { + const table = useTableContext() + const columnFilters = table.state.columnFilters as Array + + // Write through the raw controlled-state handler instead of + // `table.setColumnFilters`: the table API auto-removes filters with empty + // values, but a just-added filter row legitimately starts with `value: ''` + // while the user is still building it. + const setColumnFilters = (filters: Array) => { + table.options.onColumnFiltersChange?.(filters) + } -export function DataTableFilterList({ - table, - columnFilters, - onColumnFiltersChange, -}: DataTableFilterListProps) { const id = React.useId() const labelId = React.useId() const descriptionId = React.useId() @@ -97,7 +98,7 @@ export function DataTableFilterList({ ) const getColumnFilterVariant = React.useCallback( - (column: Column) => { + (column: Column) => { if (column.columnDef.meta?.variant) { return column.columnDef.meta.variant } @@ -141,9 +142,9 @@ export function DataTableFilterList({ const newFilter = onFilterAddImpl(firstFilterableColumn.id) if (newFilter) { - onColumnFiltersChange([...columnFilters, newFilter]) + setColumnFilters([...columnFilters, newFilter]) } - }, [columnFilters, onFilterAddImpl, filterableColumns, onColumnFiltersChange]) + }, [columnFilters, onFilterAddImpl, filterableColumns, table]) const onFilterUpdate = React.useCallback( ( @@ -199,14 +200,14 @@ export function DataTableFilterList({ } return filter }) - onColumnFiltersChange(newFilters) + setColumnFilters(newFilters) }, [ columnFilters, filterableColumns, getColumnFilterVariant, getFilterOperators, - onColumnFiltersChange, + table, ], ) @@ -215,9 +216,9 @@ export function DataTableFilterList({ const newFilters = columnFilters.filter((filter) => { return filter.filterId !== filterId }) - onColumnFiltersChange(newFilters) + setColumnFilters(newFilters) }, - [columnFilters, onColumnFiltersChange], + [columnFilters, table], ) const onFilterInputRender = React.useCallback( @@ -227,7 +228,7 @@ export function DataTableFilterList({ filterId, inputId, }: { - column: Column + column: Column operator: FilterOperator filterId: string inputId: string @@ -669,9 +670,9 @@ export function DataTableFilterList({ if (columnFilters.length > 0) { const updatedFilters = columnFilters.map((f) => ({ ...f, - joinOperator: value, + joinOperator: value as JoinOperator, })) - onColumnFiltersChange(updatedFilters) + setColumnFilters(updatedFilters) } }} > @@ -913,7 +914,7 @@ export function DataTableFilterList({ aria-label="Reset all filters" variant="outline" size="sm" - onClick={() => onColumnFiltersChange([])} + onClick={() => setColumnFilters([])} > Reset filters diff --git a/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-pagination.tsx b/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-pagination.tsx index 1bda90c5e9..7c15dcd327 100644 --- a/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-pagination.tsx +++ b/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-pagination.tsx @@ -6,8 +6,8 @@ import { ChevronsLeft, ChevronsRight, } from 'lucide-react' -import type { ReactTable, RowData } from '@tanstack/react-table' -import type { features } from '@/main' +import * as React from 'react' +import { useTableContext } from '@/hooks/table' import { Button } from '@/components/ui/button' import { @@ -18,15 +18,13 @@ import { SelectValue, } from '@/components/ui/select' -interface DataTablePaginationProps { - table: ReactTable +export function DataTablePagination({ + pageSizeOptions = [10, 20, 30, 40, 50], +}: { pageSizeOptions?: Array -} +}): React.ReactNode { + const table = useTableContext() -export function DataTablePagination({ - table, - pageSizeOptions = [10, 20, 30, 40, 50], -}: DataTablePaginationProps) { return (
diff --git a/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-sort-list.tsx b/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-sort-list.tsx index b032bec4f5..86e6faaa1e 100644 --- a/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-sort-list.tsx +++ b/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-sort-list.tsx @@ -8,14 +8,8 @@ import { GripVertical, Trash2, } from 'lucide-react' -import type { - ColumnSort, - ReactTable, - RowData, - SortDirection, - SortingState, -} from '@tanstack/react-table' -import type { features } from '@/main' +import type { ColumnSort, SortDirection } from '@tanstack/react-table' +import { useTableContext } from '@/hooks/table' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -48,17 +42,10 @@ import { } from '@/components/ui/sortable' import { cn } from '@/lib/utils' -interface DataTableSortListProps { - table: ReactTable - sorting: SortingState - onSortingChange: (sorting: SortingState) => void -} +export function DataTableSortList(): React.ReactNode { + const table = useTableContext() + const sorting = table.state.sorting -export function DataTableSortList({ - table, - sorting, - onSortingChange, -}: DataTableSortListProps) { const labelId = React.useId() const descriptionId = React.useId() const listId = React.useId() @@ -112,7 +99,7 @@ export function DataTableSortList({ return ( table.setSorting(value)} getItemValue={(item) => item.id} > diff --git a/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-view-options.tsx b/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-view-options.tsx index ba24a65fa4..7f95e3aebe 100644 --- a/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-view-options.tsx +++ b/examples/react/kitchen-sink-shadcn-base/src/components/data-table/data-table-view-options.tsx @@ -2,12 +2,7 @@ import * as React from 'react' import { Check, ChevronsUpDown, GripVertical, Settings2 } from 'lucide-react' -import type { - ColumnOrderState, - ReactTable, - RowData, -} from '@tanstack/react-table' -import type { features } from '@/main' +import { useTableContext } from '@/hooks/table' import { Button } from '@/components/ui/button' import { @@ -33,21 +28,16 @@ import { SortableOverlay, } from '@/components/ui/sortable' -interface DataTableViewOptionsProps { - table: ReactTable - columnOrder: ColumnOrderState - onColumnOrderChange: (columnOrder: ColumnOrderState) => void -} - -export function DataTableViewOptions({ - table, - columnOrder, - onColumnOrderChange, -}: DataTableViewOptionsProps) { +export function DataTableViewOptions(): React.ReactNode { + const table = useTableContext() const triggerRef = React.useRef(null) + const columnOrder = table.state.columnOrder return ( - + table.setColumnOrder(order)} + > ({ size="sm" className="ml-auto hidden h-8 gap-2 focus:outline-none focus:ring-1 focus:ring-ring lg:flex" onPointerDown={(event) => { - // prevent implicit pointer capture - // https://www.w3.org/TR/pointerevents3/#implicit-pointer-capture const target = event.target if (!(target instanceof HTMLElement)) return if (target.hasPointerCapture(event.pointerId)) { @@ -72,7 +60,6 @@ export function DataTableViewOptions({ event.ctrlKey === false && event.pointerType === 'mouse' ) { - // prevent trigger from stealing focus from the active item after opening. event.preventDefault() } }} diff --git a/examples/react/kitchen-sink-shadcn-base/src/components/data-table/header-components.tsx b/examples/react/kitchen-sink-shadcn-base/src/components/data-table/header-components.tsx new file mode 100644 index 0000000000..4994c0799a --- /dev/null +++ b/examples/react/kitchen-sink-shadcn-base/src/components/data-table/header-components.tsx @@ -0,0 +1,50 @@ +'use client' + +import { Subscribe } from '@tanstack/react-table' +import { useHeaderContext, useTableContext } from '@/hooks/table' +import { Checkbox } from '@/components/ui/checkbox' +import { cn } from '@/lib/utils' + +export function SelectAllHeader(): React.ReactNode { + const table = useTableContext() + + return ( + + {() => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + )} + + ) +} + +export function ResizeHandle(): React.ReactNode { + const header = useHeaderContext() + const table = useTableContext() + + if (!header.column.getCanResize()) return null + + return ( + + {() => ( +
header.column.resetSize()} + onMouseDown={header.getResizeHandler()} + onTouchStart={header.getResizeHandler()} + className={cn( + 'absolute right-[-2px] z-10 top-1/2 h-6 w-[3px] -translate-y-1/2 cursor-e-resize select-none touch-none rounded-md transition-colors hover:bg-blue-600 before:absolute before:left-[-4px] before:right-[-4px] before:top-0 before:h-full before:content-[""]', + header.column.getIsResizing() && 'bg-blue-600', + )} + /> + )} + + ) +} diff --git a/examples/react/kitchen-sink-shadcn-base/src/components/ui/table.tsx b/examples/react/kitchen-sink-shadcn-base/src/components/ui/table.tsx index cf9aef00b2..65bb62a034 100644 --- a/examples/react/kitchen-sink-shadcn-base/src/components/ui/table.tsx +++ b/examples/react/kitchen-sink-shadcn-base/src/components/ui/table.tsx @@ -12,7 +12,7 @@ function Table({ className, ...props }: React.ComponentProps<'table'>) { > @@ -23,7 +23,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) { return ( ) @@ -33,7 +33,7 @@ function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) { return ( ) @@ -44,7 +44,7 @@ function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) { tr]:last:border-b-0', + 'grid border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className, )} {...props} @@ -57,7 +57,7 @@ function TableRow({ className, ...props }: React.ComponentProps<'tr'>) { ) {
[role=checkbox]]:translate-y-[2px]', + 'flex shrink-0 items-center overflow-clip h-10 px-2 text-left font-medium whitespace-nowrap text-ellipsis text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', className, )} {...props} @@ -83,7 +83,7 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) { [role=checkbox]]:translate-y-[2px]', + 'flex shrink-0 items-center overflow-hidden p-2 whitespace-nowrap text-ellipsis [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', className, )} {...props} diff --git a/examples/react/kitchen-sink-shadcn-base/src/hooks/features.ts b/examples/react/kitchen-sink-shadcn-base/src/hooks/features.ts new file mode 100644 index 0000000000..730a2273ba --- /dev/null +++ b/examples/react/kitchen-sink-shadcn-base/src/hooks/features.ts @@ -0,0 +1,72 @@ +import { + aggregationFns, + columnFacetingFeature, + columnFilteringFeature, + columnGroupingFeature, + columnOrderingFeature, + columnPinningFeature, + columnResizingFeature, + columnSizingFeature, + columnVisibilityFeature, + createExpandedRowModel, + createFacetedRowModel, + createFacetedUniqueValues, + createFilteredRowModel, + createGroupedRowModel, + createPaginatedRowModel, + createSortedRowModel, + filterFns, + globalFilteringFeature, + metaHelper, + rowExpandingFeature, + rowPaginationFeature, + rowSelectionFeature, + rowSortingFeature, + sortFns, + tableFeatures, +} from '@tanstack/react-table' +import { rankItem } from '@tanstack/match-sorter-utils' + +export interface MyColumnMeta { + label?: string + variant?: 'text' | 'number' | 'date' | 'boolean' | 'select' | 'multi-select' + options?: Array<{ label: string; value: string; count?: number }> +} + +const fuzzyFilterFn = ( + row: { getValue: (id: string) => unknown }, + columnId: string, + value: unknown, + addMeta?: (meta: object) => void, +) => { + const itemRank = rankItem(row.getValue(columnId), value as string) + addMeta?.({ itemRank }) + return itemRank.passed +} + +export const features = tableFeatures({ + rowSortingFeature, + rowPaginationFeature, + rowSelectionFeature, + rowExpandingFeature, + columnFilteringFeature, + columnFacetingFeature, + columnOrderingFeature, + columnVisibilityFeature, + columnSizingFeature, + columnResizingFeature, + columnPinningFeature, + columnGroupingFeature, + globalFilteringFeature, + columnMeta: metaHelper(), + filteredRowModel: createFilteredRowModel(), + facetedRowModel: createFacetedRowModel(), + facetedUniqueValues: createFacetedUniqueValues(), + paginatedRowModel: createPaginatedRowModel(), + sortedRowModel: createSortedRowModel(), + groupedRowModel: createGroupedRowModel(), + expandedRowModel: createExpandedRowModel(), + filterFns: { ...filterFns, fuzzy: fuzzyFilterFn }, + sortFns, + aggregationFns, +}) diff --git a/examples/react/kitchen-sink-shadcn-base/src/hooks/table.ts b/examples/react/kitchen-sink-shadcn-base/src/hooks/table.ts new file mode 100644 index 0000000000..15040d3641 --- /dev/null +++ b/examples/react/kitchen-sink-shadcn-base/src/hooks/table.ts @@ -0,0 +1,69 @@ +import { createTableHook } from '@tanstack/react-table' +import { features } from '@/hooks/features' +import { dynamicFilterFn } from '@/lib/data-table' + +import { DataTablePagination } from '@/components/data-table/data-table-pagination' +import { DataTableFilterList } from '@/components/data-table/data-table-filter-list' +import { DataTableSortList } from '@/components/data-table/data-table-sort-list' +import { DataTableViewOptions } from '@/components/data-table/data-table-view-options' + +import { + ActionsCell, + AgeCell, + DateCell, + DepartmentCell, + GroupedCell, + SelectCell, + StatusCell, + TextCell, +} from '@/components/data-table/cell-components' + +import { ColumnHeader } from '@/components/data-table/data-table-column-header' +import { + ResizeHandle, + SelectAllHeader, +} from '@/components/data-table/header-components' + +export const { + createAppColumnHelper, + useAppTable, + useTableContext, + useCellContext, + useHeaderContext, +} = createTableHook({ + features, + defaultColumn: { + size: 120, + minSize: 60, + maxSize: 800, + filterFn: dynamicFilterFn, + }, + globalFilterFn: 'fuzzy', + getRowId: (row: any) => row.id, + enableRowSelection: true, + columnResizeMode: 'onChange' as const, + + tableComponents: { + Pagination: DataTablePagination, + FilterList: DataTableFilterList, + SortList: DataTableSortList, + ViewOptions: DataTableViewOptions, + }, + + cellComponents: { + SelectCell, + TextCell, + AgeCell, + StatusCell, + DepartmentCell, + DateCell, + GroupedCell, + ActionsCell, + }, + + headerComponents: { + ColumnHeader, + SelectAllHeader, + ResizeHandle, + }, +}) diff --git a/examples/react/kitchen-sink-shadcn-base/src/lib/data-table.ts b/examples/react/kitchen-sink-shadcn-base/src/lib/data-table.ts index acb58f259c..baf38829de 100644 --- a/examples/react/kitchen-sink-shadcn-base/src/lib/data-table.ts +++ b/examples/react/kitchen-sink-shadcn-base/src/lib/data-table.ts @@ -15,9 +15,8 @@ import type { JoinOperator, } from '@/types' import type { FilterFn } from '@tanstack/react-table' -import type { features } from '../main' -export const fuzzyFilter: FilterFn = ( +export const fuzzyFilter: FilterFn = ( row, columnId, value, @@ -60,7 +59,7 @@ function isSameDay(date1: Date, date2: Date): boolean { return date1Str === date2Str } -const filterFn_enhancedEquals: FilterFn = ( +const filterFn_enhancedEquals: FilterFn = ( row, columnId: string, filterValue: unknown, @@ -85,7 +84,7 @@ const filterFn_enhancedEquals: FilterFn = ( filterFn_enhancedEquals.resolveFilterValue = (val: any) => isFalsy(val) -const filterFn_enhancedGreaterThan: FilterFn = ( +const filterFn_enhancedGreaterThan: FilterFn = ( row, columnId: string, filterValue: unknown, @@ -106,7 +105,7 @@ const filterFn_enhancedGreaterThan: FilterFn = ( filterFn_enhancedGreaterThan.resolveFilterValue = (val: any) => isFalsy(val) -const filterFn_enhancedGreaterThanOrEqualTo: FilterFn = (row, columnId: string, filterValue: unknown) => { +const filterFn_enhancedGreaterThanOrEqualTo: FilterFn = (row, columnId: string, filterValue: unknown) => { const rowValue = row.getValue(columnId) if (isValidDate(rowValue) && isValidDate(filterValue)) { @@ -124,7 +123,7 @@ const filterFn_enhancedGreaterThanOrEqualTo: FilterFn = (r filterFn_enhancedGreaterThanOrEqualTo.resolveFilterValue = (val: any) => isFalsy(val) -const filterFn_enhancedLessThan: FilterFn = ( +const filterFn_enhancedLessThan: FilterFn = ( row, columnId: string, filterValue: unknown, @@ -145,7 +144,7 @@ const filterFn_enhancedLessThan: FilterFn = ( filterFn_enhancedLessThan.resolveFilterValue = (val: any) => isFalsy(val) -const filterFn_enhancedLessThanOrEqualTo: FilterFn = ( +const filterFn_enhancedLessThanOrEqualTo: FilterFn = ( row, columnId: string, filterValue: unknown, @@ -167,7 +166,7 @@ const filterFn_enhancedLessThanOrEqualTo: FilterFn = ( filterFn_enhancedLessThanOrEqualTo.resolveFilterValue = (val: any) => isFalsy(val) -const filterFn_startsWith: FilterFn = ( +const filterFn_startsWith: FilterFn = ( row, columnId: string, filterValue: string, @@ -178,7 +177,7 @@ const filterFn_startsWith: FilterFn = ( filterFn_startsWith.resolveFilterValue = (val: any) => isFalsy(val) -const filterFn_endsWith: FilterFn = ( +const filterFn_endsWith: FilterFn = ( row, columnId: string, filterValue: string, @@ -189,7 +188,7 @@ const filterFn_endsWith: FilterFn = ( filterFn_endsWith.resolveFilterValue = (val: any) => isFalsy(val) -const filterFn_isEmpty: FilterFn = ( +const filterFn_isEmpty: FilterFn = ( row, columnId: string, ) => { @@ -204,7 +203,7 @@ const filterFn_isEmpty: FilterFn = ( filterFn_isEmpty.resolveFilterValue = (val: any) => isFalsy(val) -const filterFn_inBetween: FilterFn = ( +const filterFn_inBetween: FilterFn = ( row, columnId: string, filterValue: unknown, @@ -246,7 +245,7 @@ const filterFn_inBetween: FilterFn = ( filterFn_inBetween.autoRemove = (val: any) => isFalsy(val) -const filterFn_isRelativeToToday: FilterFn = ( +const filterFn_isRelativeToToday: FilterFn = ( row, columnId: string, filterValue: unknown, @@ -289,7 +288,7 @@ const filterFn_isRelativeToToday: FilterFn = ( filterFn_isRelativeToToday.autoRemove = (val: any) => isFalsy(val) -export const dynamicFilterFn: FilterFn = ( +export const dynamicFilterFn: FilterFn = ( row, columnId: string, filterValue: unknown, diff --git a/examples/react/kitchen-sink-shadcn-base/src/main.tsx b/examples/react/kitchen-sink-shadcn-base/src/main.tsx index 629117dacb..67df95abca 100644 --- a/examples/react/kitchen-sink-shadcn-base/src/main.tsx +++ b/examples/react/kitchen-sink-shadcn-base/src/main.tsx @@ -5,67 +5,16 @@ import { TanStackDevtools } from '@tanstack/react-devtools' import * as ReactDOM from 'react-dom/client' import './index.css' import { useDebouncedCallback } from '@tanstack/react-pacer/debouncer' - -import { - CheckCircle, - ChevronDown, - ChevronRight, - Clock, - Code, - CreditCard, - Megaphone, - MoreHorizontal, - Search, - ShoppingCart, - Users, - XCircle, -} from 'lucide-react' -import { - aggregationFns, - columnFacetingFeature, - columnFilteringFeature, - columnGroupingFeature, - columnOrderingFeature, - columnPinningFeature, - columnResizingFeature, - columnSizingFeature, - columnVisibilityFeature, - createColumnHelper, - createExpandedRowModel, - createFacetedRowModel, - createFacetedUniqueValues, - createFilteredRowModel, - createGroupedRowModel, - createPaginatedRowModel, - createSortedRowModel, - filterFns, - globalFilteringFeature, - metaHelper, - rowExpandingFeature, - rowPaginationFeature, - rowSelectionFeature, - rowSortingFeature, - sortFns, - tableFeatures, - useTable, -} from '@tanstack/react-table' +import { Search } from 'lucide-react' import { tableDevtoolsPlugin, useTanStackTableDevtools, } from '@tanstack/react-table-devtools' -import { rankItem } from '@tanstack/match-sorter-utils' -import type { Person } from '@/lib/make-data' -import type { - Column, - ColumnPinningState, - ColumnSizingState, - ExpandedState, - GroupingState, - SortingState, -} from '@tanstack/react-table' +import type { Column } from '@tanstack/react-table' import type { ExtendedColumnFilter } from '@/types' +import type { Person } from '@/lib/make-data' +import type { features } from '@/hooks/features' import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/checkbox' import { Table, TableBody, @@ -74,81 +23,14 @@ import { TableHeader, TableRow, } from '@/components/ui/table' - -import { departments, makeData, statuses } from '@/lib/make-data' -import { DataTablePagination } from '@/components/data-table/data-table-pagination' -import { DataTableViewOptions } from '@/components/data-table/data-table-view-options' -import { DataTableColumnHeader } from '@/components/data-table/data-table-column-header' - -import { Badge } from '@/components/ui/badge' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { cn, formatDate, toSentenceCase } from '@/lib/utils' -import { DataTableSortList } from '@/components/data-table/data-table-sort-list' -import { DataTableFilterList } from '@/components/data-table/data-table-filter-list' -import { dynamicFilterFn } from '@/lib/data-table' +import { makeData } from '@/lib/make-data' +import { cn } from '@/lib/utils' +import { useAppTable } from '@/hooks/table' +import { columns } from '@/columns' import { ThemeProvider } from '@/components/theme-provider' import { ModeToggle } from '@/components/mode-toggle' import { Input } from '@/components/ui/input' -interface MyColumnMeta { - label?: string - variant?: 'text' | 'number' | 'date' | 'boolean' | 'select' | 'multi-select' - options?: Array<{ label: string; value: string; count?: number }> -} - -// Local fuzzy filter implementation for the filterFns registry slot. -// Defined here to avoid a circular type dependency with data-table.ts. -const fuzzyFilterFn = ( - row: { getValue: (id: string) => unknown }, - columnId: string, - value: unknown, - addMeta?: (meta: object) => void, -) => { - const itemRank = rankItem(row.getValue(columnId), value as string) - addMeta?.({ itemRank }) - return itemRank.passed -} - -export const features = tableFeatures({ - rowSortingFeature, - rowPaginationFeature, - rowSelectionFeature, - rowExpandingFeature, - columnFilteringFeature, - columnFacetingFeature, - columnOrderingFeature, - columnVisibilityFeature, - columnSizingFeature, - columnResizingFeature, - columnPinningFeature, - columnGroupingFeature, - globalFilteringFeature, - columnMeta: metaHelper(), - filteredRowModel: createFilteredRowModel(), - facetedRowModel: createFacetedRowModel(), - facetedUniqueValues: createFacetedUniqueValues(), - paginatedRowModel: createPaginatedRowModel(), - sortedRowModel: createSortedRowModel(), - groupedRowModel: createGroupedRowModel(), - expandedRowModel: createExpandedRowModel(), - filterFns: { ...filterFns, fuzzy: fuzzyFilterFn }, - sortFns, - aggregationFns, -}) - -const columnHelper = createColumnHelper() -/** - * CSS for left/right pinned columns. Verbatim port of the helper from - * `examples/react/column-pinning-sticky/src/main.tsx` so a pinned column gets - * `position: sticky` plus the appropriate offset and edge shadow. - */ function getCommonPinningStyles( column: Column, isSelected = false, @@ -178,456 +60,154 @@ function getCommonPinningStyles( } function App() { - const [rowSelection, setRowSelection] = React.useState({}) - const [sorting, setSorting] = React.useState([]) const [columnFilters, setColumnFilters] = React.useState< Array >([]) - const [columnVisibility, setColumnVisibility] = React.useState({}) - const [columnSizing, setColumnSizing] = React.useState({}) const [globalFilter, setGlobalFilter] = React.useState('') - const [columnPinning, setColumnPinning] = React.useState({ - left: ['select'], - right: ['actions'], - }) - const [grouping, setGrouping] = React.useState([]) - const [expanded, setExpanded] = React.useState({}) - - const columns = React.useMemo( - () => - columnHelper.columns([ - columnHelper.display({ - id: 'select', - header: ({ table }) => ( - - table.toggleAllPageRowsSelected(!!value) - } - aria-label="Select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-0.5" - /> - ), - maxSize: 40, - enableSorting: false, - enableHiding: false, - enableResizing: false, - }), - columnHelper.accessor('firstName', { - id: 'firstName', - header: ({ column }) => ( - - ), - cell: (info) => String(info.getValue()), - meta: { - label: 'First Name', - variant: 'text', - }, - }), - columnHelper.accessor((row) => row.lastName, { - id: 'lastName', - header: ({ column }) => ( - - ), - cell: (info) => String(info.getValue()), - meta: { - label: 'Last Name', - variant: 'text', - }, - }), - columnHelper.accessor('age', { - id: 'age', - header: ({ column }) => ( - - ), - cell: (info) => {String(info.getValue())}, - aggregationFn: 'mean', - aggregatedCell: ({ getValue }) => ( - - Avg: {Math.round(Number(getValue()) * 10) / 10} - - ), - meta: { - label: 'Age', - variant: 'number', - }, - }), - columnHelper.accessor('email', { - id: 'email', - header: ({ column }) => ( - - ), - cell: (info) => info.cell.getValue(), - meta: { - label: 'Email', - variant: 'text', - }, - }), - columnHelper.accessor('status', { - id: 'status', - header: ({ column }) => ( - - ), - cell: (info) => { - const status = info.getValue() - // Group/aggregated rows can pass undefined here — bail out cleanly. - if (!status) return null - const icons: Record = { - active: , - inactive: , - pending: , - } - - return ( - - {icons[status]} - {toSentenceCase(status)} - - ) - }, - aggregatedCell: () => null, - meta: { - label: 'Status', - variant: 'select', - options: statuses.map((status) => ({ - label: toSentenceCase(status), - value: status, - })), - }, - }), - columnHelper.accessor('department', { - id: 'department', - header: ({ column }) => ( - - ), - cell: (info) => { - const department = info.getValue() - // Group/aggregated rows can pass undefined here — bail out cleanly. - if (!department) return null - const icons: Record = { - engineering: , - marketing: , - sales: , - hr: , - finance: , - } - - return ( - - {icons[department]} - {toSentenceCase(department)} - - ) - }, - aggregatedCell: () => null, - meta: { - label: 'Department', - variant: 'multi-select', - options: departments.map((department) => ({ - label: toSentenceCase(department), - value: department, - })), - }, - }), - columnHelper.accessor('joinDate', { - id: 'joinDate', - header: ({ column }) => ( - - ), - cell: (info) => formatDate(info.getValue()), - aggregationFn: 'min', - aggregatedCell: ({ getValue }) => { - const earliest = getValue() - return ( - - Earliest: {earliest ? formatDate(earliest) : '—'} - - ) - }, - meta: { - label: 'Join Date', - variant: 'date', - }, - }), - columnHelper.display({ - id: 'actions', - enableHiding: false, - cell: ({ row }) => { - const person = row.original - return ( - - } - > - Open menu - - - - Actions - navigator.clipboard.writeText(person.id)} - > - Copy ID - - - View details - View profile - - - ) - }, - maxSize: 30, - enableResizing: false, - }), - ]), - [], - ) - const [data, setData] = React.useState(() => makeData(1_000)) - const [columnOrder, setColumnOrder] = React.useState>(() => - columns.map((c) => c.id ?? ''), - ) const refreshData = () => setData(makeData(1_000)) const stressTest = () => setData(makeData(1_000_000)) - const table = useTable( + const table = useAppTable( { - key: 'kitchen-sink-shadcn-base', // needed for devtools - features, + key: 'kitchen-sink-shadcn-base', columns, data, - defaultColumn: { - minSize: 60, - maxSize: 800, - filterFn: dynamicFilterFn, - }, - globalFilterFn: 'fuzzy', + debugTable: true, state: { - rowSelection, - sorting, - columnVisibility, - columnOrder, - columnSizing, columnFilters, globalFilter, - columnPinning, - grouping, - expanded, }, - onSortingChange: setSorting, - onColumnVisibilityChange: setColumnVisibility, - onColumnOrderChange: setColumnOrder, - onColumnSizingChange: setColumnSizing, onColumnFiltersChange: setColumnFilters, onGlobalFilterChange: setGlobalFilter, - onColumnPinningChange: setColumnPinning, - onGroupingChange: setGrouping, - onExpandedChange: setExpanded, - getRowId: (row) => row.id, - enableRowSelection: true, - onRowSelectionChange: setRowSelection, - columnResizeMode: 'onChange', - debugTable: true, + initialState: { + columnPinning: { left: ['select'], right: ['actions'] }, + columnOrder: columns.map((c) => c.id ?? ''), + }, }, - (state) => state, // default selector + (state) => state, // default state selector ) useTanStackTableDevtools(table) - const columnSizeVars = React.useMemo(() => { - const headers = table.getFlatHeaders() - const colSizes: { [key: string]: number } = {} - for (const header of headers) { - colSizes[`--header-${header.id}-size`] = header.getSize() - colSizes[`--col-${header.column.id}-size`] = header.column.getSize() - } - return colSizes - }, [table.state.columnSizing]) - return ( -
-
- - - - -
-
-
-
- - setGlobalFilter(String(value))} - placeholder="Search all columns..." - className="pl-8" - /> -
- - - + +
+
+ + + +
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers - .filter((header) => header.column.getIsVisible()) - .map((header) => { - return ( - [role=checkbox]]:mx-auto': - header.column.id === 'select', - })} - style={{ - width: `calc(var(--header-${header.id}-size) * 1px)`, - ...getCommonPinningStyles(header.column), - }} - > - {header.isPlaceholder ? null : ( - - )} - {header.column.getCanResize() && ( -
header.column.resetSize()} - onMouseDown={header.getResizeHandler()} - onTouchStart={header.getResizeHandler()} - className={cn( - 'absolute right-[-2px] z-10 top-1/2 h-6 w-[3px] -translate-y-1/2 cursor-e-resize select-none touch-none rounded-md transition-colors hover:bg-blue-600 before:absolute before:left-[-4px] before:right-[-4px] before:top-0 before:h-full before:content-[""]', - header.column.getIsResizing() && 'bg-blue-600', - )} - /> +
+
+
+ + setGlobalFilter(String(value))} + placeholder="Search all columns..." + className="pl-8" + /> +
+ + + +
+
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers + .filter((header) => header.column.getIsVisible()) + .map((header) => ( + + {(h) => ( + [role=checkbox]]:mx-auto': + h.column.id === 'select', + })} + style={{ + flexGrow: h.getSize(), + width: h.getSize(), + ...getCommonPinningStyles(h.column), + }} + > + {h.isPlaceholder ? null : } + + )} - - ) - })} - - ))} - - - {table.getRowModel().rows.map((row) => { - return ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( - {row.getVisibleCells().map((cell) => { - return ( - [role=checkbox]]:mx-auto', - )} - style={{ - width: `calc(var(--col-${cell.column.id}-size) * 1px)`, - ...getCommonPinningStyles( - cell.column, - row.getIsSelected(), - ), - }} - > - {cell.getIsGrouped() ? ( - // Group header cell: chevron toggles row expansion, - // count shows number of rows in the group. - - ) : ( - // FlexRender now dispatches based on cell mode: - // aggregated → columnDef.aggregatedCell (or - // columnDef.cell), placeholder → null, otherwise - // columnDef.cell. So we don't need to branch here. - - )} - - ) - })} + {row.getVisibleCells().map((cell) => ( + + {(c) => ( + [role=checkbox]]:mx-auto', + )} + style={{ + flexGrow: c.column.getSize(), + width: c.column.getSize(), + ...getCommonPinningStyles( + c.column, + row.getIsSelected(), + ), + }} + > + {c.getIsGrouped() ? ( + + ) : ( + + )} + + )} + + ))} - ) - })} - -
+ ))} + +
+
+
-
-
+ ) } -// Small debounced wrapper around the shadcn Input — adapted from -// `examples/react/filters-fuzzy/src/main.tsx` so the global filter doesn't -// run on every keystroke at 200k rows. function DebouncedInput({ value: initialValue, onChange, diff --git a/examples/react/kitchen-sink-shadcn-radix/src/columns.tsx b/examples/react/kitchen-sink-shadcn-radix/src/columns.tsx new file mode 100644 index 0000000000..45775cd127 --- /dev/null +++ b/examples/react/kitchen-sink-shadcn-radix/src/columns.tsx @@ -0,0 +1,108 @@ +import type { Person } from '@/lib/make-data' +import { createAppColumnHelper } from '@/hooks/table' +import { + AgeAggregatedCell, + JoinDateAggregatedCell, +} from '@/components/data-table/cell-components' +import { toSentenceCase } from '@/lib/utils' +import { departments, statuses } from '@/lib/make-data' + +const columnHelper = createAppColumnHelper() + +export const columns = columnHelper.columns([ + columnHelper.display({ + id: 'select', + header: ({ header }) => , + cell: ({ cell }) => , + maxSize: 40, + enableSorting: false, + enableHiding: false, + enableResizing: false, + }), + columnHelper.accessor('firstName', { + id: 'firstName', + header: ({ header }) => , + cell: ({ cell }) => , + meta: { + label: 'First Name', + variant: 'text', + }, + }), + columnHelper.accessor((row) => row.lastName, { + id: 'lastName', + header: ({ header }) => , + cell: ({ cell }) => , + meta: { + label: 'Last Name', + variant: 'text', + }, + }), + columnHelper.accessor('age', { + id: 'age', + header: ({ header }) => , + cell: ({ cell }) => , + aggregationFn: 'mean', + aggregatedCell: () => , + size: 80, + meta: { + label: 'Age', + variant: 'number', + }, + }), + columnHelper.accessor('email', { + id: 'email', + header: ({ header }) => , + cell: ({ cell }) => , + size: 200, + meta: { + label: 'Email', + variant: 'text', + }, + }), + columnHelper.accessor('status', { + id: 'status', + header: ({ header }) => , + cell: ({ cell }) => , + aggregatedCell: () => null, + meta: { + label: 'Status', + variant: 'select', + options: statuses.map((status) => ({ + label: toSentenceCase(status), + value: status, + })), + }, + }), + columnHelper.accessor('department', { + id: 'department', + header: ({ header }) => , + cell: ({ cell }) => , + aggregatedCell: () => null, + meta: { + label: 'Department', + variant: 'multi-select', + options: departments.map((department) => ({ + label: toSentenceCase(department), + value: department, + })), + }, + }), + columnHelper.accessor('joinDate', { + id: 'joinDate', + header: ({ header }) => , + cell: ({ cell }) => , + aggregationFn: 'min', + aggregatedCell: () => , + meta: { + label: 'Join Date', + variant: 'date', + }, + }), + columnHelper.display({ + id: 'actions', + enableHiding: false, + cell: ({ cell }) => , + maxSize: 40, + enableResizing: false, + }), +]) diff --git a/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/cell-components.tsx b/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/cell-components.tsx new file mode 100644 index 0000000000..0bc8939dcf --- /dev/null +++ b/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/cell-components.tsx @@ -0,0 +1,187 @@ +'use client' + +import { + CheckCircle, + ChevronDown, + ChevronRight, + Clock, + Code, + CreditCard, + Megaphone, + MoreHorizontal, + ShoppingCart, + Users, + XCircle, +} from 'lucide-react' +import { Subscribe } from '@tanstack/react-table' +import type { Person } from '@/lib/make-data' +import { useCellContext, useTableContext } from '@/hooks/table' +import { Checkbox } from '@/components/ui/checkbox' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { formatDate, toSentenceCase } from '@/lib/utils' + +export function SelectCell(): React.ReactNode { + const cell = useCellContext() + const table = useTableContext() + const row = cell.row + + return ( + + {() => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + )} + + ) +} + +export function TextCell(): React.ReactNode { + const cell = useCellContext() + return <>{String(cell.getValue())} +} + +export function AgeCell(): React.ReactNode { + const cell = useCellContext() + return {String(cell.getValue())} +} + +export function StatusCell(): React.ReactNode { + const cell = useCellContext() + const status = cell.getValue() + if (!status) return null + + const icons: Record = { + active: , + inactive: , + pending: , + } + + return ( + + {icons[status]} + {toSentenceCase(status)} + + ) +} + +export function DepartmentCell(): React.ReactNode { + const cell = useCellContext() + const department = cell.getValue() + if (!department) return null + + const icons: Record = { + engineering: , + marketing: , + sales: , + hr: , + finance: , + } + + return ( + + {icons[department]} + {toSentenceCase(department)} + + ) +} + +export function DateCell(): React.ReactNode { + const cell = useCellContext() + return <>{formatDate(cell.getValue())} +} + +export function GroupedCell(): React.ReactNode { + const cell = useCellContext() + const table = useTableContext() + const row = cell.row + + return ( + + {() => ( + + )} + + ) +} + +export function ActionsCell(): React.ReactNode { + const cell = useCellContext() + const person = cell.row.original as Person + + return ( + + + + + + Actions + navigator.clipboard.writeText(person.id)} + > + Copy ID + + + View details + View profile + + + ) +} + +export function AgeAggregatedCell(): React.ReactNode { + const cell = useCellContext() + return ( + + Avg: {Math.round(Number(cell.getValue()) * 10) / 10} + + ) +} + +export function JoinDateAggregatedCell(): React.ReactNode { + const cell = useCellContext() + const earliest = cell.getValue() + return ( + + Earliest: {earliest ? formatDate(earliest) : '—'} + + ) +} diff --git a/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-column-header.tsx b/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-column-header.tsx index 68cccd4303..1defb23e7e 100644 --- a/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-column-header.tsx +++ b/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-column-header.tsx @@ -10,8 +10,8 @@ import { PinOff, Ungroup, } from 'lucide-react' -import type { CellData, Column, RowData } from '@tanstack/react-table' -import type { features } from '@/main' +import { Subscribe } from '@tanstack/react-table' +import { useHeaderContext, useTableContext } from '@/hooks/table' import { Button } from '@/components/ui/button' import { @@ -23,128 +23,139 @@ import { } from '@/components/ui/dropdown-menu' import { cn } from '@/lib/utils' -/** - * Per-column header dropdown that consolidates the v9 column actions: - * sort asc/desc, group by, pin left/right, unpin, and hide. Items are - * conditionally rendered based on `column.getCan*()` so it's safe to use - * even when some features are not registered. - * - * Inspired by the shadcn data-table docs `DataTableColumnHeader` component - * (https://ui.shadcn.com/docs/components/radix/data-table) but extended to - * cover grouping and pinning since the kitchen-sink uses the full v9 surface. - */ -interface DataTableColumnHeaderProps< - TData extends RowData, - TValue extends CellData = CellData, -> extends React.HTMLAttributes { - column: Column - title: string -} +export function ColumnHeader({ + title, + className, +}: { + title?: string + className?: string +}): React.ReactNode { + const header = useHeaderContext() + const table = useTableContext() + const column = header.column + + const displayTitle = column.columnDef.meta?.label ?? title ?? column.id -export function DataTableColumnHeader< - TData extends RowData, - TValue extends CellData = CellData, ->({ column, title, className }: DataTableColumnHeaderProps) { const canSort = column.getCanSort() const canHide = column.getCanHide() const canPin = column.getCanPin() const canGroup = column.getCanGroup() - // No actions available — render the title plain. if (!canSort && !canHide && !canPin && !canGroup) { - return
{title}
+ return
{displayTitle}
} - const sorted = canSort ? column.getIsSorted() : false - const pinned = canPin ? column.getIsPinned() : false - const grouped = canGroup ? column.getIsGrouped() : false - return (
- - - - - - {canSort && ( - <> - column.toggleSorting(false)}> - - Asc - - column.toggleSorting(true)}> - - Desc - - - )} - {canGroup && ( - <> - {canSort ? : null} - - {grouped ? ( + ({ + sorting: s.sorting, + grouping: s.grouping, + columnPinning: s.columnPinning, + })} + > + {() => { + const sorted = canSort ? column.getIsSorted() : false + const pinned = canPin ? column.getIsPinned() : false + const grouped = canGroup ? column.getIsGrouped() : false + + return ( + + + + + + {canSort && ( <> - - Ungroup + column.toggleSorting(false)} + > + + Asc + + column.toggleSorting(true)} + > + + Desc + - ) : ( + )} + {canGroup && ( + <> + {canSort ? : null} + + {grouped ? ( + <> + + Ungroup + + ) : ( + <> + + Group by + + )} + + + )} + {canPin && ( + <> + {canSort || canGroup ? : null} + column.pin('left')} + disabled={pinned === 'left'} + > + + Pin left + + column.pin('right')} + disabled={pinned === 'right'} + > + + Pin right + + {pinned ? ( + column.pin(false)}> + + Unpin + + ) : null} + + )} + {canHide && ( <> - - Group by + + column.toggleVisibility(false)} + > + + Hide + )} - - - )} - {canPin && ( - <> - {canSort || canGroup ? : null} - column.pin('left')} - disabled={pinned === 'left'} - > - - Pin left - - column.pin('right')} - disabled={pinned === 'right'} - > - - Pin right - - {pinned ? ( - column.pin(false)}> - - Unpin - - ) : null} - - )} - {canHide && ( - <> - - column.toggleVisibility(false)}> - - Hide - - - )} - - + + + ) + }} +
) } diff --git a/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-filter-list.tsx b/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-filter-list.tsx index cd4b36e06d..59e0554d7f 100644 --- a/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-filter-list.tsx +++ b/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-filter-list.tsx @@ -14,13 +14,8 @@ import type { FilterOperator, JoinOperator, } from '@/types' -import type { - CellData, - Column, - ReactTable, - RowData, -} from '@tanstack/react-table' -import type { features } from '@/main' +import type { Column, RowData } from '@tanstack/react-table' +import type { features } from '@/hooks/features' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -59,11 +54,12 @@ import { CommandItem, CommandList, } from '@/components/ui/command' +import { useTableContext } from '@/hooks/table' -function getColumnOptions({ +function getColumnOptions({ column, }: { - column: Column + column: Column }): Array<{ label: string; value: string; count?: number }> { const customOptions = column.columnDef.meta?.options @@ -78,17 +74,18 @@ function getColumnOptions({ })) } -interface DataTableFilterListProps { - table: ReactTable - columnFilters: Array - onColumnFiltersChange: (filters: Array) => void -} +export function DataTableFilterList(): React.ReactNode { + const table = useTableContext() + const columnFilters = table.state.columnFilters as Array + + // Write through the raw controlled-state handler instead of + // `table.setColumnFilters`: the table API auto-removes filters with empty + // values, but a just-added filter row legitimately starts with `value: ''` + // while the user is still building it. + const setColumnFilters = (filters: Array) => { + table.options.onColumnFiltersChange?.(filters) + } -export function DataTableFilterList({ - table, - columnFilters, - onColumnFiltersChange, -}: DataTableFilterListProps) { const id = React.useId() const labelId = React.useId() const descriptionId = React.useId() @@ -101,7 +98,7 @@ export function DataTableFilterList({ ) const getColumnFilterVariant = React.useCallback( - (column: Column) => { + (column: Column) => { if (column.columnDef.meta?.variant) { return column.columnDef.meta.variant } @@ -145,9 +142,9 @@ export function DataTableFilterList({ const newFilter = onFilterAddImpl(firstFilterableColumn.id) if (newFilter) { - onColumnFiltersChange([...columnFilters, newFilter]) + setColumnFilters([...columnFilters, newFilter]) } - }, [columnFilters, onFilterAddImpl, filterableColumns, onColumnFiltersChange]) + }, [columnFilters, onFilterAddImpl, filterableColumns, table]) const onFilterUpdate = React.useCallback( ( @@ -203,14 +200,14 @@ export function DataTableFilterList({ } return filter }) - onColumnFiltersChange(newFilters) + setColumnFilters(newFilters) }, [ columnFilters, filterableColumns, getColumnFilterVariant, getFilterOperators, - onColumnFiltersChange, + table, ], ) @@ -219,9 +216,9 @@ export function DataTableFilterList({ const newFilters = columnFilters.filter((filter) => { return filter.filterId !== filterId }) - onColumnFiltersChange(newFilters) + setColumnFilters(newFilters) }, - [columnFilters, onColumnFiltersChange], + [columnFilters, table], ) const onFilterInputRender = React.useCallback( @@ -231,7 +228,7 @@ export function DataTableFilterList({ filterId, inputId, }: { - column: Column + column: Column operator: FilterOperator filterId: string inputId: string @@ -666,7 +663,7 @@ export function DataTableFilterList({ ...f, joinOperator: value, })) - onColumnFiltersChange(updatedFilters) + setColumnFilters(updatedFilters) } }} > @@ -903,7 +900,7 @@ export function DataTableFilterList({ aria-label="Reset all filters" variant="outline" size="sm" - onClick={() => onColumnFiltersChange([])} + onClick={() => setColumnFilters([])} > Reset filters diff --git a/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-pagination.tsx b/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-pagination.tsx index 1bda90c5e9..7c15dcd327 100644 --- a/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-pagination.tsx +++ b/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-pagination.tsx @@ -6,8 +6,8 @@ import { ChevronsLeft, ChevronsRight, } from 'lucide-react' -import type { ReactTable, RowData } from '@tanstack/react-table' -import type { features } from '@/main' +import * as React from 'react' +import { useTableContext } from '@/hooks/table' import { Button } from '@/components/ui/button' import { @@ -18,15 +18,13 @@ import { SelectValue, } from '@/components/ui/select' -interface DataTablePaginationProps { - table: ReactTable +export function DataTablePagination({ + pageSizeOptions = [10, 20, 30, 40, 50], +}: { pageSizeOptions?: Array -} +}): React.ReactNode { + const table = useTableContext() -export function DataTablePagination({ - table, - pageSizeOptions = [10, 20, 30, 40, 50], -}: DataTablePaginationProps) { return (
diff --git a/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-sort-list.tsx b/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-sort-list.tsx index 06ff421076..2504c5c111 100644 --- a/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-sort-list.tsx +++ b/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-sort-list.tsx @@ -8,14 +8,8 @@ import { GripVertical, Trash2, } from 'lucide-react' -import type { - ColumnSort, - ReactTable, - RowData, - SortDirection, - SortingState, -} from '@tanstack/react-table' -import type { features } from '@/main' +import type { ColumnSort, SortDirection } from '@tanstack/react-table' +import { useTableContext } from '@/hooks/table' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -48,17 +42,10 @@ import { } from '@/components/ui/sortable' import { cn } from '@/lib/utils' -interface DataTableSortListProps { - table: ReactTable - sorting: SortingState - onSortingChange: (sorting: SortingState) => void -} +export function DataTableSortList(): React.ReactNode { + const table = useTableContext() + const sorting = table.state.sorting -export function DataTableSortList({ - table, - sorting, - onSortingChange, -}: DataTableSortListProps) { const labelId = React.useId() const descriptionId = React.useId() const listId = React.useId() @@ -112,7 +99,7 @@ export function DataTableSortList({ return ( table.setSorting(value)} getItemValue={(item) => item.id} > diff --git a/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-view-options.tsx b/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-view-options.tsx index a07f664db1..cb28e604c5 100644 --- a/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-view-options.tsx +++ b/examples/react/kitchen-sink-shadcn-radix/src/components/data-table/data-table-view-options.tsx @@ -2,12 +2,7 @@ import * as React from 'react' import { Check, ChevronsUpDown, GripVertical, Settings2 } from 'lucide-react' -import type { - ColumnOrderState, - ReactTable, - RowData, -} from '@tanstack/react-table' -import type { features } from '@/main' +import { useTableContext } from '@/hooks/table' import { Button } from '@/components/ui/button' import { @@ -33,21 +28,16 @@ import { SortableOverlay, } from '@/components/ui/sortable' -interface DataTableViewOptionsProps { - table: ReactTable - columnOrder: ColumnOrderState - onColumnOrderChange: (columnOrder: ColumnOrderState) => void -} - -export function DataTableViewOptions({ - table, - columnOrder, - onColumnOrderChange, -}: DataTableViewOptionsProps) { +export function DataTableViewOptions(): React.ReactNode { + const table = useTableContext() const triggerRef = React.useRef(null) + const columnOrder = table.state.columnOrder return ( - + table.setColumnOrder(order)} + >
-
- + ) } -// Small debounced wrapper around the shadcn Input — adapted from -// `examples/react/filters-fuzzy/src/main.tsx` so the global filter doesn't -// run on every keystroke at 200k rows. function DebouncedInput({ value: initialValue, onChange, diff --git a/examples/react/virtualized-columns/src/main.tsx b/examples/react/virtualized-columns/src/main.tsx index 5abb4be1ff..9f7574c294 100644 --- a/examples/react/virtualized-columns/src/main.tsx +++ b/examples/react/virtualized-columns/src/main.tsx @@ -257,35 +257,37 @@ interface TableHeadCellProps { function TableHeadCell({ header, table }: TableHeadCellProps) { return ( -
-
( +
- - {{ - asc: ' 🔼', - desc: ' 🔽', - }[header.column.getIsSorted() as string] ?? null} - -
header.column.resetSize()} - onMouseDown={header.getResizeHandler()} - onTouchStart={header.getResizeHandler()} - className={`resizer ${ - header.column.getIsResizing() ? 'isResizing' : '' - }`} - /> -
- - + +