import React, {
useState,
useEffect,
useMemo,
forwardRef,
useRef,
useId,
useCallback,
} from 'react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragOverlay,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
horizontalListSortingStrategy,
} from '@dnd-kit/sortable'
import PrimitiveText from '../Primitives/PrimitiveText/PrimitiveText'
import {
DataTableProps,
SortDirection,
SortConfig,
ColumnDefinition,
SearchConfig,
ColumnFilter,
FilterType,
ColumnType,
RowActionsConfig,
DataTableExportFormat,
} from './types'
import { TableTokenType } from './dataTable.tokens'
import {
sortData,
searchData,
applyColumnFilters,
updateColumnFilter,
exportSelectedRowsToCSV,
getSelectedRowCount,
createSearchConfig,
clearAllFiltersAndSearch,
getColumnStyles,
haveSameFilterOptions,
getDataTableBodyState,
downloadDataTableExport,
} from './utils'
import DataTableHeader from './DataTableHeader'
import TableHeader from './TableHeader'
import TableBodyComponent from './TableBody'
import TableFooter from './TableFooter'
import BulkActionBar from './TableBody/BulkActionBar'
import Block from '../Primitives/Block/Block'
import Button from '../Button/Button'
import { Spinner } from '../Spinner'
import { EmptyState } from '../EmptyState'
import { ButtonSize, ButtonType } from '../Button/types'
import { Settings, Check, CircleAlert, Download } from 'lucide-react'
import Menu from '../Menu/Menu'
import { MenuGroupType, MenuAlignment } from '../Menu/types'
import { useMobileDataTable } from './hooks/useMobileDataTable'
import MobileColumnDrawer from './MobileColumnDrawer'
// eslint-disable-next-line import-x/no-cycle -- intentional recursion: DataTable renders PivotTableModal whose preview renders a nested DataTable
import PivotTableModal from './PivotTableModal'
import type { PivotTableConfig } from './PivotTableModal/types'
import { useResponsiveTokens } from '../../hooks/useResponsiveTokens'
import { useTheme } from '../../context/ThemeContext'
import styled from 'styled-components'
import { FOUNDATION_THEME } from '../../tokens'
const ScrollableContainer = styled(Block)`
overflow-x: auto;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
/* Hide scrollbars for Chrome, Safari and Opera */
&::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
-ms-overflow-style: none;
scrollbar-width: none;
`
const DefaultTableState = ({
onRetry,
tableToken,
isDarkTheme,
}: {
onRetry?: () => void
tableToken: TableTokenType
isDarkTheme: boolean
}) => {
const stateTextColor = tableToken.dataTable.table.body.cell.color
const iconBackground = isDarkTheme
? FOUNDATION_THEME.colors.red[900]
: FOUNDATION_THEME.colors.red[50]
const iconColor = isDarkTheme
? FOUNDATION_THEME.colors.red[400]
: FOUNDATION_THEME.colors.red[600]
return (
Unable to load data
{onRetry && (
)}
)
}
const DataTable = forwardRef(
>(
{
data,
columns: initialColumns,
idField,
title,
description,
descriptionTooltipProps,
defaultSort,
enableSearch = false,
searchPlaceholder = 'Search...',
enableFiltering = false,
enableAdvancedFilter = false,
advancedFilterComponent,
advancedFilters = [],
columnFreeze = 0,
columnFreezeRight = 0,
serverSideSearch = false,
serverSideFiltering = false,
serverSidePagination = false,
isLoading = false,
error = false,
renderErrorState,
onRetry,
showEmptyState = false,
renderEmptyState,
enableColumnManager = true,
enableColumnReordering = false,
onColumnReorder,
columnManagerMaxSelections,
columnManagerAlwaysSelected,
columnManagerPrimaryAction,
columnManagerSecondaryAction,
columnManagerWidth,
showHeader = true,
showToolbar = true,
showSettings = false,
showFooter = true,
enableInlineEdit = false,
showActionsColumn = true,
enableRowExpansion = false,
enableRowSelection = false,
rowSelectionConfig,
showBulkActionBar = true,
onRowSelectionChange,
renderExpandedRow,
isRowExpandable,
showSkeleton = false,
skeletonVariant = 'pulse',
isRowLoading,
pagination = {
currentPage: 1,
pageSize: 10,
totalRows: 0,
pageSizeOptions: [10, 20, 50, 100],
},
onPageChange,
onPageSizeChange,
onSortChange,
onSearchChange,
onFilterChange,
onAdvancedFiltersChange,
onRowSave,
onRowCancel,
onRowExpansionChange,
onRowClick,
onFieldChange,
headerSlot1,
headerSlot2,
bulkActions,
exportConfig,
rowActions,
onOperations,
onInsertLeft,
onInsertRight,
onDeleteColumn,
getRowStyle,
enableRowAnimation,
rowAnimationConfig,
tableBodyHeight,
mobileColumnsToShow,
enablePivotTable = false,
pivotTableConfig,
dateLabel,
...rest
}: DataTableProps,
ref: React.Ref
) => {
const tableToken = useResponsiveTokens('TABLE')
const { theme } = useTheme()
const isDarkTheme = theme === 'dark'
const mobileConfig = useMobileDataTable(mobileColumnsToShow)
const scrollContainerRef = useRef(null)
const tableContainerRef = useRef(null)
const [isNarrowContainer, setIsNarrowContainer] =
useState(false)
const tableId = useId()
const tableLabelId = title ? `${tableId}-label` : undefined
const tableDescriptionId = description
? `${tableId}-description`
: undefined
const statusRegionId = `${tableId}-status`
const [sortConfig, setSortConfig] = useState(
defaultSort || null
)
const [visibleColumns, setVisibleColumns] = useState<
ColumnDefinition[]
>(() => {
const allVisibleColumns = initialColumns.filter(
(col) => col.isVisible !== false
)
if (columnManagerMaxSelections && columnManagerAlwaysSelected) {
const alwaysSelectedFields = columnManagerAlwaysSelected.map(
(field) => String(field)
)
const alwaysSelectedCols = allVisibleColumns.filter((col) =>
alwaysSelectedFields.includes(String(col.field))
)
const selectableCols = allVisibleColumns.filter(
(col) => !alwaysSelectedFields.includes(String(col.field))
)
const maxSelectableCount =
columnManagerMaxSelections - alwaysSelectedCols.length
const limitedSelectableCols = selectableCols.slice(
0,
Math.max(0, maxSelectableCount)
)
return [...alwaysSelectedCols, ...limitedSelectableCols]
}
return allVisibleColumns
})
useEffect(() => {
const existingVisibleKeys = new Set(
visibleColumns.map((c) => c.field)
)
const updatedVisibleColumns: ColumnDefinition[] = []
visibleColumns.forEach((col) => {
const matchingColumn = initialColumns.find(
(item) => item.field === col.field
)
if (matchingColumn) {
if (matchingColumn.isVisible !== false) {
// Take the incoming column as-is so props the consumer
// removed (filterType, filterOptions) revert instead of
// surviving from the previous state.
updatedVisibleColumns.push(matchingColumn)
}
}
})
const newColumns = initialColumns.filter(
(col) =>
!existingVisibleKeys.has(col.field) &&
col.isVisible !== false
)
newColumns.forEach((newCol) => {
const initialIndex = initialColumns.findIndex(
(c) => c.field === newCol.field
)
const insertIndex = Math.min(
initialIndex,
updatedVisibleColumns.length
)
updatedVisibleColumns.splice(insertIndex, 0, newCol)
})
const hasChanges =
visibleColumns.length !== updatedVisibleColumns.length ||
updatedVisibleColumns.some((updatedCol, index) => {
const originalCol = visibleColumns[index]
if (!originalCol) return true
if (updatedCol.field !== originalCol.field) return true
return (
updatedCol.headerSubtext !==
originalCol.headerSubtext ||
updatedCol.header !== originalCol.header ||
updatedCol.type !== originalCol.type ||
updatedCol.filterType !== originalCol.filterType ||
!haveSameFilterOptions(
updatedCol.filterOptions,
originalCol.filterOptions
) ||
(updatedCol.type === ColumnType.CUSTOM &&
updatedCol.renderCell !== originalCol.renderCell)
)
})
if (hasChanges) {
setVisibleColumns(updatedVisibleColumns)
}
}, [initialColumns])
const [previousColumnCount, setPreviousColumnCount] = useState(
() => initialColumns.filter((col) => col.isVisible !== false).length
)
const [currentPage, setCurrentPage] = useState(
pagination?.currentPage || 1
)
const [pageSize, setPageSize] = useState(
pagination?.pageSize || 10
)
useEffect(() => {
if (serverSidePagination && pagination) {
if (pagination.currentPage !== currentPage) {
setCurrentPage(pagination.currentPage)
}
if (pagination.pageSize !== pageSize) {
setPageSize(pagination.pageSize)
}
}
}, [
serverSidePagination,
pagination?.currentPage,
pagination?.pageSize,
currentPage,
pageSize,
])
const [selectedRows, setSelectedRows] = useState<
Record
>({})
const [selectAll, setSelectAll] = useState(
false
)
const [searchConfig, setSearchConfig] = useState({
query: '',
caseSensitive: false,
})
const [columnFilters, setColumnFilters] = useState([])
const [editingRows, setEditingRows] = useState>(
{}
)
const [editValues, setEditValues] = useState>({})
const [expandedRows, setExpandedRows] = useState<
Record
>({})
const [isFormatEnabled, setIsFormatEnabled] = useState(true)
const [isPivotModalOpen, setIsPivotModalOpen] = useState(false)
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false)
const [selectedRowForDrawer, setSelectedRowForDrawer] =
useState(null)
const [selectedRowIndexForDrawer, setSelectedRowIndexForDrawer] =
useState(-1)
const [internalLoading, setInternalLoading] = useState(false)
const [isExporting, setIsExporting] = useState(false)
useEffect(() => {
if (serverSidePagination && !isLoading && internalLoading) {
setInternalLoading(false)
}
}, [serverSidePagination, isLoading, internalLoading])
const [activeId, setActiveId] = useState(null)
const lastReorderedColumnsRef = useRef[] | null>(
null
)
const [focusedCell, setFocusedCell] = useState<{
rowIndex: number
colIndex: number
} | null>(null)
const [measuredFrozenWidths, setMeasuredFrozenWidths] = useState<
number[]
>([])
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const handleDragStart = (event: DragEndEvent) => {
setActiveId(event.active.id as string)
}
const handleDragOver = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = visibleColumns.findIndex(
(col) => String(col.field) === active.id
)
const newIndex = visibleColumns.findIndex(
(col) => String(col.field) === over.id
)
if (
oldIndex !== -1 &&
newIndex !== -1 &&
oldIndex >= columnFreeze &&
newIndex >= columnFreeze
) {
const reorderedColumns = arrayMove(
visibleColumns,
oldIndex,
newIndex
)
setVisibleColumns(reorderedColumns)
lastReorderedColumnsRef.current = reorderedColumns
}
}
}
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = visibleColumns.findIndex(
(col) => String(col.field) === active.id
)
const newIndex = visibleColumns.findIndex(
(col) => String(col.field) === over.id
)
if (
oldIndex !== -1 &&
newIndex !== -1 &&
oldIndex >= columnFreeze &&
newIndex >= columnFreeze
) {
const reorderedColumns = arrayMove(
visibleColumns,
oldIndex,
newIndex
)
onColumnReorder?.(reorderedColumns as ColumnDefinition[])
}
} else if (lastReorderedColumnsRef.current) {
onColumnReorder?.(
lastReorderedColumnsRef.current as ColumnDefinition[]
)
}
lastReorderedColumnsRef.current = null
setActiveId(null)
}
const handleDragCancel = () => {
setActiveId(null)
}
const columnIds = visibleColumns
.filter((_, index) => index >= columnFreeze)
.map((col) => String(col.field))
const effectiveColumnFreeze = mobileConfig.disableColumnFreeze
? 0
: columnFreeze
const effectiveEnableColumnManager = mobileConfig.hideColumnManager
? false
: enableColumnManager
const { mobileVisibleColumns, mobileOverflowColumns } = useMemo(() => {
if (!mobileConfig.enableColumnOverflow) {
return {
mobileVisibleColumns: visibleColumns,
mobileOverflowColumns: [],
}
}
const visible = visibleColumns.slice(
0,
mobileConfig.maxVisibleColumns
)
const overflow = visibleColumns.slice(
mobileConfig.maxVisibleColumns
)
return {
mobileVisibleColumns: visible,
mobileOverflowColumns: overflow,
}
}, [
visibleColumns,
mobileConfig.enableColumnOverflow,
mobileConfig.maxVisibleColumns,
])
const effectiveVisibleColumns = mobileConfig.enableColumnOverflow
? mobileVisibleColumns
: visibleColumns
const stateAreaHeight = useMemo(() => {
if (tableBodyHeight !== undefined) {
return tableBodyHeight
}
return pageSize <= 5 ? '300px' : '600px'
}, [pageSize, tableBodyHeight])
const formatOptions: MenuGroupType[] = [
{
items: [
{
label: 'Format',
slot3: isFormatEnabled ? : null,
onClick: () => setIsFormatEnabled(!isFormatEnabled),
},
],
showSeparator: false,
},
]
const removeNumberFormatting = (value: unknown): string => {
if (value == null) return ''
const stringValue = String(value)
let cleaned = stringValue.replace(/[$€£¥₹₽₪₩₦₡₵₸₴₺₻₼₽¢]/g, '')
cleaned = cleaned.replace(/%/g, '')
cleaned = cleaned.replace(/,/g, '')
cleaned = cleaned.trim()
const numericValue = parseFloat(cleaned)
if (!isNaN(numericValue)) {
return numericValue.toString()
}
return stringValue
}
const getDisplayValue = (
value: unknown,
column: ColumnDefinition
): unknown => {
if (
!isFormatEnabled &&
(column.type === ColumnType.NUMBER ||
(typeof value === 'string' &&
/[$€£¥₹₽₪₩₦₡₵₸₴₺₻₼₽¢%,\d]/.test(value)))
) {
return removeNumberFormatting(value)
}
return value
}
const processedData = useMemo(() => {
let result = [...data]
if (serverSidePagination) {
return result
}
if (
enableSearch &&
!serverSideSearch &&
searchConfig.query.trim()
) {
result = searchData(result, searchConfig, visibleColumns)
}
if (
enableFiltering &&
!serverSideFiltering &&
columnFilters.length > 0
) {
result = applyColumnFilters(result, columnFilters)
}
if (sortConfig && sortConfig.field) {
result = sortData(result, sortConfig, visibleColumns)
}
return result
}, [
data,
searchConfig,
columnFilters,
sortConfig,
visibleColumns,
enableSearch,
enableFiltering,
serverSideSearch,
serverSideFiltering,
serverSidePagination,
])
const totalRows = useMemo(() => {
if (
serverSidePagination ||
serverSideSearch ||
serverSideFiltering
) {
return pagination?.totalRows || data.length
}
return processedData.length
}, [
serverSidePagination,
serverSideSearch,
serverSideFiltering,
pagination?.totalRows,
data.length,
processedData.length,
])
useEffect(() => {
if (
!serverSidePagination &&
!serverSideSearch &&
!serverSideFiltering
) {
const totalPages = Math.ceil(totalRows / pageSize)
if (currentPage > totalPages && totalPages > 0) {
setCurrentPage(1)
}
}
}, [
totalRows,
pageSize,
currentPage,
serverSidePagination,
serverSideSearch,
serverSideFiltering,
])
useEffect(() => {
const container = tableContainerRef.current
if (!container) return
const updateContainerWidth = () => {
const width = container.clientWidth
setIsNarrowContainer(width < 640)
}
updateContainerWidth()
const resizeObserver = new ResizeObserver(updateContainerWidth)
resizeObserver.observe(container)
return () => {
resizeObserver.disconnect()
}
}, [])
const currentData = useMemo(() => {
if (
serverSideSearch ||
serverSideFiltering ||
serverSidePagination
) {
return processedData
}
const effectiveCurrentPage =
serverSidePagination && pagination
? pagination.currentPage
: currentPage
const effectivePageSize =
serverSidePagination && pagination
? pagination.pageSize
: pageSize
const startIndex = (effectiveCurrentPage - 1) * effectivePageSize
return processedData.slice(
startIndex,
startIndex + effectivePageSize
)
}, [
processedData,
currentPage,
pageSize,
serverSideSearch,
serverSideFiltering,
serverSidePagination,
pagination?.currentPage,
pagination?.pageSize,
])
const isTableLoading =
isLoading || (serverSidePagination && internalLoading)
const bodyState = getDataTableBodyState({
isLoading: isTableLoading,
error,
hasRows: currentData.length > 0,
})
const isErrorState = bodyState === 'error'
const shouldRenderRows = bodyState === 'rows'
const exportFormats = useMemo(() => {
const configuredFormats = exportConfig?.formats?.filter(
(format): format is DataTableExportFormat =>
format === 'csv' || format === 'xlsx'
)
return configuredFormats && configuredFormats.length > 0
? [...new Set(configuredFormats)]
: ['csv']
}, [exportConfig?.formats])
const handleTableExport = async (format: DataTableExportFormat) => {
if (!exportConfig?.enabled || isExporting) return
setIsExporting(true)
try {
const scope = exportConfig.scope || 'currentPage'
let rows = scope === 'allLoaded' ? processedData : currentData
if (exportConfig.onExport) {
const suppliedRows = await exportConfig.onExport({
visibleColumns,
filters: columnFilters,
advancedFilters,
search: searchConfig,
sort: sortConfig,
scope,
})
if (suppliedRows === undefined) return
rows = suppliedRows
}
const defaultFileName = `${title || 'data'}-export-${new Date().toISOString().split('T')[0]}`
await downloadDataTableExport(
rows,
visibleColumns,
format,
exportConfig.fileName || defaultFileName,
{ getDisplayValue, dateLabel }
)
} catch (error) {
alert(error instanceof Error ? error.message : 'Export failed')
} finally {
setIsExporting(false)
}
}
const exportButton = exportConfig?.enabled ? (
exportFormats.length === 1 ? (
}
size={ButtonSize.SMALL}
loading={isExporting}
disabled={isExporting}
aria-label={`Export ${exportFormats[0].toUpperCase()}`}
onClick={() => void handleTableExport(exportFormats[0])}
>
Export
) : (