refactor: update configuration and enhance skeleton components for improved UI consistency
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m5s
CI / go (push) Successful in 1m5s
CI / bird2 (push) Successful in 19s
CI / release (push) Successful in 4m22s

Modified .npmrc to set a new store directory. Updated eslint configuration to ignore additional paths. Adjusted tsconfig to exclude specific components and refined the SectionCardsSkeleton and AnalyticsDashboardSkeleton for better layout and loading states. Removed the deprecated DashboardQuickActions component to streamline the codebase.
This commit is contained in:
Denozordec
2026-07-09 18:23:53 +07:00
parent 3a9bd0dba3
commit 39f1295438
78 changed files with 8226 additions and 449 deletions
@@ -31,7 +31,10 @@ function DataGridColumnFilter<TData, TValue>({
options,
}: DataGridColumnFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues()
const selectedValues = new Set(column?.getFilterValue() as string[])
const filterValue = column?.getFilterValue()
const selectedValues = new Set(
Array.isArray(filterValue) ? (filterValue as string[]) : []
)
const [searchQuery, setSearchQuery] = useState("")
const filteredOptions = useMemo(() => {
@@ -53,16 +56,13 @@ function DataGridColumnFilter<TData, TValue>({
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden"
className="px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal"
>
<Badge variant="secondary" className="px-1 font-normal">
{selectedValues.size} selected
</Badge>
) : (
@@ -72,7 +72,7 @@ function DataGridColumnFilter<TData, TValue>({
<Badge
variant="secondary"
key={option.value}
className="rounded-sm px-1 font-normal"
className="px-1 font-normal"
>
{option.label}
</Badge>
@@ -102,28 +102,39 @@ function DataGridColumnFilter<TData, TValue>({
<div className="p-1">
{filteredOptions.map((option) => {
const isSelected = selectedValues.has(option.value)
const facetCount = facets?.get(option.value)
const toggleOption = () => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}
return (
<div
key={option.value}
onClick={() => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
role="button"
tabIndex={0}
aria-pressed={isSelected}
onClick={toggleOption}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
toggleOption()
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
"rounded-md relative flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none",
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
)}
>
<div
className={cn(
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
"border-primary rounded-sm flex h-4 w-4 items-center justify-center border",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
@@ -132,12 +143,12 @@ function DataGridColumnFilter<TData, TValue>({
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
<option.icon className="text-muted-foreground h-4 w-4" />
)}
<span>{option.label}</span>
{facets?.get(option.value) && (
{facetCount !== undefined && (
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
{facetCount}
</span>
)}
</div>
@@ -150,8 +161,16 @@ function DataGridColumnFilter<TData, TValue>({
<div className="bg-border -mx-1 my-1 h-px" />
<div className="p-1">
<div
role="button"
tabIndex={0}
onClick={() => column?.setFilterValue(undefined)}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
column?.setFilterValue(undefined)
}
}}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
>
Clear filters
</div>
@@ -30,6 +30,7 @@ interface DataGridColumnHeaderProps<
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
title?: string
icon?: ReactNode
/** Reserved; pin controls are gated by tableLayout.columnsPinnable + column.getCanPin(). */
pinnable?: boolean
filter?: ReactNode
visibility?: boolean
@@ -47,7 +48,10 @@ function DataGridColumnHeaderInner<TData, TValue>({
const resolvedTitle = title ?? getColumnHeaderLabel(column)
const columnOrder = table.getState().columnOrder
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
const columnVisibilityKey =
props.tableLayout?.columnsVisibility && visibility
? JSON.stringify(table.getState().columnVisibility)
: ""
const isSorted = column.getIsSorted()
const isPinned = column.getIsPinned()
const canSort = column.getCanSort()
@@ -74,18 +78,18 @@ function DataGridColumnHeaderInner<TData, TValue>({
)
const headerButtonClassName = cn(
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg",
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground px-2 font-normal h-6 rounded-lg",
className
)
const sortIcon =
canSort &&
(isSorted === "desc" ? (
<ArrowDownIcon className="size-3.25" />
<ArrowDownIcon className="size-3.25" aria-hidden="true" />
) : isSorted === "asc" ? (
<ArrowUpIcon className="size-3.25" />
<ArrowUpIcon className="size-3.25" aria-hidden="true" />
) : (
<ChevronsUpDownIcon className="mt-px size-3.25" />
<ChevronsUpDownIcon className="mt-px size-3.25" aria-hidden="true" />
))
const hasControls =
@@ -276,7 +280,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
if (hasControls) {
return (
<div className="flex h-full items-center justify-between gap-1.5">
<div className="-ms-2 flex h-full items-center justify-between gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger
render={
@@ -299,7 +303,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
<Button
size="icon-sm"
variant="ghost"
className="-me-1 size-7 rounded-md"
className="rounded-lg -me-1 size-7"
onClick={() => column.pin(false)}
aria-label={`Unpin ${resolvedTitle} column`}
title={`Unpin ${resolvedTitle} column`}
@@ -313,7 +317,7 @@ function DataGridColumnHeaderInner<TData, TValue>({
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
return (
<div className="flex h-full items-center">
<div className="-ms-2 flex h-full items-center">
<Button
variant="ghost"
className={headerButtonClassName}
@@ -4,8 +4,12 @@ import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@evobgp/ui/lib/utils"
import { Button } from "@evobgp/ui/components/button"
import {
SelectMenu,
} from "@/components/select-field"
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@evobgp/ui/components/select"
import { Skeleton } from "@evobgp/ui/components/skeleton"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
@@ -31,11 +35,8 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
const defaultProps: Partial<DataGridPaginationProps> = {
sizes: [5, 10, 25, 50, 100],
sizesLabel: "Show",
sizesDescription: "per page",
sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5,
more: false,
info: "{from} - {to} of {count}",
infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: "Rows per page",
@@ -46,24 +47,24 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
const btnBaseClasses = "size-7 p-0 text-sm"
const btnBaseClasses = "p-0 text-sm"
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
const pageIndex = table.getState().pagination.pageIndex
const pageSize = table.getState().pagination.pageSize
const from = pageIndex * pageSize + 1
const from = recordCount === 0 ? 0 : pageIndex * pageSize + 1
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
const pageCount = table.getPageCount()
// Replace placeholders in paginationInfo
const paginationInfo = mergedProps?.info
const paginationInfo = mergedProps.info
? mergedProps.info
.replace("{from}", from.toString())
.replace("{to}", to.toString())
.replace("{count}", recordCount.toString())
.replaceAll("{from}", from.toString())
.replaceAll("{to}", to.toString())
.replaceAll("{count}", recordCount.toString())
: `${from} - ${to} of ${recordCount}`
// Pagination limit logic
const paginationMoreLimit = mergedProps?.moreLimit || 5
const paginationMoreLimit = mergedProps.moreLimit || 5
// Determine the start and end of the pagination group
const currentGroupStart =
@@ -137,47 +138,48 @@ function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
data-slot="data-grid-pagination"
className={cn(
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
mergedProps?.className
mergedProps.className
)}
>
<div className="order-2 flex flex-wrap items-center gap-2 pb-2.5 sm:order-1 sm:pb-0">
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
{isLoading ? (
mergedProps?.sizesSkeleton
mergedProps.sizesSkeleton
) : (
<>
<div className="shrink-0 text-sm text-muted-foreground whitespace-nowrap">
<div className="text-muted-foreground text-sm">
{mergedProps.rowsPerPageLabel}
</div>
<SelectMenu
items={
mergedProps?.sizes?.map((size: number) => ({
value: `${size}`,
label: `${size}`,
})) ?? []
}
<Select
value={`${pageSize}`}
triggerClassName="min-w-20 w-auto tabular-nums"
size="sm"
side="top"
contentClassName="min-w-20"
onValueChange={(value) => {
if (!value) return
table.setPageSize(Number(value))
const newPageSize = Number(value)
table.setPageSize(newPageSize)
}}
/>
>
<SelectTrigger className="w-16" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent side="top" className="min-w-18">
{mergedProps.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
{isLoading ? (
mergedProps?.infoSkeleton
mergedProps.infoSkeleton
) : (
<>
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1">
<div className="text-muted-foreground order-2 text-sm text-nowrap sm:order-1">
{paginationInfo}
</div>
{pageCount > 1 && (
<div className="order-1 flex items-center space-x-1 sm:order-2">
<div className="order-1 flex items-center space-x-1">
<Button
size="icon-sm"
variant="ghost"
@@ -25,6 +25,11 @@ const INITIAL_METRICS = {
trackHeight: 0,
} as const
const SCROLLBAR_CLASSNAME =
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
type ScrollbarMetrics = {
@@ -365,11 +370,11 @@ function DataGridScrollArea({
data-slot="data-grid-scrollbar"
data-orientation="horizontal"
orientation="horizontal"
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
className={SCROLLBAR_CLASSNAME}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</ScrollAreaPrimitive.Scrollbar>
)}
@@ -379,11 +384,11 @@ function DataGridScrollArea({
data-slot="data-grid-scrollbar"
data-orientation="vertical"
orientation="vertical"
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
className={SCROLLBAR_CLASSNAME}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</ScrollAreaPrimitive.Scrollbar>
)}
@@ -71,10 +71,10 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
aria-label="Drag to reorder row"
disabled
>
<GripHorizontalIcon
/>
<GripHorizontalIcon aria-hidden="true" />
</Button>
)
}
@@ -87,11 +87,11 @@ function DataGridTableDndRowHandle({ className }: { className?: string }) {
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
aria-label="Drag to reorder row"
{...context.attributes}
{...context.listeners}
>
<GripHorizontalIcon
/>
<GripHorizontalIcon aria-hidden="true" />
</Button>
)
}
@@ -119,15 +119,10 @@ function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
return (
<SortableRowContext.Provider value={{ attributes, listeners }}>
<DataGridTableBodyRow
row={row}
dndRef={setNodeRef}
dndStyle={style}
key={row.id}
>
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
<DataGridTableBodyRow row={row} dndRef={setNodeRef} dndStyle={style}>
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => {
return (
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
@@ -227,7 +222,7 @@ function DataGridTableDndRows<TData>({
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
{headerGroup.headers.map((header, index) => {
const { column } = header
@@ -6,6 +6,7 @@ import {
ReactNode,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
@@ -111,11 +112,11 @@ function DataGridTableDndHeader<TData>({
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
)}
<span className="grow truncate">
<div className="grow">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</span>
</div>
{props.tableLayout?.columnsResizable && column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
@@ -184,33 +185,40 @@ function DataGridTableDnd<TData>({
}, [isDraggingColumn])
// Custom modifier to restrict dragging within table bounds with edge offset
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => {
if (!draggingNodeRect || !containerRef.current) {
return { ...transform, y: 0 }
const modifiers = useMemo(() => {
const restrictToTableBounds: Modifier = ({
draggingNodeRect,
transform,
}) => {
if (!draggingNodeRect || !containerRef.current) {
return { ...transform, y: 0 }
}
const containerRect = containerRef.current.getBoundingClientRect()
const edgeOffset = 0
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
const maxX =
containerRect.right -
draggingNodeRect.left -
draggingNodeRect.width +
edgeOffset
return {
...transform,
x: Math.min(Math.max(transform.x, minX), maxX),
y: 0, // Lock vertical movement
}
}
const containerRect = containerRef.current.getBoundingClientRect()
const edgeOffset = 0
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
const maxX =
containerRect.right -
draggingNodeRect.left -
draggingNodeRect.width +
edgeOffset
return {
...transform,
x: Math.min(Math.max(transform.x, minX), maxX),
y: 0, // Lock vertical movement
}
}
return [restrictToTableBounds]
}, [])
return (
<DndContext
collisionDetection={closestCenter}
id={useId()}
modifiers={[restrictToTableBounds]}
modifiers={modifiers}
onDragCancel={() => setIsDraggingColumn(false)}
onDragEnd={(event) => {
setIsDraggingColumn(false)
@@ -233,7 +241,7 @@ function DataGridTableDnd<TData>({
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
@@ -277,19 +285,16 @@ function DataGridTableDnd<TData>({
return (
<Fragment key={row.id}>
<DataGridTableBodyRow row={row}>
{row
.getVisibleCells()
.map((cell: Cell<TData, unknown>) => {
return (
<SortableContext
key={cell.id}
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
<DataGridTableDndCell cell={cell} />
</SortableContext>
)
})}
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{row
.getVisibleCells()
.map((cell: Cell<TData, unknown>) => (
<DataGridTableDndCell cell={cell} key={cell.id} />
))}
</SortableContext>
</DataGridTableBodyRow>
{row.getIsExpanded() && (
<DataGridTableBodyRowExpandded row={row} />
@@ -1,9 +1,9 @@
import {
CSSProperties,
memo,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
@@ -11,6 +11,8 @@ import {
DataGridTableBase,
DataGridTableBody,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
@@ -19,10 +21,12 @@ import {
DataGridTableRenderedRow,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridTableMergedHeaderGroups,
getDataGridTableRowSections,
getPinningStyles,
hasDataGridTableRightPinnedColumns,
} from "@/components/reui/data-grid/data-grid-table"
import { DATA_GRID_MESSAGES_RU } from "@/lib/data-grid-defaults"
import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table"
import { Column, flexRender, Row, Table } from "@tanstack/react-table"
import {
useVirtualizer,
VirtualItem,
@@ -69,7 +73,6 @@ interface DataGridTableVirtualProps<TData> {
interface VirtualBodyProps<TData> {
table: Table<TData>
columnCount: number
topRows: Row<TData>[]
centerRows: Row<TData>[]
bottomRows: Row<TData>[]
@@ -84,49 +87,140 @@ interface VirtualBodyProps<TData> {
measureRowRef?: (element: HTMLTableRowElement | null) => void
}
function DataGridTableVirtualSpacer({
columnCount,
function DataGridTableVirtualPinnedPlaceholderCell<TData>({
column,
}: {
column: Column<TData>
}) {
const { props } = useDataGrid()
const isPinned = column.getIsPinned()
const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left")
const isFirstRightPinned =
isPinned === "right" && column.getIsFirstColumn("right")
return (
<td
aria-hidden="true"
style={{
...(props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
getPinningStyles(column)),
...(props.tableLayout?.columnsResizable && {
width: `calc(var(--col-${column.id}-size) * 1px)`,
}),
}}
data-pinned={isPinned || undefined}
data-last-col={
isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
}
className={cn(
"p-0",
props.tableLayout?.cellBorder && "border-e",
props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
)}
/>
)
}
function DataGridTableVirtualUtilityRow<TData>({
table,
children,
centerCellClassName,
centerCellStyle,
rowClassName,
ariaHidden,
}: {
table: Table<TData>
children: ReactNode
centerCellClassName?: string
centerCellStyle?: CSSProperties
rowClassName?: string
ariaHidden?: boolean
}) {
const { props } = useDataGrid()
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
const rightVisibleColumns = table.getRightVisibleLeafColumns()
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
return (
<tr aria-hidden={ariaHidden || undefined} className={rowClassName}>
{leftVisibleColumns.map((column) => (
<DataGridTableVirtualPinnedPlaceholderCell
column={column}
key={column.id}
/>
))}
<td
colSpan={Math.max(centerVisibleColumns.length, 1)}
className={centerCellClassName}
style={centerCellStyle}
>
{children}
</td>
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
{rightVisibleColumns.map((column) => (
<DataGridTableVirtualPinnedPlaceholderCell
column={column}
key={column.id}
/>
))}
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
</tr>
)
}
function DataGridTableVirtualSpacer<TData>({
table,
height,
}: {
columnCount: number
table: Table<TData>
height: number
}) {
if (height <= 0) return null
return (
<tr aria-hidden="true">
<td colSpan={columnCount} style={{ height, padding: 0 }} />
</tr>
<DataGridTableVirtualUtilityRow
table={table}
ariaHidden
centerCellClassName="p-0"
centerCellStyle={{ height, padding: 0 }}
>
{null}
</DataGridTableVirtualUtilityRow>
)
}
function DataGridTableVirtualStatusRow({
function DataGridTableVirtualStatusRow<TData>({
table,
children,
className,
columnCount,
}: {
table: Table<TData>
children: ReactNode
className?: string
columnCount: number
}) {
return (
<tr>
<td
colSpan={columnCount}
className={cn(
"text-muted-foreground py-4 text-center text-sm",
className
)}
>
{children}
</td>
</tr>
<DataGridTableVirtualUtilityRow
table={table}
centerCellClassName={cn(
"text-muted-foreground py-4 text-center text-sm",
className
)}
>
{children}
</DataGridTableVirtualUtilityRow>
)
}
function DataGridTableVirtualBody<TData>({
table,
columnCount,
topRows,
centerRows,
bottomRows,
@@ -179,7 +273,7 @@ function DataGridTableVirtualBody<TData>({
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-start"
columnCount={columnCount}
table={table}
height={leadingSpacerHeight}
/>
)
@@ -203,7 +297,7 @@ function DataGridTableVirtualBody<TData>({
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-end"
columnCount={columnCount}
table={table}
height={trailingSpacerHeight}
/>
)
@@ -216,10 +310,7 @@ function DataGridTableVirtualBody<TData>({
if (showFetchingRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-loading"
columnCount={columnCount}
>
<DataGridTableVirtualStatusRow key="virtual-status-loading" table={table}>
<div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" />
{loadingMoreMessage}
@@ -232,7 +323,7 @@ function DataGridTableVirtualBody<TData>({
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-complete"
columnCount={columnCount}
table={table}
className="py-3 text-xs"
>
{allRowsLoadedMessage}
@@ -280,13 +371,12 @@ function DataGridTableVirtual<TData>({
virtualizerOptions,
}: DataGridTableVirtualProps<TData>) {
const { table, props } = useDataGrid()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
table,
props.tableLayout?.rowsPinnable
)
const columnCount =
table.getVisibleFlatColumns().length +
(props.tableLayout?.columnsResizable ? 1 : 0)
const isInfiniteMode = typeof onFetchMore === "function"
const [viewportElements, setViewportElements] =
useState<DataGridTableVirtualScrollElements>({
@@ -305,9 +395,9 @@ function DataGridTableVirtual<TData>({
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || DATA_GRID_MESSAGES_RU.loadingMessage
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
const allRowsLoadedMessage =
props.allRowsLoadedMessage || DATA_GRID_MESSAGES_RU.allRecordsLoadedMessage
props.allRowsLoadedMessage || "All records loaded"
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({
@@ -371,10 +461,7 @@ function DataGridTableVirtual<TData>({
isVirtualizationEnabled && customMeasureElement
? virtualizer.measureElement
: undefined
const resolvedFetchMoreOffset = useMemo(
() => Math.max(0, fetchMoreOffset),
[fetchMoreOffset]
)
const resolvedFetchMoreOffset = Math.max(0, fetchMoreOffset)
useEffect(() => {
if (
@@ -416,29 +503,21 @@ function DataGridTableVirtual<TData>({
<DataGridTableBase>
{renderHeader && (
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
{headerGroup.headers.map((header, hIndex) => {
{mergedHeaderGroups.map((headerGroup) => (
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
{headerGroup.headers
.filter((header) => header.column.getIsPinned() !== "right")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={hIndex}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
@@ -446,8 +525,36 @@ function DataGridTableVirtual<TData>({
</DataGridTableHeadRowCell>
)
})}
</DataGridTableHeadRow>
))}
{props.tableLayout?.columnsResizable &&
hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
{headerGroup.headers
.filter((header) => header.column.getIsPinned() === "right")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
!hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
</DataGridTableHeadRow>
))}
</DataGridTableHead>
)}
@@ -459,7 +566,6 @@ function DataGridTableVirtual<TData>({
<DataGridTableBody>
<MemoizedVirtualBody
table={table}
columnCount={columnCount}
topRows={topRows}
centerRows={centerRows}
bottomRows={bottomRows}
@@ -9,8 +9,9 @@ import {
TouchEvent as ReactTouchEvent,
Ref,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
@@ -19,7 +20,6 @@ import {
Column,
flexRender,
Header,
HeaderGroup,
Row,
Table,
} from "@tanstack/react-table"
@@ -28,15 +28,12 @@ import { cva } from "class-variance-authority"
import { cn } from "@evobgp/ui/lib/utils"
import { Checkbox } from "@evobgp/ui/components/checkbox"
import { Spinner } from "@evobgp/ui/components/spinner"
import { DATA_GRID_MESSAGES_RU } from "@/lib/data-grid-defaults"
const headerCellSpacingVariants = cva("", {
variants: {
size: {
dense:
"px-2 h-8",
default:
"px-3",
dense: "px-2 h-8",
default: "px-3",
},
},
defaultVariants: {
@@ -47,10 +44,8 @@ const headerCellSpacingVariants = cva("", {
const bodyCellSpacingVariants = cva("", {
variants: {
size: {
dense:
"px-2 py-1.5",
default:
"px-3 py-2",
dense: "px-2 py-1.5",
default: "px-3 py-2",
},
},
defaultVariants: {
@@ -61,10 +56,8 @@ const bodyCellSpacingVariants = cva("", {
const footerCellSpacingVariants = cva("", {
variants: {
size: {
dense:
"px-2 py-1.5",
default:
"px-3 py-2",
dense: "px-2 py-1.5",
default: "px-3 py-2",
},
},
defaultVariants: {
@@ -78,9 +71,12 @@ function getPinningStyles<TData>(column: Column<TData>): CSSProperties {
return {
left: isPinned === "left" ? `${column.getStart("left")}px` : undefined,
right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined,
position: isPinned ? "sticky" : "relative",
position: isPinned ? "sticky" : undefined,
transform: isPinned ? "translateZ(0)" : undefined,
contain: isPinned ? "paint" : undefined,
width: column.getSize(),
zIndex: isPinned ? 1 : 0,
zIndex: isPinned ? 30 : undefined,
backgroundClip: isPinned ? "padding-box" : undefined,
}
}
@@ -338,6 +334,55 @@ function getDataGridTableResolvedRows<TData>(
return resolvedRows
}
function getDataGridTableOrderedVisibleColumns<TData>(table: Table<TData>) {
return [
...table.getLeftVisibleLeafColumns(),
...table.getCenterVisibleLeafColumns(),
...table.getRightVisibleLeafColumns(),
] as Column<TData>[]
}
function getDataGridTableOrderedVisibleCells<TData>(row: Row<TData>) {
return [
...row.getLeftVisibleCells(),
...row.getCenterVisibleCells(),
...row.getRightVisibleCells(),
] as Cell<TData, unknown>[]
}
function getDataGridTableMergedHeaderGroups<TData>(table: Table<TData>) {
const leftHeaderGroups = table.getLeftHeaderGroups()
const centerHeaderGroups = table.getCenterHeaderGroups()
const rightHeaderGroups = table.getRightHeaderGroups()
const headerGroupCount = Math.max(
leftHeaderGroups.length,
centerHeaderGroups.length,
rightHeaderGroups.length
)
return Array.from({ length: headerGroupCount }, (_, index) => {
const leftGroup = leftHeaderGroups[index]
const centerGroup = centerHeaderGroups[index]
const rightGroup = rightHeaderGroups[index]
return {
id:
[leftGroup?.id, centerGroup?.id, rightGroup?.id]
.filter(Boolean)
.join(":") || `header-group-${index}`,
headers: [
...(leftGroup?.headers ?? []),
...(centerGroup?.headers ?? []),
...(rightGroup?.headers ?? []),
] as Header<TData, unknown>[],
}
})
}
function hasDataGridTableRightPinnedColumns<TData>(table: Table<TData>) {
return (table.getState().columnPinning.right?.length ?? 0) > 0
}
function DataGridTableFillCol() {
const { props } = useDataGrid()
@@ -391,14 +436,17 @@ function DataGridTableFillFootCell() {
aria-hidden="true"
data-slot="data-grid-table-fill-foot-cell"
style={{ width: "var(--data-grid-fill-size, 0px)" }}
className="border-t p-0"
className="p-0"
/>
)
}
function DataGridTableBase({ children }: { children: ReactNode }) {
const { props, table } = useDataGrid()
const visibleColumns = table.getVisibleLeafColumns()
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
const rightVisibleColumns = table.getRightVisibleLeafColumns()
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
/**
* Compute column widths as CSS custom properties once upfront (memoized).
@@ -427,7 +475,7 @@ function DataGridTableBase({ children }: { children: ReactNode }) {
<table
data-slot="data-grid-table"
className={cn(
"text-foreground text-sm caption-bottom text-left align-middle font-normal rtl:text-right",
"text-foreground caption-bottom text-left align-middle text-sm font-normal rtl:text-right",
props.tableLayout?.columnsResizable ? "min-w-0" : "w-full min-w-full",
props.tableLayout?.width === "auto" ? "table-auto" : "table-fixed",
!props.tableLayout?.columnsResizable && "",
@@ -445,7 +493,7 @@ function DataGridTableBase({ children }: { children: ReactNode }) {
}
>
<colgroup>
{visibleColumns.map((column) => (
{[...leftVisibleColumns, ...centerVisibleColumns].map((column) => (
<col
key={column.id}
style={
@@ -457,7 +505,20 @@ function DataGridTableBase({ children }: { children: ReactNode }) {
}
/>
))}
<DataGridTableFillCol />
{hasRightPinnedColumns ? <DataGridTableFillCol /> : null}
{rightVisibleColumns.map((column) => (
<col
key={column.id}
style={
props.tableLayout?.columnsResizable
? { width: `calc(var(--col-${column.id}-size) * 1px)` }
: props.tableLayout?.width === "fixed"
? { width: column.getSize() }
: undefined
}
/>
))}
{!hasRightPinnedColumns ? <DataGridTableFillCol /> : null}
</colgroup>
{children}
</table>
@@ -476,6 +537,7 @@ function DataGridTableViewport({
style?: CSSProperties
}) {
const { props, table } = useDataGrid()
const didApplyAutoSizeColumnRef = useRef<string | null>(null)
const [viewportElement, setViewportElement] = useState<HTMLDivElement | null>(
null
)
@@ -483,16 +545,34 @@ function DataGridTableViewport({
const handleViewportRef = useCallback(
(node: HTMLDivElement | null) => {
setViewportElement(node)
if (props.tableLayout?.columnsResizable && node) {
const scrollViewport =
(node.closest(
'[data-slot="scroll-area-viewport"]'
) as HTMLElement | null) ?? node.parentElement
const measurementTarget = scrollViewport ?? node
setContainerWidth(measurementTarget.clientWidth)
} else if (!node) {
setContainerWidth(0)
}
assignRef(viewportRef, node)
},
[viewportRef]
[props.tableLayout?.columnsResizable, viewportRef]
)
const fillWidth =
props.tableLayout?.columnsResizable && containerWidth > 0
? Math.max(0, containerWidth - table.getTotalSize())
: 0
const autoSizeColumnId = table
.getVisibleLeafColumns()
.find(
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
)?.id
useEffect(() => {
useLayoutEffect(() => {
if (!viewportElement || !props.tableLayout?.columnsResizable) {
setContainerWidth(0)
return
@@ -520,6 +600,22 @@ function DataGridTableViewport({
}
}, [props.tableLayout?.columnsResizable, viewportElement])
useLayoutEffect(() => {
if (!props.tableLayout?.columnsResizable) return
if (!autoSizeColumnId || fillWidth <= 0) return
if (didApplyAutoSizeColumnRef.current === autoSizeColumnId) return
const autoSizeColumn = table.getColumn(autoSizeColumnId)
if (!autoSizeColumn) return
didApplyAutoSizeColumnRef.current = autoSizeColumnId
table.setColumnSizing((old) => ({
...old,
[autoSizeColumnId]:
(old[autoSizeColumnId] ?? autoSizeColumn.getSize()) + fillWidth,
}))
}, [autoSizeColumnId, fillWidth, props.tableLayout?.columnsResizable, table])
return (
<div
data-slot="data-grid-table-viewport"
@@ -556,20 +652,18 @@ function DataGridTableHead({ children }: { children: ReactNode }) {
)
}
function DataGridTableHeadRow<TData>({
function DataGridTableHeadRow({
children,
headerGroup,
rowId,
}: {
children: ReactNode
headerGroup: HeaderGroup<TData>
rowId: string
}) {
const { props } = useDataGrid()
return (
<tr
key={headerGroup.id}
className={cn(
"bg-muted/40",
props.tableLayout?.headerBorder && "[&>th]:border-b",
props.tableLayout?.cellBorder && "*:last:border-e-0",
props.tableLayout?.stripped && "bg-transparent",
@@ -578,7 +672,6 @@ function DataGridTableHeadRow<TData>({
)}
>
{children}
<DataGridTableFillHeadCell />
</tr>
)
}
@@ -598,9 +691,13 @@ function DataGridTableHeadRowCell<TData>({
const { column } = header
const isPinned = column.getIsPinned()
const isFirstLeftPinned =
isPinned === "left" && column.getIsFirstColumn("left")
const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left")
const isFirstRightPinned =
isPinned === "right" && column.getIsFirstColumn("right")
const isLastRightPinned =
isPinned === "right" && column.getIsLastColumn("right")
const isLastVisibleColumn =
column.getIndex() ===
header.getContext().table.getVisibleLeafColumns().length - 1
@@ -610,7 +707,6 @@ function DataGridTableHeadRowCell<TData>({
return (
<th
key={header.id}
ref={dndRef}
style={{
...(props.tableLayout?.width === "fixed" &&
@@ -626,23 +722,31 @@ function DataGridTableHeadRowCell<TData>({
...(dndStyle ? dndStyle : null),
}}
data-pinned={isPinned || undefined}
data-outer-pinned-col={
isFirstLeftPinned ? "left" : isLastRightPinned ? "right" : undefined
}
data-last-col={
isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
}
className={cn(
"text-secondary-foreground/80 h-9 relative text-left align-middle font-normal rtl:text-right [&:has([role=checkbox])]:pe-0",
"text-foreground relative h-10 text-left align-middle font-medium rtl:text-right [&:has([role=checkbox])]:pe-0",
headerCellSpacing,
props.tableLayout?.headerBackground && "bg-muted",
props.tableLayout?.cellBorder && "border-e",
props.tableLayout?.columnsResizable &&
column.getCanResize() &&
"overflow-visible",
(isPinned ? "overflow-hidden" : "overflow-visible"),
props.tableLayout?.columnsResizable &&
column.getCanResize() &&
isLastVisibleColumn &&
"pe-8",
props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
"[&[data-pinned][data-last-col]]:border-border data-pinned:bg-muted/90 data-pinned:backdrop-blur-xs [&:not([data-pinned]):has(+[data-pinned])_div.cursor-col-resize:last-child]:opacity-0 [&[data-last-col=left]_div.cursor-col-resize:last-child]:opacity-0 [&[data-pinned=left][data-last-col=left]]:border-e! [&[data-pinned=right]:last-child_div.cursor-col-resize:last-child]:opacity-0 [&[data-pinned=right][data-last-col=right]]:border-s!",
cn(
"data-pinned:bg-muted data-outer-pinned-col:bg-clip-padding data-pinned:isolate",
"[&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right]:last-child_div.cursor-col-resize:last-child]:opacity-0 [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]",
"[&:not([data-pinned]):has(+[data-pinned])_div.cursor-col-resize:last-child]:opacity-0 [&[data-last-col=left]_div.cursor-col-resize:last-child]:opacity-0"
),
header.column.columnDef.meta?.headerClassName,
column.getIndex() === 0 ||
column.getIndex() === header.headerGroup.headers.length - 1
@@ -662,6 +766,7 @@ function DataGridTableHeadRowCellResize<TData>({
}) {
const { props, table } = useDataGrid()
const { column } = header
const isPinned = column.getIsPinned()
const isLastVisibleColumn =
column.getIndex() ===
header.getContext().table.getVisibleLeafColumns().length - 1
@@ -703,7 +808,9 @@ function DataGridTableHeadRowCellResize<TData>({
"absolute top-0 h-full cursor-col-resize user-select-none touch-none z-10 flex",
isLastVisibleColumn
? "end-0 w-5 justify-end before:hidden"
: "-end-2 w-5 justify-center before:absolute before:inset-y-0 before:w-px before:-translate-x-px before:bg-border",
: isPinned
? "end-0 w-5 justify-end before:hidden"
: "-end-2 w-5 justify-center before:absolute before:inset-y-0 before:w-px before:-translate-x-px before:bg-border",
column.getIsResizing() &&
(isResizeModeOnEnd
? "opacity-100"
@@ -766,7 +873,7 @@ function DataGridTableResizeIndicator({
>
<div className="bg-primary/85 absolute inset-y-0 left-0 w-px -translate-x-1/2" />
<div
className="bg-primary absolute top-0 left-0 -translate-x-1/2 rounded-b-sm shadow-xs"
className="bg-primary rounded-b-sm absolute top-0 left-0 -translate-x-1/2 shadow-xs"
style={{
width: 5,
height: Math.max(headerHeight, 6),
@@ -777,7 +884,13 @@ function DataGridTableResizeIndicator({
}
function DataGridTableRowSpacer() {
return <tbody aria-hidden="true" className="h-2"></tbody>
return (
<tbody
aria-hidden="true"
className="h-2"
data-slot="data-grid-table-body-spacer"
></tbody>
)
}
function DataGridTableBody({ children }: { children: ReactNode }) {
@@ -785,8 +898,8 @@ function DataGridTableBody({ children }: { children: ReactNode }) {
return (
<tbody
data-slot="data-grid-table-body"
className={cn(
"[&_tr:last-child]:border-0",
props.tableLayout?.rowRounded &&
"[&_td:first-child]:rounded-l-lg",
props.tableLayout?.rowRounded &&
@@ -802,7 +915,10 @@ function DataGridTableBody({ children }: { children: ReactNode }) {
function DataGridTableFoot({ children }: { children: ReactNode }) {
const { props } = useDataGrid()
return (
<tfoot className={cn("border-t", props.tableClassNames?.footer)}>
<tfoot
data-slot="data-grid-table-foot"
className={cn(props.tableClassNames?.footer)}
>
{children}
</tfoot>
)
@@ -810,10 +926,14 @@ function DataGridTableFoot({ children }: { children: ReactNode }) {
function DataGridTableFootRow({ children }: { children: ReactNode }) {
const { props } = useDataGrid()
const footRowBottomBorderClasses = "[&:not(:last-child)>td]:border-b"
return (
<tr
data-slot="data-grid-table-foot-row"
className={cn(
"bg-muted/40 dark:bg-background",
props.tableLayout?.footerBackground && "bg-muted/40 dark:bg-background",
props.tableLayout?.rowBorder && footRowBottomBorderClasses,
props.tableLayout?.cellBorder && "*:last:border-e-0"
)}
>
@@ -840,8 +960,9 @@ function DataGridTableFootRowCell({
<td
colSpan={colSpan}
className={cn(
"text-secondary-foreground/80 border-t align-middle font-medium",
"text-secondary-foreground/80 align-middle font-medium",
spacing,
props.tableLayout?.footerBackground && "bg-muted/40 dark:bg-background",
props.tableLayout?.cellBorder && "border-e",
className
)}
@@ -870,7 +991,6 @@ function DataGridTableBodyRowSkeleton({ children }: { children: ReactNode }) {
)}
>
{children}
<DataGridTableFillBodyCell />
</tr>
)
}
@@ -904,7 +1024,7 @@ function DataGridTableBodyRowSkeletonCell<TData>({
column.columnDef.meta?.cellClassName,
props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
'[&[data-pinned][data-last-col]]:border-border data-pinned:bg-background/90 data-pinned:backdrop-blur-xs" [&[data-pinned=left][data-last-col=left]]:border-e! [&[data-pinned=right][data-last-col=right]]:border-s!',
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]",
column.getIndex() === 0 ||
column.getIndex() === table.getVisibleFlatColumns().length - 1
? props.tableClassNames?.edgeCell
@@ -934,6 +1054,9 @@ function DataGridTableBodyRow<TData>({
const { props, table } = useDataGrid()
const isRowPinned = row.getIsPinned()
const bodyRowBottomBorderClasses =
"[&:not(:last-child)>td]:border-b [tbody:has(+tfoot)_&:last-child>td]:border-b [*:has(>[data-slot=data-grid]+[data-slot=data-grid-pagination])_[data-slot=data-grid]_&:last-child>td]:border-b"
return (
<tr
ref={(node) => {
@@ -954,8 +1077,9 @@ function DataGridTableBodyRow<TData>({
props.onRowClick && "cursor-pointer",
!props.tableLayout?.stripped &&
props.tableLayout?.rowBorder &&
"border-border border-b [&:not(:last-child)>td]:border-b",
props.tableLayout?.cellBorder && "*:last:border-e-0",
bodyRowBottomBorderClasses,
props.tableLayout?.cellBorder &&
`*:last:border-e-0 ${bodyRowBottomBorderClasses}`,
props.tableLayout?.stripped &&
"odd:bg-muted/90 odd:hover:bg-muted hover:bg-transparent",
table.options.enableRowSelection && "*:first:relative",
@@ -969,23 +1093,22 @@ function DataGridTableBodyRow<TData>({
)}
>
{children}
<DataGridTableFillBodyCell />
</tr>
)
}
function DataGridTableBodyRowExpandded<TData>({ row }: { row: Row<TData> }) {
const { props, table } = useDataGrid()
const bodyRowBottomBorderClasses =
"[&:not(:last-child)>td]:border-b [tbody:has(+tfoot)_&:last-child>td]:border-b [*:has(>[data-slot=data-grid]+[data-slot=data-grid-pagination])_[data-slot=data-grid]_&:last-child>td]:border-b"
return (
<tr
className={cn(
props.tableLayout?.rowBorder && "[&:not(:last-child)>td]:border-b"
)}
className={cn(props.tableLayout?.rowBorder && bodyRowBottomBorderClasses)}
>
<td
colSpan={
row.getVisibleCells().length +
getDataGridTableOrderedVisibleCells(row).length +
(props.tableLayout?.columnsResizable ? 1 : 0)
}
>
@@ -1022,9 +1145,7 @@ function DataGridTableBodyRowCell<TData>({
return (
<td
key={cell.id}
ref={dndRef}
{...(props.tableLayout?.columnsDraggable && !isPinned ? { cell } : {})}
style={{
...(props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
@@ -1048,7 +1169,11 @@ function DataGridTableBodyRowCell<TData>({
cell.column.columnDef.meta?.cellClassName,
props.tableLayout?.columnsPinnable &&
column.getCanPin() &&
'[&[data-pinned][data-last-col]]:border-border data-pinned:bg-background/90 data-pinned:backdrop-blur-xs" [&[data-pinned=left][data-last-col=left]]:border-e! [&[data-pinned=right][data-last-col=right]]:border-s!',
cn(
"data-pinned:bg-background data-pinned:isolate",
"[&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)]",
"[&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
),
column.getIndex() === 0 ||
column.getIndex() === row.getVisibleCells().length - 1
? props.tableClassNames?.edgeCell
@@ -1069,6 +1194,12 @@ function DataGridTableRenderedRow<TData>({
pinnedBoundary?: DataGridTablePinnedBoundary
rowRef?: React.Ref<HTMLTableRowElement>
}) {
const { props, table } = useDataGrid()
const leftVisibleCells = row.getLeftVisibleCells()
const centerVisibleCells = row.getCenterVisibleCells()
const rightVisibleCells = row.getRightVisibleCells()
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
return (
<Fragment>
<DataGridTableBodyRow
@@ -1076,11 +1207,24 @@ function DataGridTableRenderedRow<TData>({
pinnedBoundary={pinnedBoundary}
rowRef={rowRef}
>
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => (
{[...leftVisibleCells, ...centerVisibleCells].map(
(cell: Cell<TData, unknown>) => (
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
)}
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
{rightVisibleCells.map((cell: Cell<TData, unknown>) => (
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
))}
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
</DataGridTableBodyRow>
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
</Fragment>
@@ -1090,16 +1234,16 @@ function DataGridTableRenderedRow<TData>({
function DataGridTableEmpty() {
const { table, props } = useDataGrid()
const visibleColumnCount =
table.getVisibleLeafColumns().length +
getDataGridTableOrderedVisibleColumns(table).length +
(props.tableLayout?.columnsResizable ? 1 : 0)
return (
<tr>
<td
colSpan={Math.max(visibleColumnCount, 1)}
className="text-muted-foreground text-sm py-6 text-center"
className="text-muted-foreground py-6 text-center text-sm"
>
{props.emptyMessage || DATA_GRID_MESSAGES_RU.emptyMessage}
{props.emptyMessage || "No data available"}
</td>
</tr>
)
@@ -1110,9 +1254,9 @@ function DataGridTableLoader() {
return (
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2">
<div className="text-muted-foreground bg-card rounded-lg text-sm flex items-center gap-2 border px-4 py-2 leading-none font-medium">
<div className="text-muted-foreground bg-card rounded-lg flex items-center gap-2 border px-4 py-2 text-sm leading-none font-medium">
<Spinner className="size-5 opacity-60" />
{props.loadingMessage || DATA_GRID_MESSAGES_RU.loadingMessage}
{props.loadingMessage || "Loading..."}
</div>
</div>
)
@@ -1124,7 +1268,7 @@ function DataGridTableRowPin<TData>({ row }: { row: Row<TData> }) {
return (
<button
type="button"
aria-label={isPinned ? DATA_GRID_MESSAGES_RU.unpinRowLabel : DATA_GRID_MESSAGES_RU.pinRowLabel}
aria-label={isPinned ? "Unpin row" : "Pin row"}
onClick={() => {
if (isPinned) {
row.pin(false)
@@ -1133,7 +1277,7 @@ function DataGridTableRowPin<TData>({ row }: { row: Row<TData> }) {
}
}}
className={cn(
"text-muted-foreground hover:text-foreground inline-flex size-7 items-center justify-center rounded-md transition-colors",
"text-muted-foreground hover:text-foreground rounded-lg inline-flex size-7 items-center justify-center transition-colors",
isPinned && "text-primary hover:text-primary/80"
)}
>
@@ -1180,7 +1324,7 @@ function DataGridTableRowSelect<TData>({ row }: { row: Row<TData> }) {
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={DATA_GRID_MESSAGES_RU.selectRowLabel}
aria-label="Select row"
className="align-[inherit]"
/>
</>
@@ -1199,7 +1343,7 @@ function DataGridTableRowSelectAll() {
indeterminate={isSomeSelected && !isAllSelected}
disabled={isLoading || recordCount === 0}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label={DATA_GRID_MESSAGES_RU.selectAllLabel}
aria-label="Select all"
className="align-[inherit]"
/>
)
@@ -1210,15 +1354,31 @@ function DataGridTableBodyRows<TData>({ table }: { table: Table<TData> }) {
const pagination = table.getState().pagination
if (isLoading && props.loadingMode === "skeleton" && pagination?.pageSize) {
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
const rightVisibleColumns = table.getRightVisibleLeafColumns()
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
return (
<>
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => (
<DataGridTableBodyRowSkeletonCell column={column} key={colIndex}>
{[...leftVisibleColumns, ...centerVisibleColumns].map((column) => (
<DataGridTableBodyRowSkeletonCell column={column} key={column.id}>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
))}
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
{rightVisibleColumns.map((column) => (
<DataGridTableBodyRowSkeletonCell column={column} key={column.id}>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
))}
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
<DataGridTableFillBodyCell />
) : null}
</DataGridTableBodyRowSkeleton>
))}
</>
@@ -1250,7 +1410,7 @@ function DataGridTableBodyRows<TData>({ table }: { table: Table<TData> }) {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{props.loadingMessage || DATA_GRID_MESSAGES_RU.loadingMessage}
{props.loadingMessage || "Loading..."}
</div>
</td>
</tr>
@@ -1289,35 +1449,29 @@ const MemoizedDataGridTableBodyRows = memo(
function DataGridTableHeader<TData>() {
const { table, props } = useDataGrid()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
return (
<DataGridTableViewport>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
{headerGroup.headers.map((header, index) => {
{mergedHeaderGroups.map((headerGroup) => {
return (
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
{headerGroup.headers
.filter((header) => header.column.getIsPinned() !== "right")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={index}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
@@ -1325,9 +1479,37 @@ function DataGridTableHeader<TData>() {
</DataGridTableHeadRowCell>
)
})}
</DataGridTableHeadRow>
)
})}
{props.tableLayout?.columnsResizable &&
hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
{headerGroup.headers
.filter((header) => header.column.getIsPinned() === "right")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
!hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
</DataGridTableBase>
</DataGridTableViewport>
@@ -1342,36 +1524,36 @@ function DataGridTable<TData>({
renderHeader?: boolean
}) {
const { table, props } = useDataGrid()
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
return (
<DataGridTableViewport>
<DataGridTableBase>
{renderHeader && (
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
{headerGroup.headers.map((header, index) => {
{mergedHeaderGroups.map((headerGroup) => {
return (
<DataGridTableHeadRow
key={headerGroup.id}
rowId={headerGroup.id}
>
{headerGroup.headers
.filter((header) => header.column.getIsPinned() !== "right")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={index}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
<DataGridTableHeadRowCell
header={header}
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
@@ -1379,9 +1561,40 @@ function DataGridTable<TData>({
</DataGridTableHeadRowCell>
)
})}
</DataGridTableHeadRow>
)
})}
{props.tableLayout?.columnsResizable &&
hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
{headerGroup.headers
.filter((header) => header.column.getIsPinned() === "right")
.map((header) => {
const { column } = header
return (
<DataGridTableHeadRowCell
header={header}
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
{props.tableLayout?.columnsResizable &&
!hasRightPinnedColumns ? (
<DataGridTableFillHeadCell />
) : null}
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
)}
@@ -1413,6 +1626,8 @@ export {
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableFootRow,
DataGridTableFootRowCell,
@@ -1427,8 +1642,11 @@ export {
DataGridTableRowSelectAll,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridTableMergedHeaderGroups,
getPinningStyles,
getDataGridTableResolvedRows,
getDataGridTableRowSections,
hasDataGridTableRightPinnedColumns,
}
export type { DataGridTablePinnedBoundary }
@@ -17,6 +17,7 @@ declare module "@tanstack/react-table" {
cellClassName?: string
skeleton?: ReactNode
expandedContent?: (row: TData) => ReactNode
autoSize?: boolean
}
}
@@ -81,6 +82,7 @@ export interface DataGridProps<TData extends object> {
rowRounded?: boolean
stripped?: boolean
headerBackground?: boolean
footerBackground?: boolean
headerBorder?: boolean
headerSticky?: boolean
width?: "auto" | "fixed"
@@ -106,7 +108,7 @@ export interface DataGridProps<TData extends object> {
}
const DataGridContext = createContext<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
DataGridContextProps<any> | undefined
>(undefined)
@@ -192,7 +194,8 @@ function DataGrid<TData extends object>({
rowRounded: false,
stripped: false,
headerSticky: false,
headerBackground: true,
headerBackground: false,
footerBackground: false,
headerBorder: true,
width: "fixed",
columnsVisibility: false,
@@ -253,12 +256,7 @@ function DataGridContainer({
return (
<div
data-slot="data-grid"
className={cn(
"w-full overflow-hidden",
border &&
"border-border rounded-lg border",
className
)}
className={cn("w-full overflow-hidden", className)}
>
{children}
</div>
+256
View File
@@ -0,0 +1,256 @@
import { createContext, useCallback, useContext, useState } from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@evobgp/ui/lib/utils"
// Types
type TimelineContextValue = {
activeStep: number
setActiveStep: (step: number) => void
}
// Context
const TimelineContext = createContext<TimelineContextValue | undefined>(
undefined
)
const useTimeline = () => {
const context = useContext(TimelineContext)
if (!context) {
throw new Error("useTimeline must be used within a Timeline")
}
return context
}
// Components
interface TimelineProps extends useRender.ComponentProps<"div"> {
defaultValue?: number
value?: number
onValueChange?: (value: number) => void
orientation?: "horizontal" | "vertical"
}
function Timeline({
defaultValue = 1,
value,
onValueChange,
orientation = "vertical",
className,
render,
children,
...props
}: TimelineProps) {
const [activeStep, setInternalStep] = useState(defaultValue)
const setActiveStep = useCallback(
(step: number) => {
if (value === undefined) {
setInternalStep(step)
}
onValueChange?.(step)
},
[value, onValueChange]
)
const currentStep = value ?? activeStep
const defaultProps = {
className: cn(
"group/timeline flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
className
),
"data-orientation": orientation,
"data-slot": "timeline",
children,
}
return (
<TimelineContext.Provider
value={{ activeStep: currentStep, setActiveStep }}
>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</TimelineContext.Provider>
)
}
// TimelineContent
function TimelineContent({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cn("text-muted-foreground text-sm", className),
"data-slot": "timeline-content",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineDate
type TimelineDateProps = useRender.ComponentProps<"time">
function TimelineDate({
className,
render,
children,
...props
}: TimelineDateProps) {
const defaultProps = {
className: cn(
"mb-1 block font-medium text-muted-foreground text-xs group-data-[orientation=vertical]/timeline:max-sm:h-4",
className
),
"data-slot": "timeline-date",
children,
}
return useRender({
defaultTagName: "time",
render,
props: mergeProps<"time">(defaultProps, props),
})
}
// TimelineHeader
function TimelineHeader({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cn(className),
"data-slot": "timeline-header",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineIndicator
type TimelineIndicatorProps = useRender.ComponentProps<"div">
function TimelineIndicator({
className,
children,
render,
...props
}: TimelineIndicatorProps) {
const defaultProps = {
"aria-hidden": true,
className: cn(
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute size-4 rounded-full border-2 border-primary/20 group-data-[orientation=vertical]/timeline:top-0 group-data-[orientation=horizontal]/timeline:left-0 group-data-completed/timeline-item:border-primary",
className
),
"data-slot": "timeline-indicator",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineItem
interface TimelineItemProps extends useRender.ComponentProps<"div"> {
step: number
}
function TimelineItem({
step,
className,
render,
children,
...props
}: TimelineItemProps) {
const { activeStep } = useTimeline()
const defaultProps = {
className: cn(
"group/timeline-item relative flex flex-1 flex-col gap-0.5 group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8 group-data-[orientation=vertical]/timeline:not-last:pb-6 has-[+[data-completed]]:**:data-[slot=timeline-separator]:bg-primary",
className
),
"data-completed": step <= activeStep || undefined,
"data-slot": "timeline-item",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineSeparator
function TimelineSeparator({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
"aria-hidden": true,
className: cn(
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute self-start bg-primary/10 group-last/timeline-item:hidden group-data-[orientation=horizontal]/timeline:h-0.5 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem-0.25rem)] group-data-[orientation=horizontal]/timeline:w-[calc(100%-1rem-0.25rem)] group-data-[orientation=vertical]/timeline:w-0.5 group-data-[orientation=horizontal]/timeline:translate-x-4.5 group-data-[orientation=vertical]/timeline:translate-y-4.5",
className
),
"data-slot": "timeline-separator",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineTitle
function TimelineTitle({
className,
render,
children,
...props
}: useRender.ComponentProps<"h3">) {
const defaultProps = {
className: cn("font-medium text-sm", className),
"data-slot": "timeline-title",
children,
}
return useRender({
defaultTagName: "h3",
render,
props: mergeProps<"h3">(defaultProps, props),
})
}
export {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
}