chore(web): update vendored ReUI components to latest registry
Обновлены вендоренные компоненты ReUI (base-nova) до актуальных версий реестра: семейство data-grid (плюс новые data-grid-i18n и data-grid-cell-selection), filters, cascader, frame, kanban, stepper, timeline, badge, alert, icon-tile, icon-stack и новый code-block (подсветка на shiki). Shadcn-примитивы в packages/ui обновлены тем же запуском CLI. Импорты "cn" из новых файлов переведены на алиас @evobgp/ui/lib/utils, пакет cn из dependencies удалён, локальные eslint-disable в вендоренных файлах поправлены.
This commit is contained in:
@@ -41,6 +41,7 @@
|
||||
"react-hook-form": "^7.60.0",
|
||||
"recharts": "3.8.0",
|
||||
"shadcn": "^4.19.0",
|
||||
"shiki": "^4.4.3",
|
||||
"sonner": "^1.7.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
useCascaderActions,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CascaderColumnPanel } from "@/components/reui/cascader/cascader-columns"
|
||||
import {
|
||||
@@ -81,7 +79,8 @@ export function useCascaderVirtualizer({
|
||||
|
||||
const measureEstimate = React.useCallback(() => estimateSize, [estimateSize])
|
||||
|
||||
// React Compiler bails on `useVirtualizer`; harmless, rows memoise one by one.
|
||||
// React Compiler bails on `useVirtualizer` HERE, and only here - the bail does not
|
||||
// propagate to the components that call this hook. They opt out themselves.
|
||||
const virtualizer = useVirtualizer<HTMLElement, HTMLElement>({
|
||||
count,
|
||||
getScrollElement,
|
||||
@@ -225,6 +224,11 @@ function CascaderVirtualRows({
|
||||
estimateSize,
|
||||
overscan,
|
||||
}: CascaderVirtualItemsProps) {
|
||||
/* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches
|
||||
`getVirtualItems()` on the virtualizer, whose identity never changes, so the
|
||||
window freezes on scroll. Inert where no compiler runs. */
|
||||
"use no memo"
|
||||
|
||||
const {
|
||||
estimateRowSize,
|
||||
overscan: rootOverscan,
|
||||
@@ -375,6 +379,11 @@ function CascaderVirtualColumnRows({
|
||||
overscan,
|
||||
activeIndex,
|
||||
}: CascaderVirtualColumnProps & { activeIndex: number }) {
|
||||
/* The `useCascaderVirtualizer` bail does NOT reach here: the compiler caches
|
||||
`getVirtualItems()` on the virtualizer, whose identity never changes, so the
|
||||
window freezes on scroll. Inert where no compiler runs. */
|
||||
"use no memo"
|
||||
|
||||
const {
|
||||
estimateRowSize,
|
||||
overscan: rootOverscan,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
useCascaderLoader,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||
import type { Column } from "@tanstack/react-table"
|
||||
|
||||
@@ -31,6 +32,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
|
||||
title,
|
||||
options,
|
||||
}: DataGridColumnFilterProps<TData, TValue>) {
|
||||
const { i18n } = useDataGrid()
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const filterValue = column?.getFilterValue()
|
||||
const selectedValues = new Set(
|
||||
@@ -64,7 +66,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
|
||||
<div className="hidden space-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge variant="secondary" className="px-1 font-normal">
|
||||
{selectedValues.size} selected
|
||||
{i18n.labels.filterSelectedCount(selectedValues.size)}
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
@@ -97,7 +99,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||
No results found.
|
||||
{i18n.labels.filterNoResults}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
@@ -173,7 +175,7 @@ function DataGridColumnFilter<TData extends object, TValue>({
|
||||
}}
|
||||
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
|
||||
{i18n.labels.filterClear}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -47,16 +47,22 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
filter,
|
||||
visibility = false,
|
||||
}: DataGridColumnHeaderProps<TData, TValue>) {
|
||||
const { isLoading, table, props } = useDataGrid()
|
||||
const { i18n, isLoading, table, props } = useDataGrid()
|
||||
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
||||
|
||||
// TanStack's columnOrder defaults to [] until a consumer seeds it; fall
|
||||
// back to the definition order so Move Left/Right work out of the box.
|
||||
// The order a move rewrites: the consumer's columnOrder (TanStack defaults it
|
||||
// to []), then every leaf it leaves out, in definition order - the same
|
||||
// completion TanStack applies when rendering, so a rendered neighbour is
|
||||
// always present to re-seat beside, even after columns are added later.
|
||||
const columnOrderState = table.state.columnOrder
|
||||
const columnOrder =
|
||||
columnOrderState.length > 0
|
||||
? columnOrderState
|
||||
: table.getAllLeafColumns().map((leafColumn) => leafColumn.id)
|
||||
const definitionOrder = table
|
||||
.getAllColumns()
|
||||
.flatMap((topColumn) => topColumn.getLeafColumns())
|
||||
.map((leafColumn) => leafColumn.id)
|
||||
const columnOrder = [
|
||||
...columnOrderState,
|
||||
...definitionOrder.filter((id) => !columnOrderState.includes(id)),
|
||||
]
|
||||
const columnVisibilityKey =
|
||||
props.tableLayout?.columnsVisibility && visibility
|
||||
? JSON.stringify(table.state.columnVisibility)
|
||||
@@ -67,9 +73,46 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
const canPin = column.getCanPin()
|
||||
const canResize = column.getCanResize()
|
||||
|
||||
const columnIndex = columnOrder.indexOf(column.id)
|
||||
const canMoveLeft = columnIndex > 0
|
||||
const canMoveRight = columnIndex < columnOrder.length - 1
|
||||
// Move neighbours come from what is RENDERED: the column's own pin bucket,
|
||||
// visible columns only. Stepping through the raw columnOrder would trade
|
||||
// places with a hidden or pinned column - an enabled click that moves nothing.
|
||||
// With grouping in TanStack's default "reorder" mode, grouped columns render
|
||||
// first whatever columnOrder says: they neither move nor serve as a target.
|
||||
const groupedColumnMode = (
|
||||
table.options as { groupedColumnMode?: false | "reorder" | "remove" }
|
||||
).groupedColumnMode
|
||||
const isHoistedByGrouping = (target: object) =>
|
||||
groupedColumnMode !== false &&
|
||||
typeof (target as { getIsGrouped?: unknown }).getIsGrouped === "function" &&
|
||||
(target as { getIsGrouped: () => boolean }).getIsGrouped()
|
||||
const renderedPeers = (
|
||||
isPinned === "start"
|
||||
? table.getStartVisibleLeafColumns()
|
||||
: isPinned === "end"
|
||||
? table.getEndVisibleLeafColumns()
|
||||
: table.getCenterVisibleLeafColumns()
|
||||
)
|
||||
.filter((leafColumn) => !isHoistedByGrouping(leafColumn))
|
||||
.map((leafColumn) => leafColumn.id)
|
||||
const renderedIndex = renderedPeers.indexOf(column.id)
|
||||
const leftNeighbour =
|
||||
renderedIndex > 0 ? renderedPeers[renderedIndex - 1] : undefined
|
||||
const rightNeighbour =
|
||||
renderedIndex !== -1 && renderedIndex < renderedPeers.length - 1
|
||||
? renderedPeers[renderedIndex + 1]
|
||||
: undefined
|
||||
const canMoveLeft = leftNeighbour !== undefined
|
||||
const canMoveRight = rightNeighbour !== undefined
|
||||
|
||||
/** Re-seats this column beside a rendered neighbour; every other column,
|
||||
* hidden or pinned ones included, keeps its place in the full order. */
|
||||
const moveBeside = (neighbourId: string, side: "before" | "after") => {
|
||||
const newOrder = columnOrder.filter((id) => id !== column.id)
|
||||
const at = newOrder.indexOf(neighbourId)
|
||||
if (at === -1) return
|
||||
newOrder.splice(side === "before" ? at : at + 1, 0, column.id)
|
||||
table.setColumnOrder(newOrder)
|
||||
}
|
||||
|
||||
const handleSort = () => {
|
||||
if (isSorted === "asc") {
|
||||
@@ -139,7 +182,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowUpIcon className="size-3.5!" />
|
||||
<span className="grow">Asc</span>
|
||||
<span className="grow">{i18n.labels.sortAscending}</span>
|
||||
{isSorted === "asc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
@@ -156,7 +199,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5!" />
|
||||
<span className="grow">Desc</span>
|
||||
<span className="grow">{i18n.labels.sortDescending}</span>
|
||||
{isSorted === "desc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
@@ -176,7 +219,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
onClick={() => column.pin(isPinned === "start" ? false : "start")}
|
||||
>
|
||||
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">Pin to left</span>
|
||||
<span className="grow">{i18n.labels.pinColumnStart}</span>
|
||||
{isPinned === "start" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
@@ -186,7 +229,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
onClick={() => column.pin(isPinned === "end" ? false : "end")}
|
||||
>
|
||||
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">Pin to right</span>
|
||||
<span className="grow">{i18n.labels.pinColumnEnd}</span>
|
||||
{isPinned === "end" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
@@ -204,32 +247,22 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
<DropdownMenuItem
|
||||
key="move-left"
|
||||
onClick={() => {
|
||||
if (columnIndex > 0) {
|
||||
const newOrder = [...columnOrder]
|
||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
||||
newOrder.splice(columnIndex - 1, 0, movedColumn)
|
||||
table.setColumnOrder(newOrder)
|
||||
}
|
||||
if (leftNeighbour) moveBeside(leftNeighbour, "before")
|
||||
}}
|
||||
disabled={!canMoveLeft || isPinned !== false}
|
||||
>
|
||||
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>Move to Left</span>
|
||||
<span>{i18n.labels.moveColumnStart}</span>
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="move-right"
|
||||
onClick={() => {
|
||||
if (columnIndex < columnOrder.length - 1) {
|
||||
const newOrder = [...columnOrder]
|
||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
||||
newOrder.splice(columnIndex + 1, 0, movedColumn)
|
||||
table.setColumnOrder(newOrder)
|
||||
}
|
||||
if (rightNeighbour) moveBeside(rightNeighbour, "after")
|
||||
}}
|
||||
disabled={!canMoveRight || isPinned !== false}
|
||||
>
|
||||
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>Move to Right</span>
|
||||
<span>{i18n.labels.moveColumnEnd}</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
@@ -244,7 +277,7 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
<DropdownMenuSub key="visibility">
|
||||
<DropdownMenuSubTrigger>
|
||||
<Settings2Icon className="size-3.5!" />
|
||||
<span>Columns</span>
|
||||
<span>{i18n.labels.columnsMenu}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent side="right">
|
||||
{table
|
||||
@@ -282,7 +315,8 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
canMoveRight,
|
||||
visibility,
|
||||
table,
|
||||
columnIndex,
|
||||
leftNeighbour,
|
||||
rightNeighbour,
|
||||
columnOrder,
|
||||
columnVisibilityKey, // Needed to update checkbox states when visibility changes
|
||||
])
|
||||
@@ -314,8 +348,8 @@ function DataGridColumnHeaderInner<TData extends object, TValue>({
|
||||
variant="ghost"
|
||||
className="rounded-lg -me-1 size-7"
|
||||
onClick={() => column.pin(false)}
|
||||
aria-label={`Unpin ${resolvedTitle} column`}
|
||||
title={`Unpin ${resolvedTitle} column`}
|
||||
aria-label={i18n.labels.unpinColumn(resolvedTitle)}
|
||||
title={i18n.labels.unpinColumn(resolvedTitle)}
|
||||
>
|
||||
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactElement } from "react"
|
||||
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
getColumnHeaderLabel,
|
||||
useDataGrid,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
|
||||
import type { Table } from "@tanstack/react-table"
|
||||
|
||||
@@ -21,13 +24,15 @@ function DataGridColumnVisibility<TData extends object>({
|
||||
table: Table<DataGridFeatures, TData>
|
||||
trigger: ReactElement<Record<string, unknown>>
|
||||
}) {
|
||||
const { i18n } = useDataGrid()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={trigger} />
|
||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="font-medium">
|
||||
Toggle Columns
|
||||
{i18n.labels.toggleColumns}
|
||||
</DropdownMenuLabel>
|
||||
{table
|
||||
.getAllColumns()
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
export interface DataGridI18nLabels {
|
||||
/* The column header menu. */
|
||||
sortAscending: string
|
||||
sortDescending: string
|
||||
pinColumnStart: string
|
||||
pinColumnEnd: string
|
||||
moveColumnStart: string
|
||||
moveColumnEnd: string
|
||||
columnsMenu: string
|
||||
unpinColumn: (title: string) => string
|
||||
toggleColumns: string
|
||||
/* Row and cell affordances. */
|
||||
rowCreate: string
|
||||
pinRow: string
|
||||
unpinRow: string
|
||||
selectRow: string
|
||||
selectAll: string
|
||||
expandRow: string
|
||||
collapseRow: string
|
||||
dragToReorder: string
|
||||
dragToReorderRow: string
|
||||
reorderingUnavailable: string
|
||||
/* Grid states. */
|
||||
loading: string
|
||||
empty: string
|
||||
allRowsLoaded: string
|
||||
/* Pagination. */
|
||||
rowsPerPage: string
|
||||
paginationInfo: (info: { from: number; to: number; count: number }) => string
|
||||
previousPage: string
|
||||
nextPage: string
|
||||
goToPage: (page: number) => string
|
||||
paginationEllipsis: string
|
||||
/* The faceted column filter. */
|
||||
filterSelectedCount: (count: number) => string
|
||||
filterNoResults: string
|
||||
filterClear: string
|
||||
}
|
||||
|
||||
export interface DataGridI18nConfig {
|
||||
labels: DataGridI18nLabels
|
||||
}
|
||||
|
||||
export type DataGridI18nOverrides = {
|
||||
labels?: Partial<DataGridI18nLabels>
|
||||
}
|
||||
|
||||
const DEFAULT_DATA_GRID_LABELS: DataGridI18nLabels = {
|
||||
sortAscending: "Asc",
|
||||
sortDescending: "Desc",
|
||||
pinColumnStart: "Pin to left",
|
||||
pinColumnEnd: "Pin to right",
|
||||
moveColumnStart: "Move to left",
|
||||
moveColumnEnd: "Move to right",
|
||||
columnsMenu: "Columns",
|
||||
unpinColumn: (title) => `Unpin ${title} column`,
|
||||
toggleColumns: "Toggle Columns",
|
||||
rowCreate: "Add row",
|
||||
pinRow: "Pin row",
|
||||
unpinRow: "Unpin row",
|
||||
selectRow: "Select row",
|
||||
selectAll: "Select all",
|
||||
expandRow: "Expand row",
|
||||
collapseRow: "Collapse row",
|
||||
dragToReorder: "Drag to reorder",
|
||||
dragToReorderRow: "Drag to reorder row",
|
||||
reorderingUnavailable: "Reordering unavailable",
|
||||
loading: "Loading...",
|
||||
empty: "No data available",
|
||||
allRowsLoaded: "All records loaded",
|
||||
rowsPerPage: "Rows per page",
|
||||
paginationInfo: ({ from, to, count }) => `${from} - ${to} of ${count}`,
|
||||
previousPage: "Go to previous page",
|
||||
nextPage: "Go to next page",
|
||||
goToPage: (page) => `Go to page ${page}`,
|
||||
paginationEllipsis: "...",
|
||||
filterSelectedCount: (count) => `${count} selected`,
|
||||
filterNoResults: "No results found.",
|
||||
filterClear: "Clear filters",
|
||||
}
|
||||
|
||||
const DEFAULT_DATA_GRID_I18N: DataGridI18nConfig = Object.freeze({
|
||||
labels: Object.freeze(DEFAULT_DATA_GRID_LABELS),
|
||||
})
|
||||
|
||||
/**
|
||||
* A shallow merge per section, deliberately: a deep merge would leak a
|
||||
* default back into a function-valued label the consumer replaced. With no
|
||||
* overrides the frozen default is returned as-is, so the merge is free to
|
||||
* run on every render without producing a new identity.
|
||||
*/
|
||||
export function mergeDataGridI18n(
|
||||
overrides?: DataGridI18nOverrides
|
||||
): DataGridI18nConfig {
|
||||
if (!overrides?.labels) return DEFAULT_DATA_GRID_I18N
|
||||
return {
|
||||
labels: { ...DEFAULT_DATA_GRID_LABELS, ...overrides.labels },
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ interface DataGridPaginationProps {
|
||||
sizesDescription?: string
|
||||
sizesSkeleton?: ReactNode
|
||||
more?: boolean
|
||||
/** Page buttons the truncated middle shows; the row adds first, last and
|
||||
* an ellipsis for each hidden stretch. Clamped to a minimum of 3. */
|
||||
moreLimit?: number
|
||||
info?: string
|
||||
infoSkeleton?: ReactNode
|
||||
@@ -30,19 +32,113 @@ interface DataGridPaginationProps {
|
||||
ellipsisText?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* One slot in the page row: a page button, or an ellipsis that jumps over the
|
||||
* pages it stands for.
|
||||
*/
|
||||
type DataGridPaginationItem =
|
||||
| { type: "page"; index: number }
|
||||
| { type: "ellipsis"; direction: "previous"; target: number }
|
||||
| { type: "ellipsis"; direction: "next"; target: number }
|
||||
|
||||
/**
|
||||
* The adaptive window: first page, the run around the current one, last page,
|
||||
* with an ellipsis standing in for each hidden stretch. It replaced fixed
|
||||
* BLOCKS (1-5, then 6-10) because a block never shows the last page, so on a
|
||||
* 50-page grid there was no way to reach the end - the primitive wires only
|
||||
* `previousPage()` / `nextPage()`, so there is no last-page arrow either.
|
||||
*
|
||||
* `limit` is `moreLimit`: how many page buttons the truncated middle shows.
|
||||
* At the default of 5 this emits exactly the same rows as the block version
|
||||
* did at `moreLimit: 5`, so the prop keeps both its meaning and its default.
|
||||
* An ellipsis never stands for a single page, because the early return covers
|
||||
* every count a full row could hold.
|
||||
*/
|
||||
function getDataGridPaginationItems(
|
||||
pageIndex: number,
|
||||
pageCount: number,
|
||||
limit: number
|
||||
): DataGridPaginationItem[] {
|
||||
// Pages flanking the current one, per side. The run is rebuilt from it so
|
||||
// the count is always odd: an even `limit` would make the head and tail
|
||||
// rows one slot wider than the middle, and the footer would twitch as the
|
||||
// user pages through.
|
||||
const sibling = Math.max(0, Math.floor(((Math.floor(limit) || 3) - 3) / 2))
|
||||
const pages = sibling * 2 + 3
|
||||
|
||||
// Two ellipses cost the width of two pages, so below this there is nothing
|
||||
// to gain by hiding any.
|
||||
if (pageCount <= pages + 2) {
|
||||
return Array.from({ length: pageCount }, (_, index) => ({
|
||||
type: "page" as const,
|
||||
index,
|
||||
}))
|
||||
}
|
||||
|
||||
if (pageIndex <= sibling + 2) {
|
||||
return [
|
||||
...Array.from({ length: pages }, (_, index) => ({
|
||||
type: "page" as const,
|
||||
index,
|
||||
})),
|
||||
{ type: "ellipsis", direction: "next", target: pages },
|
||||
{ type: "page", index: pageCount - 1 },
|
||||
]
|
||||
}
|
||||
|
||||
if (pageIndex >= pageCount - sibling - 3) {
|
||||
return [
|
||||
{ type: "page", index: 0 },
|
||||
{
|
||||
type: "ellipsis",
|
||||
direction: "previous",
|
||||
target: pageCount - pages - 1,
|
||||
},
|
||||
...Array.from({ length: pages }, (_, offset) => ({
|
||||
type: "page" as const,
|
||||
index: pageCount - pages + offset,
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{ type: "page", index: 0 },
|
||||
{
|
||||
type: "ellipsis",
|
||||
direction: "previous",
|
||||
target: pageIndex - sibling - 1,
|
||||
},
|
||||
...Array.from({ length: sibling * 2 + 1 }, (_, offset) => ({
|
||||
type: "page" as const,
|
||||
index: pageIndex - sibling + offset,
|
||||
})),
|
||||
{ type: "ellipsis", direction: "next", target: pageIndex + sibling + 1 },
|
||||
{ type: "page", index: pageCount - 1 },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* A page button is square at one digit and grows from there, so the row does
|
||||
* not reflow when the count crosses 10 or 100. The floor is each style's own
|
||||
* `icon-sm` square, which is also its `sm` HEIGHT, so the numbers stay square
|
||||
* and keep the arrows' height. Hardcoding one value would be right in three
|
||||
* styles and wrong in five.
|
||||
*/
|
||||
const PAGE_BUTTON_WIDTH_CLASS =
|
||||
"min-w-7"
|
||||
|
||||
function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
||||
const { table, recordCount, isLoading } = useDataGrid()
|
||||
const { i18n, table, recordCount, isLoading } = useDataGrid()
|
||||
|
||||
const defaultProps: Partial<DataGridPaginationProps> = {
|
||||
sizes: [5, 10, 25, 50, 100],
|
||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||
moreLimit: 5,
|
||||
info: "{from} - {to} of {count}",
|
||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
||||
rowsPerPageLabel: "Rows per page",
|
||||
previousPageLabel: "Go to previous page",
|
||||
nextPageLabel: "Go to next page",
|
||||
ellipsisText: "...",
|
||||
rowsPerPageLabel: i18n.labels.rowsPerPage,
|
||||
previousPageLabel: i18n.labels.previousPage,
|
||||
nextPageLabel: i18n.labels.nextPage,
|
||||
ellipsisText: i18n.labels.paginationEllipsis,
|
||||
}
|
||||
|
||||
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
|
||||
@@ -55,84 +151,21 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
||||
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
|
||||
const pageCount = table.getPageCount()
|
||||
|
||||
// Replace placeholders in paginationInfo
|
||||
// A supplied `info` keeps its placeholder-template contract; the default
|
||||
// routes through the i18n label function, where word order is free.
|
||||
const paginationInfo = mergedProps.info
|
||||
? mergedProps.info
|
||||
.replaceAll("{from}", from.toString())
|
||||
.replaceAll("{to}", to.toString())
|
||||
.replaceAll("{count}", recordCount.toString())
|
||||
: `${from} - ${to} of ${recordCount}`
|
||||
: i18n.labels.paginationInfo({ from, to, count: recordCount })
|
||||
|
||||
// Pagination limit logic
|
||||
const paginationMoreLimit = mergedProps.moreLimit || 5
|
||||
|
||||
// Determine the start and end of the pagination group
|
||||
const currentGroupStart =
|
||||
Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit
|
||||
const currentGroupEnd = Math.min(
|
||||
currentGroupStart + paginationMoreLimit,
|
||||
pageCount
|
||||
const paginationItems = getDataGridPaginationItems(
|
||||
pageIndex,
|
||||
pageCount,
|
||||
mergedProps.moreLimit ?? 5
|
||||
)
|
||||
|
||||
// Render page buttons based on the current group
|
||||
const renderPageButtons = () => {
|
||||
const buttons = []
|
||||
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
|
||||
buttons.push(
|
||||
<Button
|
||||
key={i}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={cn(btnBaseClasses, "text-muted-foreground", {
|
||||
"bg-accent text-accent-foreground": pageIndex === i,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (pageIndex !== i) {
|
||||
table.setPageIndex(i)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return buttons
|
||||
}
|
||||
|
||||
// Render a "previous" ellipsis button if there are previous pages to show
|
||||
const renderEllipsisPrevButton = () => {
|
||||
if (currentGroupStart > 0) {
|
||||
return (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
className={btnBaseClasses}
|
||||
variant="ghost"
|
||||
onClick={() => table.setPageIndex(currentGroupStart - 1)}
|
||||
>
|
||||
{mergedProps.ellipsisText}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Render a "next" ellipsis button if there are more pages to show after the current group
|
||||
const renderEllipsisNextButton = () => {
|
||||
if (currentGroupEnd < pageCount) {
|
||||
return (
|
||||
<Button
|
||||
className={btnBaseClasses}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => table.setPageIndex(currentGroupEnd)}
|
||||
>
|
||||
{mergedProps.ellipsisText}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid-pagination"
|
||||
@@ -156,7 +189,15 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
||||
table.setPageSize(newPageSize)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-16" size="sm">
|
||||
{/* w-fit with a min, never a fixed width: a fixed w-16 clipped
|
||||
the value "100" by 1px at nova's paddings, while fit-content
|
||||
grows the trigger for 3-digit sizes and the min keeps the
|
||||
1-2 digit ones from collapsing narrower than 64px. */}
|
||||
<SelectTrigger
|
||||
aria-label={mergedProps.rowsPerPageLabel}
|
||||
className="w-fit min-w-16"
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
@@ -183,7 +224,7 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
||||
{paginationInfo}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="order-1 flex items-center space-x-1">
|
||||
<div className="order-1 flex flex-wrap items-center justify-center gap-1 sm:flex-nowrap">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
@@ -197,11 +238,49 @@ function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
{renderEllipsisPrevButton()}
|
||||
|
||||
{renderPageButtons()}
|
||||
|
||||
{renderEllipsisNextButton()}
|
||||
{paginationItems.map((item) =>
|
||||
item.type === "page" ? (
|
||||
<Button
|
||||
key={`page-${item.index}`}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={i18n.labels.goToPage(item.index + 1)}
|
||||
aria-current={
|
||||
pageIndex === item.index ? "page" : undefined
|
||||
}
|
||||
className={cn(
|
||||
PAGE_BUTTON_WIDTH_CLASS,
|
||||
"px-1.5 text-sm",
|
||||
"text-muted-foreground",
|
||||
{
|
||||
"bg-accent text-accent-foreground":
|
||||
pageIndex === item.index,
|
||||
}
|
||||
)}
|
||||
onClick={() => {
|
||||
if (pageIndex !== item.index) {
|
||||
table.setPageIndex(item.index)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.index + 1}
|
||||
</Button>
|
||||
) : (
|
||||
/* Clickable, unlike the shadcn PaginationEllipsis, which is
|
||||
an aria-hidden span. This one MOVES the user, so it needs
|
||||
a name saying where. */
|
||||
<Button
|
||||
key={`ellipsis-${item.direction}`}
|
||||
size="icon-sm"
|
||||
className={btnBaseClasses}
|
||||
variant="ghost"
|
||||
aria-label={i18n.labels.goToPage(item.target + 1)}
|
||||
onClick={() => table.setPageIndex(item.target)}
|
||||
>
|
||||
{mergedProps.ellipsisText}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="icon-sm"
|
||||
|
||||
@@ -19,8 +19,11 @@ const INITIAL_METRICS = {
|
||||
trackHeight: 0,
|
||||
} as const
|
||||
|
||||
// Track footprint, measured: horizontal is 8px tall and vertical 6px wide, and
|
||||
// each loses 1px to its transparent border plus 2px to p-px, so the thumbs land
|
||||
// at 5px and 3px. Shrink these further and the thumb stops being a grab target.
|
||||
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"
|
||||
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2 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-1.5 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
|
||||
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
|
||||
|
||||
@@ -454,12 +457,12 @@ function DataGridScrollArea({
|
||||
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto relative h-full w-2 touch-none p-px"
|
||||
className="pointer-events-auto relative h-full w-1.5 touch-none p-px"
|
||||
onPointerDown={handleTrackPointerDown}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-border absolute end-px w-2",
|
||||
"bg-border absolute end-px w-1.5",
|
||||
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
|
||||
"rounded-full"
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
@@ -105,7 +107,7 @@ type DataGridTableDndRowDecoration<TData extends object> = (context: {
|
||||
function DataGridTableDndRowHandle({
|
||||
className,
|
||||
disabled,
|
||||
disabledLabel = "Reordering unavailable",
|
||||
disabledLabel,
|
||||
}: {
|
||||
className?: string
|
||||
/**
|
||||
@@ -118,7 +120,10 @@ function DataGridTableDndRowHandle({
|
||||
/** Announced and shown on hover in place of the drag affordance. */
|
||||
disabledLabel?: string
|
||||
}) {
|
||||
const { i18n } = useDataGrid()
|
||||
const context = useContext(SortableRowContext)
|
||||
const resolvedDisabledLabel =
|
||||
disabledLabel ?? i18n.labels.reorderingUnavailable
|
||||
|
||||
if (!context || disabled) {
|
||||
return (
|
||||
@@ -133,8 +138,8 @@ function DataGridTableDndRowHandle({
|
||||
disabled && "cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
aria-label={disabled ? disabledLabel : "Drag to reorder row"}
|
||||
title={disabled ? disabledLabel : undefined}
|
||||
aria-label={disabled ? resolvedDisabledLabel : i18n.labels.dragToReorderRow}
|
||||
title={disabled ? resolvedDisabledLabel : undefined}
|
||||
disabled
|
||||
>
|
||||
<GripHorizontalIcon aria-hidden="true" />
|
||||
@@ -150,7 +155,7 @@ function DataGridTableDndRowHandle({
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
aria-label="Drag to reorder row"
|
||||
aria-label={i18n.labels.dragToReorderRow}
|
||||
{...context.attributes}
|
||||
{...context.listeners}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
@@ -69,7 +67,7 @@ function DataGridTableDndHeader<TData extends object>({
|
||||
}: {
|
||||
header: Header<DataGridFeatures, TData, unknown>
|
||||
}) {
|
||||
const { props } = useDataGrid()
|
||||
const { i18n, props } = useDataGrid()
|
||||
const { column } = header
|
||||
|
||||
// Check if column ordering is enabled for this column
|
||||
@@ -115,7 +113,7 @@ function DataGridTableDndHeader<TData extends object>({
|
||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="Drag to reorder"
|
||||
aria-label={i18n.labels.dragToReorder}
|
||||
>
|
||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import type { CSSProperties, ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import type {
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
DataGridTableInstance,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableAddRow,
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableEmpty,
|
||||
@@ -49,6 +50,33 @@ type DataGridTableVirtualizerInstance = Virtualizer<
|
||||
|
||||
type DataGridTableVirtualScrollAlignment = "auto" | "center" | "start" | "end"
|
||||
|
||||
interface DataGridTableColumnVirtualizerOptions {
|
||||
/** Off unless explicitly true; a column window is opt-in. */
|
||||
enabled?: boolean
|
||||
overscan?: number
|
||||
}
|
||||
|
||||
type DataGridTableColumnScrollRequest = {
|
||||
align: DataGridTableVirtualScrollAlignment
|
||||
behavior: ScrollBehavior
|
||||
columnId: string | undefined
|
||||
columnIndex: number
|
||||
scrollElement: HTMLElement
|
||||
}
|
||||
|
||||
function isSameDataGridTableColumnScrollRequest(
|
||||
previous: DataGridTableColumnScrollRequest | null,
|
||||
next: DataGridTableColumnScrollRequest
|
||||
) {
|
||||
return (
|
||||
previous?.align === next.align &&
|
||||
previous.behavior === next.behavior &&
|
||||
previous.columnId === next.columnId &&
|
||||
previous.columnIndex === next.columnIndex &&
|
||||
previous.scrollElement === next.scrollElement
|
||||
)
|
||||
}
|
||||
|
||||
type DataGridTableVirtualScrollRequest = {
|
||||
align: DataGridTableVirtualScrollAlignment
|
||||
behavior: ScrollBehavior
|
||||
@@ -267,6 +295,17 @@ interface DataGridTableVirtualProps<TData extends object> {
|
||||
scrollToRowAlign?: DataGridTableVirtualScrollAlignment
|
||||
/** Index within the center (non-pinned) row section to reveal. */
|
||||
scrollToRowIndex?: number
|
||||
/**
|
||||
* Opt-in horizontal virtualization of the CENTER columns. Activates only
|
||||
* under a fixed table layout with a single ungrouped header row; pinned
|
||||
* columns stay mounted, and anything else falls back to full-column
|
||||
* rendering. `scrollBehavior` is shared with controlled row scrolling.
|
||||
*/
|
||||
columnVirtualizerOptions?: DataGridTableColumnVirtualizerOptions
|
||||
/** Alignment used when revealing a controlled target column. */
|
||||
scrollToColumnAlign?: DataGridTableVirtualScrollAlignment
|
||||
/** Index within the center (non-pinned) visible leaf columns to reveal. */
|
||||
scrollToColumnIndex?: number
|
||||
footerContent?: ReactNode
|
||||
renderHeader?: boolean
|
||||
onFetchMore?: () => void
|
||||
@@ -290,6 +329,7 @@ interface VirtualBodyProps<TData extends object> {
|
||||
loadingMoreMessage: ReactNode
|
||||
allRowsLoadedMessage: ReactNode
|
||||
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
||||
centerColumnWindow?: { start: number; end: number }
|
||||
}
|
||||
|
||||
function DataGridTableVirtualPinnedPlaceholderCell<TData extends object>({
|
||||
@@ -424,6 +464,24 @@ function DataGridTableVirtualStatusRow<TData extends object>({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A scroll frame only shifts the window, so every surviving row's inputs are
|
||||
* identical and its last render is reused; the per-frame cost is the rows
|
||||
* entering the window, not all mounted rows. Cell-level state (selection,
|
||||
* focus, row checks) repaints through each cell's own Subscribe, and any real
|
||||
* data change rebuilds the row wrappers, so identity comparison is safe.
|
||||
*/
|
||||
const MemoizedRenderedRow = memo(
|
||||
DataGridTableRenderedRow,
|
||||
(prev, next) =>
|
||||
prev.row === next.row &&
|
||||
prev.rowIndex === next.rowIndex &&
|
||||
prev.rowRef === next.rowRef &&
|
||||
prev.pinnedBoundary === next.pinnedBoundary &&
|
||||
prev.centerWindow?.start === next.centerWindow?.start &&
|
||||
prev.centerWindow?.end === next.centerWindow?.end
|
||||
) as typeof DataGridTableRenderedRow
|
||||
|
||||
function DataGridTableVirtualBody<TData extends object>({
|
||||
table,
|
||||
topRows,
|
||||
@@ -438,6 +496,7 @@ function DataGridTableVirtualBody<TData extends object>({
|
||||
loadingMoreMessage,
|
||||
allRowsLoadedMessage,
|
||||
measureRowRef,
|
||||
centerColumnWindow,
|
||||
}: VirtualBodyProps<TData>) {
|
||||
const { isLoading } = useDataGrid()
|
||||
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
||||
@@ -479,9 +538,10 @@ function DataGridTableVirtualBody<TData extends object>({
|
||||
|
||||
topRows.forEach((row, index) => {
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
<MemoizedRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
centerWindow={centerColumnWindow}
|
||||
pinnedBoundary={
|
||||
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
|
||||
}
|
||||
@@ -506,11 +566,12 @@ function DataGridTableVirtualBody<TData extends object>({
|
||||
if (!row) return
|
||||
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
<MemoizedRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
rowRef={measureRowRef}
|
||||
rowIndex={virtualRow.index}
|
||||
centerWindow={centerColumnWindow}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -527,7 +588,12 @@ function DataGridTableVirtualBody<TData extends object>({
|
||||
} else {
|
||||
centerRows.forEach((row, rowIndex) => {
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow key={row.id} row={row} rowIndex={rowIndex} />
|
||||
<MemoizedRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
rowIndex={rowIndex}
|
||||
centerWindow={centerColumnWindow}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -557,9 +623,10 @@ function DataGridTableVirtualBody<TData extends object>({
|
||||
|
||||
bottomRows.forEach((row, index) => {
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
<MemoizedRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
centerWindow={centerColumnWindow}
|
||||
pinnedBoundary={
|
||||
index === 0 && (topRows.length > 0 || hasMiddleSection)
|
||||
? "bottom"
|
||||
@@ -576,10 +643,15 @@ function DataGridTableVirtualBody<TData extends object>({
|
||||
* Memoized virtual body: skip re-renders during active column resize.
|
||||
* Column widths update via CSS variables on the <table> element,
|
||||
* so the browser handles width changes without React re-renders.
|
||||
* A cell-selection drag gets the same treatment: painting goes through each
|
||||
* cell's own Subscribe, and the virtualizer re-renders itself from inside the
|
||||
* memo boundary, so parent-driven reconciliation during the drag is waste.
|
||||
*/
|
||||
const MemoizedVirtualBody = memo(
|
||||
DataGridTableVirtualBody,
|
||||
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
|
||||
(_prev, next) =>
|
||||
!!next.table.state.columnResizing.isResizingColumn ||
|
||||
next.table._isSelectingCells === true
|
||||
) as typeof DataGridTableVirtualBody
|
||||
|
||||
function DataGridTableVirtual<TData extends object>({
|
||||
@@ -589,6 +661,9 @@ function DataGridTableVirtual<TData extends object>({
|
||||
scrollBehavior = "auto",
|
||||
scrollToRowAlign = "auto",
|
||||
scrollToRowIndex,
|
||||
columnVirtualizerOptions,
|
||||
scrollToColumnAlign = "auto",
|
||||
scrollToColumnIndex,
|
||||
footerContent,
|
||||
renderHeader = true,
|
||||
onFetchMore,
|
||||
@@ -597,9 +672,22 @@ function DataGridTableVirtual<TData extends object>({
|
||||
fetchMoreOffset = 0,
|
||||
virtualizerOptions,
|
||||
}: DataGridTableVirtualProps<TData>) {
|
||||
const { table, props } = useDataGrid<TData>()
|
||||
const { i18n, table, props } = useDataGrid<TData>()
|
||||
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
|
||||
// Column windows only where the geometry is provable: a fixed table
|
||||
// layout (undefined defaults to fixed; the colgroup then owns every
|
||||
// width, so colSpan spacers cannot drift) and a single ungrouped header
|
||||
// row (a colSpan group cannot be windowed). Anything else falls back to
|
||||
// full-column rendering silently, the same posture as row virtualization
|
||||
// toward unsupported shapes.
|
||||
const columnVirtualizationActive =
|
||||
columnVirtualizerOptions?.enabled === true &&
|
||||
props.tableLayout?.width !== "auto" &&
|
||||
mergedHeaderGroups.length === 1 &&
|
||||
(mergedHeaderGroups[0]?.headers.every((header) => header.colSpan <= 1) ??
|
||||
false)
|
||||
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
||||
table,
|
||||
props.tableLayout?.rowsPinnable
|
||||
@@ -622,9 +710,9 @@ function DataGridTableVirtual<TData extends object>({
|
||||
|
||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
||||
const loadingMoreMessage =
|
||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
||||
props.fetchingMoreMessage || props.loadingMessage || i18n.labels.loading
|
||||
const allRowsLoadedMessage =
|
||||
props.allRowsLoadedMessage || "All records loaded"
|
||||
props.allRowsLoadedMessage || i18n.labels.allRowsLoaded
|
||||
|
||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setViewportElements({
|
||||
@@ -679,6 +767,69 @@ function DataGridTableVirtual<TData extends object>({
|
||||
...virtualizerOptionsRest,
|
||||
}) as DataGridTableVirtualizerInstance
|
||||
|
||||
// Horizontal offsets invert in RTL; the virtualizer must be told.
|
||||
const isRtl = useMemo(
|
||||
() =>
|
||||
viewportElements.containerElement
|
||||
? getComputedStyle(viewportElements.containerElement).direction ===
|
||||
"rtl"
|
||||
: false,
|
||||
[viewportElements.containerElement]
|
||||
)
|
||||
|
||||
const resolveColumnKey = useCallback(
|
||||
(index: number) => centerVisibleColumns[index]?.id ?? index,
|
||||
[centerVisibleColumns]
|
||||
)
|
||||
|
||||
const resolveColumnEstimateSize = useCallback(
|
||||
(index: number) => centerVisibleColumns[index]?.getSize() ?? 0,
|
||||
[centerVisibleColumns]
|
||||
)
|
||||
|
||||
const columnVirtualizer = useVirtualizer({
|
||||
horizontal: true,
|
||||
isRtl,
|
||||
count: columnVirtualizationActive ? centerVisibleColumns.length : 0,
|
||||
getScrollElement: resolveScrollElement,
|
||||
getItemKey: resolveColumnKey,
|
||||
estimateSize: resolveColumnEstimateSize,
|
||||
overscan: columnVirtualizerOptions?.overscan ?? 3,
|
||||
})
|
||||
|
||||
// Column sizes are exact reads of getSize(), never DOM-measured, so a
|
||||
// resize commit or a visibility/order/pinning change must resync the
|
||||
// virtualizer's cached sizes by hand.
|
||||
useEffect(() => {
|
||||
if (columnVirtualizationActive) columnVirtualizer.measure()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
columnVirtualizationActive,
|
||||
columnVirtualizer,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
table.state.columnSizing,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
table.state.columnVisibility,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
table.state.columnOrder,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
table.state.columnPinning,
|
||||
])
|
||||
|
||||
const virtualColumns = columnVirtualizationActive
|
||||
? columnVirtualizer.getVirtualItems()
|
||||
: []
|
||||
// The window is a contiguous inclusive [start, end] over the center
|
||||
// columns; before the scroll element resolves the item list is empty and
|
||||
// the grid renders full-width for that first frame.
|
||||
const centerColumnWindow =
|
||||
columnVirtualizationActive && virtualColumns.length > 0
|
||||
? {
|
||||
start: virtualColumns[0]!.index,
|
||||
end: virtualColumns[virtualColumns.length - 1]!.index,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const virtualItems = isVirtualizationEnabled
|
||||
? virtualizer.getVirtualItems()
|
||||
: []
|
||||
@@ -705,6 +856,51 @@ function DataGridTableVirtual<TData extends object>({
|
||||
// before the consumer flips isFetchingMore, and loops at end-of-data when
|
||||
// hasMore is never set.
|
||||
const fetchMoreFiredAtCountRef = useRef<number | null>(null)
|
||||
const lastColumnScrollRequestRef =
|
||||
useRef<DataGridTableColumnScrollRequest | null>(null)
|
||||
|
||||
// Controlled column reveal: same dedupe posture as the row request - the
|
||||
// signature keeps ordinary renders from re-scrolling, and exact column
|
||||
// sizes mean no post-measure follow-up pass is needed.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!columnVirtualizationActive ||
|
||||
scrollToColumnIndex === undefined ||
|
||||
scrollToColumnIndex < 0 ||
|
||||
scrollToColumnIndex >= centerVisibleColumns.length ||
|
||||
!viewportElements.scrollElement
|
||||
) {
|
||||
return
|
||||
}
|
||||
const request: DataGridTableColumnScrollRequest = {
|
||||
align: scrollToColumnAlign,
|
||||
behavior: scrollBehavior,
|
||||
columnId: centerVisibleColumns[scrollToColumnIndex]?.id,
|
||||
columnIndex: scrollToColumnIndex,
|
||||
scrollElement: viewportElements.scrollElement,
|
||||
}
|
||||
if (
|
||||
isSameDataGridTableColumnScrollRequest(
|
||||
lastColumnScrollRequestRef.current,
|
||||
request
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
lastColumnScrollRequestRef.current = request
|
||||
columnVirtualizer.scrollToIndex(scrollToColumnIndex, {
|
||||
align: scrollToColumnAlign,
|
||||
behavior: scrollBehavior === "smooth" ? "smooth" : "auto",
|
||||
})
|
||||
}, [
|
||||
columnVirtualizationActive,
|
||||
columnVirtualizer,
|
||||
centerVisibleColumns,
|
||||
scrollToColumnAlign,
|
||||
scrollToColumnIndex,
|
||||
scrollBehavior,
|
||||
viewportElements.scrollElement,
|
||||
])
|
||||
|
||||
// Resolve after every commit so a stable getter can expose a replaced ref;
|
||||
// the request signature prevents duplicate scrolling on ordinary renders.
|
||||
@@ -847,30 +1043,79 @@ function DataGridTableVirtual<TData extends object>({
|
||||
virtualItems,
|
||||
])
|
||||
|
||||
return (
|
||||
<DataGridTableViewport
|
||||
viewportRef={handleViewportRef}
|
||||
className={!usesExternalScrollArea ? "block" : undefined}
|
||||
style={
|
||||
usesExternalScrollArea
|
||||
? undefined
|
||||
: {
|
||||
height,
|
||||
overflow: "auto",
|
||||
position: "relative",
|
||||
// Standalone mode: this node IS the scroll container, so it
|
||||
// must stay at its parent's width (not the resizable table
|
||||
// width) or horizontal scrolling becomes impossible.
|
||||
width: "auto",
|
||||
}
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
{renderHeader && (
|
||||
<DataGridTableHead>
|
||||
{mergedHeaderGroups.map((headerGroup) => (
|
||||
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
|
||||
{headerGroup.headers
|
||||
// The header re-renders only when its real inputs move: the table
|
||||
// wrapper (any table state change recreates it), the layout props, and
|
||||
// the column window. A scroll frame changes none of them, so the whole
|
||||
// sortable-header subtree is reused instead of rebuilt per frame.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const headerNode = useMemo(
|
||||
() =>
|
||||
renderHeader && (
|
||||
<DataGridTableHead>
|
||||
{mergedHeaderGroups.map((headerGroup) => (
|
||||
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
|
||||
{/* Under an active column window the single ungrouped header
|
||||
row is bucketed start / windowed center / end, with each
|
||||
off-window flank one colSpan spacer sized by the intact
|
||||
colgroup - the same shape the body rows take. */}
|
||||
{centerColumnWindow
|
||||
? headerGroup.headers
|
||||
.filter((header) => header.column.getIsPinned() === "start")
|
||||
.map((header) => (
|
||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
header.column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
))
|
||||
: null}
|
||||
{centerColumnWindow && centerColumnWindow.start > 0 ? (
|
||||
<th
|
||||
aria-hidden="true"
|
||||
data-slot="data-grid-table-virtual-col-spacer"
|
||||
colSpan={centerColumnWindow.start}
|
||||
className="p-0"
|
||||
/>
|
||||
) : null}
|
||||
{centerColumnWindow
|
||||
? headerGroup.headers
|
||||
.filter((header) => !header.column.getIsPinned())
|
||||
.slice(centerColumnWindow.start, centerColumnWindow.end + 1)
|
||||
.map((header) => (
|
||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
header.column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
))
|
||||
: null}
|
||||
{centerColumnWindow &&
|
||||
centerVisibleColumns.length - 1 - centerColumnWindow.end > 0 ? (
|
||||
<th
|
||||
aria-hidden="true"
|
||||
data-slot="data-grid-table-virtual-col-spacer"
|
||||
colSpan={
|
||||
centerVisibleColumns.length - 1 - centerColumnWindow.end
|
||||
}
|
||||
className="p-0"
|
||||
/>
|
||||
) : null}
|
||||
{!centerColumnWindow &&
|
||||
headerGroup.headers
|
||||
.filter((header) => header.column.getIsPinned() !== "end")
|
||||
.map((header) => {
|
||||
const { column } = header
|
||||
@@ -890,38 +1135,66 @@ function DataGridTableVirtual<TData extends object>({
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
hasRightPinnedColumns ? (
|
||||
<DataGridTableFillHeadCell />
|
||||
) : null}
|
||||
{headerGroup.headers
|
||||
.filter((header) => header.column.getIsPinned() === "end")
|
||||
.map((header) => {
|
||||
const { column } = header
|
||||
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
|
||||
<DataGridTableFillHeadCell />
|
||||
) : null}
|
||||
{headerGroup.headers
|
||||
.filter((header) => header.column.getIsPinned() === "end")
|
||||
.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} />
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
!hasRightPinnedColumns ? (
|
||||
<DataGridTableFillHeadCell />
|
||||
) : null}
|
||||
</DataGridTableHeadRow>
|
||||
))}
|
||||
</DataGridTableHead>
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
|
||||
<DataGridTableFillHeadCell />
|
||||
) : null}
|
||||
</DataGridTableHeadRow>
|
||||
))}
|
||||
</DataGridTableHead>
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
renderHeader,
|
||||
table,
|
||||
props.tableLayout,
|
||||
centerColumnWindow,
|
||||
hasRightPinnedColumns,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<DataGridTableViewport
|
||||
viewportRef={handleViewportRef}
|
||||
className={!usesExternalScrollArea ? "block" : undefined}
|
||||
style={
|
||||
usesExternalScrollArea
|
||||
? undefined
|
||||
: {
|
||||
height,
|
||||
overflow: "auto",
|
||||
position: "relative",
|
||||
// Standalone mode: this node IS the scroll container, so it
|
||||
// must stay at its parent's width (not the resizable table
|
||||
// width) or horizontal scrolling becomes impossible.
|
||||
width: "auto",
|
||||
}
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
{headerNode}
|
||||
|
||||
{renderHeader &&
|
||||
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
@@ -943,7 +1216,12 @@ function DataGridTableVirtual<TData extends object>({
|
||||
loadingMoreMessage={loadingMoreMessage}
|
||||
allRowsLoadedMessage={allRowsLoadedMessage}
|
||||
measureRowRef={measureRowRef}
|
||||
centerColumnWindow={centerColumnWindow}
|
||||
/>
|
||||
{/* Same appended region as the standard body, so onRowCreate and
|
||||
a consumer draft work in the virtual layout too. */}
|
||||
{props.appendRow}
|
||||
<DataGridTableAddRow />
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,19 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useRef } from "react"
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react"
|
||||
import type { ReactNode } from "react"
|
||||
import {
|
||||
mergeDataGridI18n,
|
||||
type DataGridI18nConfig,
|
||||
type DataGridI18nOverrides,
|
||||
} from "@/components/reui/data-grid/data-grid-i18n"
|
||||
import {
|
||||
cellSelectionFeature,
|
||||
columnFacetingFeature,
|
||||
columnFilteringFeature,
|
||||
columnOrderingFeature,
|
||||
@@ -30,6 +43,7 @@ import {
|
||||
tableFeatures,
|
||||
} from "@tanstack/react-table"
|
||||
import type {
|
||||
Cell,
|
||||
Column,
|
||||
ColumnFiltersState,
|
||||
ReactTable,
|
||||
@@ -49,6 +63,39 @@ import { cn } from "@evobgp/ui/lib/utils"
|
||||
* installing the data grid no longer widens `ColumnMeta` for every other
|
||||
* table in the consuming app.
|
||||
*/
|
||||
/**
|
||||
* Per-column write contract for the spreadsheet operations (paste, fill,
|
||||
* clear, cut). Presence marks the column writable; every write still flows
|
||||
* through `onCellsChange`, so the consumer's state stays the only data owner.
|
||||
*/
|
||||
export interface DataGridColumnCellEdit<TData> {
|
||||
/**
|
||||
* Set false for a column that formats clipboard output but is never
|
||||
* written; a function decides per row, so locked rows (archived, another
|
||||
* user's, a totals row) reject like any read-only cell. Defaults to true.
|
||||
*/
|
||||
editable?: boolean | ((row: TData) => boolean)
|
||||
/**
|
||||
* Clipboard or fill string to typed value. Return undefined to reject the
|
||||
* cell into `rejected`. Without it the raw string passes through unchanged -
|
||||
* a deliberate non-coercion, so a number column receives strings only when
|
||||
* the consumer chose not to parse.
|
||||
*/
|
||||
parse?: (raw: string, row: TData) => unknown
|
||||
/** Typed value to clipboard string. Fallback: String(value ?? ""). */
|
||||
format?: (value: unknown, row: TData) => string
|
||||
/** Value dispatched by Delete/Backspace and cut. Defaults to null. */
|
||||
clearValue?: unknown
|
||||
/**
|
||||
* Opt into the grid's built-in free-text editor: an overlay input (or
|
||||
* auto-growing textarea) flush over the focused cell, opened by Enter, F2,
|
||||
* typing, or double-click, committing through `onCellsChange` as source
|
||||
* `"edit"` with `parse` applied. Columns without it stay consumer-edited
|
||||
* via `onCellEditRequest` or their own cell renderers.
|
||||
*/
|
||||
control?: "text" | "textarea"
|
||||
}
|
||||
|
||||
export interface DataGridColumnMeta<TData> {
|
||||
headerTitle?: string
|
||||
headerClassName?: string
|
||||
@@ -56,6 +103,15 @@ export interface DataGridColumnMeta<TData> {
|
||||
skeleton?: ReactNode
|
||||
expandedContent?: (row: TData) => ReactNode
|
||||
autoSize?: boolean
|
||||
cellEdit?: DataGridColumnCellEdit<TData>
|
||||
/**
|
||||
* Under `columnsResizable`, this column absorbs the free space the filler
|
||||
* strip would otherwise hold, so the grid always reads full-width and the
|
||||
* built-in editor covers the whole cell. One column per grid; while free
|
||||
* space remains, manual resizing of this column is visually a no-op (the
|
||||
* absorbed space compensates), the flex-column trade-off.
|
||||
*/
|
||||
fillWidth?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,6 +161,10 @@ export const dataGridFeatures = tableFeatures({
|
||||
rowSelectionFeature,
|
||||
rowExpandingFeature,
|
||||
rowPinningFeature,
|
||||
// Registration alone is inert: it seeds a `cellSelection: []` slice and
|
||||
// prototype methods but binds no DOM handlers, so grids without
|
||||
// `tableLayout.cellSelection` render and behave exactly as before.
|
||||
cellSelectionFeature,
|
||||
sortedRowModel: createSortedRowModel(),
|
||||
filteredRowModel: createFilteredRowModel(),
|
||||
paginatedRowModel: createPaginatedRowModel(),
|
||||
@@ -156,6 +216,131 @@ export function getColumnHeaderLabel<TData extends RowData, TValue>(
|
||||
return String(column.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* The td contract for spreadsheet selection. Call inside a
|
||||
* `Subscribe source={table.atoms.cellSelection}` render only: the reads are
|
||||
* builder calls whose state dependency React Compiler cannot see, exactly the
|
||||
* trap the row-selection checkbox documents.
|
||||
*/
|
||||
export function getDataGridCellSelectionCellAttrs<TData extends object>(
|
||||
cell: Cell<DataGridFeatures, TData, unknown>
|
||||
): {
|
||||
"aria-selected": boolean
|
||||
"data-col-id": string
|
||||
"data-cell-selected": true | undefined
|
||||
"data-cell-focused": true | undefined
|
||||
"data-cell-edge-top": true | undefined
|
||||
"data-cell-edge-right": true | undefined
|
||||
"data-cell-edge-bottom": true | undefined
|
||||
"data-cell-edge-left": true | undefined
|
||||
} {
|
||||
const selected = cell.getIsSelected()
|
||||
const edges = cell.getSelectionEdges()
|
||||
return {
|
||||
"aria-selected": selected,
|
||||
"data-col-id": cell.column.id,
|
||||
"data-cell-selected": selected || undefined,
|
||||
"data-cell-focused": cell.getIsFocused() || undefined,
|
||||
"data-cell-edge-top": edges.top || undefined,
|
||||
"data-cell-edge-right": edges.right || undefined,
|
||||
"data-cell-edge-bottom": edges.bottom || undefined,
|
||||
"data-cell-edge-left": edges.left || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection chrome for a body td, activated by the data attributes above plus
|
||||
* the imperatively toggled `data-cell-fill-target` (fill-drag preview, written
|
||||
* outside React like the resize indicator). A ::before overlay, not outline
|
||||
* or box-shadow: an outline ring hugs the cell box and cannot line up with
|
||||
* the gridlines the perimeter paints on, and the pinned-column dividers
|
||||
* already own the cell's shadow slot (tailwind-merge would collapse a second
|
||||
* shadow-[...] into it).
|
||||
*/
|
||||
export const dataGridCellSelectionCellClasses = cn(
|
||||
"relative select-none",
|
||||
// A light tint: the selection must read as a range without drowning the
|
||||
// gridlines under a heavy fill. The focused cell stays unfilled. Pinned
|
||||
// cells must stay OPAQUE (they hide scrolled content), so they get the
|
||||
// tint pre-mixed over the background, and the focused pinned cell keeps
|
||||
// a solid background instead of turning transparent.
|
||||
"data-[cell-selected]:bg-primary/4",
|
||||
"data-[cell-selected]:data-pinned:bg-[color-mix(in_oklab,var(--primary)_4%,var(--background))]",
|
||||
"data-[cell-focused]:bg-transparent!",
|
||||
"data-[cell-focused]:data-pinned:bg-background!",
|
||||
// Every selection line is one layout-free ::before overlay per cell. It
|
||||
// reaches 1px BEYOND the cell so each line paints exactly ON the shared
|
||||
// gridline it replaces: the range perimeter and the interior dividers
|
||||
// share this geometry, so where they meet they coincide instead of
|
||||
// stacking into a wider edge, every side stays exactly 1px, and
|
||||
// selecting never adds real borders that shift layout. At the table's
|
||||
// own boundary there is no shared gridline and the overshoot leaves the
|
||||
// scrollable content box, where it is CLIPPED - the perimeter simply
|
||||
// vanished on the first column, last column and last row. So every
|
||||
// overshoot that can hit a boundary goes through a variable the
|
||||
// boundary cells zero out. Longhand inset utilities only, logical
|
||||
// start/end
|
||||
// so RTL mirrors for free; a separate override rule cannot do this,
|
||||
// equal-specificity variants leave shorthand-vs-longhand to sort order.
|
||||
"data-[cell-selected]:before:pointer-events-none data-[cell-selected]:before:absolute data-[cell-selected]:before:top-[var(--data-grid-overlay-top,-1px)] data-[cell-selected]:before:start-[var(--data-grid-overlay-start,-1px)] data-[cell-selected]:before:end-[var(--data-grid-overlay-end,-1px)] data-[cell-selected]:before:bottom-[var(--data-grid-overlay-bottom,-1px)] data-[cell-selected]:before:border-primary data-[cell-selected]:before:content-['']",
|
||||
"data-[cell-focused]:before:pointer-events-none data-[cell-focused]:before:absolute data-[cell-focused]:before:top-[var(--data-grid-overlay-top,-1px)] data-[cell-focused]:before:start-[var(--data-grid-overlay-start,-1px)] data-[cell-focused]:before:end-[var(--data-grid-overlay-end,-1px)] data-[cell-focused]:before:bottom-[var(--data-grid-overlay-bottom,-1px)] data-[cell-focused]:before:border-primary data-[cell-focused]:before:content-['']",
|
||||
// Interior dividers: selected cells repaint their own end and bottom
|
||||
// gridlines on the overlay, so gridline-less grids still divide a range.
|
||||
// The gray color is guarded by not- variants (higher specificity), so a
|
||||
// side that belongs to the range perimeter deterministically stays
|
||||
// primary.
|
||||
"data-[cell-selected]:before:border-e data-[cell-selected]:before:border-b",
|
||||
"data-[cell-selected]:not-data-[cell-edge-right]:before:border-e-border",
|
||||
"data-[cell-selected]:not-data-[cell-edge-bottom]:before:border-b-border",
|
||||
// The Sheets model: a single selected cell IS its own range, so the four
|
||||
// edge attributes below already draw its full primary box; inside a
|
||||
// larger range the anchor reads by its unfilled background alone. The
|
||||
// explicit ring only covers a focused cell with no selection at all.
|
||||
// Edge sides are logical (border-e/border-s): the feature derives edges
|
||||
// from display-order column indexes, which mirror in RTL.
|
||||
"data-[cell-focused]:not-data-[cell-selected]:before:border",
|
||||
"data-[cell-edge-top]:before:border-t data-[cell-edge-right]:before:border-e data-[cell-edge-bottom]:before:border-b data-[cell-edge-left]:before:border-s",
|
||||
// While a fill drag is live the preview border is the ONE painter for
|
||||
// the whole pending region, so the source cells' own chrome rests -
|
||||
// otherwise its lines double against the region border at every shared
|
||||
// edge. display is the no-cascade-fight switch (see below).
|
||||
"in-data-[cell-filling]:data-[cell-selected]:before:hidden",
|
||||
"in-data-[cell-filling]:data-[cell-focused]:before:hidden",
|
||||
// While the built-in editor is open its outline is the one border; the
|
||||
// edited cell's own overlay chrome hides so nothing peeks around the
|
||||
// flush editor. display is the switch because no other before: rule
|
||||
// sets it, so there is no cascade order to lose.
|
||||
"in-data-[cell-editing]:data-[cell-focused]:before:hidden",
|
||||
// Clamped to the cell edge at the grid's own boundary, the overlay's
|
||||
// lines sit flush against the container border instead of being clipped
|
||||
// away outside the scrollable content. A custom property has no cascade
|
||||
// fight to lose. First/last-child cover the boundary columns: when a
|
||||
// filler td follows the last data column, that cell's overshoot lands
|
||||
// INSIDE the table and needs no clamp, and the td:first/last-child of a
|
||||
// row are its logical start/end in RTL too.
|
||||
"in-[tr:last-child]:[--data-grid-overlay-bottom:0px]",
|
||||
// The top overshoot lands under a sticky header, which is positioned and
|
||||
// paints over it - so the first row's perimeter lost its top line. Clamp
|
||||
// inside the cell there, exactly like the other three boundaries.
|
||||
"in-[tr:first-child]:[--data-grid-overlay-top:0px]",
|
||||
"first:[--data-grid-overlay-start:0px]",
|
||||
"last:[--data-grid-overlay-end:0px]",
|
||||
// A pinned column is a boundary like the grid's own edges: the sticky
|
||||
// neighbor would cover an on-gridline overlay line, and re-painting it
|
||||
// from the pinned side doubles into a 2px blur whenever subpixel
|
||||
// positions disagree (browser zoom, fractional fill widths). One
|
||||
// painter instead: the overlay clamps inside its own cell there, the
|
||||
// same custom-property clamp the first and last cells use.
|
||||
"[&:has(+td[data-pinned])]:[--data-grid-overlay-end:0px]",
|
||||
"[&:has(+[data-slot=data-grid-table-fill-body-cell]:last-child)]:[--data-grid-overlay-end:0px]",
|
||||
"[&:has(+[data-slot=data-grid-table-fill-body-cell]+td[data-pinned])]:[--data-grid-overlay-end:0px]",
|
||||
"[td[data-pinned]+&]:[--data-grid-overlay-start:0px]",
|
||||
// Tint only: the dashed region outline is one overlay element drawn by
|
||||
// the fill session, so neighboring target cells never double their edges.
|
||||
"data-[cell-fill-target]:bg-primary/4",
|
||||
"data-[cell-fill-target]:data-pinned:bg-[color-mix(in_oklab,var(--primary)_4%,var(--background))]"
|
||||
)
|
||||
|
||||
export type DataGridApiFetchParams = {
|
||||
pageIndex: number
|
||||
pageSize: number
|
||||
@@ -188,6 +373,10 @@ export interface DataGridContextProps<TData extends object> {
|
||||
table: DataGridTableInstance<TData>
|
||||
recordCount: number
|
||||
isLoading: boolean
|
||||
/** Stable per-grid prefix for the DOM ids ARIA relations point at. */
|
||||
gridId: string
|
||||
/** The merged label set; components read `i18n.labels.<key>`. */
|
||||
i18n: DataGridI18nConfig
|
||||
/**
|
||||
* Internal coordinator for `meta.autoSize` columns. Lives at the core level
|
||||
* so every table variant and viewport instance shares one application state.
|
||||
@@ -195,13 +384,66 @@ export interface DataGridContextProps<TData extends object> {
|
||||
autoSize?: DataGridAutoSizeController
|
||||
}
|
||||
|
||||
/**
|
||||
* Row and column ids are consumer strings; DOM ids cannot carry spaces or
|
||||
* quotes. One shared sanitizer so `aria-activedescendant` writers and the id
|
||||
* renderers always agree on the same encoding.
|
||||
*/
|
||||
export function toDataGridDomId(value: string): string {
|
||||
return value.replace(/[^\w-]/g, "_")
|
||||
}
|
||||
|
||||
export type DataGridAutoSizeController = {
|
||||
/**
|
||||
* Grows the first visible `meta.autoSize` column by the given free space.
|
||||
* Applies at most once per column id; safe to call from every viewport
|
||||
* measurement. Returns true when a sizing update was dispatched.
|
||||
* Feeds the controller one viewport measurement: the SIGNED free space
|
||||
* between the scroll container and the table, negative while the table
|
||||
* overflows. The first sight of a `meta.autoSize` column absorbs the
|
||||
* free space into its width; after that, every measurement REFLOWS the
|
||||
* same growth - a wider window widens the column, a narrower one gives
|
||||
* the space back down to the column's `minSize` (or its starting width
|
||||
* when none is set) - so the grid tracks its container live. Commits
|
||||
* are coalesced for performance: sub-pixel deltas are dropped, a
|
||||
* streaming resize gets one leading commit plus one trailing commit at
|
||||
* settle, and a width the user dragged is never touched. Safe to call
|
||||
* from every viewport measurement; returns true when a sizing update
|
||||
* was dispatched.
|
||||
*/
|
||||
apply: (fillWidth: number) => boolean
|
||||
apply: (freeSpace: number) => boolean
|
||||
}
|
||||
|
||||
type DataGridAutoSizeReflowState = {
|
||||
applied: { columnId: string; base: number; grown: number } | null
|
||||
settleTimer: ReturnType<typeof setTimeout> | null
|
||||
pendingFreeSpace: number
|
||||
lastCommitAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookkeeping keyed on the TABLE STORE, never on a React instance: dev
|
||||
* StrictMode double-mounts the tree, so two controller instances serve one
|
||||
* page and only the first would remember what it absorbed - the second
|
||||
* reads the grown width as a user drag and freezes the column. The store
|
||||
* object is the one identity that provably survives that dance (the
|
||||
* absorbed sizing rides inside it), and the WeakMap lets a discarded
|
||||
* table take its bookkeeping with it.
|
||||
*/
|
||||
const dataGridAutoSizeStates = new WeakMap<
|
||||
object,
|
||||
DataGridAutoSizeReflowState
|
||||
>()
|
||||
|
||||
function getDataGridAutoSizeState(store: object): DataGridAutoSizeReflowState {
|
||||
let state = dataGridAutoSizeStates.get(store)
|
||||
if (!state) {
|
||||
state = {
|
||||
applied: null,
|
||||
settleTimer: null,
|
||||
pendingFreeSpace: 0,
|
||||
lastCommitAt: 0,
|
||||
}
|
||||
dataGridAutoSizeStates.set(store, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
function createDataGridAutoSizeController<TData extends object>(
|
||||
@@ -217,29 +459,95 @@ function createDataGridAutoSizeController<TData extends object>(
|
||||
*/
|
||||
getTable: () => DataGridTableInstance<TData>
|
||||
): DataGridAutoSizeController {
|
||||
let applied: { columnId: string; base: number; grown: number } | null = null
|
||||
// Reflow coalescing: a live window drag streams a measurement per frame,
|
||||
// and every accepted commit re-renders the table. One leading commit
|
||||
// keeps single snaps (a maximize) instant; the rest of the stream parks
|
||||
// its latest value behind one trailing timer, so a continuous drag costs
|
||||
// two commits instead of sixty a second. The trailing timer may fire
|
||||
// after the grid unmounts; it re-reads the table then, and a sizing
|
||||
// write to the consumer's store is inert once nothing renders it.
|
||||
const SETTLE_MS = 150
|
||||
|
||||
const reflow = (freeSpace: number): boolean => {
|
||||
const table = getTable()
|
||||
const state = getDataGridAutoSizeState(table.store)
|
||||
// LIVE state, never the render snapshot: a measurement can run between
|
||||
// our own sizing commit and React's re-render, and the stale snapshot
|
||||
// would hide the width this controller just wrote.
|
||||
const columnSizing =
|
||||
table.atoms.columnSizing?.get() ?? table.state.columnSizing
|
||||
if (!state.applied) return false
|
||||
const autoSizeColumn = table
|
||||
.getVisibleLeafColumns()
|
||||
.find(
|
||||
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
|
||||
)
|
||||
if (!autoSizeColumn || autoSizeColumn.id !== state.applied.columnId)
|
||||
return false
|
||||
// A width this coordinator did not write belongs to the user's drag;
|
||||
// reflow stands down until a reset re-arms the column.
|
||||
const currentSize = columnSizing[state.applied.columnId]
|
||||
if (currentSize !== undefined && currentSize !== state.applied.grown) {
|
||||
return false
|
||||
}
|
||||
// The free space as it would measure with our growth removed; the
|
||||
// target re-absorbs exactly that, floored at minSize so a narrow
|
||||
// window hands space back without ever crushing the column (no
|
||||
// explicit minSize floors at the starting width instead).
|
||||
const freeAtBase = freeSpace + (state.applied.grown - state.applied.base)
|
||||
const floor = autoSizeColumn.columnDef.minSize ?? state.applied.base
|
||||
const target = Math.max(floor, state.applied.base + freeAtBase)
|
||||
if (Math.abs(target - state.applied.grown) < 1) return false
|
||||
state.applied = { ...state.applied, grown: target }
|
||||
table.setColumnSizing((old) => ({
|
||||
...old,
|
||||
[state.applied!.columnId]: target,
|
||||
}))
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
apply(fillWidth: number) {
|
||||
apply(freeSpace: number) {
|
||||
const table = getTable()
|
||||
const columnSizing = table.state.columnSizing
|
||||
const state = getDataGridAutoSizeState(table.store)
|
||||
// Same live read as `reflow`; the snapshot race is what made the
|
||||
// re-arm check clear the bookkeeping right after the first absorb.
|
||||
const columnSizing =
|
||||
table.atoms.columnSizing?.get() ?? table.state.columnSizing
|
||||
|
||||
// Re-arm after reset flows (double-click resetSize, resetColumnSizing,
|
||||
// controlled state replacement) so the column re-fills instead of
|
||||
// leaving a dead blank strip.
|
||||
if (applied && columnSizing[applied.columnId] === undefined) {
|
||||
applied = null
|
||||
if (state.applied && columnSizing[state.applied.columnId] === undefined) {
|
||||
state.applied = null
|
||||
}
|
||||
|
||||
if (fillWidth <= 0) return false
|
||||
|
||||
const autoSizeColumn = table
|
||||
.getVisibleLeafColumns()
|
||||
.find(
|
||||
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
|
||||
)
|
||||
|
||||
if (!autoSizeColumn || applied?.columnId === autoSizeColumn.id) {
|
||||
if (autoSizeColumn && state.applied?.columnId === autoSizeColumn.id) {
|
||||
state.pendingFreeSpace = freeSpace
|
||||
const now = Date.now()
|
||||
if (now - state.lastCommitAt > SETTLE_MS) {
|
||||
state.lastCommitAt = now
|
||||
return reflow(freeSpace)
|
||||
}
|
||||
if (state.settleTimer !== null) clearTimeout(state.settleTimer)
|
||||
state.settleTimer = setTimeout(() => {
|
||||
state.settleTimer = null
|
||||
state.lastCommitAt = Date.now()
|
||||
reflow(state.pendingFreeSpace)
|
||||
}, SETTLE_MS)
|
||||
return false
|
||||
}
|
||||
|
||||
const fillWidth = Math.max(0, freeSpace)
|
||||
if (fillWidth <= 0) return false
|
||||
|
||||
if (!autoSizeColumn) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -249,13 +557,13 @@ function createDataGridAutoSizeController<TData extends object>(
|
||||
// un-resizable: the drag committed, the next viewport measurement
|
||||
// stamped the fill back on top, and the column snapped to its old width.
|
||||
//
|
||||
// Deliberately keyed on observed state rather than on `applied`, which
|
||||
// Deliberately keyed on observed state rather than on `state.applied`, which
|
||||
// is per-coordinator memory: anything that rebuilds the coordinator
|
||||
// (a remount, a new table store) forgets what it did, and the guard has
|
||||
// to survive that. An explicit reset clears the entry and re-arms the
|
||||
// fill, which is what makes double-click-to-reset still work.
|
||||
const currentSize = columnSizing[autoSizeColumn.id]
|
||||
if (currentSize !== undefined && currentSize !== applied?.grown) {
|
||||
if (currentSize !== undefined && currentSize !== state.applied?.grown) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -264,13 +572,14 @@ function createDataGridAutoSizeController<TData extends object>(
|
||||
// user hasn't manually resized that column since, so visibility
|
||||
// toggles cannot ratchet the table wider than its container forever.
|
||||
const revert =
|
||||
applied && columnSizing[applied.columnId] === applied.grown
|
||||
? applied
|
||||
state.applied &&
|
||||
columnSizing[state.applied.columnId] === state.applied.grown
|
||||
? state.applied
|
||||
: null
|
||||
const base = columnSizing[autoSizeColumn.id] ?? autoSizeColumn.getSize()
|
||||
const grown = base + fillWidth
|
||||
|
||||
applied = { columnId: autoSizeColumn.id, base, grown }
|
||||
state.applied = { columnId: autoSizeColumn.id, base, grown }
|
||||
table.setColumnSizing((old) => {
|
||||
const next = { ...old, [autoSizeColumn.id]: grown }
|
||||
if (revert && next[revert.columnId] === revert.grown) {
|
||||
@@ -291,6 +600,104 @@ export type DataGridRequestParams = {
|
||||
columnFilters?: ColumnFiltersState
|
||||
}
|
||||
|
||||
/** Which spreadsheet operation produced a cell change batch. */
|
||||
export type DataGridCellsChangeSource =
|
||||
| "paste"
|
||||
| "cut"
|
||||
| "clear"
|
||||
| "fill"
|
||||
| "edit"
|
||||
|
||||
export interface DataGridCellChange<TData> {
|
||||
rowId: string
|
||||
columnId: string
|
||||
/** The consumer's row object, so immutable write-back needs no second lookup. */
|
||||
row: TData
|
||||
/** Accessor value at dispatch time; enables consumer-side undo stacks. */
|
||||
previousValue: unknown
|
||||
/** Typed value produced by `meta.cellEdit.parse`, or `clearValue` for clear/cut. */
|
||||
value: unknown
|
||||
}
|
||||
|
||||
/** What onCellsCopy receives after the grid writes the clipboard. */
|
||||
export interface DataGridCopyDetails {
|
||||
/** The TSV text that reached the clipboard. */
|
||||
text: string
|
||||
/** The same content as a row-major grid of formatted fields. */
|
||||
grid: string[][]
|
||||
/** True when the write came from a cut, which clears the region next. */
|
||||
cut: boolean
|
||||
}
|
||||
|
||||
/** One resolved positive region, in pre-paginated display indexes. */
|
||||
export interface DataGridCellSelectionBound {
|
||||
minRowIndex: number
|
||||
maxRowIndex: number
|
||||
minColumnIndex: number
|
||||
maxColumnIndex: number
|
||||
}
|
||||
|
||||
/** What onCellSelectionChange receives, resolved from the live selection. */
|
||||
export interface DataGridCellSelectionSnapshot {
|
||||
/** The virtually focused cell, the active range's anchor. Null when clear. */
|
||||
focused: { rowId: string; columnId: string } | null
|
||||
/** Every positive region, geometric order. */
|
||||
bounds: DataGridCellSelectionBound[]
|
||||
/** The most recent region, the one copy and paste act on. */
|
||||
activeBound: DataGridCellSelectionBound | null
|
||||
/**
|
||||
* Cells of the selection the view can show - the count that matches the
|
||||
* painted range and what a batch will touch.
|
||||
*/
|
||||
visibleCellCount: number
|
||||
}
|
||||
|
||||
export interface DataGridCellRejection {
|
||||
rowId: string
|
||||
columnId: string
|
||||
raw: string
|
||||
reason: "readonly" | "invalid"
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifecycle of a row for the optional CRUD indications: "new" and "dirty"
|
||||
* tint the row, "deleted" mutes and strikes it. Purely presentational - the
|
||||
* consumer's state stays the only owner of what the statuses mean.
|
||||
*/
|
||||
export type DataGridRowStatus = "new" | "dirty" | "deleted"
|
||||
|
||||
/**
|
||||
* Lifecycle of a cell for the optional CRUD indications: a corner mark in
|
||||
* the amber tone for "dirty", the destructive tone for "invalid".
|
||||
*/
|
||||
export type DataGridCellStatus = "dirty" | "invalid"
|
||||
|
||||
/**
|
||||
* Asks the consumer to open its editor for the focused cell: Enter or F2, or
|
||||
* a typed printable character (then `initialText` carries it, the
|
||||
* Notion/Airtable type-to-replace flow). Dispatched only for cells whose
|
||||
* column is writable via `meta.cellEdit`.
|
||||
*/
|
||||
export interface DataGridCellEditRequest<TData> {
|
||||
rowId: string
|
||||
columnId: string
|
||||
row: TData
|
||||
previousValue: unknown
|
||||
/** The typed character that started the edit; absent for Enter and F2. */
|
||||
initialText?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* One batch per operation, however many cells it spans: a 10k-cell paste is
|
||||
* one callback, one consumer setState, one table rebuild. The grid never
|
||||
* writes data itself.
|
||||
*/
|
||||
export interface DataGridCellsChangeDetails<TData> {
|
||||
source: DataGridCellsChangeSource
|
||||
changes: DataGridCellChange<TData>[]
|
||||
rejected: DataGridCellRejection[]
|
||||
}
|
||||
|
||||
export interface DataGridProps<
|
||||
TFeatures extends TableFeatures,
|
||||
TData extends object,
|
||||
@@ -300,7 +707,62 @@ export interface DataGridProps<
|
||||
recordCount: number
|
||||
children?: ReactNode
|
||||
onRowClick?: (row: TData) => void
|
||||
/**
|
||||
* Receives every spreadsheet write batch (paste, cut, clear, fill, edit).
|
||||
* Served through the props getter like `onRowClick`, so an inline identity
|
||||
* never republishes the context. Absent: copy still works, writes no-op.
|
||||
*/
|
||||
onCellsChange?: (details: DataGridCellsChangeDetails<TData>) => void
|
||||
/**
|
||||
* Opens the consumer's editor from the keyboard: Enter, F2, or typing on
|
||||
* the focused cell. Absent: Enter keeps moving down, typing does nothing.
|
||||
*/
|
||||
onCellEditRequest?: (request: DataGridCellEditRequest<TData>) => void
|
||||
/**
|
||||
* Fires on every cell-selection change (focus moves, ranges grow or
|
||||
* clear) with a resolved snapshot, so a selection count, formula bar or
|
||||
* context panel needs no reach into TanStack internals.
|
||||
*/
|
||||
onCellSelectionChange?: (snapshot: DataGridCellSelectionSnapshot) => void
|
||||
/**
|
||||
* Fires after the grid writes the clipboard (Cmd/Ctrl+C or X, or the
|
||||
* native copy and cut events), e.g. to confirm with a toast.
|
||||
*/
|
||||
onCellsCopy?: (details: DataGridCopyDetails) => void
|
||||
/**
|
||||
* Renders an "Add row" affordance as the table body's last row; clicking
|
||||
* it is the consumer's cue to start creating a row. Absent: no row.
|
||||
*/
|
||||
onRowCreate?: () => void
|
||||
/** Label of the onRowCreate affordance. Defaults to "Add row". */
|
||||
rowCreateLabel?: ReactNode
|
||||
/**
|
||||
* Rendered inside the table body after the data rows: the slot a DRAFT
|
||||
* row lives in while it is being created, before it joins `data` and the
|
||||
* pagination math. Supply a `<tr>` of cells; the colgroup sizes them.
|
||||
*/
|
||||
appendRow?: ReactNode
|
||||
/**
|
||||
* Optional CRUD indication per row. Return undefined for no indication;
|
||||
* omit the prop entirely to render none anywhere.
|
||||
*/
|
||||
getRowStatus?: (row: TData) => DataGridRowStatus | undefined
|
||||
/**
|
||||
* Optional CRUD indication per cell: the classic corner mark on cells the
|
||||
* consumer tracks as edited or invalid.
|
||||
*/
|
||||
getCellStatus?: (
|
||||
row: TData,
|
||||
columnId: string
|
||||
) => DataGridCellStatus | undefined
|
||||
isLoading?: boolean
|
||||
/**
|
||||
* Overrides for every built-in string: menu items, aria labels,
|
||||
* pagination copy, state messages. Merged over the defaults; a more
|
||||
* specific component prop (`rowCreateLabel`, `loadingMessage`, the
|
||||
* pagination label props) still wins over its `i18n` counterpart.
|
||||
*/
|
||||
i18n?: DataGridI18nOverrides
|
||||
loadingMode?: "skeleton" | "spinner"
|
||||
loadingMessage?: ReactNode | string
|
||||
fetchingMoreMessage?: ReactNode | string
|
||||
@@ -325,6 +787,36 @@ export interface DataGridProps<
|
||||
columnsDraggable?: boolean
|
||||
rowsDraggable?: boolean
|
||||
rowsPinnable?: boolean
|
||||
/** Spreadsheet cell range selection: mouse ranges, keyboard, clipboard chrome. */
|
||||
cellSelection?: boolean
|
||||
/**
|
||||
* "range" (default): drag, Shift and Ctrl/Cmd gestures and Shift+keys
|
||||
* grow multi-cell regions. "single": one focused cell only - every
|
||||
* gesture collapses to it and Ctrl/Cmd+A is inert.
|
||||
*/
|
||||
cellSelectionMode?: "single" | "range"
|
||||
/** Drag-to-fill handle on the selection corner. Requires cellSelection. */
|
||||
cellFillHandle?: boolean
|
||||
/**
|
||||
* How a cell opens its editor from the mouse: "dblclick" (default,
|
||||
* the spreadsheet standard - first click focuses, second edits) or
|
||||
* "click", opening the editor as soon as a plain click lands on the
|
||||
* focused cell; modifier clicks and drags still select.
|
||||
*/
|
||||
cellEditMode?: "dblclick" | "click"
|
||||
/**
|
||||
* When true, committing an edit with Enter moves focus down (Shift+Enter
|
||||
* up), the Sheets and Excel convention. Off by default: the commit keeps
|
||||
* focus on the edited cell. Tab always commits and moves across.
|
||||
*/
|
||||
cellEditEnterAdvance?: boolean
|
||||
/**
|
||||
* The handle's look: "dot" is a solid primary dot kept inside the
|
||||
* cell corner; "ring" is the Sheets-style hollow circle riding the
|
||||
* corner point, clamped at the grid's own boundary; "square" is the
|
||||
* Excel-style solid square inside the corner. Default "dot".
|
||||
*/
|
||||
cellFillHandleVariant?: "dot" | "ring" | "square"
|
||||
}
|
||||
tableClassNames?: {
|
||||
base?: string
|
||||
@@ -335,6 +827,20 @@ export interface DataGridProps<
|
||||
bodyRow?: string
|
||||
footer?: string
|
||||
edgeCell?: string
|
||||
/** Override the getRowStatus tints; defaults are the warning-muted set. */
|
||||
rowNew?: string
|
||||
rowDirty?: string
|
||||
rowDeleted?: string
|
||||
/**
|
||||
* Override the pinned-row chrome (muted tint, boundary shadow). A grid
|
||||
* that pins a draft row for quick-create wants it to read as an active
|
||||
* row, not a muted one.
|
||||
*/
|
||||
rowPinned?: string
|
||||
/** The onRowCreate affordance row, e.g. to match the grid's row height. */
|
||||
rowCreate?: string
|
||||
/** The fill handle, e.g. to restyle or resize it beyond the built-in variants. */
|
||||
cellFillHandle?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,11 +878,16 @@ function DataGridProvider<TData extends object>({
|
||||
}) {
|
||||
// Latest-props ref: context reads always resolve fresh props through the
|
||||
// getter below without the memoized context value depending on unstable
|
||||
// Stable for the grid's lifetime; ARIA ids and relations hang off it.
|
||||
const gridId = useId()
|
||||
// ReactNode/function prop identities (inline emptyMessage/onRowClick would
|
||||
// otherwise publish a new context value on every consumer render - at
|
||||
// mousemove rate during a resize drag, piercing the body-rows memo).
|
||||
const propsRef = useRef(props)
|
||||
propsRef.current = props
|
||||
const i18n = mergeDataGridI18n(props.i18n)
|
||||
const i18nRef = useRef(i18n)
|
||||
i18nRef.current = i18n
|
||||
|
||||
// Same treatment for the table itself, which v9 - unlike v8 - re-creates on
|
||||
// every state change. Depending on it directly would republish the context
|
||||
@@ -401,6 +912,32 @@ function DataGridProvider<TData extends object>({
|
||||
table.setOptions((old) => ({ ...old, columnResizeMode: resizeMode }))
|
||||
}, [table, resizeMode])
|
||||
|
||||
// With the selection UI on, every paste/fill/clear batch replaces `data`,
|
||||
// and the feature's default autoResetCellSelection would wipe the selection
|
||||
// after each write. Ranges are row-id based and unresolvable ids drop out
|
||||
// of the bounds safely, so keeping state across data edits is the correct
|
||||
// default here.
|
||||
const cellSelectionOn = !!props.tableLayout?.cellSelection
|
||||
useEffect(() => {
|
||||
if (!cellSelectionOn || table.atoms.cellSelection == null) return
|
||||
if (table.options.autoResetCellSelection === false) return
|
||||
table.setOptions((old) => ({ ...old, autoResetCellSelection: false }))
|
||||
}, [table, cellSelectionOn])
|
||||
|
||||
// Rendered row indexes re-base per page, so a range kept across a page flip
|
||||
// re-lights the same positions over different rows. A page change clears
|
||||
// the selection instead - the rule Excel-family grids apply to paginated
|
||||
// range selection.
|
||||
const pageKey = `${table.state.pagination?.pageIndex}:${table.state.pagination?.pageSize}`
|
||||
const previousPageKeyRef = useRef(pageKey)
|
||||
useEffect(() => {
|
||||
if (previousPageKeyRef.current === pageKey) return
|
||||
previousPageKeyRef.current = pageKey
|
||||
if (!cellSelectionOn || table.atoms.cellSelection == null) return
|
||||
tableRef.current.resetCellSelection(true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageKey, cellSelectionOn])
|
||||
|
||||
// One autoSize coordinator per table instance so split header/body viewports
|
||||
// cannot apply the growth twice. Keyed on `table.store`, which v9 keeps
|
||||
// stable for the life of the table, rather than on `table` itself: the
|
||||
@@ -419,6 +956,9 @@ function DataGridProvider<TData extends object>({
|
||||
// ReactNode/function props (messages, onRowClick) are also excluded: they
|
||||
// are served fresh through the props getter, so unstable inline identities
|
||||
// cannot invalidate the context value.
|
||||
// `cellSelection` state is excluded on purpose too: a drag writes it once
|
||||
// per cell crossed, and nothing reads it through context - cells and the
|
||||
// selection bar subscribe to `table.atoms.cellSelection` directly.
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
get props() {
|
||||
@@ -427,8 +967,12 @@ function DataGridProvider<TData extends object>({
|
||||
get table() {
|
||||
return tableRef.current
|
||||
},
|
||||
get i18n() {
|
||||
return i18nRef.current
|
||||
},
|
||||
recordCount: props.recordCount,
|
||||
isLoading: props.isLoading || false,
|
||||
gridId,
|
||||
autoSize,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -495,6 +1039,8 @@ function DataGrid<TFeatures extends TableFeatures, TData extends object>({
|
||||
columnsDraggable: false,
|
||||
rowsDraggable: false,
|
||||
rowsPinnable: false,
|
||||
cellSelection: false,
|
||||
cellFillHandle: false,
|
||||
},
|
||||
tableClassNames: {
|
||||
base: "",
|
||||
@@ -555,7 +1101,9 @@ function DataGridContainer({
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid"
|
||||
className={cn("w-full overflow-hidden", className)}
|
||||
// relative: anchors floating chrome composed inside the container,
|
||||
// like the fill-drag preview outline.
|
||||
className={cn("relative w-full overflow-hidden", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { FilterFieldPicker } from "@/components/reui/filters/filters-builder"
|
||||
import {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
Cascader,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
filterControlSizes,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import type { FilterDraftAction } from "@/components/reui/filters/filters-draft"
|
||||
import type { FilterPathCollapse } from "@/components/reui/filters/filters-lib"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
Cascader,
|
||||
|
||||
@@ -162,6 +162,14 @@ export interface FilterField<V = unknown, O = unknown> {
|
||||
operators?:
|
||||
| FilterOperator[]
|
||||
| ((field: FilterField<V, O>) => FilterOperator[])
|
||||
/**
|
||||
* The operator this field FALLS BACK to, not a way to preselect one. Both
|
||||
* create paths deliberately start a rule on `operator: ""` so the condition
|
||||
* menu opens with nothing chosen, so this is read when an existing rule
|
||||
* changes field, and it is the value `FilterFieldPicker` hands to `onSelect`.
|
||||
* Ignored, with a dev warning, when it names an operator the field does not
|
||||
* offer; the first visible operator wins instead.
|
||||
*/
|
||||
defaultOperator?: string
|
||||
|
||||
/** Overrides the editor chosen from `type`. See `FilterEditorProps`. */
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { FiltersAdvanced } from "@/components/reui/filters/filters-advanced"
|
||||
import { FiltersBuilder } from "@/components/reui/filters/filters-builder"
|
||||
|
||||
@@ -3,36 +3,30 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@evobgp/ui/lib/utils"
|
||||
|
||||
/**
|
||||
* CSS variable architecture for FramePanel theming:
|
||||
*
|
||||
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
|
||||
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
|
||||
* border-(--frame-panel-border-color). This means:
|
||||
*
|
||||
* - variant="inverse" overrides those vars on Frame → all panels pick it up
|
||||
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
|
||||
* which wins over bg-(--frame-panel-bg) by Tailwind source order — no
|
||||
* :not() or !important needed
|
||||
* Frame sets --frame-panel-bg and --frame-panel-border-color; FramePanel reads
|
||||
* them back as bg-(--frame-panel-bg) and border-(--frame-panel-border-color).
|
||||
* So variant="inverse" re-points every panel from one place, and a consumer's
|
||||
* own bg-* on a panel still wins on source order, with no :not() and no `!`.
|
||||
*/
|
||||
const frameVariants = cva(
|
||||
[
|
||||
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
|
||||
"(--radius-xl)] [--frame-radius:var(--radius-xl)]",
|
||||
"(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
|
||||
// Each rung is the radius that style's own .cn-card resolves through, so a
|
||||
// Frame and a Card side by side agree. lyra/sera are 0px, NOT
|
||||
// var(--radius-none): no such token exists, and it only reached 0 by being
|
||||
// invalid, which also left --frame-radius empty for anything reading it.
|
||||
"[--frame-radius:var(--radius-xl)]",
|
||||
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
|
||||
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
|
||||
"[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
|
||||
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
|
||||
// Default panel token values — overridden per-variant below
|
||||
"[--frame-panel-px:calc(var(--frame-panel-px-base)+var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)+var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)+var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)+var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)+var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)+var(--frame-panel-footer-py-adjust))]",
|
||||
// Luma alone re-times the frame: wider gap and padding, roomier bars.
|
||||
"",
|
||||
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
|
||||
// Concentric inner radius: the panel corner nests smoothly inside the frame
|
||||
// corner instead of matching it. The panel sits inset from the frame's outer
|
||||
// edge by the frame's 1px border + --frame-px padding, so its radius is
|
||||
// reduced by that same gap (radius − gap keeps the two arcs parallel). This
|
||||
// base value assumes the bordered default/inverse frame; `ghost` drops the
|
||||
// 1px border term and `dense` pins it back to the frame radius (its panels
|
||||
// are pulled flush to the edge).
|
||||
"[--frame-panel-radius:calc(var(--frame-radius)_-_var(--frame-px)_-_1px)]",
|
||||
// Concentric: the panel nests inside the frame's corner rather than copying
|
||||
// it. It is inset by the 1px border plus --frame-px, so subtracting exactly
|
||||
// that keeps the two arcs parallel. `ghost` drops the border term (no
|
||||
// border), `dense` pins it to the frame radius (panels sit flush).
|
||||
"[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px)-1px)]",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
@@ -40,17 +34,12 @@ const frameVariants = cva(
|
||||
default: "border border-[var(--frame-border-color)] bg-clip-padding",
|
||||
inverse:
|
||||
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
|
||||
// No frame border, so the panel is inset by --frame-px padding only.
|
||||
ghost: "[--frame-panel-radius:calc(var(--frame-radius)_-_var(--frame-px))]",
|
||||
ghost: "[--frame-panel-radius:calc(var(--frame-radius)-var(--frame-px))]",
|
||||
},
|
||||
// Header/footer vertical rhythm is tighter than the panel body's, and
|
||||
// the gap widens as the frame grows: the bars read as chrome rather than
|
||||
// as another content block. py ladder is 0.5 / 1.5 / 2 / 2.5 against a
|
||||
// body py of 2 / 3.5 / 4 / 5. These vars are style-agnostic - no
|
||||
// style-*.css overrides them - so this single ladder drives all shadcn
|
||||
// styles. `px` is deliberately left level with the body so header,
|
||||
// content and footer stay left-aligned. `xs` holds at 0.5 (2px): it is
|
||||
// the practical floor, since anything lower stops reading as padding.
|
||||
// Bars read as chrome, not a second content block: py runs 0.5/1.5/2/2.5
|
||||
// against a body py of 2/3.5/4/5, while px stays level with the body so
|
||||
// header, content and footer left-align. xs floors at 0.5 (2px), below
|
||||
// which it stops reading as padding. No style-*.css overrides these.
|
||||
spacing: {
|
||||
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(0.5)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(0.5)]",
|
||||
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(1.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(1.5)]",
|
||||
@@ -72,9 +61,8 @@ const frameVariants = cva(
|
||||
],
|
||||
},
|
||||
dense: {
|
||||
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars.
|
||||
// Padding is 0 and panels are pulled flush to the frame edge (-mx-px), so
|
||||
// their corners align with the frame radius rather than nesting inside it.
|
||||
// Parent selectors, not CSS vars: these are positional. Panels are
|
||||
// pulled flush (-mx-px), so corners align with the frame's own radius.
|
||||
true: "p-0 gap-0 border-[var(--frame-border-color)] [--frame-panel-radius:var(--frame-radius)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
|
||||
false: "",
|
||||
},
|
||||
@@ -117,13 +105,10 @@ function FramePanel({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
|
||||
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
|
||||
// via className overrides these by Tailwind source order - no ! needed.
|
||||
"relative overflow-hidden rounded-(--frame-panel-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
|
||||
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
|
||||
!fit && "grow",
|
||||
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-panel-radius)_-_1px)] before:shadow-black/5",
|
||||
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-panel-radius)-1px)] before:shadow-black/5",
|
||||
"dark:bg-clip-border dark:before:shadow-white/5",
|
||||
"px-(--frame-panel-px) py-(--frame-panel-py)",
|
||||
className
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import type {
|
||||
CollisionDetection,
|
||||
DragCancelEvent,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
@@ -22,12 +23,16 @@ import type {
|
||||
UniqueIdentifier,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
closestCenter,
|
||||
defaultDropAnimationSideEffects,
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
getFirstCollision,
|
||||
KeyboardSensor,
|
||||
MeasuringStrategy,
|
||||
MouseSensor,
|
||||
pointerWithin,
|
||||
rectIntersection,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
@@ -254,6 +259,49 @@ function Kanban<T>({
|
||||
[columns, columnIds, getItemValue, isColumn]
|
||||
)
|
||||
|
||||
// The droppable under the pointer, not the one the dragged rect overlaps
|
||||
// most: in the gap between columns that overlap flips with every live-preview
|
||||
// move, and each flip re-runs dragOver until React bails out.
|
||||
const lastOverIdRef = useRef<UniqueIdentifier | null>(null)
|
||||
const collisionDetection = useCallback<CollisionDetection>(
|
||||
(args) => {
|
||||
if (isColumn(args.active.id)) {
|
||||
return closestCenter({
|
||||
...args,
|
||||
droppableContainers: args.droppableContainers.filter((container) =>
|
||||
isColumn(container.id)
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
// Keyboard drags carry no pointer.
|
||||
if (!args.pointerCoordinates) return rectIntersection(args)
|
||||
|
||||
let overId = getFirstCollision(pointerWithin(args), "id")
|
||||
if (overId != null) {
|
||||
// Over a column's empty space: resolve to its closest item, if any.
|
||||
if (isColumn(overId)) {
|
||||
const itemIds = new Set(columns[overId as string].map(getItemValue))
|
||||
overId =
|
||||
closestCenter({
|
||||
...args,
|
||||
droppableContainers: args.droppableContainers.filter(
|
||||
(container) => itemIds.has(container.id as string)
|
||||
),
|
||||
})[0]?.id ?? overId
|
||||
}
|
||||
lastOverIdRef.current = overId
|
||||
return [{ id: overId }]
|
||||
}
|
||||
|
||||
// Between droppables: hold the last target so the preview stays put.
|
||||
return lastOverIdRef.current != null
|
||||
? [{ id: lastOverIdRef.current }]
|
||||
: rectIntersection(args)
|
||||
},
|
||||
[columns, getItemValue, isColumn]
|
||||
)
|
||||
|
||||
const commitChange = useCallback(
|
||||
(
|
||||
finalValue: Record<string, T[]>,
|
||||
@@ -312,6 +360,7 @@ function Kanban<T>({
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
lastOverIdRef.current = null
|
||||
setActiveId(event.active.id)
|
||||
onDragStart?.(event)
|
||||
|
||||
@@ -421,6 +470,7 @@ function Kanban<T>({
|
||||
}
|
||||
|
||||
dragOriginRef.current = null
|
||||
lastOverIdRef.current = null
|
||||
setActiveId(null)
|
||||
onDragCancel?.(event)
|
||||
},
|
||||
@@ -437,6 +487,7 @@ function Kanban<T>({
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
lastOverIdRef.current = null
|
||||
setActiveId(null)
|
||||
onDragEnd?.(event)
|
||||
|
||||
@@ -589,6 +640,7 @@ function Kanban<T>({
|
||||
modifiers={modifiers}
|
||||
accessibility={accessibility}
|
||||
measuring={MEASURING_CONFIG}
|
||||
collisionDetection={collisionDetection}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import type { HTMLAttributes, ReactElement } from "react"
|
||||
import {
|
||||
Children,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useCallback, useContext, useState } from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user