feat(mcp): подготовить сервер и доступ к приёмке

This commit is contained in:
Denozordec committed 2026-11-03 03:12:03 +07:00
commit bf3d50e1ff
3835 files changed
+537122

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,189 @@
"use client"
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"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Separator } from "@/components/ui/separator"
import { CirclePlusIcon, CheckIcon } from "lucide-react"
interface DataGridColumnFilterProps<TData extends object, TValue> {
column?: Column<DataGridFeatures, TData, TValue>
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
}
function DataGridColumnFilter<TData extends object, TValue>({
column,
title,
options,
}: DataGridColumnFilterProps<TData, TValue>) {
const { i18n } = useDataGrid()
const facets = column?.getFacetedUniqueValues()
const filterValue = column?.getFilterValue()
const selectedValues = new Set(
Array.isArray(filterValue) ? (filterValue as string[]) : []
)
const [searchQuery, setSearchQuery] = useState("")
const filteredOptions = useMemo(() => {
if (!searchQuery) return options
return options.filter((option) =>
option.label.toLowerCase().includes(searchQuery.toLowerCase())
)
}, [options, searchQuery])
return (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm">
<CirclePlusIcon className="size-4" />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge variant="secondary" className="px-1 font-normal">
{i18n.labels.filterSelectedCount(selectedValues.size)}
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
}
/>
<PopoverContent className="w-[200px] p-0" align="start">
<div className="p-2">
<Input
placeholder={title}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
/>
</div>
<div className="max-h-[300px] overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm">
{i18n.labels.filterNoResults}
</div>
) : (
<div className="p-1">
{filteredOptions.map((option) => {
const isSelected = selectedValues.has(option.value)
const facetCount = facets?.get(option.value)
const toggleOption = () => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}
return (
<div
key={option.value}
role="button"
tabIndex={0}
aria-pressed={isSelected}
onClick={toggleOption}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
toggleOption()
}
}}
className={cn(
"rounded-md relative flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none",
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
)}
>
<div
className={cn(
"border-primary rounded-sm flex h-4 w-4 items-center justify-center border",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon className="text-muted-foreground h-4 w-4" />
)}
<span>{option.label}</span>
{facetCount !== undefined && (
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facetCount}
</span>
)}
</div>
)
})}
</div>
)}
{selectedValues.size > 0 && (
<>
<div className="bg-border -mx-1 my-1 h-px" />
<div className="p-1">
<div
role="button"
tabIndex={0}
onClick={() => column?.setFilterValue(undefined)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
column?.setFilterValue(undefined)
}
}}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
>
{i18n.labels.filterClear}
</div>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
)
}
export { DataGridColumnFilter, type DataGridColumnFilterProps }
@@ -0,0 +1,397 @@
"use client"
import { memo, useMemo } from "react"
import type { HTMLAttributes, ReactNode } from "react"
import {
getColumnHeaderLabel,
useDataGrid,
} from "@/components/reui/data-grid/data-grid"
import type { DataGridFeatures } from "@/components/reui/data-grid/data-grid"
import { Subscribe } from "@tanstack/react-table"
import type { Column } from "@tanstack/react-table"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
interface DataGridColumnHeaderProps<
TData extends object,
TValue,
> extends HTMLAttributes<HTMLDivElement> {
column: Column<DataGridFeatures, TData, TValue>
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
title?: string
icon?: ReactNode
/** Reserved; pin controls are gated by tableLayout.columnsPinnable + column.getCanPin(). */
pinnable?: boolean
filter?: ReactNode
visibility?: boolean
}
function DataGridColumnHeaderInner<TData extends object, TValue>({
column,
title,
icon,
className,
filter,
visibility = false,
}: DataGridColumnHeaderProps<TData, TValue>) {
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.
const columnOrderState = table.state.columnOrder
const columnOrder =
columnOrderState.length > 0
? columnOrderState
: table.getAllLeafColumns().map((leafColumn) => leafColumn.id)
const columnVisibilityKey =
props.tableLayout?.columnsVisibility && visibility
? JSON.stringify(table.state.columnVisibility)
: ""
const isSorted = column.getIsSorted()
const isPinned = column.getIsPinned()
const canSort = column.getCanSort()
const canPin = column.getCanPin()
const canResize = column.getCanResize()
const columnIndex = columnOrder.indexOf(column.id)
const canMoveLeft = columnIndex > 0
const canMoveRight = columnIndex < columnOrder.length - 1
const handleSort = () => {
if (isSorted === "asc") {
column.toggleSorting(true)
} else if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}
const headerLabelClassName = cn(
"text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5",
className
)
const headerButtonClassName = cn(
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground px-2 font-normal h-6 rounded-lg",
className
)
const sortIcon =
canSort &&
(isSorted === "desc" ? (
<ArrowDownIcon className="size-3.25" aria-hidden="true" />
) : isSorted === "asc" ? (
<ArrowUpIcon className="size-3.25" aria-hidden="true" />
) : (
<ChevronsUpDownIcon className="mt-px size-3.25" aria-hidden="true" />
))
const hasControls =
props.tableLayout?.columnsMovable ||
(props.tableLayout?.columnsVisibility && visibility) ||
(props.tableLayout?.columnsPinnable && canPin) ||
filter
const menuItems = useMemo(() => {
const items: ReactNode[] = []
let hasPreviousSection = false
// Filter section
if (filter) {
items.push(
<DropdownMenuGroup key="group-filter">
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
</DropdownMenuGroup>
)
hasPreviousSection = true
}
// Sort section
if (canSort) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-sort" />)
}
items.push(
<DropdownMenuItem
key="sort-asc"
onClick={() => {
if (isSorted === "asc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}}
disabled={!canSort}
>
<ArrowUpIcon className="size-3.5!" />
<span className="grow">{i18n.labels.sortAscending}</span>
{isSorted === "asc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="sort-desc"
onClick={() => {
if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(true)
}
}}
disabled={!canSort}
>
<ArrowDownIcon className="size-3.5!" />
<span className="grow">{i18n.labels.sortDescending}</span>
{isSorted === "desc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Pin section
if (props.tableLayout?.columnsPinnable && canPin) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-pin" />)
}
items.push(
<DropdownMenuItem
key="pin-left"
onClick={() => column.pin(isPinned === "start" ? false : "start")}
>
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">{i18n.labels.pinColumnStart}</span>
{isPinned === "start" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="pin-right"
onClick={() => column.pin(isPinned === "end" ? false : "end")}
>
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">{i18n.labels.pinColumnEnd}</span>
{isPinned === "end" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Move section
if (props.tableLayout?.columnsMovable) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-move" />)
}
items.push(
<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)
}
}}
disabled={!canMoveLeft || isPinned !== false}
>
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
<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)
}
}}
disabled={!canMoveRight || isPinned !== false}
>
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
<span>{i18n.labels.moveColumnEnd}</span>
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Visibility section
if (props.tableLayout?.columnsVisibility && visibility) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-visibility" />)
}
items.push(
<DropdownMenuSub key="visibility">
<DropdownMenuSubTrigger>
<Settings2Icon className="size-3.5!" />
<span>{i18n.labels.columnsMenu}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right">
{table
.getAllColumns()
.filter((col) => col.getCanHide())
.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={col.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => col.toggleVisibility(!!value)}
className="capitalize"
>
{getColumnHeaderLabel(col)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
return items
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
filter,
canSort,
isSorted,
column,
props.tableLayout?.columnsPinnable,
props.tableLayout?.columnsMovable,
props.tableLayout?.columnsVisibility,
canPin,
isPinned,
canMoveLeft,
canMoveRight,
visibility,
table,
columnIndex,
columnOrder,
columnVisibilityKey, // Needed to update checkbox states when visibility changes
])
if (hasControls) {
return (
<div className="-ms-2 flex h-full items-center justify-between gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
}
/>
<DropdownMenuContent className="w-40" align="start">
{menuItems}
</DropdownMenuContent>
</DropdownMenu>
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
<Button
size="icon-sm"
variant="ghost"
className="rounded-lg -me-1 size-7"
onClick={() => column.pin(false)}
aria-label={i18n.labels.unpinColumn(resolvedTitle)}
title={i18n.labels.unpinColumn(resolvedTitle)}
>
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
</Button>
)}
</div>
)
}
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
return (
<div className="-ms-2 flex h-full items-center">
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading}
onClick={handleSort}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
</div>
)
}
return (
<div className={headerLabelClassName}>
{icon && icon}
{resolvedTitle}
</div>
)
}
const DataGridColumnHeaderMemo = memo(DataGridColumnHeaderInner) as <
TData extends object,
TValue,
>(
props: DataGridColumnHeaderProps<TData, TValue> & {
/** Internal: the state slices the header re-renders on. Not part of the public API. */
subscribedState?: unknown
}
) => ReactNode
/**
* Sort and pin state reaches this header through builder calls on `column`
* (`getIsSorted()`, `getIsPinned()`), and `column` is a stable reference. That
* combination is the one v9's fresh-table-per-state-change does NOT cover:
* React Compiler is free to memoize against the stable column and never
* re-evaluate those reads, which shows up as frozen sort arrows and pin
* controls. The `Subscribe` below turns the slices this header actually reads
* into a real reactive dependency, and threading the selection through as a
* prop is what lets it past the `memo` - which would otherwise see unchanged
* props and skip the render anyway.
*/
function DataGridColumnHeader<TData extends object, TValue>(
props: DataGridColumnHeaderProps<TData, TValue>
) {
const { table } = useDataGrid()
return (
<Subscribe
source={table.store}
selector={(state) => ({
sorting: state.sorting,
columnPinning: state.columnPinning,
columnOrder: state.columnOrder,
columnVisibility: state.columnVisibility,
})}
>
{(subscribed) => (
<DataGridColumnHeaderMemo {...props} subscribedState={subscribed} />
)}
</Subscribe>
)
}
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
@@ -0,0 +1,59 @@
"use client"
import type { ReactElement } from "react"
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"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
function DataGridColumnVisibility<TData extends object>({
table,
trigger,
}: {
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">
{i18n.labels.toggleColumns}
</DropdownMenuLabel>
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{getColumnHeaderLabel(column)}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export { DataGridColumnVisibility }
@@ -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 },
}
}
@@ -0,0 +1,237 @@
"use client"
import type { JSX, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
interface DataGridPaginationProps {
sizes?: number[]
sizesInfo?: string
sizesLabel?: string
sizesDescription?: string
sizesSkeleton?: ReactNode
more?: boolean
moreLimit?: number
info?: string
infoSkeleton?: ReactNode
className?: string
rowsPerPageLabel?: string
previousPageLabel?: string
nextPageLabel?: string
ellipsisText?: string
}
function DataGridPagination(props: DataGridPaginationProps): JSX.Element {
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,
infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: i18n.labels.rowsPerPage,
previousPageLabel: i18n.labels.previousPage,
nextPageLabel: i18n.labels.nextPage,
ellipsisText: i18n.labels.paginationEllipsis,
}
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
const btnBaseClasses = "p-0 text-sm"
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
const pageIndex = table.state.pagination.pageIndex
const pageSize = table.state.pagination.pageSize
const from = recordCount === 0 ? 0 : pageIndex * pageSize + 1
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
const pageCount = table.getPageCount()
// 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())
: 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
)
// 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"
aria-label={i18n.labels.goToPage(i + 1)}
aria-current={pageIndex === i ? "page" : undefined}
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"
className={cn(
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
mergedProps.className
)}
>
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
{isLoading ? (
mergedProps.sizesSkeleton
) : (
<>
<div className="text-muted-foreground text-sm">
{mergedProps.rowsPerPageLabel}
</div>
<Select
value={`${pageSize}`}
onValueChange={(value) => {
const newPageSize = Number(value)
table.setPageSize(newPageSize)
}}
>
{/* 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
align="start"
alignItemWithTrigger={false}
className="min-w-(--anchor-width)"
>
{mergedProps.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
{isLoading ? (
mergedProps.infoSkeleton
) : (
<>
<div className="text-muted-foreground order-2 text-sm text-nowrap sm:order-1">
{paginationInfo}
</div>
{pageCount > 1 && (
<div className="order-1 flex items-center space-x-1">
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">
{mergedProps.previousPageLabel}
</span>
<ChevronLeftIcon className="size-4" />
</Button>
{renderEllipsisPrevButton()}
{renderPageButtons()}
{renderEllipsisNextButton()}
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">{mergedProps.nextPageLabel}</span>
<ChevronRightIcon className="size-4" />
</Button>
</div>
)}
</>
)}
</div>
</div>
)
}
export { DataGridPagination, type DataGridPaginationProps }
@@ -0,0 +1,483 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import type { PointerEvent, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
const MIN_THUMB_SIZE = 24
const FALLBACK_SCROLLBAR_SIZE = 12
const INITIAL_METRICS = {
hasVerticalOverflow: false,
headerHeight: 0,
horizontalScrollbarSize: 0,
thumbHeight: 0,
thumbTop: 0,
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 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"
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
type ScrollbarMetrics = {
hasVerticalOverflow: boolean
headerHeight: number
horizontalScrollbarSize: number
thumbHeight: number
thumbTop: number
trackHeight: number
}
type ObservedElements = {
header: HTMLElement | null
horizontalScrollbar: HTMLElement | null
table: HTMLElement | null
tableViewport: HTMLElement | null
}
type DataGridScrollAreaProps = Omit<
ScrollAreaPrimitive.Root.Props,
"children"
> & {
children: ReactNode
orientation?: DataGridScrollAreaOrientation
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {
return (
next.hasVerticalOverflow === prev.hasVerticalOverflow &&
next.headerHeight === prev.headerHeight &&
next.horizontalScrollbarSize === prev.horizontalScrollbarSize &&
next.thumbHeight === prev.thumbHeight &&
next.thumbTop === prev.thumbTop &&
next.trackHeight === prev.trackHeight
)
}
function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {
element.style.setProperty(
"--data-grid-scrollbar-header-height",
`${metrics.headerHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-height",
`${metrics.thumbHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-top",
`${metrics.thumbTop}px`
)
element.style.setProperty(
"--data-grid-scrollbar-track-height",
`${metrics.trackHeight}px`
)
}
function DataGridScrollArea({
children,
className,
orientation = "both",
...props
}: DataGridScrollAreaProps) {
const { props: dataGridProps, table } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null)
const overlayRef = useRef<HTMLDivElement | null>(null)
const viewportRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{
pointerId: number
startScrollTop: number
startY: number
} | null>(null)
const metricsRef = useRef<ScrollbarMetrics>(INITIAL_METRICS)
const observedElementsRef = useRef<ObservedElements>({
header: null,
horizontalScrollbar: null,
table: null,
tableViewport: null,
})
const showHorizontal = orientation !== "vertical"
const showVertical = orientation !== "horizontal"
const usesCustomVerticalScrollbar =
showVertical && !!dataGridProps.tableLayout?.headerSticky
// Pinned columns are sticky and never scroll, so the horizontal scrollbar
// track is inset to span only the scrollable center region between them.
const isColumnsPinnable = !!dataGridProps.tableLayout?.columnsPinnable
const scrollbarInsetStart = isColumnsPinnable ? table.getStartTotalSize() : 0
const scrollbarInsetEnd = isColumnsPinnable ? table.getEndTotalSize() : 0
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
useState(false)
const clearDragState = useCallback(() => {
dragRef.current = null
document.body.style.userSelect = ""
document.body.style.webkitUserSelect = ""
}, [])
// The overlay is mounted one commit after the sync that detected overflow,
// so it misses that sync's write. Seeding it from the ref callback lands the
// geometry during commit, before the browser paints the track.
const setOverlayRef = useCallback((node: HTMLDivElement | null) => {
overlayRef.current = node
if (node) applyMetrics(node, metricsRef.current)
}, [])
const resetMetrics = useCallback(() => {
if (!areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
metricsRef.current = INITIAL_METRICS
if (overlayRef.current) applyMetrics(overlayRef.current, INITIAL_METRICS)
}
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
}, [])
const syncCustomVerticalScrollbar = useCallback(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport || !usesCustomVerticalScrollbar) {
resetMetrics()
return
}
const { header, horizontalScrollbar } = observedElementsRef.current
const headerHeight = header?.getBoundingClientRect().height ?? 0
const viewportHeight = viewport.clientHeight
const viewportWidth = viewport.clientWidth
const scrollHeight = viewport.scrollHeight
const scrollWidth = viewport.scrollWidth
const hasHorizontalOverflow =
showHorizontal && scrollWidth > viewportWidth + 0.5
const horizontalScrollbarSize = hasHorizontalOverflow
? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE
: 0
const trackHeight = Math.max(
0,
viewportHeight - headerHeight - horizontalScrollbarSize
)
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
let nextMetrics: ScrollbarMetrics
if (trackHeight === 0 || maxScroll === 0) {
nextMetrics = {
hasVerticalOverflow: false,
headerHeight,
horizontalScrollbarSize,
thumbHeight: trackHeight,
thumbTop: 0,
trackHeight,
}
} else {
const bodyContentHeight = Math.max(
trackHeight,
scrollHeight - headerHeight
)
const thumbHeight = clamp(
trackHeight * (trackHeight / bodyContentHeight),
MIN_THUMB_SIZE,
trackHeight
)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const thumbTop =
maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0
nextMetrics = {
hasVerticalOverflow: true,
headerHeight,
horizontalScrollbarSize,
thumbHeight,
thumbTop,
trackHeight,
}
}
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
metricsRef.current = nextMetrics
// Scoped to the overlay, never to the container. These four properties
// inherit, and thumbTop changes on essentially every scroll frame, so
// writing them on the element that wraps the whole grid invalidates
// computed style for every row and cell each frame. The overlay subtree
// is their only reader.
if (overlayRef.current) applyMetrics(overlayRef.current, nextMetrics)
}
setHasCustomVerticalOverflow((prev) =>
prev === nextMetrics.hasVerticalOverflow
? prev
: nextMetrics.hasVerticalOverflow
)
}, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar])
useEffect(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport) return
if (!usesCustomVerticalScrollbar) {
const frame = requestAnimationFrame(resetMetrics)
return () => cancelAnimationFrame(frame)
}
let frame = 0
const scheduleSync = () => {
cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
}
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(scheduleSync)
const observed = new Set<HTMLElement>()
const observeElement = (element: HTMLElement | null) => {
if (element && observer && !observed.has(element)) {
observer.observe(element)
observed.add(element)
}
}
const resolveObservedElements = () => {
observedElementsRef.current = {
header: container.querySelector(
'[data-slot="data-grid-table"] thead'
) as HTMLElement | null,
horizontalScrollbar: container.querySelector(
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
) as HTMLElement | null,
table: container.querySelector(
'[data-slot="data-grid-table"]'
) as HTMLElement | null,
tableViewport: container.querySelector(
'[data-slot="data-grid-table-viewport"]'
) as HTMLElement | null,
}
observeElement(observedElementsRef.current.header)
observeElement(observedElementsRef.current.table)
observeElement(observedElementsRef.current.tableViewport)
return !!(
observedElementsRef.current.header && observedElementsRef.current.table
)
}
observeElement(viewport)
const resolvedOnMount = resolveObservedElements()
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
// A table that mounts after this effect (empty state swapped for data)
// would otherwise never be observed and the custom scrollbar would
// overlap the sticky header. One-shot: disconnects once resolved.
let mutationObserver: MutationObserver | null = null
if (!resolvedOnMount && typeof MutationObserver !== "undefined") {
mutationObserver = new MutationObserver(() => {
if (resolveObservedElements()) {
mutationObserver?.disconnect()
mutationObserver = null
scheduleSync()
}
})
mutationObserver.observe(container, { childList: true, subtree: true })
}
return () => {
cancelAnimationFrame(frame)
observer?.disconnect()
mutationObserver?.disconnect()
viewport.removeEventListener("scroll", scheduleSync)
clearDragState()
}
}, [
clearDragState,
resetMetrics,
syncCustomVerticalScrollbar,
usesCustomVerticalScrollbar,
])
const scrollToThumbOffset = (nextThumbTop: number) => {
const viewport = viewportRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport) return
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
if (maxScroll === 0 || maxThumbTop === 0) {
viewport.scrollTop = 0
return
}
const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop
viewport.scrollTop = ratio * maxScroll
}
const handleThumbPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
if (!viewport) return
event.preventDefault()
event.stopPropagation()
event.currentTarget.setPointerCapture(event.pointerId)
dragRef.current = {
pointerId: event.pointerId,
startScrollTop: viewport.scrollTop,
startY: event.clientY,
}
document.body.style.userSelect = "none"
document.body.style.webkitUserSelect = "none"
}
const handleThumbPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
const dragState = dragRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport || !dragState || dragState.pointerId !== event.pointerId) {
return
}
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
if (maxThumbTop === 0 || maxScroll === 0) return
const deltaY = event.clientY - dragState.startY
const nextScrollTop =
dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll
viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)
}
const handleThumbPointerUp = (event: PointerEvent<HTMLDivElement>) => {
if (dragRef.current?.pointerId !== event.pointerId) return
clearDragState()
}
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const { thumbHeight } = metricsRef.current
if (event.target !== event.currentTarget) return
event.preventDefault()
event.stopPropagation()
const rect = event.currentTarget.getBoundingClientRect()
const offsetY = event.clientY - rect.top - thumbHeight / 2
scrollToThumbOffset(offsetY)
}
return (
<div ref={containerRef} className="relative">
<ScrollAreaPrimitive.Root
data-slot="data-grid-scroll-area"
// Styling hook: present while the sticky-header scroll mode detects
// vertical overflow, so consumers can style scrollable vs short
// grids with a plain ancestor attribute selector.
data-overflow-vertical={hasCustomVerticalOverflow ? "true" : undefined}
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
data-slot="scroll-area-viewport"
className="size-full"
>
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
{children}
</ScrollAreaPrimitive.Content>
</ScrollAreaPrimitive.Viewport>
{showHorizontal && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="horizontal"
orientation="horizontal"
className={SCROLLBAR_CLASSNAME}
style={
scrollbarInsetStart > 0 || scrollbarInsetEnd > 0
? {
marginInlineStart: scrollbarInsetStart || undefined,
marginInlineEnd: scrollbarInsetEnd || undefined,
}
: undefined
}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</ScrollAreaPrimitive.Scrollbar>
)}
{showVertical && !usesCustomVerticalScrollbar && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="vertical"
orientation="vertical"
className={SCROLLBAR_CLASSNAME}
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className={SCROLLBAR_THUMB_CLASSNAME}
/>
</ScrollAreaPrimitive.Scrollbar>
)}
</ScrollAreaPrimitive.Root>
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
<div
ref={setOverlayRef}
aria-hidden="true"
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-1.5 touch-none p-px"
onPointerDown={handleTrackPointerDown}
>
<div
className={cn(
"bg-border absolute end-px w-1.5",
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
"rounded-full"
)}
onLostPointerCapture={clearDragState}
onPointerCancel={handleThumbPointerUp}
onPointerDown={handleThumbPointerDown}
onPointerMove={handleThumbPointerMove}
onPointerUp={handleThumbPointerUp}
/>
</div>
</div>
)}
</div>
)
}
export { DataGridScrollArea }
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
@@ -0,0 +1,736 @@
"use client"
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import type { CSSProperties, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import type {
DataGridFeatures,
DataGridTableInstance,
} from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
DragOverlay,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type CollisionDetection,
type DragCancelEvent,
type DragEndEvent,
type DragMoveEvent,
type DragOverEvent,
type DragStartEvent,
type Modifier,
type UniqueIdentifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
type SortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { flexRender } from "@tanstack/react-table"
import type { Cell, HeaderGroup, Row, } from "@tanstack/react-table"
import { createPortal } from "react-dom"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { GripHorizontalIcon } from "lucide-react"
// Context to share sortable listeners from row to handle
type SortableContextValue = ReturnType<typeof useSortable>
const SortableRowContext = createContext<Pick<
SortableContextValue,
"attributes" | "listeners"
> | null>(null)
/**
* Tree metadata attached to every sortable row, readable from
* `active.data.current` / `over.data.current` in any drag event. Cross-parent
* drops can be resolved from it without re-deriving the shape of the table.
*/
type DataGridTableDndRowData = {
type: "data-grid-row"
/** Tree depth, 0 for root rows. */
depth: number
/** Index within the parent's children, or within the root rows. */
index: number
/** Parent row id, or null for root rows. */
parentId: string | null
}
/**
* Per-row render slot for drop indicators and depth guides. The returned node
* is positioned over the row, so it never adds a column, shifts striping, or
* gets clipped by a truncating resizable cell.
*/
type DataGridTableDndRowDecoration<TData extends object> = (context: {
row: Row<DataGridFeatures, TData>
isDragging: boolean
isOver: boolean
}) => ReactNode
function DataGridTableDndRowHandle({
className,
disabled,
disabledLabel,
}: {
className?: string
/**
* Renders the grip inert instead of withdrawing it. A grid that reorders on
* one truth (manual order) and sorts on another cannot honour both at once,
* but dropping the handle entirely collapses the gutter and reads as broken
* rather than as unavailable. Keep the column's shape, mute the control.
*/
disabled?: boolean
/** 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 (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
// The Button's own disabled treatment supplies the muting; only the
// cursor needs saying, so the grip reads as unavailable rather than
// merely unresponsive.
disabled && "cursor-not-allowed",
className
)}
aria-label={disabled ? resolvedDisabledLabel : i18n.labels.dragToReorderRow}
title={disabled ? resolvedDisabledLabel : undefined}
disabled
>
<GripHorizontalIcon aria-hidden="true" />
</Button>
)
}
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
aria-label={i18n.labels.dragToReorderRow}
{...context.attributes}
{...context.listeners}
>
<GripHorizontalIcon aria-hidden="true" />
</Button>
)
}
/**
* The rows do not move while one is being carried.
*
* Sliding the siblings apart opens a gap the carried row could go into, which
* reads well in a list of identical rows and badly in a table: the gap is the
* height of the row you are holding, so with rows of unequal height it never
* matches the slot it claims to be, and the row you picked up slides away from
* where it started - which is exactly the position you need to remember if you
* decide not to drop it.
*
* Holding everything still keeps the origin legible, and nothing is lost: the
* DragOverlay clone follows the pointer and the drop indicator names the seam.
* Pass `verticalListSortingStrategy` as `sortingStrategy` for the old feel.
*/
const holdRowsInPlaceStrategy: SortingStrategy = () => null
function DataGridTableDndRow<TData extends object>({
row,
renderRowDecoration,
dropIndicator = true,
}: {
row: Row<DataGridFeatures, TData>
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
dropIndicator?: boolean
}) {
const rowData: DataGridTableDndRowData = {
type: "data-grid-row",
depth: row.depth,
index: row.index,
parentId: row.getParentRow()?.id ?? null,
}
const {
transform,
setNodeRef,
isDragging,
isOver,
attributes,
listeners,
index,
activeIndex,
overIndex,
} = useSortable({
id: row.id,
data: rowData,
})
// Which edge of THIS row the carried row would land on, or null when it is
// not the drop target. Nothing slides apart any more, so the bar is the only
// thing that says where the drop goes: it marks the row at the destination
// index, on the side the carried row comes to rest.
//
// Dragging down it lands after the target, dragging up before it, so the
// edge follows the direction of travel.
const dropEdge =
dropIndicator && activeIndex !== -1 && index === overIndex && !isDragging
? activeIndex < overIndex
? "bottom"
: "top"
: null
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
// dnd-kit's transition is deliberately dropped. A transition on a transform
// property of a `tr` does not merely fail to animate in Chrome, it stops the
// transform applying at all: the element sits at the start value forever.
// The drag source escapes it because dnd-kit disables its own transition
// while it is being dragged, which is why the carried row used to be the
// ONLY one that moved and every other row silently refused to open a gap.
// Displacement therefore lands in one step, which is what a table wants.
zIndex: isDragging ? 1 : 0,
position: "relative",
cursor: isDragging ? "grabbing" : undefined,
// The row you are holding is drawn by the DragOverlay, so the one left
// behind is not a second copy of it - it is the slot you came from, and it
// stays exactly where it was. Fading alone read as "this row is busy";
// the outline says "this is the space you are moving out of", which is the
// thing you need if you change your mind mid-drag.
...(isDragging && {
opacity: 0.4,
// Inset so the dashes sit inside the row box and cannot be clipped by
// the neighbouring row's border.
outline: "1px dashed var(--border)",
outlineOffset: "-1px",
}),
}
const decoration = renderRowDecoration?.({ row, isDragging, isOver })
return (
<SortableRowContext.Provider value={{ attributes, listeners }}>
<DataGridTableBodyRow row={row} dndRef={setNodeRef} dndStyle={style}>
{row
.getVisibleCells()
.map((cell: Cell<DataGridFeatures, TData, unknown>, index, cells) => {
return (
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{decoration && index === cells.length - 1 ? (
// Rides inside the last cell rather than in a `td` of its own.
// An absolutely positioned `td` is still a cell as far as table
// layout is concerned, so it added a NINTH column with no width
// of its own, and under `table-layout: fixed` that new column
// swallowed the whole surplus the real columns had been sharing
// — every column snapped back to its declared size and the row's
// content visibly narrowed the moment a drag began. A plain
// element adds no column. It still anchors to the ROW, because
// the row is the nearest positioned ancestor, so the decoration
// spans the full width and is not clipped by the cell.
<div
aria-hidden="true"
data-slot="data-grid-table-row-decoration"
className="pointer-events-none absolute inset-0"
>
{decoration}
</div>
) : null}
{dropEdge && index === cells.length - 1 ? (
// Same anchoring trick as the decoration above: a plain
// element inside the last cell, so it adds no column and
// cannot disturb `table-layout: fixed`. It spans the row
// because the row is the nearest positioned ancestor.
<div
aria-hidden="true"
data-slot="data-grid-table-row-drop-indicator"
data-edge={dropEdge}
className="pointer-events-none absolute inset-0 z-20"
>
{/* Two solid pixels down the leading edge, the same marker
the tree drag uses for its drop target. A wash across
the row has to stay faint enough not to read as a
selected row, and in the achromatic styles primary
carries no chroma at all, so faint plus colourless is
just grey. The bar reads at any weight and leaves the
row's own background to hover and selection.
The bar is the whole indicator: the gap the rows have
already opened says which side, so a rule across the
seam as well only competes with the row borders it sits
between. `data-edge` still carries the direction for
anyone styling their own. */}
<span className="bg-primary absolute inset-y-0 start-0 w-0.5" />
</div>
) : null}
</DataGridTableBodyRowCell>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRow>
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
</SortableRowContext.Provider>
)
}
function DataGridTableDndRowsBody<TData extends object>({
table,
dataIds,
renderRowDecoration,
dropIndicator,
sortingStrategy,
}: {
table: DataGridTableInstance<TData>
dataIds: UniqueIdentifier[]
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
dropIndicator?: boolean
sortingStrategy: SortingStrategy
}) {
const { isLoading, props } = useDataGrid()
const pagination = table.state.pagination
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
return (
<>
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRowSkeleton>
))}
</>
)
}
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
return (
<SortableContext items={dataIds} strategy={sortingStrategy}>
{table.getRowModel().rows.map((row: Row<DataGridFeatures, TData>) => {
return (
<DataGridTableDndRow
row={row}
renderRowDecoration={renderRowDecoration}
dropIndicator={dropIndicator}
key={row.id}
/>
)
})}
</SortableContext>
)
}
/**
* Memoized body rows: 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.
*/
const MemoizedDataGridTableDndRowsBody = memo(
DataGridTableDndRowsBody,
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
) as typeof DataGridTableDndRowsBody
function DataGridTableDndRows<TData extends object>({
handleDragEnd,
dataIds,
footerContent,
collisionDetection = closestCenter,
modifiers,
sortingStrategy = holdRowsInPlaceStrategy,
renderRowDecoration,
dropIndicator = true,
onDragStart,
onDragMove,
onDragOver,
onDragCancel,
}: {
handleDragEnd: (event: DragEndEvent) => void
dataIds: UniqueIdentifier[]
footerContent?: ReactNode
/** Overrides the default `closestCenter` strategy. */
collisionDetection?: CollisionDetection
/**
* Replaces the default axis restriction, e.g. drop `restrictToVerticalAxis`
* to allow the horizontal gesture that tree re-parenting relies on. The
* table container clamp is always applied after these, so a dragged row
* cannot leave the grid.
*/
modifiers?: Modifier[]
/**
* Replaces the default `verticalListSortingStrategy`. Return null from a
* strategy to leave every row exactly where it is. A tree needs that: its drop
* is either INTO the hovered row or BETWEEN two rows, and which one it is
* flips as the pointer crosses a single row, so a gap that opens for one and
* shuts for the other flickers the whole surface. Such a caller draws its own
* insertion line instead, and pairs this with a modifier that holds the
* carried row still, since a gap nothing moves into is just a hole.
*/
sortingStrategy?: SortingStrategy
/** Per-row slot for drop indicators and depth guides. */
renderRowDecoration?: DataGridTableDndRowDecoration<TData>
/**
* Draws a line on the seam the carried row would land on. On by default;
* pass `false` when `renderRowDecoration` paints its own insertion affordance
* and the two would compete.
*/
dropIndicator?: boolean
onDragStart?: (event: DragStartEvent) => void
onDragMove?: (event: DragMoveEvent) => void
onDragOver?: (event: DragOverEvent) => void
onDragCancel?: (event: DragCancelEvent) => void
}) {
const { table, props } = useDataGrid<TData>()
const tableContainerRef = useRef<HTMLDivElement>(null)
const [isDraggingRow, setIsDraggingRow] = useState(false)
// The overlay is portalled to the document body. dnd-kit renders DragOverlay
// in place, and it positions with `position: fixed` against viewport
// coordinates - so any ancestor that establishes a containing block for fixed
// descendants silently re-anchors it. `content-visibility`, `contain`,
// `transform`, `filter` and `will-change` all do that, and the first two are
// exactly what a card grid uses to defer off-screen work. The clone then
// lands offset by that ancestor's own top/left, and the container clamp
// below mis-clamps too, because its rects are measured in viewport space.
//
// Resolve the portal target on pickup, without a document read during
// hydration or an extra render before the first drag.
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null)
// The row being carried, plus the column widths measured off the header the
// moment the drag starts. The clone lives outside the table, so it has no
// columns of its own and has to be told what they are.
const [carried, setCarried] = useState<{
id: UniqueIdentifier
width: number
height: number
columns: number[]
} | null>(null)
const pickUpRow = useCallback((id: UniqueIdentifier) => {
setPortalTarget(document.body)
const container = tableContainerRef.current
const head = container?.querySelector("thead tr")
if (!container || !head) {
setCarried(null)
return
}
// The clone has to be exactly as tall as the row it was lifted from.
// A fixed height reads as the grid growing under the pointer the moment
// you pick a row up, and it is wrong in both directions: rows whose
// content wraps are taller than any constant, and dense rows are shorter.
const source = Array.from(
container.querySelectorAll<HTMLElement>("tbody tr[data-row-id]")
).find((candidate) => candidate.dataset.rowId === String(id))
const height = source?.getBoundingClientRect().height ?? 0
// The fill cell is a header-only spacer that soaks up the surplus a column
// resize leaves behind, and the clone renders data cells only. Measuring it
// in would make the clone's table wider than the cells it actually holds,
// and `table-fixed` hands that orphaned width back out across every column
// -- the carried row comes out visibly wider than the row it was lifted
// from. So the width is the sum of what we render, never the header's own.
const columns = Array.from(head.children)
.filter(
(cell) =>
cell.getAttribute("data-slot") !== "data-grid-table-fill-head-cell"
)
.map((cell) => cell.getBoundingClientRect().width)
setCarried({
id,
width: columns.reduce((total, width) => total + width, 0),
height,
columns,
})
}, [])
const carriedRow = carried
? table
.getRowModel()
.rows.find((row: Row<DataGridFeatures, TData>) => row.id === carried.id)
: undefined
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
// Keyboard reordering moves one sortable position per keypress instead
// of the sensor's raw 25px default.
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
useEffect(() => {
if (!isDraggingRow) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingRow])
const resolvedModifiers = useMemo(() => {
const restrictToTableContainer: Modifier = ({
transform,
draggingNodeRect,
}) => {
if (!tableContainerRef.current || !draggingNodeRect) {
return transform
}
const containerRect = tableContainerRef.current.getBoundingClientRect()
const { x, y } = transform
const minX = containerRect.left - draggingNodeRect.left
const maxX = containerRect.right - draggingNodeRect.right
const minY = containerRect.top - draggingNodeRect.top
const maxY = containerRect.bottom - draggingNodeRect.bottom
return {
...transform,
// The horizontal rail only engages while the default axis restriction
// is in force. A row is exactly as wide as the viewport, so minX and
// maxX both collapse to 0 and clamping x erases it entirely: harmless
// under restrictToVerticalAxis, which zeroes x anyway, but fatal for a
// caller that replaced the restriction precisely to READ x, as a tree
// does to resolve drop depth. Vertical is railed either way, which is
// what actually keeps a dragged row inside the grid.
x: modifiers ? x : Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y)),
}
}
// The container clamp is a safety rail rather than a policy, so it stays
// applied even when the caller replaces the axis restriction.
return [
...(modifiers ?? [restrictToVerticalAxis]),
restrictToTableContainer,
]
}, [modifiers])
return (
<DndContext
id={useId()}
collisionDetection={collisionDetection}
modifiers={resolvedModifiers}
onDragCancel={(event) => {
setIsDraggingRow(false)
setCarried(null)
onDragCancel?.(event)
}}
onDragEnd={(event) => {
setIsDraggingRow(false)
setCarried(null)
handleDragEnd(event)
}}
onDragMove={onDragMove}
onDragOver={onDragOver}
onDragStart={(event) => {
setIsDraggingRow(true)
pickUpRow(event.active.id)
onDragStart?.(event)
}}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={tableContainerRef}
className={
isDraggingRow
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map(
(headerGroup: HeaderGroup<DataGridFeatures, TData>, index) => {
return (
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
{headerGroup.headers.map((header, index) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={index}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize
header={header}
/>
)}
</DataGridTableHeadRowCell>
)
})}
<DataGridTableFillHeadCell />
</DataGridTableHeadRow>
)
}
)}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedDataGridTableDndRowsBody
table={table}
dataIds={dataIds}
renderRowDecoration={renderRowDecoration}
dropIndicator={dropIndicator}
sortingStrategy={sortingStrategy}
/>
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
{/* The row you are actually holding. It is a real clone rendered outside
the table, which is the only way a dragged row can follow the pointer
without disturbing the grid: it adds no cell, so it cannot alter the
column widths, and it floats above the rows rather than through them.
Its presence also tells dnd-kit to stop translating the source row, so
the row left behind simply dims in place.
Portalled to the body so the fixed positioning resolves against the
viewport wherever the grid is mounted. React context crosses a portal,
so DndContext still reaches it. */}
{portalTarget
? createPortal(
<DragOverlay dropAnimation={null}>
{carried && carriedRow ? (
<table
aria-hidden="true"
style={{ width: carried.width, tableLayout: "fixed" }}
className="bg-background border-border pointer-events-none cursor-grabbing rounded-md border shadow-lg"
>
<tbody>
{/* Padding rides on the inner element, not the cell. A `td` can
never render narrower than its own horizontal padding, so a
column resized below that would silently widen here and the
clone would stop matching the row it came from. Height comes
from the measured source row for the same reason the widths
do: the clone has no row of its own to inherit it from. */}
<tr
style={{ height: carried.height || undefined }}
className="[&>td]:p-0 [&>td]:align-middle"
>
{carriedRow
.getVisibleCells()
.map(
(
cell: Cell<DataGridFeatures, TData, unknown>,
index: number
) => (
<td
key={cell.id}
// Falls back to the column's own size so an unforeseen
// header/cell count mismatch degrades to a real width
// rather than to `auto`.
style={{
width:
carried.columns[index] ??
cell.column.getSize(),
}}
>
<div className="truncate px-3">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</div>
</td>
)
)}
</tr>
</tbody>
</table>
) : null}
</DragOverlay>,
portalTarget
)
: null}
</DndContext>
)
}
export { DataGridTableDndRowHandle, DataGridTableDndRows }
export type { DataGridTableDndRowData, DataGridTableDndRowDecoration }
@@ -0,0 +1,363 @@
"use client"
import {
Fragment,
memo,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import type { CSSProperties, ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import type {
DataGridFeatures,
DataGridTableInstance,
} from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFillBodyCell,
DataGridTableFillHeadCell,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from "@dnd-kit/core"
import {
horizontalListSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { flexRender } from "@tanstack/react-table"
import type {
Cell,
Header,
HeaderGroup,
Row,
} from "@tanstack/react-table"
import { Button } from "@/components/ui/button"
import { GripVerticalIcon } from "lucide-react"
function DataGridTableDndHeader<TData extends object>({
header,
}: {
header: Header<DataGridFeatures, TData, unknown>
}) {
const { i18n, props } = useDataGrid()
const { column } = header
// Check if column ordering is enabled for this column
const canOrder =
(column.columnDef as { enableColumnOrdering?: boolean })
.enableColumnOrdering !== false
const {
attributes,
isDragging,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: header.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
whiteSpace: "nowrap",
width: props.tableLayout?.columnsResizable
? `calc(var(--header-${header.id}-size) * 1px)`
: header.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableHeadRowCell
header={header}
dndStyle={style}
dndRef={setNodeRef}
>
<div className="flex items-center justify-start gap-0.5">
{canOrder && (
<Button
size="icon-sm"
variant="ghost"
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
{...attributes}
{...listeners}
aria-label={i18n.labels.dragToReorder}
>
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
)}
<div className="grow">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</div>
{props.tableLayout?.columnsResizable && column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</div>
</DataGridTableHeadRowCell>
)
}
function DataGridTableDndCell<TData extends object>({
cell,
}: {
cell: Cell<DataGridFeatures, TData, unknown>
}) {
const { props } = useDataGrid()
const { isDragging, setNodeRef, transform, transition } = useSortable({
id: cell.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
width: props.tableLayout?.columnsResizable
? `calc(var(--col-${cell.column.id}-size) * 1px)`
: cell.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
}
function DataGridTableDndBodyRows<TData extends object>({
table,
}: {
table: DataGridTableInstance<TData>
}) {
const { isLoading, props } = useDataGrid()
const pagination = table.state.pagination
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
return (
<>
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
<DataGridTableFillBodyCell />
</DataGridTableBodyRowSkeleton>
))}
</>
)
}
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
return (
<>
{table.getRowModel().rows.map((row: Row<DataGridFeatures, TData>) => {
return (
<Fragment key={row.id}>
<DataGridTableBodyRow row={row}>
<SortableContext
items={table.state.columnOrder}
strategy={horizontalListSortingStrategy}
>
{row
.getVisibleCells()
.map((cell: Cell<DataGridFeatures, TData, unknown>) => (
<DataGridTableDndCell cell={cell} key={cell.id} />
))}
</SortableContext>
<DataGridTableFillBodyCell />
</DataGridTableBodyRow>
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
</Fragment>
)
})}
</>
)
}
/**
* Memoized body rows: 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.
*/
const MemoizedDataGridTableDndBodyRows = memo(
DataGridTableDndBodyRows,
(_prev, next) => !!next.table.state.columnResizing.isResizingColumn
) as typeof DataGridTableDndBodyRows
function DataGridTableDnd<TData extends object>({
handleDragEnd,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
footerContent?: ReactNode
}) {
const { table, props } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null)
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
// Keyboard reordering moves one sortable position per keypress instead
// of the sensor's raw 25px default.
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
useEffect(() => {
if (!isDraggingColumn) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingColumn])
// Custom modifier to restrict dragging within table bounds with edge offset
const modifiers = useMemo(() => {
const restrictToTableBounds: Modifier = ({
draggingNodeRect,
transform,
}) => {
if (!draggingNodeRect || !containerRef.current) {
return { ...transform, y: 0 }
}
const containerRect = containerRef.current.getBoundingClientRect()
const edgeOffset = 0
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
const maxX =
containerRect.right -
draggingNodeRect.left -
draggingNodeRect.width +
edgeOffset
return {
...transform,
x: Math.min(Math.max(transform.x, minX), maxX),
y: 0, // Lock vertical movement
}
}
return [restrictToTableBounds]
}, [])
return (
<DndContext
collisionDetection={closestCenter}
id={useId()}
modifiers={modifiers}
onDragCancel={() => setIsDraggingColumn(false)}
onDragEnd={(event) => {
setIsDraggingColumn(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingColumn(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={containerRef}
className={
isDraggingColumn
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map(
(headerGroup: HeaderGroup<DataGridFeatures, TData>, index) => {
return (
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
<SortableContext
items={table.state.columnOrder}
strategy={horizontalListSortingStrategy}
>
{headerGroup.headers.map((header) => (
<DataGridTableDndHeader
header={header}
key={header.id}
/>
))}
</SortableContext>
<DataGridTableFillHeadCell />
</DataGridTableHeadRow>
)
}
)}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedDataGridTableDndBodyRows table={table} />
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDnd }
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
+10
View File
@@ -0,0 +1,10 @@
export * from "./data-grid"
export * from "./data-grid-table"
export * from "./data-grid-pagination"
export * from "./data-grid-scroll-area"
export * from "./data-grid-column-header"
export * from "./data-grid-column-visibility"
export * from "./data-grid-column-filter"
export * from "./data-grid-table-virtual"
export * from "./data-grid-table-dnd"
export * from "./data-grid-table-dnd-rows"