'use client'
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 {
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 { 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 { dynamicFilterFn, getFilterOperators } from '@/lib/data-table'
import { departments, makeData, statuses } from '@/lib/make-data'
import './styles/globals.css'
type Option = {
value: string
label: string
}
type TextInputProps = Omit<React.ComponentProps<typeof Input>, 'onChange'> & {
label?: string
icon?: React.ReactNode
onChange?: React.ChangeEventHandler<HTMLInputElement>
}
function TextInput({ label, icon, ...props }: TextInputProps) {
return (
<Box flex={props.style?.flex}>
{label ? (
<Text fontSize="sm" fontWeight="medium" mb="1">
{label}
</Text>
) : null}
<Box position="relative">
{icon ? (
<Box
position="absolute"
left="3"
top="50%"
translateY="-50%"
color="fg.muted"
pointerEvents="none"
>
{icon}
</Box>
) : null}
<Input ps={icon ? '9' : undefined} {...props} />
</Box>
</Box>
)
}
type SelectFieldProps = {
'aria-label'?: string
label?: string
options: Array<string | Option>
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 (
<Box width={width} flex={flex}>
{label ? (
<Text fontSize="sm" fontWeight="medium" mb="1">
{label}
</Text>
) : null}
<NativeSelect.Root width="100%">
<NativeSelect.Field
aria-label={ariaLabel}
value={value ?? ''}
onChange={(event) => onChange?.(event.currentTarget.value || null)}
>
{normalizedOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</NativeSelect.Field>
<NativeSelect.Indicator />
</NativeSelect.Root>
</Box>
)
}
type MultiSelectFieldProps = {
label?: string
options: Array<string | Option>
value?: Array<string>
onChange?: (value: Array<string>) => 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 (
<Box flex={flex}>
{label ? (
<Text fontSize="sm" fontWeight="medium" mb="1">
{label}
</Text>
) : null}
<select
multiple
value={value}
style={{
width: '100%',
minHeight: 40,
padding: 8,
border: '1px solid var(--chakra-colors-border)',
borderRadius: 'var(--chakra-radii-md)',
background: 'var(--chakra-colors-bg)',
}}
onChange={(event: React.ChangeEvent<HTMLSelectElement>) =>
onChange?.(
Array.from(event.currentTarget.selectedOptions).map(
(option) => option.value,
),
)
}
>
{normalizedOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</Box>
)
}
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 (
<Checkbox.Root
checked={indeterminate ? 'indeterminate' : checked}
onCheckedChange={(details) => onCheckedChange?.(details.checked === true)}
>
<Checkbox.HiddenInput {...props} />
<Checkbox.Control />
{label ? <Checkbox.Label>{label}</Checkbox.Label> : null}
</Checkbox.Root>
)
}
function ProgressBar({ value }: { value: number }) {
return (
<Box bg="bg.muted" rounded="full" height="2" overflow="hidden">
<Box bg="colorPalette.solid" height="100%" width={`${value}%`} />
</Box>
)
}
function useCloseOnOutsidePointerDown(
open: boolean,
ref: React.RefObject<HTMLElement | null>,
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<HTMLDivElement>(null)
const close = React.useCallback(() => setOpen(false), [])
useCloseOnOutsidePointerDown(open, rootRef, close)
return (
<Box ref={rootRef} display="inline-block">
<DropdownCloseContext.Provider value={close}>
<Menu.Root
open={open}
onOpenChange={(details) => setOpen(details.open)}
positioning={{ placement: 'bottom-end', offset: { mainAxis: 6 } }}
>
<Menu.Trigger asChild>{trigger}</Menu.Trigger>
<Menu.Positioner>
<Menu.Content minW={width}>{children}</Menu.Content>
</Menu.Positioner>
</Menu.Root>
</DropdownCloseContext.Provider>
</Box>
)
}
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 (
<Menu.Item
value={value}
disabled={disabled}
colorPalette={colorPalette}
onClick={handleSelection}
onSelect={handleSelection}
>
{icon}
{children}
</Menu.Item>
)
}
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<HTMLDivElement>(null)
const close = React.useCallback(() => onOpenChange(false), [onOpenChange])
useCloseOnOutsidePointerDown(open, rootRef, close)
return (
<Box ref={rootRef} display="inline-block">
<Popover.Root
open={open}
onOpenChange={(details) => onOpenChange(details.open)}
positioning={{ placement: 'bottom-end', offset: { mainAxis: 6 } }}
>
<Popover.Trigger asChild>{trigger}</Popover.Trigger>
<Popover.Positioner>
<Popover.Content width={width} maxW="calc(100vw - 32px)">
<Popover.Body>{children}</Popover.Body>
</Popover.Content>
</Popover.Positioner>
</Popover.Root>
</Box>
)
}
function ChakraExampleProvider({ children }: { children: React.ReactNode }) {
return (
<ChakraProvider value={defaultSystem}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</ChakraProvider>
)
}
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<MyColumnMeta>(),
filteredRowModel: createFilteredRowModel(),
facetedRowModel: createFacetedRowModel(),
facetedUniqueValues: createFacetedUniqueValues(),
paginatedRowModel: createPaginatedRowModel(),
sortedRowModel: createSortedRowModel(),
groupedRowModel: createGroupedRowModel(),
expandedRowModel: createExpandedRowModel(),
filterFns: { ...filterFns, fuzzy: fuzzyFilterFn },
sortFns,
aggregationFns,
})
const columnHelper = createColumnHelper<typeof features, Person>()
type AppTable = ReactTable<typeof features, Person>
type AppColumn = Column<typeof features, Person, any>
function SortableFrame({
id,
children,
}: {
id: string
children: React.ReactNode
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id })
return (
<Box
ref={setNodeRef}
{...attributes}
{...listeners}
style={{
opacity: isDragging ? 0.6 : 1,
transform: CSS.Transform.toString(transform),
transition,
cursor: 'grab',
}}
>
{children}
</Box>
)
}
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<SortingState>([])
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,
isSelected = false,
): React.CSSProperties {
const isPinned = column.getIsPinned()
const isLastLeftPinnedColumn =
isPinned === 'left' && column.getIsLastColumn('left')
const isFirstRightPinnedColumn =
isPinned === 'right' && column.getIsFirstColumn('right')
return {
boxShadow: isLastLeftPinnedColumn
? '-4px 0 4px -4px var(--chakra-colors-border) inset'
: isFirstRightPinnedColumn
? '4px 0 4px -4px var(--chakra-colors-border) inset'
: undefined,
left: isPinned === 'left' ? `${column.getStart('left')}px` : undefined,
right: isPinned === 'right' ? `${column.getAfter('right')}px` : undefined,
position: isPinned ? 'sticky' : 'relative',
borderRight: isLastLeftPinnedColumn
? '1px solid var(--chakra-colors-border)'
: undefined,
borderLeft: isFirstRightPinnedColumn
? '1px solid var(--chakra-colors-border)'
: undefined,
background: isSelected
? 'var(--chakra-colors-blue-subtle)'
: isPinned
? 'var(--chakra-colors-bg)'
: undefined,
zIndex: isPinned ? 2 : 0,
}
}
function DepartmentIcon({ department }: { department: Person['department'] }) {
const icons: Record<Person['department'], React.ReactElement> = {
engineering: <IconCode size={16} />,
marketing: <IconBriefcase size={16} />,
sales: <IconBuildingStore size={16} />,
hr: <IconUsersGroup size={16} />,
finance: <IconCreditCard size={16} />,
}
return icons[department]
}
function DepartmentPill({ department }: { department: Person['department'] }) {
return (
<Box
as="span"
style={{
display: 'inline-flex',
maxWidth: '100%',
height: 24,
minWidth: 0,
alignItems: 'center',
gap: 6,
paddingInline: 10,
borderRadius: 999,
border: '1px solid var(--chakra-colors-border)',
fontSize: 'var(--chakra-font-sizes-sm)',
}}
>
<Box
as="span"
style={{
display: 'inline-flex',
width: 16,
height: 16,
flex: '0 0 16px',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
>
<DepartmentIcon department={department} />
</Box>
<Box
as="span"
style={{
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{toSentenceCase(department)}
</Box>
</Box>
)
}
function EllipsisText({ children }: { children: React.ReactNode }) {
return (
<Box
as="span"
style={{
display: 'block',
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{children}
</Box>
)
}
function StatusBadge({ status }: { status: Person['status'] }) {
const colorPalette: Record<Person['status'], string> = {
active: 'green',
inactive: 'red',
pending: 'yellow',
}
return (
<Badge colorPalette={colorPalette[status]} variant="subtle">
<IconCheck size={14} />
{toSentenceCase(status)}
</Badge>
)
}
function RowActions({ person }: { person: Person }) {
return (
<DropdownMenu
trigger={
<IconButton variant="subtle" aria-label="Open row actions">
<IconDotsVertical size={18} />
</IconButton>
}
>
<DropdownMenuItem
value="copy-id"
onSelect={() => {
void navigator.clipboard.writeText(person.id)
}}
>
Copy ID
</DropdownMenuItem>
<Menu.Separator />
<DropdownMenuItem value="view-details">View details</DropdownMenuItem>
<DropdownMenuItem value="view-profile">View profile</DropdownMenuItem>
</DropdownMenu>
)
}
function SortIcon({ direction }: { direction: 'asc' | 'desc' | undefined }) {
if (direction === 'asc') return <IconArrowUp size={16} />
if (direction === 'desc') return <IconArrowDown size={16} />
return <IconArrowsSort className="sort-icon-unsorted" size={16} />
}
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 <Text fontWeight="semibold">{title}</Text>
}
return (
<HStack gap={4} wrap="nowrap">
{canSort ? (
<Button
variant="plain"
height="auto"
minW="0"
p="0"
onClick={column.getToggleSortingHandler()}
className="sort-trigger"
style={{ minWidth: 0 }}
>
<HStack gap={4} wrap="nowrap">
<Text fontWeight="semibold" truncate>
{title}
</Text>
<SortIcon direction={direction} />
</HStack>
</Button>
) : (
<Text fontWeight="semibold">{title}</Text>
)}
<DropdownMenu
trigger={
<IconButton
variant="subtle"
size="sm"
aria-label={`Open ${title} column menu`}
>
<IconChevronDown size={16} />
</IconButton>
}
>
{canSort ? (
<>
<DropdownMenuItem
value={`${column.id}-sort-asc`}
icon={<IconArrowUp size={16} />}
onSelect={() => column.toggleSorting(false)}
>
Asc
</DropdownMenuItem>
<DropdownMenuItem
value={`${column.id}-sort-desc`}
icon={<IconArrowDown size={16} />}
onSelect={() => column.toggleSorting(true)}
>
Desc
</DropdownMenuItem>
</>
) : null}
{canGroup ? (
<DropdownMenuItem
value={`${column.id}-group`}
icon={<IconCategory size={16} />}
onSelect={column.getToggleGroupingHandler()}
>
{grouped ? 'Ungroup' : 'Group by'}
</DropdownMenuItem>
) : null}
{canPin ? (
<>
<Menu.Separator />
<DropdownMenuItem
value={`${column.id}-pin-left`}
disabled={pinned === 'left'}
icon={<IconPinned size={16} />}
onSelect={() => column.pin('left')}
>
Pin left
</DropdownMenuItem>
<DropdownMenuItem
value={`${column.id}-pin-right`}
disabled={pinned === 'right'}
icon={<IconPinned size={16} />}
onSelect={() => column.pin('right')}
>
Pin right
</DropdownMenuItem>
{pinned ? (
<DropdownMenuItem
value={`${column.id}-unpin`}
icon={<IconPinned size={16} opacity={0.45} />}
onSelect={() => column.pin(false)}
>
Unpin
</DropdownMenuItem>
) : null}
</>
) : null}
{canHide ? (
<>
<Menu.Separator />
<DropdownMenuItem
value={`${column.id}-hide`}
icon={<IconEyeOff size={16} />}
onSelect={() => column.toggleVisibility(false)}
>
Hide
</DropdownMenuItem>
</>
) : null}
</DropdownMenu>
</HStack>
)
}
function ViewOptionsPopover({
table,
columnOrder,
onColumnOrderChange,
}: {
table: AppTable
columnOrder: Array<string>
onColumnOrderChange: React.Dispatch<React.SetStateAction<Array<string>>>
}) {
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 (
<FloatingPanel
open={opened}
onOpenChange={setOpened}
width="320px"
trigger={
<Button variant="outline" size="sm">
<IconSettings size={16} />
View
</Button>
}
>
<Stack gap="sm">
<TextInput
label="Search columns"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
/>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
<SortableContext
items={columns.map((column) => column.id)}
strategy={verticalListSortingStrategy}
>
<Stack gap={4}>
{columns.map((column) => (
<SortableFrame key={column.id} id={column.id}>
<HStack justify="space-between" wrap="nowrap">
<CheckboxField
checked={column.getIsVisible()}
label={column.columnDef.meta?.label ?? column.id}
onCheckedChange={(checked) =>
column.toggleVisibility(checked)
}
/>
<IconGripVertical size={16} opacity={0.45} />
</HStack>
</SortableFrame>
))}
</Stack>
</SortableContext>
</DndContext>
</Stack>
</FloatingPanel>
)
}
function SortListPopover({
table,
sorting,
onSortingChange,
}: {
table: AppTable
sorting: SortingState
onSortingChange: React.Dispatch<React.SetStateAction<SortingState>>
}) {
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<SortingState[number]>) => {
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 (
<FloatingPanel
open={opened}
onOpenChange={setOpened}
width="520px"
trigger={
<Button variant="outline" size="sm">
<IconArrowsSort size={16} />
Sort
{sorting.length ? (
<Badge fontSize="sm">{sorting.length}</Badge>
) : null}
</Button>
}
>
<Stack gap="md">
<Text fontWeight="semibold">
{sorting.length ? 'Sort by' : 'No sorting applied'}
</Text>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
<SortableContext
items={sorting.map((sort) => sort.id)}
strategy={verticalListSortingStrategy}
>
<Stack gap="xs">
{sorting.map((sort, index) => (
<SortableFrame key={sort.id} id={sort.id}>
<HStack wrap="nowrap" align="flex-end">
<IconGripVertical size={18} opacity={0.45} />
<SelectField
label="Column"
options={columnOptions}
value={sort.id}
onChange={(value) => {
if (value) updateSort(index, { id: value })
}}
flex={1}
/>
<SelectField
label="Direction"
options={[
{ value: 'asc', label: 'Asc' },
{ value: 'desc', label: 'Desc' },
]}
value={sort.desc ? 'desc' : 'asc'}
onChange={(value) =>
updateSort(index, { desc: value === 'desc' })
}
width="110px"
/>
<IconButton
variant="subtle"
color="red"
aria-label="Remove sort"
onClick={() =>
onSortingChange((current) =>
current.filter((_, sortIndex) => sortIndex !== index),
)
}
>
<IconTrash size={16} />
</IconButton>
</HStack>
</SortableFrame>
))}
</Stack>
</SortableContext>
</DndContext>
<HStack>
<Button
size="sm"
onClick={addSort}
disabled={sorting.length >= sortableColumns.length}
>
Add sort
</Button>
<Button
size="sm"
variant="subtle"
onClick={() => table.resetSorting()}
>
Reset
</Button>
</HStack>
</Stack>
</FloatingPanel>
)
}
function FilterValueInput({
column,
filter,
onFilterUpdate,
}: {
column: AppColumn
filter: ExtendedColumnFilter
onFilterUpdate: (
filterId: string,
patch: Partial<ExtendedColumnFilter>,
) => 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 <Text color="fg.muted">No value required</Text>
}
if (variant === 'select') {
const options = column.columnDef.meta?.options ?? []
return (
<SelectField
label="Value"
options={options}
value={typeof filter.value === 'string' ? filter.value : null}
onChange={(value) => onFilterUpdate(filter.filterId!, { value })}
/>
)
}
if (variant === 'multi-select') {
const options = column.columnDef.meta?.options ?? []
return (
<MultiSelectField
label="Value"
options={options}
value={Array.isArray(filter.value) ? filter.value : []}
onChange={(value) => onFilterUpdate(filter.filterId!, { value })}
/>
)
}
if (variant === 'date') {
if (operator === 'inRange') {
const value = Array.isArray(filter.value) ? filter.value : []
return (
<HStack align="flex-end" width="100%">
<TextInput
label="From"
type="date"
value={toDateInputValue(value[0])}
onChange={(event) =>
onFilterUpdate(filter.filterId!, {
value: [
event.currentTarget.value
? new Date(event.currentTarget.value).toISOString()
: undefined,
value[1],
],
})
}
/>
<TextInput
label="To"
type="date"
value={toDateInputValue(value[1])}
onChange={(event) =>
onFilterUpdate(filter.filterId!, {
value: [
value[0],
event.currentTarget.value
? new Date(event.currentTarget.value).toISOString()
: undefined,
],
})
}
/>
</HStack>
)
}
return (
<TextInput
label="Value"
type="date"
value={toDateInputValue(filter.value)}
onChange={(event) =>
onFilterUpdate(filter.filterId!, {
value: event.currentTarget.value
? new Date(event.currentTarget.value).toISOString()
: undefined,
})
}
/>
)
}
if (variant === 'number') {
return (
<TextInput
label="Value"
type="number"
value={
typeof filter.value === 'number' || typeof filter.value === 'string'
? filter.value
: ''
}
onChange={(event) =>
onFilterUpdate(filter.filterId!, {
value:
event.currentTarget.value === ''
? ''
: Number(event.currentTarget.value),
})
}
/>
)
}
return (
<TextInput
label="Value"
value={typeof filter.value === 'string' ? filter.value : ''}
onChange={(event) =>
onFilterUpdate(filter.filterId!, { value: event.currentTarget.value })
}
/>
)
}
function FilterListPopover({
table,
columnFilters,
onColumnFiltersChange,
}: {
table: AppTable
columnFilters: Array<ExtendedColumnFilter>
onColumnFiltersChange: React.Dispatch<
React.SetStateAction<Array<ExtendedColumnFilter>>
>
}) {
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<ExtendedColumnFilter>,
) => {
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 (
<FloatingPanel
open={opened}
onOpenChange={setOpened}
width="760px"
trigger={
<Button variant="outline" size="sm">
<IconFilter size={16} />
Filter
{columnFilters.length ? (
<Badge fontSize="sm">{columnFilters.length}</Badge>
) : null}
</Button>
}
>
<Stack gap="md">
<Text fontWeight="semibold">Filters</Text>
{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 (
<HStack key={filter.filterId} align="flex-end" wrap="nowrap">
{index === 0 ? (
<Text width="70px" pb="8">
Where
</Text>
) : index === 1 ? (
<SelectField
options={[
{ value: 'and', label: 'and' },
{ value: 'or', label: 'or' },
]}
value={filter.joinOperator ?? 'and'}
onChange={(joinOperator) => {
if (!joinOperator) return
onColumnFiltersChange((current) =>
current.map((item) => ({
...item,
joinOperator: joinOperator as JoinOperator,
})),
)
}}
width="90px"
/>
) : (
<Text width="70px" pb="8">
{filter.joinOperator ?? 'and'}
</Text>
)}
<SelectField
label="Field"
options={fieldOptions}
value={column.id}
onChange={(nextColumnId) => {
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"
/>
<SelectField
label="Operator"
options={operators.map((operator) => ({
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"
/>
<Box style={{ flex: 1 }}>
<FilterValueInput
column={column}
filter={filter}
onFilterUpdate={updateFilter}
/>
</Box>
<IconButton
variant="subtle"
color="red"
aria-label="Remove filter"
onClick={() =>
onColumnFiltersChange((current) =>
current.filter((item) => item.filterId !== filter.filterId),
)
}
>
<IconTrash size={16} />
</IconButton>
</HStack>
)
})}
<HStack>
<Button size="sm" onClick={addFilter}>
Add filter
</Button>
<Button
size="sm"
variant="subtle"
onClick={() => onColumnFiltersChange([])}
>
Reset
</Button>
</HStack>
</Stack>
</FloatingPanel>
)
}
function Pagination({ table }: { table: AppTable }) {
const pageIndex = table.state.pagination.pageIndex
const pageSize = table.state.pagination.pageSize
return (
<HStack justify="space-between" p="sm">
<Text fontSize="sm" color="fg.muted">
{table.getFilteredSelectedRowModel().rows.length.toLocaleString()} of{' '}
{table.getFilteredRowModel().rows.length.toLocaleString()} row(s)
selected.
</Text>
<HStack gap="xs">
<Text fontSize="sm">Rows per page:</Text>
<SelectField
aria-label="Rows per page"
options={['10', '20', '30', '40', '50']}
value={String(pageSize)}
onChange={(value) => {
table.setPageSize(Number(value))
table.setPageIndex(0)
}}
width="90px"
/>
<IconButton
variant="subtle"
aria-label="First page"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<IconChevronsLeft size={18} />
</IconButton>
<IconButton
variant="subtle"
aria-label="Previous page"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<IconChevronLeft size={18} />
</IconButton>
<Text fontSize="sm">
{pageIndex + 1} / {table.getPageCount()}
</Text>
<IconButton
variant="subtle"
aria-label="Next page"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<IconChevronRight size={18} />
</IconButton>
<IconButton
variant="subtle"
aria-label="Last page"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<IconChevronsRight size={18} />
</IconButton>
</HStack>
</HStack>
)
}
function ModeMenu() {
const { resolvedTheme, setTheme, theme } = useTheme()
const icon =
resolvedTheme === 'dark' ? <IconMoon size={18} /> : <IconSun size={18} />
return (
<DropdownMenu
width="150px"
trigger={
<IconButton variant="subtle" aria-label="Theme" title="Theme">
{icon}
</IconButton>
}
>
{(
[
{ value: 'light', label: 'Light', icon: <IconSun size={16} /> },
{ value: 'dark', label: 'Dark', icon: <IconMoon size={16} /> },
{
value: 'system',
label: 'System',
icon: <IconDeviceDesktop size={16} />,
},
] satisfies Array<{
value: 'light' | 'dark' | 'system'
label: string
icon: React.ReactNode
}>
).map((item) => (
<DropdownMenuItem
key={item.value}
value={`theme-${item.value}`}
icon={item.icon}
colorPalette={theme === item.value ? 'blue' : undefined}
onSelect={() => setTheme(item.value)}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenu>
)
}
function DebouncedTextInput({
value: initialValue,
onChange,
debounce = 300,
...props
}: {
value: string | number
onChange: (value: string | number) => void
debounce?: number
} & Omit<React.ComponentProps<typeof TextInput>, 'onChange'>) {
const [value, setValue] = React.useState(initialValue)
React.useEffect(() => {
setValue(initialValue)
}, [initialValue])
const debouncedOnChange = useDebouncedCallback(onChange, { wait: debounce })
return (
<TextInput
{...props}
value={value}
onChange={(event) => {
setValue(event.currentTarget.value)
debouncedOnChange(event.currentTarget.value)
}}
/>
)
}
function App() {
const [rowSelection, setRowSelection] = React.useState({})
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] = React.useState<
Array<ExtendedColumnFilter>
>([])
const [columnVisibility, setColumnVisibility] = React.useState({})
const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>({})
const [globalFilter, setGlobalFilter] = React.useState('')
const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>({
left: ['select'],
right: ['actions'],
})
const [grouping, setGrouping] = React.useState<GroupingState>([])
const [expanded, setExpanded] = React.useState<ExpandedState>({})
const [data, setData] = React.useState(() => makeData(1_000))
const columns = React.useMemo(
() =>
columnHelper.columns([
columnHelper.display({
id: 'select',
header: ({ table }) => (
<CheckboxField
checked={table.getIsAllPageRowsSelected()}
indeterminate={
!table.getIsAllPageRowsSelected() &&
table.getIsSomePageRowsSelected()
}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(checked)
}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<CheckboxField
checked={row.getIsSelected()}
onCheckedChange={(checked) => row.toggleSelected(checked)}
aria-label="Select row"
/>
),
maxSize: 48,
enableSorting: false,
enableHiding: false,
enableResizing: false,
}),
columnHelper.accessor('firstName', {
id: 'firstName',
header: ({ column }) => (
<ColumnHeaderMenu column={column} title="First Name" />
),
cell: (info) => (
<EllipsisText>{String(info.getValue())}</EllipsisText>
),
meta: { label: 'First Name', variant: 'text' },
}),
columnHelper.accessor((row) => row.lastName, {
id: 'lastName',
header: ({ column }) => (
<ColumnHeaderMenu column={column} title="Last Name" />
),
cell: (info) => (
<EllipsisText>{String(info.getValue())}</EllipsisText>
),
meta: { label: 'Last Name', variant: 'text' },
}),
columnHelper.accessor('age', {
id: 'age',
header: ({ column }) => (
<ColumnHeaderMenu column={column} title="Age" />
),
cell: (info) => <Text fontSize="sm">{String(info.getValue())}</Text>,
aggregationFn: 'mean',
aggregatedCell: ({ getValue }) => (
<Text fontSize="sm" color="fg.muted">
Avg: {Math.round(Number(getValue()) * 10) / 10}
</Text>
),
meta: { label: 'Age', variant: 'number' },
}),
columnHelper.accessor('email', {
id: 'email',
header: ({ column }) => (
<ColumnHeaderMenu column={column} title="Email" />
),
cell: (info) => (
<EllipsisText>{info.cell.getValue<string>()}</EllipsisText>
),
meta: { label: 'Email', variant: 'text' },
}),
columnHelper.accessor('status', {
id: 'status',
header: ({ column }) => (
<ColumnHeaderMenu column={column} title="Status" />
),
cell: (info) => {
const status = info.getValue<Person['status'] | undefined>()
return status ? <StatusBadge status={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 }) => (
<ColumnHeaderMenu column={column} title="Department" />
),
cell: (info) => {
const department = info.getValue<Person['department'] | undefined>()
return department ? (
<DepartmentPill department={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 }) => (
<ColumnHeaderMenu column={column} title="Join Date" />
),
cell: (info) => formatDate(info.getValue<string>()),
aggregationFn: 'min',
aggregatedCell: ({ getValue }) => {
const earliest = getValue<string>()
return (
<Text fontSize="sm" color="fg.muted">
Earliest: {earliest ? formatDate(earliest) : '-'}
</Text>
)
},
meta: { label: 'Join Date', variant: 'date' },
}),
columnHelper.display({
id: 'actions',
enableHiding: false,
cell: ({ row }) => <RowActions person={row.original} />,
maxSize: 44,
enableResizing: false,
}),
]),
[],
)
const [columnOrder, setColumnOrder] = React.useState<Array<string>>(() =>
columns.map((column) => column.id ?? ''),
)
const table = useTable(
{
key: 'kitchen-sink-chakra-ui',
features,
columns,
data,
defaultColumn: {
minSize: 60,
maxSize: 800,
filterFn: dynamicFilterFn,
},
globalFilterFn: 'fuzzy',
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,
},
(state) => state,
)
useTanStackTableDevtools(table)
const columnSizeVars = React.useMemo(() => {
const headers = table.getFlatHeaders()
const colSizes: Record<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])
const refreshData = () => setData(makeData(1_000))
const stressTest = () => setData(makeData(1_000_000))
return (
<SortingContext.Provider value={sorting}>
<Container maxW="none" py="md">
<Stack gap="md">
<Box borderWidth="1px" rounded="md" p="sm">
<HStack justify="flex-end" gap="xs">
<ModeMenu />
<Button variant="outline" size="sm" onClick={refreshData}>
Regenerate Data
</Button>
<Button variant="outline" size="sm" onClick={stressTest}>
Stress Test (1M rows)
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
console.info(
'table.getSelectedRowModel().flatRows',
table.getSelectedRowModel().flatRows,
)
}
>
Log Selected Rows
</Button>
</HStack>
</Box>
<HStack align="center" gap="xs">
<DebouncedTextInput
value={globalFilter}
onChange={(value) => setGlobalFilter(String(value))}
placeholder="Search all columns..."
icon={<IconSearch size={16} />}
width={{ base: '100%', md: '360px' }}
/>
<FilterListPopover
table={table}
columnFilters={columnFilters}
onColumnFiltersChange={setColumnFilters}
/>
<SortListPopover
table={table}
sorting={sorting}
onSortingChange={setSorting}
/>
<ViewOptionsPopover
table={table}
columnOrder={columnOrder}
onColumnOrderChange={setColumnOrder}
/>
</HStack>
<Box borderWidth="1px" rounded="md">
<Box overflow="auto" maxH="680px">
<Table.Root
stickyHeader
interactive
showColumnBorder
variant="outline"
style={{
minWidth: 1200,
width: `max(1200px, ${table.getTotalSize()}px)`,
tableLayout: 'fixed',
...columnSizeVars,
}}
>
<colgroup>
{table.getVisibleLeafColumns().map((column) => (
<col
key={column.id}
style={{
width: `calc(var(--col-${column.id}-size) * 1px)`,
}}
/>
))}
</colgroup>
<Table.Header>
{table.getHeaderGroups().map((headerGroup) => (
<Table.Row key={headerGroup.id}>
{headerGroup.headers
.filter((header) => header.column.getIsVisible())
.map((header) => (
<ResizableHeaderCell
key={header.id}
header={header}
table={table}
/>
))}
</Table.Row>
))}
</Table.Header>
<Table.Body>
{table.getRowModel().rows.map((row) => {
const selected = row.getIsSelected()
return (
<Table.Row
key={row.id}
aria-selected={selected}
data-selected={selected || undefined}
bg={
selected
? 'var(--chakra-colors-blue-subtle)'
: undefined
}
>
{row.getVisibleCells().map((cell) => (
<Table.Cell
key={cell.id}
align={
cell.column.id === 'select' ? 'center' : undefined
}
style={{
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
overflow: 'hidden',
...getCommonPinningStyles(cell.column, selected),
}}
>
{cell.getIsGrouped() ? (
<Button
size="xs"
variant="subtle"
onClick={row.getToggleExpandedHandler()}
disabled={!row.getCanExpand()}
style={{
paddingLeft: `calc(${row.depth} * 1.5rem + 0.5rem)`,
}}
>
{row.getIsExpanded() ? (
<IconChevronDown size={16} />
) : (
<IconChevronRight size={16} />
)}
<table.FlexRender cell={cell} />
<Text as="span" color="fg.muted" ml="4">
({row.subRows.length})
</Text>
</Button>
) : cell.column.id === 'progress' ? (
<Stack gap={4}>
<Text fontSize="sm">
{String(cell.getValue())}%
</Text>
<ProgressBar value={Number(cell.getValue())} />
</Stack>
) : (
<table.FlexRender cell={cell} />
)}
</Table.Cell>
))}
</Table.Row>
)
})}
</Table.Body>
</Table.Root>
</Box>
<Pagination table={table} />
</Box>
</Stack>
</Container>
</SortingContext.Provider>
)
}
function ResizableHeaderCell({
header,
table,
}: {
header: Header<typeof features, Person>
table: {
FlexRender: React.ComponentType<{
header: Header<typeof features, Person>
}>
}
}) {
const sorting = React.useContext(SortingContext)
const sortDirection = getSortDirection(sorting, header.column.id)
return (
<Table.ColumnHeader
colSpan={header.colSpan}
align={header.column.id === 'select' ? 'center' : undefined}
aria-sort={getAriaSort(sortDirection || false)}
data-sort={sortDirection}
style={{
width: `calc(var(--header-${header.id}-size) * 1px)`,
padding: 8,
...getCommonPinningStyles(header.column),
}}
>
<Box
style={{
position: 'relative',
paddingRight: header.column.getCanResize() ? 8 : 0,
}}
>
{header.isPlaceholder ? null : <table.FlexRender header={header} />}
{header.column.getCanResize() ? (
<Box
onDoubleClick={() => 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}
</Box>
</Table.ColumnHeader>
)
}
function Root() {
return (
<ChakraExampleProvider>
<App />
<TanStackDevtools plugins={[tableDevtoolsPlugin()]} />
</ChakraExampleProvider>
)
}
const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Failed to find the root element')
ReactDOM.createRoot(rootElement).render(
<React.StrictMode>
<Root />
</React.StrictMode>,
)