feat(ui): add column visibility and drag-and-drop reordering support (#13198)
This commit is contained in:
@@ -81,6 +81,9 @@
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.0.0",
|
||||
"@dnd-kit/sortable": "^7.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.0",
|
||||
"@medusajs/icons": "2.10.1",
|
||||
"@tanstack/react-table": "8.20.5",
|
||||
"clsx": "^1.2.1",
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from "react"
|
||||
import { Column } from "@tanstack/react-table"
|
||||
|
||||
import { Checkbox } from "@/components/checkbox"
|
||||
import { DropdownMenu } from "@/components/dropdown-menu"
|
||||
import { IconButton } from "@/components/icon-button"
|
||||
import { Tooltip } from "@/components/tooltip"
|
||||
import { Adjustments } from "@medusajs/icons"
|
||||
import { clx } from "@/utils/clx"
|
||||
|
||||
import { useDataTableContext } from "../context/use-data-table-context"
|
||||
|
||||
interface DataTableColumnVisibilityMenuProps {
|
||||
className?: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
const DataTableColumnVisibilityMenu = ({
|
||||
className,
|
||||
tooltip,
|
||||
}: DataTableColumnVisibilityMenuProps) => {
|
||||
const { instance, enableColumnVisibility } = useDataTableContext()
|
||||
|
||||
if (!enableColumnVisibility) {
|
||||
return null
|
||||
}
|
||||
|
||||
const columns = instance
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
|
||||
const handleToggleColumn = (column: Column<any, any>) => {
|
||||
column.toggleVisibility()
|
||||
}
|
||||
|
||||
const handleToggleAll = (value: boolean) => {
|
||||
instance.setColumnVisibility(
|
||||
Object.fromEntries(
|
||||
columns.map((column: Column<any, any>) => [column.id, value])
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const allColumnsVisible = columns.every((column: Column<any, any>) => column.getIsVisible())
|
||||
const someColumnsVisible = columns.some((column: Column<any, any>) => column.getIsVisible())
|
||||
|
||||
const Wrapper = tooltip ? Tooltip : React.Fragment
|
||||
const wrapperProps = tooltip ? { content: tooltip } : ({} as any)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Wrapper {...wrapperProps}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<IconButton size="small" className={className}>
|
||||
<Adjustments />
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
</Wrapper>
|
||||
<DropdownMenu.Content align="end" className="min-w-[200px] max-h-[400px] overflow-hidden">
|
||||
<DropdownMenu.Label>Toggle columns</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={(e: Event) => {
|
||||
e.preventDefault()
|
||||
handleToggleAll(!allColumnsVisible)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
checked={allColumnsVisible ? true : (someColumnsVisible && !allColumnsVisible) ? "indeterminate" : false}
|
||||
/>
|
||||
<span>Toggle all</span>
|
||||
</div>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<div className="max-h-[250px] overflow-y-auto">
|
||||
{columns.map((column: Column<any, any>) => {
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
key={column.id}
|
||||
onSelect={(e: Event) => {
|
||||
e.preventDefault()
|
||||
handleToggleColumn(column)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Checkbox checked={column.getIsVisible()} />
|
||||
<span className="truncate">
|
||||
{(column.columnDef.meta as any)?.name || column.id}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataTableColumnVisibilityMenu }
|
||||
export type { DataTableColumnVisibilityMenuProps }
|
||||
@@ -3,28 +3,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { DataTableFilter } from "@/blocks/data-table/components/data-table-filter"
|
||||
import { DataTableFilterMenu } from "@/blocks/data-table/components/data-table-filter-menu"
|
||||
import { DataTableSortingMenu } from "@/blocks/data-table/components/data-table-sorting-menu"
|
||||
import { DataTableColumnVisibilityMenu } from "@/blocks/data-table/components/data-table-column-visibility-menu"
|
||||
import { useDataTableContext } from "@/blocks/data-table/context/use-data-table-context"
|
||||
import { Button } from "@/components/button"
|
||||
import { Skeleton } from "@/components/skeleton"
|
||||
|
||||
interface DataTableFilterBarProps {
|
||||
clearAllFiltersLabel?: string
|
||||
alwaysShow?: boolean
|
||||
sortingTooltip?: string
|
||||
columnsTooltip?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
interface LocalFilter {
|
||||
id: string
|
||||
value: unknown
|
||||
isNew: boolean
|
||||
}
|
||||
|
||||
const DataTableFilterBar = ({
|
||||
clearAllFiltersLabel = "Clear all",
|
||||
alwaysShow = false,
|
||||
sortingTooltip,
|
||||
columnsTooltip,
|
||||
children,
|
||||
}: DataTableFilterBarProps) => {
|
||||
const { instance } = useDataTableContext()
|
||||
|
||||
const filterState = instance.getFiltering()
|
||||
const { instance, enableColumnVisibility } = useDataTableContext()
|
||||
|
||||
// Local state for managing intermediate filters
|
||||
const [localFilters, setLocalFilters] = React.useState<LocalFilter[]>([])
|
||||
|
||||
const parentFilterState = instance.getFiltering()
|
||||
const availableFilters = instance.getFilters()
|
||||
|
||||
// Sync parent filters with local state
|
||||
React.useEffect(() => {
|
||||
setLocalFilters(prevLocalFilters => {
|
||||
const parentIds = Object.keys(parentFilterState)
|
||||
const localIds = prevLocalFilters.map(f => f.id)
|
||||
|
||||
// Remove local filters that have been removed from parent
|
||||
const updatedLocalFilters = prevLocalFilters.filter(f =>
|
||||
parentIds.includes(f.id) || f.isNew
|
||||
)
|
||||
|
||||
// Add parent filters that don't exist locally
|
||||
parentIds.forEach(id => {
|
||||
if (!localIds.includes(id)) {
|
||||
updatedLocalFilters.push({
|
||||
id,
|
||||
value: parentFilterState[id],
|
||||
isNew: false
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Only update if there's an actual change
|
||||
if (updatedLocalFilters.length !== prevLocalFilters.length ||
|
||||
updatedLocalFilters.some((f, i) => f.id !== prevLocalFilters[i]?.id)) {
|
||||
return updatedLocalFilters
|
||||
}
|
||||
return prevLocalFilters
|
||||
})
|
||||
}, [parentFilterState])
|
||||
|
||||
// Add a new filter locally
|
||||
const addLocalFilter = React.useCallback((id: string, value: unknown) => {
|
||||
setLocalFilters(prev => [...prev, { id, value, isNew: true }])
|
||||
}, [])
|
||||
|
||||
// Update a local filter's value
|
||||
const updateLocalFilter = React.useCallback((id: string, value: unknown) => {
|
||||
setLocalFilters(prev => prev.map(f =>
|
||||
f.id === id ? { ...f, value, isNew: false } : f
|
||||
))
|
||||
|
||||
// If the filter has a meaningful value, propagate to parent
|
||||
if (value !== undefined && value !== null && value !== '' &&
|
||||
!(Array.isArray(value) && value.length === 0)) {
|
||||
instance.updateFilter({ id, value })
|
||||
}
|
||||
}, [instance])
|
||||
|
||||
// Remove a local filter
|
||||
const removeLocalFilter = React.useCallback((id: string) => {
|
||||
setLocalFilters(prev => prev.filter(f => f.id !== id))
|
||||
// Also remove from parent if it exists there
|
||||
if (parentFilterState[id] !== undefined) {
|
||||
instance.removeFilter(id)
|
||||
}
|
||||
}, [instance, parentFilterState])
|
||||
|
||||
const clearFilters = React.useCallback(() => {
|
||||
setLocalFilters([])
|
||||
instance.clearFilters()
|
||||
}, [instance])
|
||||
|
||||
const filterCount = Object.keys(filterState).length
|
||||
const filterCount = localFilters.length
|
||||
const hasAvailableFilters = availableFilters.length > 0
|
||||
|
||||
// Check if sorting is enabled
|
||||
const sortableColumns = instance.getAllColumns().filter((column) => column.getCanSort())
|
||||
const hasSorting = instance.enableSorting && sortableColumns.length > 0
|
||||
|
||||
if (filterCount === 0) {
|
||||
// Always show the filter bar when there are available filters, sorting, column visibility, or when forced
|
||||
if (filterCount === 0 && !hasAvailableFilters && !hasSorting && !enableColumnVisibility && !alwaysShow && !children) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -33,21 +119,27 @@ const DataTableFilterBar = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-ui-bg-subtle flex w-full flex-nowrap items-center gap-2 overflow-x-auto border-t px-6 py-2 md:flex-wrap">
|
||||
{Object.entries(filterState).map(([id, filter]) => (
|
||||
<DataTableFilter key={id} id={id} filter={filter} />
|
||||
))}
|
||||
{filterCount > 0 ? (
|
||||
<Button
|
||||
variant="transparent"
|
||||
size="small"
|
||||
className="text-ui-fg-muted hover:text-ui-fg-subtle flex-shrink-0 whitespace-nowrap"
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
{clearAllFiltersLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="bg-ui-bg-subtle flex w-full flex-nowrap items-center justify-between gap-2 overflow-x-auto border-t px-6 py-2">
|
||||
<div className="flex flex-nowrap items-center gap-2 md:flex-wrap">
|
||||
{localFilters.map((localFilter) => (
|
||||
<DataTableFilter
|
||||
key={localFilter.id}
|
||||
id={localFilter.id}
|
||||
filter={localFilter.value}
|
||||
isNew={localFilter.isNew}
|
||||
onUpdate={(value) => updateLocalFilter(localFilter.id, value)}
|
||||
onRemove={() => removeLocalFilter(localFilter.id)}
|
||||
/>
|
||||
))}
|
||||
{hasAvailableFilters && (
|
||||
<DataTableFilterMenu onAddFilter={addLocalFilter} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{hasSorting && <DataTableSortingMenu tooltip={sortingTooltip} />}
|
||||
{enableColumnVisibility && <DataTableColumnVisibilityMenu tooltip={columnsTooltip} />}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,13 +12,17 @@ interface DataTableFilterMenuProps {
|
||||
* The tooltip to show when hovering over the filter menu.
|
||||
*/
|
||||
tooltip?: string
|
||||
/**
|
||||
* Callback when a filter is added
|
||||
*/
|
||||
onAddFilter?: (id: string, value: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* This component adds a filter menu to the data table, allowing users
|
||||
* to filter the table's data.
|
||||
*/
|
||||
const DataTableFilterMenu = (props: DataTableFilterMenuProps) => {
|
||||
const DataTableFilterMenu = ({ tooltip, onAddFilter }: DataTableFilterMenuProps) => {
|
||||
const { instance } = useDataTableContext()
|
||||
|
||||
const enabledFilters = Object.keys(instance.getFiltering())
|
||||
@@ -33,9 +37,9 @@ const DataTableFilterMenu = (props: DataTableFilterMenuProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
const Wrapper = props.tooltip ? Tooltip : React.Fragment
|
||||
const wrapperProps = props.tooltip
|
||||
? { content: props.tooltip, hidden: filterOptions.length === 0 }
|
||||
const Wrapper = tooltip ? Tooltip : React.Fragment
|
||||
const wrapperProps = tooltip
|
||||
? { content: tooltip, hidden: filterOptions.length === 0 }
|
||||
: ({} as any)
|
||||
|
||||
if (instance.showSkeleton) {
|
||||
@@ -51,17 +55,45 @@ const DataTableFilterMenu = (props: DataTableFilterMenuProps) => {
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
</Wrapper>
|
||||
<DropdownMenu.Content side="bottom">
|
||||
{filterOptions.map((filter) => (
|
||||
<DropdownMenu.Item
|
||||
key={filter.id}
|
||||
onClick={() => {
|
||||
instance.addFilter({ id: filter.id, value: undefined })
|
||||
}}
|
||||
>
|
||||
{filter.label}
|
||||
</DropdownMenu.Item>
|
||||
))}
|
||||
<DropdownMenu.Content side="bottom" align="start">
|
||||
{filterOptions.map((filter) => {
|
||||
const getDefaultValue = () => {
|
||||
switch (filter.type) {
|
||||
case "select":
|
||||
case "multiselect":
|
||||
return []
|
||||
case "string":
|
||||
return ""
|
||||
case "number":
|
||||
return null
|
||||
case "date":
|
||||
return null
|
||||
case "radio":
|
||||
return null
|
||||
case "custom":
|
||||
return null
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu.Item
|
||||
key={filter.id}
|
||||
onClick={(e) => {
|
||||
// Prevent any bubbling that might interfere
|
||||
e.stopPropagation()
|
||||
if (onAddFilter) {
|
||||
onAddFilter(filter.id, getDefaultValue())
|
||||
} else {
|
||||
instance.addFilter({ id: filter.id, value: getDefaultValue() })
|
||||
}
|
||||
}}
|
||||
>
|
||||
{filter.label}
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
})}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import { useSortable } from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { Table } from "@/components/table"
|
||||
|
||||
interface DataTableNonSortableHeaderCellProps extends React.HTMLAttributes<HTMLTableCellElement> {
|
||||
id: string
|
||||
children: React.ReactNode
|
||||
isFirstColumn?: boolean
|
||||
}
|
||||
|
||||
export const DataTableNonSortableHeaderCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
DataTableNonSortableHeaderCellProps
|
||||
>(({ id, children, className, style: propStyle, isFirstColumn, ...props }, ref) => {
|
||||
// Still use sortable hook but without listeners
|
||||
const {
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({
|
||||
id,
|
||||
disabled: true, // Disable dragging
|
||||
})
|
||||
|
||||
// Only apply horizontal transform for smooth shifting
|
||||
const transformStyle = transform ? {
|
||||
x: transform.x,
|
||||
y: 0,
|
||||
scaleX: transform.scaleX,
|
||||
scaleY: transform.scaleY,
|
||||
} : null
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
...propStyle,
|
||||
transform: transformStyle ? CSS.Transform.toString(transformStyle) : undefined,
|
||||
transition,
|
||||
position: 'relative' as const,
|
||||
}
|
||||
|
||||
const combineRefs = (element: HTMLTableCellElement | null) => {
|
||||
setNodeRef(element)
|
||||
if (ref) {
|
||||
if (typeof ref === 'function') {
|
||||
ref(element)
|
||||
} else {
|
||||
ref.current = element
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.HeaderCell
|
||||
ref={combineRefs}
|
||||
style={style}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Table.HeaderCell>
|
||||
)
|
||||
})
|
||||
|
||||
DataTableNonSortableHeaderCell.displayName = "DataTableNonSortableHeaderCell"
|
||||
@@ -0,0 +1,71 @@
|
||||
import * as React from "react"
|
||||
import { useSortable } from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { clx } from "@/utils/clx"
|
||||
import { Table } from "@/components/table"
|
||||
|
||||
interface DataTableSortableHeaderCellProps extends React.HTMLAttributes<HTMLTableCellElement> {
|
||||
id: string
|
||||
children: React.ReactNode
|
||||
isFirstColumn?: boolean
|
||||
}
|
||||
|
||||
export const DataTableSortableHeaderCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
DataTableSortableHeaderCellProps
|
||||
>(({ id, children, className, style: propStyle, isFirstColumn, ...props }, ref) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id,
|
||||
})
|
||||
|
||||
// Only apply horizontal transform, ignore vertical movement
|
||||
const transformStyle = transform ? {
|
||||
x: transform.x,
|
||||
y: 0,
|
||||
scaleX: 1,
|
||||
scaleY: 1
|
||||
} : null
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
...propStyle,
|
||||
transform: transformStyle ? CSS.Transform.toString(transformStyle) : undefined,
|
||||
transition,
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
zIndex: isDragging ? 50 : undefined,
|
||||
backgroundColor: "white",
|
||||
position: 'relative' as const,
|
||||
}
|
||||
|
||||
const combineRefs = (element: HTMLTableCellElement | null) => {
|
||||
setNodeRef(element)
|
||||
if (ref) {
|
||||
if (typeof ref === 'function') {
|
||||
ref(element)
|
||||
} else {
|
||||
ref.current = element
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.HeaderCell
|
||||
ref={combineRefs}
|
||||
style={style}
|
||||
className={clx(className, "group/header-cell relative")}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Table.HeaderCell>
|
||||
)
|
||||
})
|
||||
|
||||
DataTableSortableHeaderCell.displayName = "DataTableSortableHeaderCell"
|
||||
@@ -4,6 +4,22 @@ import * as React from "react"
|
||||
|
||||
import { Table } from "@/components/table"
|
||||
import { flexRender } from "@tanstack/react-table"
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
horizontalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
|
||||
import { useDataTableContext } from "@/blocks/data-table/context/use-data-table-context"
|
||||
import { Skeleton } from "@/components/skeleton"
|
||||
@@ -15,6 +31,8 @@ import {
|
||||
DataTableEmptyStateProps,
|
||||
} from "../types"
|
||||
import { DataTableSortingIcon } from "./data-table-sorting-icon"
|
||||
import { DataTableSortableHeaderCell } from "./data-table-sortable-header-cell"
|
||||
import { DataTableNonSortableHeaderCell } from "./data-table-non-sortable-header-cell"
|
||||
|
||||
interface DataTableTableProps {
|
||||
/**
|
||||
@@ -42,6 +60,59 @@ const DataTableTable = (props: DataTableTableProps) => {
|
||||
const hasSelect = columns.find((c) => c.id === "select")
|
||||
const hasActions = columns.find((c) => c.id === "action")
|
||||
|
||||
// Create list of all column IDs for SortableContext
|
||||
// Use current order if available, otherwise use default order
|
||||
const sortableItems = React.useMemo(() => {
|
||||
if (instance.columnOrder && instance.columnOrder.length > 0) {
|
||||
return instance.columnOrder
|
||||
}
|
||||
return columns.map(col => col.id)
|
||||
}, [columns, instance.columnOrder])
|
||||
|
||||
// Setup drag-and-drop sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (active.id !== over?.id && over?.id) {
|
||||
const activeId = active.id as string
|
||||
const overId = over.id as string
|
||||
|
||||
// Don't allow dragging fixed columns
|
||||
if (activeId === "select" || activeId === "action") {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't allow dropping on fixed columns
|
||||
if (overId === "select" || overId === "action") {
|
||||
return
|
||||
}
|
||||
|
||||
// Use the current column order from the instance
|
||||
const currentOrder = instance.columnOrder && instance.columnOrder.length > 0
|
||||
? instance.columnOrder
|
||||
: columns.map(col => col.id)
|
||||
|
||||
const oldIndex = currentOrder.indexOf(activeId)
|
||||
const newIndex = currentOrder.indexOf(overId)
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
const newOrder = arrayMove(currentOrder, oldIndex, newIndex)
|
||||
instance.setColumnOrderFromArray(newOrder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const onKeyDownHandler = (event: KeyboardEvent) => {
|
||||
// If an editable element is focused, we don't want to select a row
|
||||
@@ -100,155 +171,349 @@ const DataTableTable = (props: DataTableTableProps) => {
|
||||
return (
|
||||
<div className="flex w-full flex-1 flex-col overflow-hidden">
|
||||
{instance.emptyState === DataTableEmptyState.POPULATED && (
|
||||
<div
|
||||
ref={scrollableRef}
|
||||
onScroll={handleHorizontalScroll}
|
||||
className="min-h-0 w-full flex-1 overflow-auto overscroll-none border-y"
|
||||
>
|
||||
<Table className="relative isolate w-full">
|
||||
<Table.Header
|
||||
className="shadow-ui-border-base sticky inset-x-0 top-0 z-[1] w-full border-b-0 border-t-0 shadow-[0_1px_1px_0]"
|
||||
style={{ transform: "translate3d(0,0,0)" }}
|
||||
instance.enableColumnOrder ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div
|
||||
ref={scrollableRef}
|
||||
onScroll={handleHorizontalScroll}
|
||||
className="min-h-0 w-full flex-1 overflow-auto overscroll-none border-y"
|
||||
>
|
||||
{instance.getHeaderGroups().map((headerGroup) => (
|
||||
<Table.Row
|
||||
key={headerGroup.id}
|
||||
className={clx("border-b-0", {
|
||||
"[&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap":
|
||||
hasActions,
|
||||
"[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap":
|
||||
hasSelect,
|
||||
})}
|
||||
<Table className="relative isolate w-full">
|
||||
<Table.Header
|
||||
className="shadow-ui-border-base sticky inset-x-0 top-0 z-[1] w-full border-b-0 border-t-0 shadow-[0_1px_1px_0]"
|
||||
style={{ transform: "translate3d(0,0,0)" }}
|
||||
>
|
||||
{headerGroup.headers.map((header, idx) => {
|
||||
const canSort = header.column.getCanSort()
|
||||
const sortDirection = header.column.getIsSorted()
|
||||
const sortHandler = header.column.getToggleSortingHandler()
|
||||
{instance.getHeaderGroups().map((headerGroup) => (
|
||||
<Table.Row
|
||||
key={headerGroup.id}
|
||||
className={clx("border-b-0", {
|
||||
"[&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap":
|
||||
hasActions,
|
||||
"[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap":
|
||||
hasSelect,
|
||||
})}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortableItems}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{headerGroup.headers.map((header, idx) => {
|
||||
const canSort = header.column.getCanSort()
|
||||
const sortDirection = header.column.getIsSorted()
|
||||
const sortHandler = header.column.getToggleSortingHandler()
|
||||
|
||||
const isActionHeader = header.id === "action"
|
||||
const isSelectHeader = header.id === "select"
|
||||
const isSpecialHeader = isActionHeader || isSelectHeader
|
||||
const isActionHeader = header.id === "action"
|
||||
const isSelectHeader = header.id === "select"
|
||||
const isSpecialHeader = isActionHeader || isSelectHeader
|
||||
const isDraggable = !isSpecialHeader
|
||||
|
||||
const Wrapper = canSort ? "button" : "div"
|
||||
const isFirstColumn = hasSelect ? idx === 1 : idx === 0
|
||||
const Wrapper = canSort ? "button" : "div"
|
||||
const isFirstColumn = hasSelect ? idx === 1 : idx === 0
|
||||
|
||||
return (
|
||||
<Table.HeaderCell
|
||||
key={header.id}
|
||||
className={clx("whitespace-nowrap", {
|
||||
"w-[calc(20px+24px+24px)] min-w-[calc(20px+24px+24px)] max-w-[calc(20px+24px+24px)]":
|
||||
isSelectHeader,
|
||||
"w-[calc(28px+24px+4px)] min-w-[calc(28px+24px+4px)] max-w-[calc(28px+24px+4px)]":
|
||||
isActionHeader,
|
||||
"after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
|
||||
isFirstColumn,
|
||||
"after:bg-ui-border-base":
|
||||
showStickyBorder && isFirstColumn,
|
||||
"bg-ui-bg-subtle sticky":
|
||||
isFirstColumn || isSelectHeader,
|
||||
"left-0":
|
||||
isSelectHeader || (isFirstColumn && !hasSelect),
|
||||
"left-[calc(20px+24px+24px)]":
|
||||
isFirstColumn && hasSelect,
|
||||
// Get header alignment from column metadata
|
||||
const headerAlign = (header.column.columnDef.meta as any)?.___alignMetaData?.headerAlign || 'left'
|
||||
const isRightAligned = headerAlign === 'right'
|
||||
const isCenterAligned = headerAlign === 'center'
|
||||
|
||||
const HeaderCellComponent = isDraggable ? DataTableSortableHeaderCell : DataTableNonSortableHeaderCell
|
||||
|
||||
return (
|
||||
<HeaderCellComponent
|
||||
key={header.id}
|
||||
id={header.id}
|
||||
isFirstColumn={isFirstColumn}
|
||||
className={clx("whitespace-nowrap", {
|
||||
"w-[calc(20px+24px+24px)] min-w-[calc(20px+24px+24px)] max-w-[calc(20px+24px+24px)]":
|
||||
isSelectHeader,
|
||||
"w-[calc(28px+24px+4px)] min-w-[calc(28px+24px+4px)] max-w-[calc(28px+24px+4px)]":
|
||||
isActionHeader,
|
||||
"after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
|
||||
isFirstColumn,
|
||||
"after:bg-ui-border-base":
|
||||
showStickyBorder && isFirstColumn,
|
||||
"bg-ui-bg-subtle sticky":
|
||||
isFirstColumn || isSelectHeader,
|
||||
"left-0":
|
||||
isSelectHeader || (isFirstColumn && !hasSelect),
|
||||
"left-[calc(20px+24px+24px)]":
|
||||
isFirstColumn && hasSelect,
|
||||
})}
|
||||
style={
|
||||
!isSpecialHeader
|
||||
? {
|
||||
width: header.column.columnDef.size,
|
||||
maxWidth: header.column.columnDef.maxSize,
|
||||
minWidth: header.column.columnDef.minSize,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Wrapper
|
||||
type={canSort ? "button" : undefined}
|
||||
onClick={canSort ? sortHandler : undefined}
|
||||
onMouseDown={canSort ? (e) => e.stopPropagation() : undefined}
|
||||
className={clx(
|
||||
"group flex cursor-default items-center gap-2",
|
||||
{
|
||||
"cursor-pointer": canSort,
|
||||
"w-full": isRightAligned || isCenterAligned,
|
||||
"w-fit": !isRightAligned && !isCenterAligned,
|
||||
"justify-end": isRightAligned,
|
||||
"justify-center": isCenterAligned,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{canSort && isRightAligned && (
|
||||
<DataTableSortingIcon direction={sortDirection} />
|
||||
)}
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{canSort && !isRightAligned && (
|
||||
<DataTableSortingIcon direction={sortDirection} />
|
||||
)}
|
||||
</Wrapper>
|
||||
</HeaderCellComponent>
|
||||
)
|
||||
})}
|
||||
style={
|
||||
!isSpecialHeader
|
||||
? {
|
||||
</SortableContext>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Header>
|
||||
<Table.Body className="border-b-0 border-t-0">
|
||||
{instance.getRowModel().rows.map((row) => {
|
||||
return (
|
||||
<Table.Row
|
||||
key={row.id}
|
||||
onMouseEnter={() => (hoveredRowId.current = row.id)}
|
||||
onMouseLeave={() => (hoveredRowId.current = null)}
|
||||
onClick={(e) => instance.onRowClick?.(e, row)}
|
||||
className={clx("group/row last-of-type:border-b-0", {
|
||||
"cursor-pointer": !!instance.onRowClick,
|
||||
})}
|
||||
>
|
||||
{row.getVisibleCells().map((cell, idx) => {
|
||||
const isSelectCell = cell.column.id === "select"
|
||||
const isActionCell = cell.column.id === "action"
|
||||
const isSpecialCell = isSelectCell || isActionCell
|
||||
|
||||
const isFirstColumn = hasSelect ? idx === 1 : idx === 0
|
||||
|
||||
return (
|
||||
<Table.Cell
|
||||
key={cell.id}
|
||||
className={clx(
|
||||
"items-stretch truncate whitespace-nowrap",
|
||||
{
|
||||
"w-[calc(20px+24px+24px)] min-w-[calc(20px+24px+24px)] max-w-[calc(20px+24px+24px)]":
|
||||
isSelectCell,
|
||||
"w-[calc(28px+24px+4px)] min-w-[calc(28px+24px+4px)] max-w-[calc(28px+24px+4px)]":
|
||||
isActionCell,
|
||||
"bg-ui-bg-base group-hover/row:bg-ui-bg-base-hover transition-fg sticky h-full":
|
||||
isFirstColumn || isSelectCell,
|
||||
"after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
|
||||
isFirstColumn,
|
||||
"after:bg-ui-border-base":
|
||||
showStickyBorder && isFirstColumn,
|
||||
"left-0":
|
||||
isSelectCell || (isFirstColumn && !hasSelect),
|
||||
"left-[calc(20px+24px+24px)]":
|
||||
isFirstColumn && hasSelect,
|
||||
}
|
||||
)}
|
||||
style={
|
||||
!isSpecialCell
|
||||
? {
|
||||
width: cell.column.columnDef.size,
|
||||
maxWidth: cell.column.columnDef.maxSize,
|
||||
minWidth: cell.column.columnDef.minSize,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</Table.Cell>
|
||||
)
|
||||
})}
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
</DndContext>
|
||||
) : (
|
||||
<div
|
||||
ref={scrollableRef}
|
||||
onScroll={handleHorizontalScroll}
|
||||
className="min-h-0 w-full flex-1 overflow-auto overscroll-none border-y"
|
||||
>
|
||||
<Table className="relative isolate w-full">
|
||||
<Table.Header
|
||||
className="shadow-ui-border-base sticky inset-x-0 top-0 z-[1] w-full border-b-0 border-t-0 shadow-[0_1px_1px_0]"
|
||||
style={{ transform: "translate3d(0,0,0)" }}
|
||||
>
|
||||
{instance.getHeaderGroups().map((headerGroup) => (
|
||||
<Table.Row
|
||||
key={headerGroup.id}
|
||||
className={clx("border-b-0", {
|
||||
"[&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap":
|
||||
hasActions,
|
||||
"[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap":
|
||||
hasSelect,
|
||||
})}
|
||||
>
|
||||
{headerGroup.headers.map((header, idx) => {
|
||||
const canSort = header.column.getCanSort()
|
||||
const sortDirection = header.column.getIsSorted()
|
||||
const sortHandler = header.column.getToggleSortingHandler()
|
||||
|
||||
const isActionHeader = header.id === "action"
|
||||
const isSelectHeader = header.id === "select"
|
||||
const isSpecialHeader = isActionHeader || isSelectHeader
|
||||
|
||||
const Wrapper = canSort ? "button" : "div"
|
||||
const isFirstColumn = hasSelect ? idx === 1 : idx === 0
|
||||
|
||||
// Get header alignment from column metadata
|
||||
const headerAlign = (header.column.columnDef.meta as any)?.___alignMetaData?.headerAlign || 'left'
|
||||
const isRightAligned = headerAlign === 'right'
|
||||
const isCenterAligned = headerAlign === 'center'
|
||||
|
||||
return (
|
||||
<Table.HeaderCell
|
||||
key={header.id}
|
||||
className={clx("whitespace-nowrap", {
|
||||
"w-[calc(20px+24px+24px)] min-w-[calc(20px+24px+24px)] max-w-[calc(20px+24px+24px)]":
|
||||
isSelectHeader,
|
||||
"w-[calc(28px+24px+4px)] min-w-[calc(28px+24px+4px)] max-w-[calc(28px+24px+4px)]":
|
||||
isActionHeader,
|
||||
"after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
|
||||
isFirstColumn,
|
||||
"after:bg-ui-border-base":
|
||||
showStickyBorder && isFirstColumn,
|
||||
"bg-ui-bg-subtle sticky":
|
||||
isFirstColumn || isSelectHeader,
|
||||
"left-0":
|
||||
isSelectHeader || (isFirstColumn && !hasSelect),
|
||||
"left-[calc(20px+24px+24px)]":
|
||||
isFirstColumn && hasSelect,
|
||||
})}
|
||||
style={
|
||||
!isSpecialHeader
|
||||
? {
|
||||
width: header.column.columnDef.size,
|
||||
maxWidth: header.column.columnDef.maxSize,
|
||||
minWidth: header.column.columnDef.minSize,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Wrapper
|
||||
type={canSort ? "button" : undefined}
|
||||
onClick={canSort ? sortHandler : undefined}
|
||||
className={clx(
|
||||
"group flex w-fit cursor-default items-center gap-2",
|
||||
{
|
||||
"cursor-pointer": canSort,
|
||||
}
|
||||
)}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{canSort && (
|
||||
<DataTableSortingIcon direction={sortDirection} />
|
||||
)}
|
||||
</Wrapper>
|
||||
</Table.HeaderCell>
|
||||
)
|
||||
})}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Header>
|
||||
<Table.Body className="border-b-0 border-t-0">
|
||||
{instance.getRowModel().rows.map((row) => {
|
||||
return (
|
||||
<Table.Row
|
||||
key={row.id}
|
||||
onMouseEnter={() => (hoveredRowId.current = row.id)}
|
||||
onMouseLeave={() => (hoveredRowId.current = null)}
|
||||
onClick={(e) => instance.onRowClick?.(e, row)}
|
||||
className={clx("group/row last-of-type:border-b-0", {
|
||||
"cursor-pointer": !!instance.onRowClick,
|
||||
<Wrapper
|
||||
type={canSort ? "button" : undefined}
|
||||
onClick={canSort ? sortHandler : undefined}
|
||||
onMouseDown={canSort ? (e) => e.stopPropagation() : undefined}
|
||||
className={clx(
|
||||
"group flex cursor-default items-center gap-2",
|
||||
{
|
||||
"cursor-pointer": canSort,
|
||||
"w-full": isRightAligned || isCenterAligned,
|
||||
"w-fit": !isRightAligned && !isCenterAligned,
|
||||
"justify-end": isRightAligned,
|
||||
"justify-center": isCenterAligned,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{canSort && isRightAligned && (
|
||||
<DataTableSortingIcon direction={sortDirection} />
|
||||
)}
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{canSort && !isRightAligned && (
|
||||
<DataTableSortingIcon direction={sortDirection} />
|
||||
)}
|
||||
</Wrapper>
|
||||
</Table.HeaderCell>
|
||||
)
|
||||
})}
|
||||
>
|
||||
{row.getVisibleCells().map((cell, idx) => {
|
||||
const isSelectCell = cell.column.id === "select"
|
||||
const isActionCell = cell.column.id === "action"
|
||||
const isSpecialCell = isSelectCell || isActionCell
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Header>
|
||||
<Table.Body className="border-b-0 border-t-0">
|
||||
{instance.getRowModel().rows.map((row) => {
|
||||
return (
|
||||
<Table.Row
|
||||
key={row.id}
|
||||
onMouseEnter={() => (hoveredRowId.current = row.id)}
|
||||
onMouseLeave={() => (hoveredRowId.current = null)}
|
||||
onClick={(e) => instance.onRowClick?.(e, row)}
|
||||
className={clx("group/row last-of-type:border-b-0", {
|
||||
"cursor-pointer": !!instance.onRowClick,
|
||||
})}
|
||||
>
|
||||
{row.getVisibleCells().map((cell, idx) => {
|
||||
const isSelectCell = cell.column.id === "select"
|
||||
const isActionCell = cell.column.id === "action"
|
||||
const isSpecialCell = isSelectCell || isActionCell
|
||||
|
||||
const isFirstColumn = hasSelect ? idx === 1 : idx === 0
|
||||
const isFirstColumn = hasSelect ? idx === 1 : idx === 0
|
||||
|
||||
return (
|
||||
<Table.Cell
|
||||
key={cell.id}
|
||||
className={clx(
|
||||
"items-stretch truncate whitespace-nowrap",
|
||||
{
|
||||
"w-[calc(20px+24px+24px)] min-w-[calc(20px+24px+24px)] max-w-[calc(20px+24px+24px)]":
|
||||
isSelectCell,
|
||||
"w-[calc(28px+24px+4px)] min-w-[calc(28px+24px+4px)] max-w-[calc(28px+24px+4px)]":
|
||||
isActionCell,
|
||||
"bg-ui-bg-base group-hover/row:bg-ui-bg-base-hover transition-fg sticky h-full":
|
||||
isFirstColumn || isSelectCell,
|
||||
"after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
|
||||
isFirstColumn,
|
||||
"after:bg-ui-border-base":
|
||||
showStickyBorder && isFirstColumn,
|
||||
"left-0":
|
||||
isSelectCell || (isFirstColumn && !hasSelect),
|
||||
"left-[calc(20px+24px+24px)]":
|
||||
isFirstColumn && hasSelect,
|
||||
}
|
||||
)}
|
||||
style={
|
||||
!isSpecialCell
|
||||
? {
|
||||
return (
|
||||
<Table.Cell
|
||||
key={cell.id}
|
||||
className={clx(
|
||||
"items-stretch truncate whitespace-nowrap",
|
||||
{
|
||||
"w-[calc(20px+24px+24px)] min-w-[calc(20px+24px+24px)] max-w-[calc(20px+24px+24px)]":
|
||||
isSelectCell,
|
||||
"w-[calc(28px+24px+4px)] min-w-[calc(28px+24px+4px)] max-w-[calc(28px+24px+4px)]":
|
||||
isActionCell,
|
||||
"bg-ui-bg-base group-hover/row:bg-ui-bg-base-hover transition-fg sticky h-full":
|
||||
isFirstColumn || isSelectCell,
|
||||
"after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
|
||||
isFirstColumn,
|
||||
"after:bg-ui-border-base":
|
||||
showStickyBorder && isFirstColumn,
|
||||
"left-0":
|
||||
isSelectCell || (isFirstColumn && !hasSelect),
|
||||
"left-[calc(20px+24px+24px)]":
|
||||
isFirstColumn && hasSelect,
|
||||
}
|
||||
)}
|
||||
style={
|
||||
!isSpecialCell
|
||||
? {
|
||||
width: cell.column.columnDef.size,
|
||||
maxWidth: cell.column.columnDef.maxSize,
|
||||
minWidth: cell.column.columnDef.minSize,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</Table.Cell>
|
||||
)
|
||||
})}
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</Table.Cell>
|
||||
)
|
||||
})}
|
||||
</Table.Row>
|
||||
)
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<DataTableEmptyStateDisplay
|
||||
state={instance.emptyState}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DataTableFilterBar } from "@/blocks/data-table/components/data-table-filter-bar"
|
||||
import { useDataTableContext } from "@/blocks/data-table/context/use-data-table-context"
|
||||
import { clx } from "@/utils/clx"
|
||||
import * as React from "react"
|
||||
|
||||
@@ -8,6 +9,14 @@ interface DataTableToolbarTranslations {
|
||||
* @default "Clear all"
|
||||
*/
|
||||
clearAll?: string
|
||||
/**
|
||||
* The tooltip for the sorting menu
|
||||
*/
|
||||
sort?: string
|
||||
/**
|
||||
* The tooltip for the columns menu
|
||||
*/
|
||||
columns?: string
|
||||
}
|
||||
|
||||
interface DataTableToolbarProps {
|
||||
@@ -23,12 +32,19 @@ interface DataTableToolbarProps {
|
||||
* The translations of strings in the toolbar.
|
||||
*/
|
||||
translations?: DataTableToolbarTranslations
|
||||
/**
|
||||
* Custom content to render in the filter bar
|
||||
*/
|
||||
filterBarContent?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Toolbar shown for the data table.
|
||||
*/
|
||||
const DataTableToolbar = (props: DataTableToolbarProps) => {
|
||||
const { instance } = useDataTableContext()
|
||||
const hasFilters = instance.getFilters().length > 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col divide-y">
|
||||
<div className={clx("flex items-center px-6 py-4", props.className)}>
|
||||
@@ -36,7 +52,12 @@ const DataTableToolbar = (props: DataTableToolbarProps) => {
|
||||
</div>
|
||||
<DataTableFilterBar
|
||||
clearAllFiltersLabel={props.translations?.clearAll}
|
||||
/>
|
||||
alwaysShow={hasFilters}
|
||||
sortingTooltip={props.translations?.sort}
|
||||
columnsTooltip={props.translations?.columns}
|
||||
>
|
||||
{props.filterBarContent}
|
||||
</DataTableFilterBar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,13 @@ const DataTableContextProvider = <TData,>({
|
||||
children,
|
||||
}: DataTableContextProviderProps<TData>) => {
|
||||
return (
|
||||
<DataTableContext.Provider value={{ instance }}>
|
||||
<DataTableContext.Provider
|
||||
value={{
|
||||
instance,
|
||||
enableColumnVisibility: instance.enableColumnVisibility,
|
||||
enableColumnOrder: instance.enableColumnOrder
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DataTableContext.Provider>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@ import { UseDataTableReturn } from "../use-data-table"
|
||||
|
||||
export interface DataTableContextValue<TData> {
|
||||
instance: UseDataTableReturn<TData>
|
||||
enableColumnVisibility: boolean
|
||||
enableColumnOrder: boolean
|
||||
}
|
||||
|
||||
export const DataTableContext =
|
||||
|
||||
@@ -248,24 +248,24 @@ const columns = [
|
||||
[
|
||||
{
|
||||
label: "Edit",
|
||||
onClick: () => {},
|
||||
onClick: () => { },
|
||||
icon: <PencilSquare />,
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
onClick: () => {},
|
||||
onClick: () => { },
|
||||
icon: <PencilSquare />,
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
onClick: () => {},
|
||||
onClick: () => { },
|
||||
icon: <PencilSquare />,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
label: "Delete",
|
||||
onClick: () => {},
|
||||
onClick: () => { },
|
||||
icon: <Trash />,
|
||||
},
|
||||
],
|
||||
@@ -385,6 +385,13 @@ const KitchenSinkDemo = () => {
|
||||
},
|
||||
})
|
||||
|
||||
const handleFilteringChange = (
|
||||
state: DataTableFilteringState,
|
||||
) => {
|
||||
console.log("Filtering changed:", state)
|
||||
setFiltering(state)
|
||||
}
|
||||
|
||||
const [pagination, setPagination] = React.useState<DataTablePaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
@@ -414,7 +421,7 @@ const KitchenSinkDemo = () => {
|
||||
},
|
||||
filtering: {
|
||||
state: filtering,
|
||||
onFilteringChange: setFiltering,
|
||||
onFilteringChange: handleFilteringChange,
|
||||
},
|
||||
rowSelection: {
|
||||
state: rowSelection,
|
||||
|
||||
@@ -5,6 +5,8 @@ import * as React from "react"
|
||||
import { clx } from "@/utils/clx"
|
||||
|
||||
import { DataTableCommandBar } from "./components/data-table-command-bar"
|
||||
import { DataTableColumnVisibilityMenu } from "./components/data-table-column-visibility-menu"
|
||||
import { DataTableFilterBar } from "./components/data-table-filter-bar"
|
||||
import { DataTableFilterMenu } from "./components/data-table-filter-menu"
|
||||
import { DataTablePagination } from "./components/data-table-pagination"
|
||||
import { DataTableSearch } from "./components/data-table-search"
|
||||
@@ -58,6 +60,8 @@ const DataTable = Object.assign(Root, {
|
||||
Search: DataTableSearch,
|
||||
SortingMenu: DataTableSortingMenu,
|
||||
FilterMenu: DataTableFilterMenu,
|
||||
FilterBar: DataTableFilterBar,
|
||||
ColumnVisibilityMenu: DataTableColumnVisibilityMenu,
|
||||
Pagination: DataTablePagination,
|
||||
CommandBar: DataTableCommandBar,
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ export type {
|
||||
DataTableColumnFilter,
|
||||
DataTableCommand,
|
||||
DataTableDateComparisonOperator,
|
||||
DataTableNumberComparisonOperator,
|
||||
DataTableEmptyState,
|
||||
DataTableEmptyStateContent,
|
||||
DataTableEmptyStateProps,
|
||||
@@ -25,3 +26,6 @@ export type {
|
||||
DataTableSortDirection,
|
||||
DataTableSortingState,
|
||||
} from "./types"
|
||||
|
||||
// Re-export types from @tanstack/react-table that are used in the public API
|
||||
export type { VisibilityState, ColumnOrderState } from "@tanstack/react-table"
|
||||
|
||||
@@ -73,10 +73,24 @@ export type DataTableSortableColumnDef = {
|
||||
enableSorting?: boolean
|
||||
}
|
||||
|
||||
export type DataTableHeaderAlignment = 'left' | 'center' | 'right'
|
||||
|
||||
export type DataTableAlignableColumnDef = {
|
||||
/**
|
||||
* The alignment of the header content.
|
||||
* @default 'left'
|
||||
*/
|
||||
headerAlign?: DataTableHeaderAlignment
|
||||
}
|
||||
|
||||
export type DataTableSortableColumnDefMeta = {
|
||||
___sortMetaData?: DataTableSortableColumnDef
|
||||
}
|
||||
|
||||
export type DataTableAlignableColumnDefMeta = {
|
||||
___alignMetaData?: DataTableAlignableColumnDef
|
||||
}
|
||||
|
||||
export type DataTableActionColumnDefMeta<TData> = {
|
||||
___actions?:
|
||||
| DataTableAction<TData>[]
|
||||
@@ -151,8 +165,8 @@ export interface DataTableColumnHelper<TData> {
|
||||
>(
|
||||
accessor: TAccessor,
|
||||
column: TAccessor extends AccessorFn<TData>
|
||||
? DataTableDisplayColumnDef<TData, TValue> & DataTableSortableColumnDef
|
||||
: DataTableIdentifiedColumnDef<TData, TValue> & DataTableSortableColumnDef
|
||||
? DataTableDisplayColumnDef<TData, TValue> & DataTableSortableColumnDef & DataTableAlignableColumnDef
|
||||
: DataTableIdentifiedColumnDef<TData, TValue> & DataTableSortableColumnDef & DataTableAlignableColumnDef
|
||||
) => TAccessor extends AccessorFn<TData>
|
||||
? AccessorFnColumnDef<TData, TValue>
|
||||
: AccessorKeyColumnDef<TData, TValue>
|
||||
@@ -192,7 +206,7 @@ export type DataTableFilteringState<
|
||||
[K in keyof T]: T[K]
|
||||
}
|
||||
|
||||
export type DataTableFilterType = "radio" | "select" | "date"
|
||||
export type DataTableFilterType = "radio" | "select" | "date" | "multiselect" | "string" | "number" | "custom"
|
||||
export type DataTableFilterOption<T = string> = {
|
||||
label: string
|
||||
value: T
|
||||
@@ -259,10 +273,57 @@ export interface DataTableDateFilterProps extends DataTableBaseFilterProps {
|
||||
options: DataTableFilterOption<DataTableDateComparisonOperator>[]
|
||||
}
|
||||
|
||||
export interface DataTableMultiselectFilterProps extends DataTableBaseFilterProps {
|
||||
type: "multiselect"
|
||||
options: DataTableFilterOption[]
|
||||
/**
|
||||
* Whether to show a search input for the options.
|
||||
* @default true
|
||||
*/
|
||||
searchable?: boolean
|
||||
}
|
||||
|
||||
export interface DataTableStringFilterProps extends DataTableBaseFilterProps {
|
||||
type: "string"
|
||||
/**
|
||||
* Placeholder text for the input.
|
||||
*/
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export interface DataTableNumberFilterProps extends DataTableBaseFilterProps {
|
||||
type: "number"
|
||||
/**
|
||||
* Placeholder text for the input.
|
||||
*/
|
||||
placeholder?: string
|
||||
/**
|
||||
* Whether to include comparison operators.
|
||||
* @default true
|
||||
*/
|
||||
includeOperators?: boolean
|
||||
}
|
||||
|
||||
export interface DataTableCustomFilterProps extends DataTableBaseFilterProps {
|
||||
type: "custom"
|
||||
/**
|
||||
* Custom render function for the filter.
|
||||
*/
|
||||
render: (props: {
|
||||
value: any
|
||||
onChange: (value: any) => void
|
||||
onRemove: () => void
|
||||
}) => React.ReactNode
|
||||
}
|
||||
|
||||
export type DataTableFilterProps =
|
||||
| DataTableRadioFilterProps
|
||||
| DataTableSelectFilterProps
|
||||
| DataTableDateFilterProps
|
||||
| DataTableMultiselectFilterProps
|
||||
| DataTableStringFilterProps
|
||||
| DataTableNumberFilterProps
|
||||
| DataTableCustomFilterProps
|
||||
|
||||
export type DataTableFilter<
|
||||
T extends DataTableFilterProps = DataTableFilterProps
|
||||
@@ -295,6 +356,29 @@ export type DataTableDateComparisonOperator = {
|
||||
$gt?: string
|
||||
}
|
||||
|
||||
export type DataTableNumberComparisonOperator = {
|
||||
/**
|
||||
* The filtered number must be greater than or equal to this value.
|
||||
*/
|
||||
$gte?: number
|
||||
/**
|
||||
* The filtered number must be less than or equal to this value.
|
||||
*/
|
||||
$lte?: number
|
||||
/**
|
||||
* The filtered number must be less than this value.
|
||||
*/
|
||||
$lt?: number
|
||||
/**
|
||||
* The filtered number must be greater than this value.
|
||||
*/
|
||||
$gt?: number
|
||||
/**
|
||||
* The filtered number must be equal to this value.
|
||||
*/
|
||||
$eq?: number
|
||||
}
|
||||
|
||||
type DataTableCommandAction = (
|
||||
selection: DataTableRowSelectionState
|
||||
) => void | Promise<void>
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ColumnFilter,
|
||||
ColumnFiltersState,
|
||||
type ColumnSort,
|
||||
type ColumnOrderState,
|
||||
getCoreRowModel,
|
||||
PaginationState,
|
||||
type RowSelectionState,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
type TableOptions,
|
||||
type Updater,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import * as React from "react"
|
||||
import {
|
||||
@@ -106,6 +108,20 @@ interface DataTableOptions<TData>
|
||||
* @default true
|
||||
*/
|
||||
autoResetPageIndex?: boolean
|
||||
/**
|
||||
* The state and callback for the column visibility.
|
||||
*/
|
||||
columnVisibility?: {
|
||||
state: VisibilityState
|
||||
onColumnVisibilityChange: (state: VisibilityState) => void
|
||||
}
|
||||
/**
|
||||
* The state and callback for the column order.
|
||||
*/
|
||||
columnOrder?: {
|
||||
state: ColumnOrderState
|
||||
onColumnOrderChange: (state: ColumnOrderState) => void
|
||||
}
|
||||
}
|
||||
|
||||
interface UseDataTableReturn<TData>
|
||||
@@ -119,6 +135,8 @@ interface UseDataTableReturn<TData>
|
||||
| "previousPage"
|
||||
| "getPageCount"
|
||||
| "getAllColumns"
|
||||
| "setColumnVisibility"
|
||||
| "setColumnOrder"
|
||||
> {
|
||||
getSorting: () => DataTableSortingState | null
|
||||
setSorting: (
|
||||
@@ -156,6 +174,10 @@ interface UseDataTableReturn<TData>
|
||||
enableFiltering: boolean
|
||||
enableSorting: boolean
|
||||
enableSearch: boolean
|
||||
enableColumnVisibility: boolean
|
||||
enableColumnOrder: boolean
|
||||
columnOrder: ColumnOrderState
|
||||
setColumnOrderFromArray: (order: string[]) => void
|
||||
}
|
||||
|
||||
const useDataTable = <TData,>({
|
||||
@@ -170,6 +192,8 @@ const useDataTable = <TData,>({
|
||||
onRowClick,
|
||||
autoResetPageIndex = true,
|
||||
isLoading = false,
|
||||
columnVisibility,
|
||||
columnOrder,
|
||||
...options
|
||||
}: DataTableOptions<TData>): UseDataTableReturn<TData> => {
|
||||
const { state: sortingState, onSortingChange } = sorting ?? {}
|
||||
@@ -180,6 +204,10 @@ const useDataTable = <TData,>({
|
||||
onRowSelectionChange,
|
||||
enableRowSelection,
|
||||
} = rowSelection ?? {}
|
||||
const { state: columnVisibilityState, onColumnVisibilityChange } = columnVisibility ?? {}
|
||||
const { state: columnOrderState, onColumnOrderChange } = columnOrder ?? {}
|
||||
|
||||
// Store filter metadata like openOnMount
|
||||
|
||||
const autoResetPageIndexHandler = React.useCallback(() => {
|
||||
return autoResetPageIndex
|
||||
@@ -230,6 +258,32 @@ const useDataTable = <TData,>({
|
||||
: undefined
|
||||
}, [onPaginationChange, paginationState])
|
||||
|
||||
const columnVisibilityStateHandler = React.useCallback(() => {
|
||||
return onColumnVisibilityChange
|
||||
? (updaterOrValue: Updater<VisibilityState>) => {
|
||||
const value =
|
||||
typeof updaterOrValue === "function"
|
||||
? updaterOrValue(columnVisibilityState ?? {})
|
||||
: updaterOrValue
|
||||
|
||||
onColumnVisibilityChange(value)
|
||||
}
|
||||
: undefined
|
||||
}, [onColumnVisibilityChange, columnVisibilityState])
|
||||
|
||||
const columnOrderStateHandler = React.useCallback(() => {
|
||||
return onColumnOrderChange
|
||||
? (updaterOrValue: Updater<ColumnOrderState>) => {
|
||||
const value =
|
||||
typeof updaterOrValue === "function"
|
||||
? updaterOrValue(columnOrderState ?? [])
|
||||
: updaterOrValue
|
||||
|
||||
onColumnOrderChange(value)
|
||||
}
|
||||
: undefined
|
||||
}, [onColumnOrderChange, columnOrderState])
|
||||
|
||||
const instance = useReactTable({
|
||||
...options,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -243,6 +297,8 @@ const useDataTable = <TData,>({
|
||||
})
|
||||
),
|
||||
pagination: paginationState,
|
||||
columnVisibility: columnVisibilityState ?? {},
|
||||
columnOrder: columnOrderState ?? [],
|
||||
},
|
||||
enableRowSelection,
|
||||
rowCount,
|
||||
@@ -250,6 +306,8 @@ const useDataTable = <TData,>({
|
||||
onRowSelectionChange: rowSelectionStateHandler(),
|
||||
onSortingChange: sortingStateHandler(),
|
||||
onPaginationChange: paginationStateHandler(),
|
||||
onColumnVisibilityChange: columnVisibilityStateHandler(),
|
||||
onColumnOrderChange: columnOrderStateHandler(),
|
||||
manualSorting: true,
|
||||
manualPagination: true,
|
||||
manualFiltering: true,
|
||||
@@ -289,7 +347,7 @@ const useDataTable = <TData,>({
|
||||
return null
|
||||
}
|
||||
|
||||
return filter.options as DataTableFilterOption<T>[]
|
||||
return ((filter as any).options as DataTableFilterOption<T>[]) || null
|
||||
},
|
||||
[getFilters]
|
||||
)
|
||||
@@ -307,11 +365,11 @@ const useDataTable = <TData,>({
|
||||
}, [instance])
|
||||
|
||||
const addFilter = React.useCallback(
|
||||
(filter: ColumnFilter) => {
|
||||
if (filter.value) {
|
||||
autoResetPageIndexHandler()?.()
|
||||
}
|
||||
onFilteringChange?.({ ...getFiltering(), [filter.id]: filter.value })
|
||||
(filter: DataTableColumnFilter) => {
|
||||
const currentFilters = getFiltering()
|
||||
const newFilters = { ...currentFilters, [filter.id]: filter.value }
|
||||
autoResetPageIndexHandler()?.()
|
||||
onFilteringChange?.(newFilters)
|
||||
},
|
||||
[onFilteringChange, getFiltering, autoResetPageIndexHandler]
|
||||
)
|
||||
@@ -424,12 +482,20 @@ const useDataTable = <TData,>({
|
||||
const enableFiltering: boolean = !!filtering
|
||||
const enableSorting: boolean = !!sorting
|
||||
const enableSearch: boolean = !!search
|
||||
const enableColumnVisibility: boolean = !!columnVisibility
|
||||
const enableColumnOrder: boolean = !!columnOrder
|
||||
|
||||
const setColumnOrderFromArray = React.useCallback((order: string[]) => {
|
||||
instance.setColumnOrder(order)
|
||||
}, [instance])
|
||||
|
||||
return {
|
||||
// Table
|
||||
getHeaderGroups: instance.getHeaderGroups,
|
||||
getRowModel: instance.getRowModel,
|
||||
getAllColumns: instance.getAllColumns,
|
||||
setColumnVisibility: instance.setColumnVisibility,
|
||||
setColumnOrder: instance.setColumnOrder,
|
||||
// Pagination
|
||||
enablePagination,
|
||||
getCanNextPage: instance.getCanNextPage,
|
||||
@@ -468,6 +534,12 @@ const useDataTable = <TData,>({
|
||||
// Loading
|
||||
isLoading,
|
||||
showSkeleton,
|
||||
// Column Visibility
|
||||
enableColumnVisibility,
|
||||
// Column Order
|
||||
enableColumnOrder,
|
||||
columnOrder: instance.getState().columnOrder,
|
||||
setColumnOrderFromArray,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -516,7 +588,7 @@ function onFilteringChangeTransformer(
|
||||
: updaterOrValue
|
||||
|
||||
const transformedValue = Object.fromEntries(
|
||||
value.map((filter) => [filter.id, filter])
|
||||
value.map((filter) => [filter.id, filter.value])
|
||||
)
|
||||
|
||||
onFilteringChange(transformedValue)
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
DataTableSelectColumnDef,
|
||||
DataTableSortableColumnDef,
|
||||
DataTableSortableColumnDefMeta,
|
||||
DataTableAlignableColumnDef,
|
||||
DataTableAlignableColumnDefMeta,
|
||||
} from "../types"
|
||||
|
||||
const createDataTableColumnHelper = <
|
||||
@@ -27,13 +29,15 @@ const createDataTableColumnHelper = <
|
||||
sortLabel,
|
||||
sortAscLabel,
|
||||
sortDescLabel,
|
||||
headerAlign,
|
||||
meta,
|
||||
enableSorting,
|
||||
...rest
|
||||
} = column as any & DataTableSortableColumnDef
|
||||
} = column as any & DataTableSortableColumnDef & DataTableAlignableColumnDef
|
||||
|
||||
const extendedMeta: DataTableSortableColumnDefMeta = {
|
||||
const extendedMeta: DataTableSortableColumnDefMeta & DataTableAlignableColumnDefMeta = {
|
||||
___sortMetaData: { sortLabel, sortAscLabel, sortDescLabel },
|
||||
___alignMetaData: { headerAlign },
|
||||
...(meta || {}),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user