Data Grid
Data Grid — кастомный компонент, портированный из ReUI (keenthemes/reui, MIT).
Загрузка превью…
src/reui/data-grid/DataGrid.vue
<script setup lang="ts" generic="TData extends object">
/**
* Порт `DataGrid`+`DataGridProvider` (см. context.ts). Слиты в один
* компонент: во Vue нет причины держать их раздельно (в оригинале
* `DataGrid` — просто функция, сливающая дефолты и делегирующая в
* `DataGridProvider`, которая пишет в контекст).
*/
import { computed, provide, watchEffect } from "vue"
import type { Table } from "@tanstack/vue-table"
import {
createDataGridAutoSizeController,
dataGridDefaultTableClassNames,
dataGridDefaultTableLayout,
DataGridContextKey,
type DataGridProps,
type DataGridTableClassNames,
type DataGridTableLayout,
} from "./context"
const props = defineProps<{
table?: Table<TData>
recordCount: number
onRowClick?: (row: TData) => void
isLoading?: boolean
loadingMode?: "skeleton" | "spinner"
loadingMessage?: unknown
fetchingMoreMessage?: unknown
allRowsLoadedMessage?: unknown
emptyMessage?: unknown
tableLayout?: DataGridTableLayout
tableClassNames?: DataGridTableClassNames
}>()
// Ensure table is provided (аналог `throw new Error('DataGrid requires a
// "table" prop')` в оригинале — там это в теле компонента, до рендера).
if (!props.table) {
throw new Error('DataGrid requires a "table" prop')
}
const table = props.table
const mergedProps = computed<DataGridProps<TData>>(() => ({
table,
recordCount: props.recordCount,
onRowClick: props.onRowClick,
isLoading: props.isLoading ?? false,
loadingMode: props.loadingMode ?? "skeleton",
loadingMessage: props.loadingMessage,
fetchingMoreMessage: props.fetchingMoreMessage,
allRowsLoadedMessage: props.allRowsLoadedMessage,
emptyMessage: props.emptyMessage,
tableLayout: { ...dataGridDefaultTableLayout, ...(props.tableLayout || {}) },
tableClassNames: {
...dataGridDefaultTableClassNames,
...(props.tableClassNames || {}),
},
}))
// Re-assert an explicit tableLayout resize mode every render so consumer-level
// useVueTable options cannot flip it back between drags. Without one, the
// consumer's own tanstack columnResizeMode (default "onEnd") is honored.
watchEffect(() => {
if (
mergedProps.value.tableLayout?.columnsResizable &&
mergedProps.value.tableLayout.columnsResizeMode
) {
table.setOptions((old) => ({
...old,
columnResizeMode: mergedProps.value.tableLayout!.columnsResizeMode,
}))
}
})
// One autoSize coordinator per table instance so split header/body viewports
// cannot apply the growth twice.
const autoSize = createDataGridAutoSizeController(table)
provide(DataGridContextKey, {
get props() {
return mergedProps.value
},
table,
get recordCount() {
return props.recordCount
},
get isLoading() {
return props.isLoading || false
},
autoSize,
})
</script>
<template>
<slot />
</template>
src/reui/data-grid/DataGridColumnFilter.vue
<script setup lang="ts" generic="TData extends object, TValue = unknown">
/**
* Порт `DataGridColumnFilter` (data-grid-column-filter.tsx).
*
* `IconPlaceholder` (см. docs/PORTING.md §5) заменён инлайновым `<svg>` —
* те же пути lucide, что и везде в этой категории ("circle-plus", "check").
* `option.icon` (React `ComponentType`) -> `Component` (Vue).
*/
import { computed, ref } from "vue"
import type { Component } from "vue"
import type { Column } from "@tanstack/vue-table"
import { cn } from "@/lib/utils"
import { Badge } from "@/components/reui/badge"
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"
interface DataGridColumnFilterOption {
label: string
value: string
icon?: Component
}
const props = defineProps<{
column?: Column<TData, TValue>
title?: string
options: DataGridColumnFilterOption[]
}>()
const searchQuery = ref("")
const facets = computed(() => props.column?.getFacetedUniqueValues())
const selectedValues = computed(() => {
const filterValue = props.column?.getFilterValue()
return new Set(Array.isArray(filterValue) ? (filterValue as string[]) : [])
})
const filteredOptions = computed(() => {
if (!searchQuery.value) return props.options
const query = searchQuery.value.toLowerCase()
return props.options.filter((option) => option.label.toLowerCase().includes(query))
})
function toggleOption(value: string) {
const next = new Set(selectedValues.value)
if (next.has(value)) {
next.delete(value)
} else {
next.add(value)
}
const filterValues = Array.from(next)
props.column?.setFilterValue(filterValues.length ? filterValues : undefined)
}
function clearFilters() {
props.column?.setFilterValue(undefined)
}
function onOptionKeydown(event: KeyboardEvent, value: string) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
toggleOption(value)
}
}
function onClearKeydown(event: KeyboardEvent) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
clearFilters()
}
}
const optionRowClass =
"style-vega:rounded-sm style-nova:rounded-md style-maia:rounded-xl style-lyra:rounded-none style-mira:rounded-md style-luma:rounded-2xl style-sera:rounded-none style-rhea:rounded-2xl 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"
const clearRowClass =
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground style-vega:rounded-sm style-nova:rounded-md style-maia:rounded-xl style-lyra:rounded-none style-mira:rounded-md style-luma:rounded-2xl style-sera:rounded-none style-rhea:rounded-2xl relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
</script>
<template>
<Popover>
<PopoverTrigger as-child>
<Button variant="outline" size="sm">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><circle cx="12" cy="12" r="10" /><path d="M8 12h8" /><path d="M12 8v8" /></svg>
{{ title }}
<template v-if="selectedValues.size > 0">
<Separator orientation="vertical" class="mx-2 h-4" />
<Badge variant="secondary" class="px-1 font-normal lg:hidden">{{ selectedValues.size }}</Badge>
<div class="hidden space-x-1 lg:flex">
<Badge v-if="selectedValues.size > 2" variant="secondary" class="px-1 font-normal">{{ `${selectedValues.size} selected` }}</Badge>
<template v-else>
<Badge
v-for="option in options.filter((o) => selectedValues.has(o.value))"
:key="option.value"
variant="secondary"
class="px-1 font-normal"
>{{ option.label }}</Badge>
</template>
</div>
</template>
</Button>
</PopoverTrigger>
<PopoverContent class="w-[200px] p-0" align="start">
<div class="p-2">
<Input v-model="searchQuery" :placeholder="title" class="h-8" />
</div>
<div class="max-h-[300px] overflow-y-auto">
<div v-if="filteredOptions.length === 0" class="text-muted-foreground py-6 text-center text-sm">No results found.</div>
<div v-else class="p-1">
<div
v-for="option in filteredOptions"
:key="option.value"
role="button"
tabindex="0"
:aria-pressed="selectedValues.has(option.value)"
:class="optionRowClass"
@click="toggleOption(option.value)"
@keydown="onOptionKeydown($event, option.value)"
>
<div
:class="
cn(
'border-primary style-vega:rounded-sm style-nova:rounded-sm style-maia:rounded-md style-lyra:rounded-none style-mira:rounded-sm style-luma:rounded-md style-sera:rounded-none style-rhea:rounded-md flex h-4 w-4 items-center justify-center border',
selectedValues.has(option.value) ? 'bg-primary text-primary-foreground' : 'opacity-50 [&_svg]:invisible'
)
"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-4 w-4"><path d="M20 6 9 17l-5-5" /></svg>
</div>
<component :is="option.icon" v-if="option.icon" class="text-muted-foreground h-4 w-4" />
<span>{{ option.label }}</span>
<span v-if="facets?.get(option.value) !== undefined" class="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">{{ facets?.get(option.value) }}</span>
</div>
</div>
<template v-if="selectedValues.size > 0">
<div class="bg-border -mx-1 my-1 h-px" />
<div class="p-1">
<div role="button" tabindex="0" :class="clearRowClass" @click="clearFilters" @keydown="onClearKeydown">Clear filters</div>
</div>
</template>
</div>
</PopoverContent>
</Popover>
</template>
src/reui/data-grid/DataGridColumnHeader.vue
<script setup lang="ts" generic="TData extends object, TValue = unknown">
/**
* Порт `DataGridColumnHeader` (data-grid-column-header.tsx).
*
* `IconPlaceholder` (dev-обвязка сайта ReUI, недоступна вне апстрима, см.
* docs/PORTING.md §5) заменена инлайновым `<svg>` — те же пути lucide, что
* и в оригинале при выбранной по умолчанию библиотеке иконок.
*/
import { computed, useSlots } from "vue"
import type { Column } from "@tanstack/vue-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 { getColumnHeaderLabel, useDataGrid } from "./context"
const props = withDefaults(
defineProps<{
column: Column<TData, TValue>
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
title?: string
class?: string
/** Reserved; pin controls are gated by tableLayout.columnsPinnable + column.getCanPin(). */
pinnable?: boolean
visibility?: boolean
}>(),
{ visibility: false }
)
const { isLoading, table, props: gridProps } = useDataGrid<TData>()
const resolvedTitle = computed(() => props.title ?? getColumnHeaderLabel(props.column))
const columnOrder = computed(() => {
const columnOrderState = table.getState().columnOrder
return columnOrderState.length > 0
? columnOrderState
: table.getAllLeafColumns().map((leafColumn) => leafColumn.id)
})
const isSorted = computed(() => props.column.getIsSorted())
const isPinned = computed(() => props.column.getIsPinned())
const canSort = computed(() => props.column.getCanSort())
const canPin = computed(() => props.column.getCanPin())
const canResize = computed(() => props.column.getCanResize())
const columnIndex = computed(() => columnOrder.value.indexOf(props.column.id))
const canMoveLeft = computed(() => columnIndex.value > 0)
const canMoveRight = computed(() => columnIndex.value < columnOrder.value.length - 1)
function handleSort() {
if (isSorted.value === "asc") {
props.column.toggleSorting(true)
} else if (isSorted.value === "desc") {
props.column.clearSorting()
} else {
props.column.toggleSorting(false)
}
}
const headerLabelClassName = computed(() =>
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",
props.class
)
)
const headerButtonClassName = computed(() =>
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 style-vega:rounded-md style-nova:rounded-lg style-maia:rounded-full style-lyra:rounded-none style-mira:rounded-md style-luma:rounded-full style-sera:rounded-none style-rhea:rounded-full",
props.class
)
)
const slots = useSlots()
const hasFilterSlot = computed(() => !!slots.filter)
const hasControls = computed(
() =>
!!gridProps.tableLayout?.columnsMovable ||
!!(gridProps.tableLayout?.columnsVisibility && props.visibility) ||
!!(gridProps.tableLayout?.columnsPinnable && canPin.value) ||
hasFilterSlot.value
)
function moveLeft() {
if (columnIndex.value > 0) {
const newOrder = [...columnOrder.value]
const [movedColumn] = newOrder.splice(columnIndex.value, 1)
newOrder.splice(columnIndex.value - 1, 0, movedColumn as string)
table.setColumnOrder(newOrder)
}
}
function moveRight() {
if (columnIndex.value < columnOrder.value.length - 1) {
const newOrder = [...columnOrder.value]
const [movedColumn] = newOrder.splice(columnIndex.value, 1)
newOrder.splice(columnIndex.value + 1, 0, movedColumn as string)
table.setColumnOrder(newOrder)
}
}
</script>
<template>
<div v-if="hasControls" class="-ms-2 flex h-full items-center justify-between gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" :class="headerButtonClassName" :disabled="isLoading">
<slot name="icon" />
{{ resolvedTitle }}
<template v-if="canSort">
<svg v-if="isSorted === 'desc'" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.25" aria-hidden="true"><path d="M12 5v14" /><path d="m19 12-7 7-7-7" /></svg>
<svg v-else-if="isSorted === 'asc'" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.25" aria-hidden="true"><path d="m5 12 7-7 7 7" /><path d="M12 19V5" /></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mt-px size-3.25" aria-hidden="true"><path d="m7 15 5 5 5-5" /><path d="m7 9 5-5 5 5" /></svg>
</template>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-40" align="start">
<DropdownMenuGroup v-if="$slots.filter">
<DropdownMenuLabel><slot name="filter" /></DropdownMenuLabel>
</DropdownMenuGroup>
<template v-if="canSort">
<DropdownMenuSeparator v-if="$slots.filter" />
<DropdownMenuItem :disabled="!canSort" @click="isSorted === 'asc' ? column.clearSorting() : column.toggleSorting(false)">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5!"><path d="m5 12 7-7 7 7" /><path d="M12 19V5" /></svg>
<span class="grow">Asc</span>
<svg v-if="isSorted === 'asc'" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-primary size-4 opacity-100!"><path d="M20 6 9 17l-5-5" /></svg>
</DropdownMenuItem>
<DropdownMenuItem :disabled="!canSort" @click="isSorted === 'desc' ? column.clearSorting() : column.toggleSorting(true)">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5!"><path d="M12 5v14" /><path d="m19 12-7 7-7-7" /></svg>
<span class="grow">Desc</span>
<svg v-if="isSorted === 'desc'" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-primary size-4 opacity-100!"><path d="M20 6 9 17l-5-5" /></svg>
</DropdownMenuItem>
</template>
<template v-if="gridProps.tableLayout?.columnsPinnable && canPin">
<DropdownMenuSeparator v-if="$slots.filter || canSort" />
<DropdownMenuItem @click="column.pin(isPinned === 'left' ? false : 'left')">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5!" aria-hidden="true"><path d="M3 19V5" /><path d="m13 6-6 6 6 6" /><path d="M7 12h14" /></svg>
<span class="grow">Pin to left</span>
<svg v-if="isPinned === 'left'" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-primary size-4 opacity-100!"><path d="M20 6 9 17l-5-5" /></svg>
</DropdownMenuItem>
<DropdownMenuItem @click="column.pin(isPinned === 'right' ? false : 'right')">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5!" aria-hidden="true"><path d="M21 19V5" /><path d="m11 6 6 6-6 6" /><path d="M17 12H3" /></svg>
<span class="grow">Pin to right</span>
<svg v-if="isPinned === 'right'" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-primary size-4 opacity-100!"><path d="M20 6 9 17l-5-5" /></svg>
</DropdownMenuItem>
</template>
<template v-if="gridProps.tableLayout?.columnsMovable">
<DropdownMenuSeparator v-if="$slots.filter || canSort || (gridProps.tableLayout?.columnsPinnable && canPin)" />
<DropdownMenuItem :disabled="!canMoveLeft || isPinned !== false" @click="moveLeft">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5!" aria-hidden="true"><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></svg>
<span>Move to Left</span>
</DropdownMenuItem>
<DropdownMenuItem :disabled="!canMoveRight || isPinned !== false" @click="moveRight">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5!" aria-hidden="true"><path d="M5 12h14" /><path d="m12 5 7 7-7 7" /></svg>
<span>Move to Right</span>
</DropdownMenuItem>
</template>
<template v-if="gridProps.tableLayout?.columnsVisibility && visibility">
<DropdownMenuSeparator
v-if="$slots.filter || canSort || (gridProps.tableLayout?.columnsPinnable && canPin) || gridProps.tableLayout?.columnsMovable"
/>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5!"><line x1="21" x2="14" y1="4" y2="4" /><line x1="10" x2="3" y1="4" y2="4" /><line x1="21" x2="12" y1="12" y2="12" /><line x1="8" x2="3" y1="12" y2="12" /><line x1="21" x2="16" y1="20" y2="20" /><line x1="12" x2="3" y1="20" y2="20" /><line x1="14" x2="14" y1="2" y2="6" /><line x1="8" x2="8" y1="10" y2="14" /><line x1="16" x2="16" y1="18" y2="22" /></svg>
<span>Columns</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuCheckboxItem
v-for="col in table.getAllColumns().filter((c) => c.getCanHide())"
:key="col.id"
:model-value="col.getIsVisible()"
class="capitalize"
@select="(event: Event) => event.preventDefault()"
@update:model-value="(value: unknown) => col.toggleVisibility(!!value)"
>
{{ getColumnHeaderLabel(col) }}
</DropdownMenuCheckboxItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</template>
</DropdownMenuContent>
</DropdownMenu>
<Button
v-if="gridProps.tableLayout?.columnsPinnable && canPin && isPinned"
size="icon-sm"
variant="ghost"
class="style-vega:rounded-md style-nova:rounded-lg style-maia:rounded-full style-lyra:rounded-none style-mira:rounded-md style-luma:rounded-full style-sera:rounded-none style-rhea:rounded-full -me-1 size-7"
:aria-label="`Unpin ${resolvedTitle} column`"
:title="`Unpin ${resolvedTitle} column`"
@click="column.pin(false)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5! opacity-50!" aria-hidden="true"><path d="M12 17v5" /><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1z" /><line x1="2" x2="22" y1="2" y2="22" /></svg>
</Button>
</div>
<div v-else-if="canSort || (gridProps.tableLayout?.columnsResizable && canResize)" class="-ms-2 flex h-full items-center">
<Button variant="ghost" :class="headerButtonClassName" :disabled="isLoading" @click="handleSort">
<slot name="icon" />
{{ resolvedTitle }}
<template v-if="canSort">
<svg v-if="isSorted === 'desc'" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.25" aria-hidden="true"><path d="M12 5v14" /><path d="m19 12-7 7-7-7" /></svg>
<svg v-else-if="isSorted === 'asc'" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.25" aria-hidden="true"><path d="m5 12 7-7 7 7" /><path d="M12 19V5" /></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mt-px size-3.25" aria-hidden="true"><path d="m7 15 5 5 5-5" /><path d="m7 9 5-5 5 5" /></svg>
</template>
</Button>
</div>
<div v-else :class="headerLabelClassName">
<slot name="icon" />
{{ resolvedTitle }}
</div>
</template>
src/reui/data-grid/DataGridColumnVisibility.vue
<script setup lang="ts" generic="TData extends object">
/**
* Порт `DataGridColumnVisibility` (data-grid-column-visibility.tsx).
*
* Оригинал принимает `trigger: ReactElement` пропом и рендерит его как
* `asChild` внутри `DropdownMenuTrigger`. Во Vue прямого аналога "проп с
* готовым элементом" нет (см. docs/PORTING.md §7 про `indicators`), поэтому
* здесь это именованный слот `trigger` на `DropdownMenuTrigger as-child`.
*/
import type { Table } from "@tanstack/vue-table"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { getColumnHeaderLabel } from "./context"
defineProps<{
table: Table<TData>
}>()
</script>
<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<slot name="trigger" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel class="font-medium">Toggle Columns</DropdownMenuLabel>
<DropdownMenuCheckboxItem
v-for="column in table.getAllColumns().filter((c) => c.getCanHide())"
:key="column.id"
class="capitalize"
:model-value="column.getIsVisible()"
@select="(event: Event) => event.preventDefault()"
@update:model-value="(value: unknown) => column.toggleVisibility(!!value)"
>
{{ getColumnHeaderLabel(column) }}
</DropdownMenuCheckboxItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</template>
src/reui/data-grid/DataGridContainer.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
/** Accepted for backwards compatibility; currently has no effect. */
border?: boolean
}>()
</script>
<template>
<div data-slot="data-grid" :class="cn('w-full overflow-hidden', props.class)">
<slot />
</div>
</template>
src/reui/data-grid/DataGridPagination.vue
<script setup lang="ts">
/**
* Порт `DataGridPagination` (data-grid-pagination.tsx). `IconPlaceholder`
* заменён инлайновым `<svg>` (lucide "chevron-left"/"chevron-right"),
* см. docs/PORTING.md §5.
*/
import { computed } from "vue"
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 { useDataGrid } from "./context"
const props = withDefaults(
defineProps<{
sizes?: number[]
moreLimit?: number
info?: string
class?: string
rowsPerPageLabel?: string
previousPageLabel?: string
nextPageLabel?: string
ellipsisText?: string
}>(),
{
sizes: () => [5, 10, 25, 50, 100],
moreLimit: 5,
info: "{from} - {to} of {count}",
rowsPerPageLabel: "Rows per page",
previousPageLabel: "Go to previous page",
nextPageLabel: "Go to next page",
ellipsisText: "...",
}
)
const { table, recordCount, isLoading } = useDataGrid()
const btnBaseClasses = "p-0 text-sm"
const btnArrowClasses = `${btnBaseClasses} rtl:transform rtl:rotate-180`
const pageIndex = computed(() => table.getState().pagination.pageIndex)
const pageSize = computed(() => table.getState().pagination.pageSize)
const from = computed(() => (recordCount === 0 ? 0 : pageIndex.value * pageSize.value + 1))
const to = computed(() => Math.min((pageIndex.value + 1) * pageSize.value, recordCount))
const pageCount = computed(() => table.getPageCount())
const paginationInfo = computed(() =>
props.info
.replaceAll("{from}", from.value.toString())
.replaceAll("{to}", to.value.toString())
.replaceAll("{count}", recordCount.toString())
)
const paginationMoreLimit = computed(() => props.moreLimit || 5)
const currentGroupStart = computed(
() => Math.floor(pageIndex.value / paginationMoreLimit.value) * paginationMoreLimit.value
)
const currentGroupEnd = computed(() =>
Math.min(currentGroupStart.value + paginationMoreLimit.value, pageCount.value)
)
const pageButtons = computed(() => {
const buttons: number[] = []
for (let i = currentGroupStart.value; i < currentGroupEnd.value; i++) buttons.push(i)
return buttons
})
function handlePageSizeChange(value: unknown) {
table.setPageSize(Number(value))
}
</script>
<template>
<div data-slot="data-grid-pagination" :class="cn('flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0', props.class)">
<div class="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
<Skeleton v-if="isLoading" class="h-8 w-44" />
<template v-else>
<div class="text-muted-foreground text-sm">{{ rowsPerPageLabel }}</div>
<Select :model-value="`${pageSize}`" @update:model-value="handlePageSizeChange">
<SelectTrigger class="w-16" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" align="start" class="min-w-(--radix-select-trigger-width)">
<SelectItem v-for="size in sizes" :key="size" :value="`${size}`">{{ size }}</SelectItem>
</SelectContent>
</Select>
</template>
</div>
<div class="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">
<Skeleton v-if="isLoading" class="h-8 w-60" />
<template v-else>
<div class="text-muted-foreground order-2 text-sm text-nowrap sm:order-1">{{ paginationInfo }}</div>
<div v-if="pageCount > 1" class="order-1 flex items-center space-x-1">
<Button size="icon-sm" variant="ghost" :class="btnArrowClasses" :disabled="!table.getCanPreviousPage()" @click="table.previousPage()">
<span class="sr-only">{{ previousPageLabel }}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="m15 18-6-6 6-6" /></svg>
</Button>
<Button v-if="currentGroupStart > 0" size="icon-sm" :class="btnBaseClasses" variant="ghost" @click="table.setPageIndex(currentGroupStart - 1)">{{ ellipsisText }}</Button>
<Button
v-for="i in pageButtons"
:key="i"
size="icon-sm"
variant="ghost"
:class="cn(btnBaseClasses, 'text-muted-foreground', { 'bg-accent text-accent-foreground': pageIndex === i })"
@click="pageIndex !== i && table.setPageIndex(i)"
>{{ i + 1 }}</Button>
<Button v-if="currentGroupEnd < pageCount" :class="btnBaseClasses" variant="ghost" size="icon-sm" @click="table.setPageIndex(currentGroupEnd)">{{ ellipsisText }}</Button>
<Button size="icon-sm" variant="ghost" :class="btnArrowClasses" :disabled="!table.getCanNextPage()" @click="table.nextPage()">
<span class="sr-only">{{ nextPageLabel }}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"><path d="m9 18 6-6-6-6" /></svg>
</Button>
</div>
</template>
</div>
</div>
</template>
src/reui/data-grid/DataGridScrollArea.vue
<script setup lang="ts" generic="TData extends object">
/**
* Порт `DataGridScrollArea` (data-grid-scroll-area.tsx).
*
* `@base-ui/react/scroll-area` -> reka-ui `ScrollAreaRoot`/`ScrollAreaViewport`/
* `ScrollAreaScrollbar`/`ScrollAreaThumb`/`ScrollAreaCorner` (тот же примитив,
* что уже обёрнут в `packages/ui/src/ui/scroll-area/`, но здесь собран
* напрямую — data-grid нужны свои `data-slot` (`data-grid-scroll-area`,
* `data-grid-scrollbar`, `data-grid-thumb`) и кастомный вертикальный
* скроллбар для режима `headerSticky`, которых нет в обёртке `ui/scroll-area`).
*
* Base UI `Viewport` пробрасывает React `ref` напрямую на DOM-узел; reka-ui
* компонент такого рефа не публикует, поэтому здесь тот же приём, что и в
* `DataGridTableViewport.vue`/context.ts: DOM-узел находится через
* `querySelector` внутри контейнера, а не через ref на компонент.
*/
import { computed, onBeforeUnmount, onMounted, ref } from "vue"
import type { HTMLAttributes } from "vue"
import {
ScrollAreaCorner,
ScrollAreaRoot,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from "reka-ui"
import { cn } from "@/lib/utils"
import { useDataGrid } from "./context"
const MIN_THUMB_SIZE = 24
const FALLBACK_SCROLLBAR_SIZE = 12
type ScrollbarMetrics = {
hasVerticalOverflow: boolean
headerHeight: number
horizontalScrollbarSize: number
thumbHeight: number
thumbTop: number
trackHeight: number
}
const INITIAL_METRICS: ScrollbarMetrics = {
hasVerticalOverflow: false,
headerHeight: 0,
horizontalScrollbarSize: 0,
thumbHeight: 0,
thumbTop: 0,
trackHeight: 0,
}
const SCROLLBAR_CLASSNAME =
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
orientation?: "horizontal" | "vertical" | "both"
}>(),
{ orientation: "both" }
)
const { props: dataGridProps, table } = useDataGrid<TData>()
const containerEl = ref<HTMLDivElement | null>(null)
const showHorizontal = computed(() => props.orientation !== "vertical")
const showVertical = computed(() => props.orientation !== "horizontal")
const usesCustomVerticalScrollbar = computed(
() => showVertical.value && !!dataGridProps.tableLayout?.headerSticky
)
const isColumnsPinnable = computed(() => !!dataGridProps.tableLayout?.columnsPinnable)
const scrollbarInsetStart = computed(() =>
isColumnsPinnable.value ? table.getLeftTotalSize() : 0
)
const scrollbarInsetEnd = computed(() =>
isColumnsPinnable.value ? table.getRightTotalSize() : 0
)
const scrollbarInsetStyle = computed(() =>
scrollbarInsetStart.value > 0 || scrollbarInsetEnd.value > 0
? {
marginInlineStart: scrollbarInsetStart.value
? `${scrollbarInsetStart.value}px`
: undefined,
marginInlineEnd: scrollbarInsetEnd.value
? `${scrollbarInsetEnd.value}px`
: undefined,
}
: undefined
)
const hasCustomVerticalOverflow = ref(false)
const metrics = { current: INITIAL_METRICS }
const dragState = ref<{ pointerId: number; startScrollTop: number; startY: number } | null>(
null
)
let stopObserving: (() => void) | null = null
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, next: ScrollbarMetrics) {
element.style.setProperty("--data-grid-scrollbar-header-height", `${next.headerHeight}px`)
element.style.setProperty("--data-grid-scrollbar-thumb-height", `${next.thumbHeight}px`)
element.style.setProperty("--data-grid-scrollbar-thumb-top", `${next.thumbTop}px`)
element.style.setProperty("--data-grid-scrollbar-track-height", `${next.trackHeight}px`)
}
function clearDragState() {
dragState.value = null
document.body.style.userSelect = ""
document.body.style.webkitUserSelect = ""
}
function resetMetrics() {
const container = containerEl.value
if (container && !areMetricsEqual(INITIAL_METRICS, metrics.current)) {
applyMetrics(container, INITIAL_METRICS)
metrics.current = INITIAL_METRICS
}
hasCustomVerticalOverflow.value = false
}
function getViewportEl(): HTMLElement | null {
return containerEl.value?.querySelector('[data-slot="scroll-area-viewport"]') ?? null
}
function syncCustomVerticalScrollbar() {
const container = containerEl.value
const viewport = getViewportEl()
if (!container || !viewport || !usesCustomVerticalScrollbar.value) {
resetMetrics()
return
}
const header = container.querySelector(
'[data-slot="data-grid-table"] thead'
) as HTMLElement | null
const horizontalScrollbar = container.querySelector(
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
) as HTMLElement | null
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.value && 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 next: ScrollbarMetrics
if (trackHeight === 0 || maxScroll === 0) {
next = {
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
next = {
hasVerticalOverflow: true,
headerHeight,
horizontalScrollbarSize,
thumbHeight,
thumbTop,
trackHeight,
}
}
if (!areMetricsEqual(next, metrics.current)) {
applyMetrics(container, next)
metrics.current = next
}
hasCustomVerticalOverflow.value = next.hasVerticalOverflow
}
function scrollToThumbOffset(nextThumbTop: number) {
const viewport = getViewportEl()
const { thumbHeight, trackHeight } = metrics.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
}
function handleThumbPointerDown(event: PointerEvent) {
const viewport = getViewportEl()
if (!viewport) return
event.preventDefault()
event.stopPropagation()
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
dragState.value = {
pointerId: event.pointerId,
startScrollTop: viewport.scrollTop,
startY: event.clientY,
}
document.body.style.userSelect = "none"
document.body.style.webkitUserSelect = "none"
}
function handleThumbPointerMove(event: PointerEvent) {
const viewport = getViewportEl()
const drag = dragState.value
const { thumbHeight, trackHeight } = metrics.current
if (!viewport || !drag || drag.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 - drag.startY
const nextScrollTop = drag.startScrollTop + (deltaY / maxThumbTop) * maxScroll
viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)
}
function handleThumbPointerUp(event: PointerEvent) {
if (dragState.value?.pointerId !== event.pointerId) return
clearDragState()
}
function handleTrackPointerDown(event: PointerEvent) {
const { thumbHeight } = metrics.current
if (event.target !== event.currentTarget) return
event.preventDefault()
event.stopPropagation()
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const offsetY = event.clientY - rect.top - thumbHeight / 2
scrollToThumbOffset(offsetY)
}
onMounted(() => {
const container = containerEl.value
const viewport = getViewportEl()
if (!container || !viewport) return
if (!usesCustomVerticalScrollbar.value) {
resetMetrics()
return
}
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 = () => {
const header = container.querySelector(
'[data-slot="data-grid-table"] thead'
) as HTMLElement | null
const table = container.querySelector('[data-slot="data-grid-table"]') as HTMLElement | null
const tableViewport = container.querySelector(
'[data-slot="data-grid-table-viewport"]'
) as HTMLElement | null
observeElement(header)
observeElement(table)
observeElement(tableViewport)
return !!(header && table)
}
observeElement(viewport)
const resolvedOnMount = resolveObservedElements()
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
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 })
}
stopObserving = () => {
cancelAnimationFrame(frame)
observer?.disconnect()
mutationObserver?.disconnect()
viewport.removeEventListener("scroll", scheduleSync)
clearDragState()
}
})
onBeforeUnmount(() => {
stopObserving?.()
})
</script>
<template>
<div ref="containerEl" class="relative">
<ScrollAreaRoot data-slot="data-grid-scroll-area" :class="cn('relative', props.class)">
<ScrollAreaViewport data-slot="scroll-area-viewport" class="size-full">
<div data-slot="scroll-area-content">
<slot />
</div>
</ScrollAreaViewport>
<ScrollAreaScrollbar
v-if="showHorizontal"
data-slot="data-grid-scrollbar"
data-orientation="horizontal"
orientation="horizontal"
:class="SCROLLBAR_CLASSNAME"
:style="scrollbarInsetStyle"
>
<ScrollAreaThumb data-slot="data-grid-thumb" :class="SCROLLBAR_THUMB_CLASSNAME" />
</ScrollAreaScrollbar>
<ScrollAreaScrollbar
v-if="showVertical && !usesCustomVerticalScrollbar"
data-slot="data-grid-scrollbar"
data-orientation="vertical"
orientation="vertical"
:class="SCROLLBAR_CLASSNAME"
>
<ScrollAreaThumb data-slot="data-grid-thumb" :class="SCROLLBAR_THUMB_CLASSNAME" />
</ScrollAreaScrollbar>
<ScrollAreaCorner />
</ScrollAreaRoot>
<div
v-if="usesCustomVerticalScrollbar && hasCustomVerticalOverflow"
aria-hidden="true"
class="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
>
<div
class="pointer-events-auto relative h-full w-2 touch-none p-px"
@pointerdown="handleTrackPointerDown"
>
<div
:class="cn('bg-border absolute end-px w-2', 'top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)', 'rounded-full')"
@lostpointercapture="clearDragState"
@pointercancel="handleThumbPointerUp"
@pointerdown="handleThumbPointerDown"
@pointermove="handleThumbPointerMove"
@pointerup="handleThumbPointerUp"
/>
</div>
</div>
</div>
</template>
src/reui/data-grid/DataGridTable.vue
<script setup lang="ts" generic="TData extends object">
/**
* Порт `DataGridTable` (registry-reui/bases/radix/reui/data-grid/data-grid-table.tsx).
*
* `@tanstack/react-table` -> `@tanstack/vue-table`: тот же `Table`/`Row`/
* `Column`/`Header`/`Cell`, `flexRender` заменён на компонент `FlexRender`
* (единственная разница в адаптере) — почти вся логика переносится
* механически (см. комментарий в context.ts).
*
* Композиция версии оригинала (много мелких функциональных подкомпонентов,
* скомпонованных в `DataGridTable`/`DataGridTableHeader`) здесь свёрнута в
* один SFC с `v-for`-разметкой вместо десятка отдельных React-компонентов:
* Vue-шаблоны не нуждаются в отдельной функции на каждый `<tr>/<td>`, чтобы
* получить читаемый вывод, а вынесение их в файлы дало бы 12+ файлов по
* 15-30 строк без выигрыша в переиспользовании — этим компонентам, в
* отличие от `DataGridTableRowSelect`/`RowSelectAll`/`RowPin`/`FootRow`/
* `FootRowCell` (вынесены отдельно, см. index.ts), потребители не имеют
* доступа напрямую в апстриме (ни один демо-блок их не импортирует).
* Разметка/`data-slot`/классы каждого фрагмента перенесены дословно из
* соответствующей функции оригинала — сверяйте по комментариям ниже.
*/
import { computed } from "vue"
import { FlexRender } from "@tanstack/vue-table"
import type { Cell, Column, Header, Row } from "@tanstack/vue-table"
import { cn } from "@/lib/utils"
import { Spinner } from "@/components/ui/spinner"
import {
getDataGridTableMergedHeaderGroups,
getDataGridTableOrderedVisibleColumns,
getDataGridTableResolvedRows,
getPinningStyles,
hasDataGridTableRightPinnedColumns,
useDataGrid,
type DataGridTablePinnedBoundary,
} from "./context"
import DataGridTableViewport from "./DataGridTableViewport.vue"
import DataGridTableFillCol from "./DataGridTableFillCol.vue"
import DataGridTableFillHeadCell from "./DataGridTableFillHeadCell.vue"
import DataGridTableFillBodyCell from "./DataGridTableFillBodyCell.vue"
import DataGridTableHeadRowCellResize from "./DataGridTableHeadRowCellResize.vue"
const props = withDefaults(
defineProps<{
renderHeader?: boolean
}>(),
{ renderHeader: true }
)
const { table, props: gridProps, isLoading } = useDataGrid<TData>()
// --- spacing helpers (статичные подстановки, как в оригинале) -------------
const headerCellSpacing = computed(() =>
gridProps.tableLayout?.dense ? "px-2 h-8" : "px-3"
)
const bodyCellSpacing = computed(() =>
gridProps.tableLayout?.dense ? "px-2 py-1.5" : "px-3 py-2"
)
const leftVisibleColumns = computed(() => table.getLeftVisibleLeafColumns())
const centerVisibleColumns = computed(() => table.getCenterVisibleLeafColumns())
const rightVisibleColumns = computed(() => table.getRightVisibleLeafColumns())
const hasRightPinnedColumns = computed(() => hasDataGridTableRightPinnedColumns(table))
const mergedHeaderGroups = computed(() => getDataGridTableMergedHeaderGroups(table))
const columnSizeVars = computed<Record<string, number> | undefined>(() => {
if (!gridProps.tableLayout?.columnsResizable) return undefined
const headers = table.getFlatHeaders()
const colSizes: Record<string, number> = {}
for (const header of headers) {
colSizes[`--header-${header.id}-size`] = header.getSize()
colSizes[`--col-${header.column.id}-size`] = header.column.getSize()
}
return colSizes
})
const tableStyle = computed(() =>
gridProps.tableLayout?.columnsResizable
? {
...columnSizeVars.value,
width: `calc(${table.getTotalSize()}px + var(--data-grid-fill-size, 0px))`,
}
: undefined
)
function colStyle(column: Column<TData, unknown>) {
if (gridProps.tableLayout?.columnsResizable) {
return { width: `calc(var(--col-${column.id}-size) * 1px)` }
}
if (gridProps.tableLayout?.width === "fixed") {
return { width: `${column.getSize()}px` }
}
return undefined
}
function isLastVisibleColumnOf(column: Column<TData, unknown>) {
return column.getIndex() === table.getVisibleLeafColumns().length - 1
}
function headCellStyle(header: Header<TData, unknown>) {
const { column } = header
return {
...(gridProps.tableLayout?.width === "fixed" &&
!gridProps.tableLayout?.columnsResizable && { width: `${header.getSize()}px` }),
...(gridProps.tableLayout?.columnsPinnable &&
column.getCanPin() &&
getPinningStyles(column)),
...(gridProps.tableLayout?.columnsResizable && {
width: `calc(var(--header-${header.id}-size) * 1px)`,
}),
}
}
function headCellClass(header: Header<TData, unknown>) {
const { column } = header
const isPinned = column.getIsPinned()
const isLastVisible = isLastVisibleColumnOf(column)
return cn(
"text-foreground relative h-10 text-left align-middle font-medium rtl:text-right [&:has([role=checkbox])]:pe-0",
headerCellSpacing.value,
gridProps.tableLayout?.headerBackground && "bg-muted",
gridProps.tableLayout?.cellBorder && "border-e",
gridProps.tableLayout?.columnsResizable &&
column.getCanResize() &&
(isPinned ? "overflow-hidden" : "overflow-visible"),
gridProps.tableLayout?.columnsResizable &&
column.getCanResize() &&
isLastVisible &&
"pe-8",
gridProps.tableLayout?.columnsPinnable &&
column.getCanPin() &&
cn(
"data-pinned:bg-muted data-outer-pinned-col:bg-clip-padding data-pinned:isolate",
"[&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right]:last-child_div.cursor-col-resize:last-child]:opacity-0 [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]",
"[&:not([data-pinned]):has(+[data-pinned])_div.cursor-col-resize:last-child]:opacity-0 [&[data-last-col=left]_div.cursor-col-resize:last-child]:opacity-0"
),
header.column.columnDef.meta?.headerClassName,
column.getIndex() === 0 || isLastVisible ? gridProps.tableClassNames?.edgeCell : ""
)
}
function headOuterPinned(header: Header<TData, unknown>) {
const isPinned = header.column.getIsPinned()
const isFirstLeftPinned = isPinned === "left" && header.column.getIsFirstColumn("left")
const isLastRightPinned = isPinned === "right" && header.column.getIsLastColumn("right")
return isFirstLeftPinned ? "left" : isLastRightPinned ? "right" : undefined
}
function headLastCol(header: Header<TData, unknown>) {
const isPinned = header.column.getIsPinned()
const isLastLeftPinned = isPinned === "left" && header.column.getIsLastColumn("left")
const isFirstRightPinned = isPinned === "right" && header.column.getIsFirstColumn("right")
return isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
}
function bodyCellClass(cell: Cell<TData, unknown>) {
const { column, row } = cell
return cn(
"align-middle",
bodyCellSpacing.value,
gridProps.tableLayout?.cellBorder && "border-e",
gridProps.tableLayout?.columnsResizable && column.getCanResize() && "truncate",
cell.column.columnDef.meta?.cellClassName,
gridProps.tableLayout?.columnsPinnable &&
column.getCanPin() &&
cn(
"data-pinned:bg-background data-pinned:isolate",
"[&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)]",
"[&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
),
column.getIndex() === 0 || column.getIndex() === row.getVisibleCells().length - 1
? gridProps.tableClassNames?.edgeCell
: ""
)
}
function bodyCellStyle(cell: Cell<TData, unknown>) {
const { column } = cell
return {
...(gridProps.tableLayout?.columnsPinnable && column.getCanPin() && getPinningStyles(column)),
...(gridProps.tableLayout?.columnsResizable && {
width: `calc(var(--col-${column.id}-size) * 1px)`,
}),
}
}
function bodyCellLastCol(cell: Cell<TData, unknown>) {
const isPinned = cell.column.getIsPinned()
const isLastLeftPinned = isPinned === "left" && cell.column.getIsLastColumn("left")
const isFirstRightPinned = isPinned === "right" && cell.column.getIsFirstColumn("right")
return isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
}
const bodyRowBottomBorderClasses =
"[&:not(:last-child)>td]:border-b [tbody:has(+tfoot)_&:last-child>td]:border-b [*:has(>[data-slot=data-grid]+[data-slot=data-grid-pagination])_[data-slot=data-grid]_&:last-child>td]:border-b"
function bodyRowClass(row: Row<TData>, dataIndex?: number, pinnedBoundary?: DataGridTablePinnedBoundary) {
const isRowPinned = row.getIsPinned()
return cn(
"hover:bg-muted/40 data-[state=selected]:bg-muted/50",
gridProps.onRowClick && "cursor-pointer",
!gridProps.tableLayout?.stripped && gridProps.tableLayout?.rowBorder && bodyRowBottomBorderClasses,
gridProps.tableLayout?.cellBorder && `*:last:border-e-0 ${bodyRowBottomBorderClasses}`,
gridProps.tableLayout?.stripped &&
(typeof dataIndex === "number"
? cn("hover:bg-transparent", dataIndex % 2 === 0 && "bg-muted/90 hover:bg-muted")
: "odd:bg-muted/90 odd:hover:bg-muted hover:bg-transparent"),
table.options.enableRowSelection && "*:first:relative",
gridProps.tableLayout?.rowsPinnable && isRowPinned && "bg-muted/30 hover:bg-muted/50",
pinnedBoundary === "top" &&
"[&>td]:shadow-[0_2px_0_rgba(0,0,0,0.03)] dark:[&>td]:shadow-[0_2px_0_rgba(255,255,255,0.06)]",
pinnedBoundary === "bottom" &&
"[&>td]:shadow-[0_2px_0_rgba(0,0,0,0.03)] dark:[&>td]:shadow-[0_2px_0_rgba(255,255,255,0.06)]",
gridProps.tableClassNames?.bodyRow
)
}
function orderedVisibleCells(row: Row<TData>) {
return [...row.getLeftVisibleCells(), ...row.getCenterVisibleCells()]
}
const resolvedRows = computed(() =>
getDataGridTableResolvedRows(table, gridProps.tableLayout?.rowsPinnable)
)
const skeletonRowCount = computed(() => table.getState().pagination?.pageSize ?? 0)
const isSkeletonLoading = computed(
() => isLoading && gridProps.loadingMode === "skeleton" && skeletonRowCount.value > 0
)
const isSpinnerLoading = computed(() => isLoading && gridProps.loadingMode === "spinner")
const visibleColumnCount = computed(
() =>
getDataGridTableOrderedVisibleColumns(table).length +
(gridProps.tableLayout?.columnsResizable ? 1 : 0)
)
function handleRowClick(row: Row<TData>) {
gridProps.onRowClick?.(row.original)
}
</script>
<template>
<DataGridTableViewport>
<table
data-slot="data-grid-table"
:class="
cn(
'text-foreground caption-bottom text-left align-middle text-sm font-normal rtl:text-right',
gridProps.tableLayout?.columnsResizable ? 'min-w-0' : 'w-full min-w-full',
gridProps.tableLayout?.width === 'auto' ? 'table-auto' : 'table-fixed',
!gridProps.tableLayout?.columnsDraggable && 'border-separate border-spacing-0',
gridProps.tableClassNames?.base
)
"
:style="tableStyle"
>
<colgroup>
<col
v-for="column in [...leftVisibleColumns, ...centerVisibleColumns]"
:key="column.id"
:style="colStyle(column)"
/>
<DataGridTableFillCol v-if="hasRightPinnedColumns" />
<col
v-for="column in rightVisibleColumns"
:key="column.id"
:style="colStyle(column)"
/>
<DataGridTableFillCol v-if="!hasRightPinnedColumns" />
</colgroup>
<!-- DataGridTableHead -->
<thead
v-if="renderHeader"
:class="
cn(
gridProps.tableClassNames?.header,
gridProps.tableLayout?.headerSticky && gridProps.tableClassNames?.headerSticky
)
"
>
<!-- DataGridTableHeadRow -->
<tr
v-for="headerGroup in mergedHeaderGroups"
:key="headerGroup.id"
:class="
cn(
gridProps.tableLayout?.headerBorder && '[&>th]:border-b',
gridProps.tableLayout?.cellBorder && '*:last:border-e-0',
gridProps.tableLayout?.stripped && 'bg-transparent',
gridProps.tableLayout?.headerBackground === false && 'bg-transparent',
gridProps.tableClassNames?.headerRow
)
"
>
<!-- DataGridTableHeadRowCell (left+center) -->
<th
v-for="header in headerGroup.headers.filter((h) => h.column.getIsPinned() !== 'right')"
:key="header.id"
scope="col"
:colspan="header.colSpan > 1 ? header.colSpan : undefined"
:aria-sort="
header.column.getIsSorted() === 'asc'
? 'ascending'
: header.column.getIsSorted() === 'desc'
? 'descending'
: undefined
"
:style="{ ...headCellStyle(header) }"
:data-pinned="header.column.getIsPinned() || undefined"
:data-outer-pinned-col="headOuterPinned(header)"
:data-last-col="headLastCol(header)"
:class="headCellClass(header)"
>
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<DataGridTableHeadRowCellResize
v-if="gridProps.tableLayout?.columnsResizable && header.column.getCanResize()"
:header="header"
/>
</th>
<DataGridTableFillHeadCell
v-if="gridProps.tableLayout?.columnsResizable && hasRightPinnedColumns"
/>
<!-- DataGridTableHeadRowCell (right-pinned) -->
<th
v-for="header in headerGroup.headers.filter((h) => h.column.getIsPinned() === 'right')"
:key="header.id"
scope="col"
:colspan="header.colSpan > 1 ? header.colSpan : undefined"
:aria-sort="
header.column.getIsSorted() === 'asc'
? 'ascending'
: header.column.getIsSorted() === 'desc'
? 'descending'
: undefined
"
:style="{ ...headCellStyle(header) }"
:data-pinned="header.column.getIsPinned() || undefined"
:data-outer-pinned-col="headOuterPinned(header)"
:data-last-col="headLastCol(header)"
:class="headCellClass(header)"
>
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<DataGridTableHeadRowCellResize
v-if="gridProps.tableLayout?.columnsResizable && header.column.getCanResize()"
:header="header"
/>
</th>
<DataGridTableFillHeadCell
v-if="gridProps.tableLayout?.columnsResizable && !hasRightPinnedColumns"
/>
</tr>
</thead>
<!-- DataGridTableRowSpacer -->
<tbody
v-if="renderHeader && (gridProps.tableLayout?.stripped || !gridProps.tableLayout?.rowBorder)"
aria-hidden="true"
class="h-2"
data-slot="data-grid-table-body-spacer"
></tbody>
<!-- DataGridTableBody -->
<tbody
data-slot="data-grid-table-body"
:class="
cn(
gridProps.tableLayout?.rowRounded &&
'style-vega:[&_td:first-child]:rounded-l-lg style-nova:[&_td:first-child]:rounded-l-lg style-maia:[&_td:first-child]:rounded-l-2xl style-lyra:[&_td:first-child]:rounded-l-none style-mira:[&_td:first-child]:rounded-l-lg style-luma:[&_td:first-child]:rounded-l-3xl style-sera:[&_td:first-child]:rounded-l-none style-rhea:[&_td:first-child]:rounded-l-2xl',
gridProps.tableLayout?.rowRounded &&
'style-vega:[&_td:last-child]:rounded-r-lg style-nova:[&_td:last-child]:rounded-r-lg style-maia:[&_td:last-child]:rounded-r-2xl style-lyra:[&_td:last-child]:rounded-r-none style-mira:[&_td:last-child]:rounded-r-lg style-luma:[&_td:last-child]:rounded-r-3xl style-sera:[&_td:last-child]:rounded-r-none style-rhea:[&_td:last-child]:rounded-r-2xl',
gridProps.tableClassNames?.body
)
"
>
<!-- Skeleton loading -->
<template v-if="isSkeletonLoading">
<tr
v-for="rowIndex in skeletonRowCount"
:key="rowIndex"
:class="
cn(
'hover:bg-muted/40 data-[state=selected]:bg-muted/50',
gridProps.onRowClick && 'cursor-pointer',
!gridProps.tableLayout?.stripped &&
gridProps.tableLayout?.rowBorder &&
'border-border border-b [&:not(:last-child)>td]:border-b',
gridProps.tableLayout?.cellBorder && '*:last:border-e-0',
gridProps.tableLayout?.stripped && 'odd:bg-muted/90 odd:hover:bg-muted hover:bg-transparent',
table.options.enableRowSelection && '*:first:relative',
gridProps.tableClassNames?.bodyRow
)
"
>
<td
v-for="column in [...leftVisibleColumns, ...centerVisibleColumns]"
:key="column.id"
:style="gridProps.tableLayout?.columnsResizable ? { width: `calc(var(--col-${column.id}-size) * 1px)` } : undefined"
:class="
cn(
'align-middle',
bodyCellSpacing,
gridProps.tableLayout?.cellBorder && 'border-e',
gridProps.tableLayout?.columnsResizable && column.getCanResize() && 'truncate',
column.columnDef.meta?.cellClassName,
column.getIndex() === 0 || column.getIndex() === table.getVisibleLeafColumns().length - 1
? gridProps.tableClassNames?.edgeCell
: ''
)
"
>
<component :is="column.columnDef.meta?.skeleton" v-if="column.columnDef.meta?.skeleton" />
</td>
<DataGridTableFillBodyCell v-if="gridProps.tableLayout?.columnsResizable && hasRightPinnedColumns" />
<td
v-for="column in rightVisibleColumns"
:key="column.id"
:style="gridProps.tableLayout?.columnsResizable ? { width: `calc(var(--col-${column.id}-size) * 1px)` } : undefined"
:class="
cn(
'align-middle',
bodyCellSpacing,
gridProps.tableLayout?.cellBorder && 'border-e',
gridProps.tableLayout?.columnsResizable && column.getCanResize() && 'truncate',
column.columnDef.meta?.cellClassName
)
"
>
<component :is="column.columnDef.meta?.skeleton" v-if="column.columnDef.meta?.skeleton" />
</td>
<DataGridTableFillBodyCell v-if="gridProps.tableLayout?.columnsResizable && !hasRightPinnedColumns" />
</tr>
</template>
<!-- Spinner loading -->
<tr v-else-if="isSpinnerLoading">
<td :colspan="Math.max(table.getVisibleFlatColumns().length + (gridProps.tableLayout?.columnsResizable ? 1 : 0), 1)" class="p-8">
<div class="flex items-center justify-center">
<Spinner class="text-muted-foreground mr-3 -ml-1 size-5" />
<template v-if="typeof gridProps.loadingMessage === 'string' || !gridProps.loadingMessage">{{ gridProps.loadingMessage || "Loading..." }}</template>
<component :is="gridProps.loadingMessage" v-else />
</div>
</td>
</tr>
<!-- Empty -->
<tr v-else-if="resolvedRows.length === 0">
<td :colspan="Math.max(visibleColumnCount, 1)" class="text-muted-foreground py-6 text-center text-sm">
<template v-if="typeof gridProps.emptyMessage === 'string' || !gridProps.emptyMessage">{{ gridProps.emptyMessage || "No data available" }}</template>
<component :is="gridProps.emptyMessage" v-else />
</td>
</tr>
<!-- Rendered rows -->
<template v-else v-for="({ row, pinnedBoundary }, rowIndex) in resolvedRows" :key="row.id">
<tr
:data-state="table.options.enableRowSelection && row.getIsSelected() ? 'selected' : undefined"
:data-index="rowIndex"
:data-row-id="row.id"
:data-row-pinned="row.getIsPinned() || undefined"
:data-row-pinned-boundary="pinnedBoundary"
@click="handleRowClick(row)"
:class="bodyRowClass(row, rowIndex, pinnedBoundary)"
>
<!-- DataGridTableBodyRowCell (left+center) -->
<td
v-for="cell in orderedVisibleCells(row)"
:key="cell.id"
:style="bodyCellStyle(cell)"
:data-pinned="cell.column.getIsPinned() || undefined"
:data-last-col="bodyCellLastCol(cell)"
:class="bodyCellClass(cell)"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</td>
<DataGridTableFillBodyCell v-if="gridProps.tableLayout?.columnsResizable && hasDataGridTableRightPinnedColumns(table)" />
<td
v-for="cell in row.getRightVisibleCells()"
:key="cell.id"
:style="bodyCellStyle(cell)"
:data-pinned="cell.column.getIsPinned() || undefined"
:data-last-col="bodyCellLastCol(cell)"
:class="bodyCellClass(cell)"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</td>
<DataGridTableFillBodyCell v-if="gridProps.tableLayout?.columnsResizable && !hasDataGridTableRightPinnedColumns(table)" />
</tr>
<!-- DataGridTableBodyRowExpandded -->
<tr
v-if="row.getIsExpanded()"
:class="cn(gridProps.tableLayout?.rowBorder && bodyRowBottomBorderClasses)"
>
<td :colspan="orderedVisibleCells(row).length + row.getRightVisibleCells().length + (gridProps.tableLayout?.columnsResizable ? 1 : 0)">
<component
:is="
table
.getAllColumns()
.find((column) => column.columnDef.meta?.expandedContent)
?.columnDef.meta?.expandedContent?.(row.original)
"
/>
</td>
</tr>
</template>
</tbody>
<!-- DataGridTableFoot -->
<tfoot
v-if="$slots.footer"
data-slot="data-grid-table-foot"
:class="cn(gridProps.tableClassNames?.footer)"
>
<slot name="footer" />
</tfoot>
</table>
</DataGridTableViewport>
</template>
src/reui/data-grid/DataGridTableDnd.vue
<script setup lang="ts" generic="TData extends object">
/**
* Порт `DataGridTableDnd` (data-grid-table-dnd.tsx) — перетаскиваемые
* заголовки колонок. `@dnd-kit/*` -> `@atlaskit/pragmatic-drag-and-drop`
* (ADR-002); тот же рецепт, что уже отработан в `reui/sortable`/`reui/kanban`
* (см. шапку `Sortable.vue`/`Kanban.vue`): один `monitorForElements()` на
* весь компонент играет роль `DndContext`, перестановка колонок вычисляется
* ОДИН раз при `onDrop` (не на каждом наведении) — тот же приём, что
* устраняет дрожание живого предпросмотра, задокументированный для Kanban.
*
* Осознанные отступления от сигнатуры оригинала:
* - оригинал принимает `handleDragEnd` пропом и оставляет коммит
* `columnOrder` на совести потребителя. Здесь колонка — это уже
* состояние `table` (`table.getState().columnOrder`), которым `table`
* владеет сама (тот же выбор уже сделан в `DataGridColumnHeader.vue`
* для Move Left/Right), поэтому коммит `table.setColumnOrder(...)`
* происходит внутри компонента напрямую. Опциональный `onMove` пропущен
* по тем же причинам, что и обязательный коммит у обычных
* Move Left/Right — переопределять момент коммита здесь незачем.
* - каждая колонка перетаскивается за отдельную ручку (grip) в заголовке,
* как и в оригинале (`{...attributes} {...listeners}` только на кнопке
* внутри `DataGridTableDndHeader`), но целью дропа (`dropTargetForElements`)
* здесь служит только сам `<th>`, а не вдобавок каждая `<td>` того же
* столбца (в оригинале `DataGridTableDndCell` тоже регистрирует
* `useSortable`) — заголовок всегда виден при `columnsDraggable`, так что
* расширение зоны дропа на тело таблицы не меняет достижимый результат,
* только удобство прицеливания мышью. Кастомный modifier "не пересекать
* границы таблицы по X, закрепить Y=0" (dnd-kit `Modifier`) не портирован:
* нативный HTML5 DnD не поддерживает программную коррекцию трансформа
* перетаскиваемого узла на лету, только собственный курсор/картинку.
* - `IconPlaceholder` (см. docs/PORTING.md §5) заменён инлайновым `<svg>`.
*/
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue"
import type { HTMLAttributes } from "vue"
import { FlexRender, type Cell, type Column, type Header, type Row } from "@tanstack/vue-table"
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"
import { draggable, dropTargetForElements, monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { reorderArray } from "@/lib/dnd/reorder"
import { createKeyboardDragHandler } from "@/lib/dnd/keyboard-drag"
import { getPinningStyles, hasDataGridTableRightPinnedColumns, useDataGrid } from "./context"
import DataGridTableViewport from "./DataGridTableViewport.vue"
import DataGridTableFillCol from "./DataGridTableFillCol.vue"
import DataGridTableFillHeadCell from "./DataGridTableFillHeadCell.vue"
import DataGridTableFillBodyCell from "./DataGridTableFillBodyCell.vue"
import DataGridTableHeadRowCellResize from "./DataGridTableHeadRowCellResize.vue"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const { table, props: gridProps } = useDataGrid<TData>()
const instanceId = Symbol("data-grid-dnd-instance")
const containerRef = ref<{ $el: HTMLElement } | null>(null)
const draggingColumnId = ref<string | null>(null)
const grabbedColumnId = ref<string | null>(null)
const headerCellSpacing = computed(() => (gridProps.tableLayout?.dense ? "px-2 h-8" : "px-3"))
const bodyCellSpacing = computed(() => (gridProps.tableLayout?.dense ? "px-2 py-1.5" : "px-3 py-2"))
const leftVisibleColumns = computed(() => table.getLeftVisibleLeafColumns())
const centerVisibleColumns = computed(() => table.getCenterVisibleLeafColumns())
const rightVisibleColumns = computed(() => table.getRightVisibleLeafColumns())
const hasRightPinnedColumns = computed(() => hasDataGridTableRightPinnedColumns(table))
const columnOrder = computed<string[]>(() => {
const state = table.getState().columnOrder
return state.length > 0 ? state : table.getAllLeafColumns().map((c) => c.id)
})
function canOrderColumn(column: Column<TData, unknown>): boolean {
return (column.columnDef as { enableColumnOrdering?: boolean }).enableColumnOrdering !== false
}
function colStyle(column: Column<TData, unknown>) {
if (gridProps.tableLayout?.columnsResizable) {
return { width: `calc(var(--col-${column.id}-size) * 1px)` }
}
if (gridProps.tableLayout?.width === "fixed") {
return { width: `${column.getSize()}px` }
}
return undefined
}
function headCellStyle(header: Header<TData, unknown>) {
const isDragging = draggingColumnId.value === header.column.id
return {
position: "relative" as const,
zIndex: isDragging ? 1 : undefined,
opacity: isDragging ? 0.8 : undefined,
...(gridProps.tableLayout?.width === "fixed" &&
!gridProps.tableLayout?.columnsResizable && { width: `${header.getSize()}px` }),
...(gridProps.tableLayout?.columnsPinnable && header.column.getCanPin() && getPinningStyles(header.column)),
...(gridProps.tableLayout?.columnsResizable && { width: `calc(var(--header-${header.id}-size) * 1px)` }),
}
}
function headCellClass(header: Header<TData, unknown>) {
const { column } = header
const isPinned = column.getIsPinned()
const isLastVisible = column.getIndex() === table.getVisibleLeafColumns().length - 1
return cn(
"text-foreground relative h-10 text-left align-middle font-medium rtl:text-right [&:has([role=checkbox])]:pe-0",
headerCellSpacing.value,
gridProps.tableLayout?.headerBackground && "bg-muted",
gridProps.tableLayout?.cellBorder && "border-e",
gridProps.tableLayout?.columnsResizable &&
column.getCanResize() &&
(isPinned ? "overflow-hidden" : "overflow-visible"),
gridProps.tableLayout?.columnsResizable && column.getCanResize() && isLastVisible && "pe-8",
header.column.columnDef.meta?.headerClassName,
column.getIndex() === 0 || isLastVisible ? gridProps.tableClassNames?.edgeCell : ""
)
}
function bodyCellClass(cell: Cell<TData, unknown>) {
const { column, row } = cell
return cn(
"align-middle",
bodyCellSpacing.value,
gridProps.tableLayout?.cellBorder && "border-e",
gridProps.tableLayout?.columnsResizable && column.getCanResize() && "truncate",
cell.column.columnDef.meta?.cellClassName,
column.getIndex() === 0 || column.getIndex() === row.getVisibleCells().length - 1
? gridProps.tableClassNames?.edgeCell
: ""
)
}
function bodyCellStyle(cell: Cell<TData, unknown>) {
const isDragging = draggingColumnId.value === cell.column.id
return {
position: "relative" as const,
zIndex: isDragging ? 1 : undefined,
opacity: isDragging ? 0.8 : undefined,
...(gridProps.tableLayout?.columnsPinnable && cell.column.getCanPin() && getPinningStyles(cell.column)),
...(gridProps.tableLayout?.columnsResizable && { width: `calc(var(--col-${cell.column.id}-size) * 1px)` }),
}
}
const bodyRowBottomBorderClasses =
"[&:not(:last-child)>td]:border-b [tbody:has(+tfoot)_&:last-child>td]:border-b [*:has(>[data-slot=data-grid]+[data-slot=data-grid-pagination])_[data-slot=data-grid]_&:last-child>td]:border-b"
function bodyRowClass(_row: Row<TData>) {
return cn(
"hover:bg-muted/40 data-[state=selected]:bg-muted/50",
gridProps.onRowClick && "cursor-pointer",
!gridProps.tableLayout?.stripped && gridProps.tableLayout?.rowBorder && bodyRowBottomBorderClasses,
gridProps.tableLayout?.cellBorder && `*:last:border-e-0 ${bodyRowBottomBorderClasses}`,
table.options.enableRowSelection && "*:first:relative",
gridProps.tableClassNames?.bodyRow
)
}
function handleRowClick(row: Row<TData>) {
gridProps.onRowClick?.(row.original)
}
const columnSizeVars = computed<Record<string, number> | undefined>(() => {
if (!gridProps.tableLayout?.columnsResizable) return undefined
const headers = table.getFlatHeaders()
const colSizes: Record<string, number> = {}
for (const header of headers) {
colSizes[`--header-${header.id}-size`] = header.getSize()
colSizes[`--col-${header.column.id}-size`] = header.column.getSize()
}
return colSizes
})
const tableStyle = computed(() =>
gridProps.tableLayout?.columnsResizable
? { ...columnSizeVars.value, width: `calc(${table.getTotalSize()}px + var(--data-grid-fill-size, 0px))` }
: undefined
)
// --- перестановка колонок ---------------------------------------------------
function commitColumnMove(activeId: string, overId: string) {
const order = columnOrder.value
const activeIndex = order.indexOf(activeId)
const overIndex = order.indexOf(overId)
if (activeIndex === -1 || overIndex === -1 || activeIndex === overIndex) return
table.setColumnOrder(reorderArray(order, activeIndex, overIndex))
}
function moveColumnByKeyboard(columnId: string, direction: -1 | 1) {
const order = columnOrder.value
const activeIndex = order.indexOf(columnId)
if (activeIndex === -1) return
const overIndex = activeIndex + direction
if (overIndex < 0 || overIndex >= order.length) return
table.setColumnOrder(reorderArray(order, activeIndex, overIndex))
// Перестановка columnOrder физически переставляет узел <th> внутри
// <table> (в отличие от списков Sortable/Kanban на flex/div-разметке) —
// Chromium снимает фокус с элемента при таком перемещении, даже если
// сам DOM-узел не пересоздаётся, а просто передвигается patchKeyedChildren.
// Без возврата фокуса следующий Enter/Escape улетает в document.body и
// не обрабатывается — обнаружено этим же интеракционным гейтом
// (tools/visual-diff/interactions/data-grid-dnd.mjs, ДОЛГ 1): Escape
// переставал отменять клавиатурный драг колонки после первого шага
// стрелкой. headerElements хранит тот же (передвинутый, не пересозданный)
// DOM-узел по columnId, поэтому фокус можно вернуть на него же.
nextTick(() => {
headerElements.get(columnId)?.querySelector<HTMLElement>('[data-slot="data-grid-dnd-handle"]')?.focus()
})
}
// --- регистрация draggable/dropTarget по заголовку --------------------------
const headerCleanups = new Map<string, () => void>()
const headerElements = new Map<string, HTMLElement>()
function registerHeaderCell(columnId: string, el: Element | null) {
headerCleanups.get(columnId)?.()
headerCleanups.delete(columnId)
if (!el) {
headerElements.delete(columnId)
return
}
const element = el as HTMLElement
headerElements.set(columnId, element)
const handle = element.querySelector('[data-slot="data-grid-dnd-handle"]') as HTMLElement | null
const cleanups = [
dropTargetForElements({
element,
getData: () => ({ dataGridDndInstance: instanceId, dataGridDndColumnId: columnId }),
}),
]
if (handle) {
cleanups.push(
draggable({
element,
dragHandle: handle,
getInitialData: () => ({ dataGridDndInstance: instanceId, dataGridDndColumnId: columnId }),
})
)
}
headerCleanups.set(columnId, combine(...cleanups))
}
let snapshotOnGrab: string[] = []
function makeHandleKeydown(columnId: string) {
return createKeyboardDragHandler({
isGrabbed: () => grabbedColumnId.value === columnId,
onGrab: () => {
grabbedColumnId.value = columnId
snapshotOnGrab = columnOrder.value
},
onMove: (direction) => moveColumnByKeyboard(columnId, direction),
onDrop: () => {
grabbedColumnId.value = null
},
onCancel: () => {
grabbedColumnId.value = null
table.setColumnOrder(snapshotOnGrab)
},
})
}
let stopEngine: (() => void) | undefined
onMounted(() => {
const container = containerRef.value?.$el as HTMLElement | undefined
const teardown = [
monitorForElements({
canMonitor: ({ source }) => source.data.dataGridDndInstance === instanceId,
onDragStart({ source }) {
draggingColumnId.value = source.data.dataGridDndColumnId as string
},
onDrop({ source, location }) {
const activeId = source.data.dataGridDndColumnId as string
draggingColumnId.value = null
const dropTargets = location.current.dropTargets.filter(
(target) => target.data.dataGridDndInstance === instanceId
)
const overId = dropTargets[0]?.data.dataGridDndColumnId as string | undefined
if (overId) commitColumnMove(activeId, overId)
},
}),
container
? autoScrollForElements({
element: container,
canScroll: ({ source }) => source.data.dataGridDndInstance === instanceId,
})
: () => {},
]
stopEngine = combine(...teardown)
})
onBeforeUnmount(() => {
stopEngine?.()
headerCleanups.forEach((stop) => stop())
headerCleanups.clear()
headerElements.clear()
})
</script>
<template>
<DataGridTableViewport ref="containerRef" :class="cn('relative', draggingColumnId && 'cursor-grabbing [&_*]:cursor-grabbing!', props.class)">
<table
data-slot="data-grid-table"
:class="
cn(
'text-foreground caption-bottom text-left align-middle text-sm font-normal rtl:text-right',
gridProps.tableLayout?.columnsResizable ? 'min-w-0' : 'w-full min-w-full',
gridProps.tableLayout?.width === 'auto' ? 'table-auto' : 'table-fixed',
'border-separate border-spacing-0',
gridProps.tableClassNames?.base
)
"
:style="tableStyle"
>
<colgroup>
<col v-for="column in [...leftVisibleColumns, ...centerVisibleColumns]" :key="column.id" :style="colStyle(column)" />
<DataGridTableFillCol v-if="hasRightPinnedColumns" />
<col v-for="column in rightVisibleColumns" :key="column.id" :style="colStyle(column)" />
<DataGridTableFillCol v-if="!hasRightPinnedColumns" />
</colgroup>
<thead :class="cn(gridProps.tableClassNames?.header, gridProps.tableLayout?.headerSticky && gridProps.tableClassNames?.headerSticky)">
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
:class="
cn(
gridProps.tableLayout?.headerBorder && '[&>th]:border-b',
gridProps.tableLayout?.cellBorder && '*:last:border-e-0',
gridProps.tableLayout?.headerBackground === false && 'bg-transparent',
gridProps.tableClassNames?.headerRow
)
"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
:ref="(el) => registerHeaderCell(header.column.id, el as Element | null)"
scope="col"
:colspan="header.colSpan > 1 ? header.colSpan : undefined"
:style="headCellStyle(header)"
:data-pinned="header.column.getIsPinned() || undefined"
:class="headCellClass(header)"
>
<div class="flex items-center justify-start gap-0.5">
<Button
v-if="canOrderColumn(header.column)"
data-slot="data-grid-dnd-handle"
size="icon-sm"
variant="ghost"
:class="cn('-ms-2 size-6', draggingColumnId === header.column.id ? 'cursor-grabbing' : 'cursor-grab active:cursor-grabbing')"
aria-label="Drag to reorder"
role="button"
aria-roledescription="sortable"
:aria-pressed="grabbedColumnId === header.column.id"
tabindex="0"
@keydown="makeHandleKeydown(header.column.id)($event)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="opacity-60 hover:opacity-100" aria-hidden="true"><circle cx="9" cy="5" r="1" /><circle cx="9" cy="12" r="1" /><circle cx="9" cy="19" r="1" /><circle cx="15" cy="5" r="1" /><circle cx="15" cy="12" r="1" /><circle cx="15" cy="19" r="1" /></svg>
</Button>
<div class="grow">
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header" :props="header.getContext()" />
</div>
<DataGridTableHeadRowCellResize v-if="gridProps.tableLayout?.columnsResizable && header.column.getCanResize()" :header="header" />
</div>
</th>
<DataGridTableFillHeadCell />
</tr>
</thead>
<tbody v-if="gridProps.tableLayout?.stripped || !gridProps.tableLayout?.rowBorder" aria-hidden="true" class="h-2" data-slot="data-grid-table-body-spacer"></tbody>
<tbody data-slot="data-grid-table-body">
<tr v-if="table.getRowModel().rows.length === 0">
<td :colspan="Math.max(table.getVisibleFlatColumns().length + 1, 1)" class="text-muted-foreground py-6 text-center text-sm">
{{ gridProps.emptyMessage || "No data available" }}
</td>
</tr>
<template v-else v-for="row in table.getRowModel().rows" :key="row.id">
<tr
:data-state="table.options.enableRowSelection && row.getIsSelected() ? 'selected' : undefined"
:data-row-id="row.id"
:class="bodyRowClass(row)"
@click="handleRowClick(row)"
>
<td v-for="cell in row.getVisibleCells()" :key="cell.id" :style="bodyCellStyle(cell)" :data-pinned="cell.column.getIsPinned() || undefined" :class="bodyCellClass(cell)">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</td>
<DataGridTableFillBodyCell />
</tr>
</template>
</tbody>
<tfoot v-if="$slots.footer" data-slot="data-grid-table-foot" :class="cn(gridProps.tableClassNames?.footer)">
<slot name="footer" />
</tfoot>
</table>
</DataGridTableViewport>
</template>
src/reui/data-grid/DataGridTableDndRow.vue
<script setup lang="ts">
/**
* Внутренний подкомпонент `DataGridTableDndRows` — одна перетаскиваемая
* строка (`DataGridTableDndRow` в оригинале, не экспортируется публично).
* Тот же приём, что и `SortableItem.vue`/`KanbanItem.vue` (ADR-002): сам
* `<tr>` — источник (`draggable`) и цель (`dropTargetForElements`) дропа,
* а клавиатурный фокус/`keydown` живут только на `DataGridTableDndRowHandle`
* внутри строки (передаётся через `DataGridRowDndHandleContextKey`).
*
* Не дженерик (см. docs/PORTING.md, `filters`/`Sortable` §: связывание
* параметра типа между независимыми `.vue`-файлами через vue-tsc ненадёжно
* на глубину нескольких компонентов) — работает с `Row<any>`, типобезопасность
* сохраняется на публичной границе `DataGridTableDndRows.vue`.
*/
import { computed, inject, nextTick, onBeforeUnmount, provide, ref, watchEffect } from "vue"
import { FlexRender, type Row } from "@tanstack/vue-table"
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"
import { cn } from "@/lib/utils"
import { DataGridRowDndHandleContextKey, DataGridRowDndInternalContextKey, useDataGrid } from "./context"
import DataGridTableFillBodyCell from "./DataGridTableFillBodyCell.vue"
const props = defineProps<{
row: Row<unknown>
}>()
const { table, props: gridProps } = useDataGrid()
const internal = inject(DataGridRowDndInternalContextKey, undefined)
const rowEl = ref<HTMLElement | null>(null)
const handleEl = ref<HTMLElement | null>(null)
const isDragging = computed(() => internal?.activeId.value === props.row.id)
function registerHandle(element: HTMLElement | null) {
handleEl.value = element
}
watchEffect((onCleanup) => {
const element = rowEl.value
if (!element || !internal) return
const stop = combine(
draggable({
element,
dragHandle: handleEl.value ?? undefined,
getInitialData: () => ({ dataGridRowDndInstance: internal.instanceId, dataGridRowDndRowId: props.row.id }),
}),
dropTargetForElements({
element,
getData: () => ({ dataGridRowDndInstance: internal.instanceId, dataGridRowDndRowId: props.row.id }),
})
)
internal.registerRowElement(props.row.id, element)
onCleanup(() => {
internal.unregisterRowElement(props.row.id)
stop()
})
})
function handleKeydown(event: KeyboardEvent) {
if (!internal) return
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault()
internal.moveByKeyboard(props.row.id, event.key === "ArrowUp" ? -1 : 1)
// Перестановка строк физически передвигает <tr> внутри <table>
// (DataGridTableDnd.vue — тот же эффект для колонок, см. комментарий
// там же и docs/PORTING.md): Chromium снимает фокус с ручки при таком
// перемещении, хотя сам DOM-узел не пересоздаётся. Без возврата
// фокуса повторное нажатие стрелки для продолжения перемещения той же
// строки улетает в document.body. handleEl — тот же (передвинутый, не
// пересозданный) узел, так как компонент строки сохраняет identity по
// :key="row.id".
nextTick(() => {
handleEl.value?.focus()
})
}
}
provide(DataGridRowDndHandleContextKey, {
listeners: { onKeydown: handleKeydown },
get isDragging() {
return isDragging.value
},
registerHandle,
})
const bodyCellSpacing = computed(() => (gridProps.tableLayout?.dense ? "px-2 py-1.5" : "px-3 py-2"))
function bodyCellClass(cell: ReturnType<Row<unknown>["getVisibleCells"]>[number]) {
return cn(
"align-middle",
bodyCellSpacing.value,
gridProps.tableLayout?.cellBorder && "border-e",
cell.column.columnDef.meta?.cellClassName
)
}
const bodyRowBottomBorderClasses =
"[&:not(:last-child)>td]:border-b [tbody:has(+tfoot)_&:last-child>td]:border-b [*:has(>[data-slot=data-grid]+[data-slot=data-grid-pagination])_[data-slot=data-grid]_&:last-child>td]:border-b"
const rowClass = computed(() =>
cn(
"hover:bg-muted/40 data-[state=selected]:bg-muted/50",
gridProps.onRowClick && "cursor-pointer",
!gridProps.tableLayout?.stripped && gridProps.tableLayout?.rowBorder && bodyRowBottomBorderClasses,
table.options.enableRowSelection && "*:first:relative",
gridProps.tableClassNames?.bodyRow
)
)
function handleRowClick() {
gridProps.onRowClick?.(props.row.original)
}
onBeforeUnmount(() => {
registerHandle(null)
})
</script>
<template>
<tr
ref="rowEl"
:data-state="table.options.enableRowSelection && row.getIsSelected() ? 'selected' : undefined"
:data-row-id="row.id"
:style="{ position: 'relative', opacity: isDragging ? 0.8 : undefined, zIndex: isDragging ? 1 : undefined }"
:class="rowClass"
@click="handleRowClick"
>
<td v-for="cell in row.getVisibleCells()" :key="cell.id" :class="bodyCellClass(cell)">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</td>
<DataGridTableFillBodyCell />
</tr>
</template>
src/reui/data-grid/DataGridTableDndRowHandle.vue
<script setup lang="ts">
/**
* Порт `DataGridTableDndRowHandle` (data-grid-table-dnd-rows.tsx). Единственный
* фокусируемый узел строки (role/tabindex/aria-pressed/keydown) — см. шапку
* `DataGridTableDndRow.vue` и `reui/sortable/SortableItemHandle.vue` (тот же
* приём). Используется внутри пользовательской ячейки (`cell: () =>
* h(DataGridTableDndRowHandle)`), поэтому читает контекст, а не пропы.
*
* `IconPlaceholder` (docs/PORTING.md §5) заменён инлайновым `<svg>`
* (lucide "grip-horizontal"). Фолбэк на "выключенную" кнопку, когда контекст
* недоступен (строка отрисована не внутри `DataGridTableDndRows`), перенесён
* из оригинала буквально (правило 4a).
*/
import type { HTMLAttributes } from "vue"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { useDataGridRowDndHandleContext } from "./context"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const context = useDataGridRowDndHandleContext()
</script>
<template>
<Button
v-if="!context.listeners"
variant="ghost"
size="icon-sm"
:class="cn('size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing', props.class)"
aria-label="Drag to reorder row"
disabled
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="9" cy="12" r="1" /><circle cx="15" cy="12" r="1" /></svg>
</Button>
<Button
v-else
:ref="(el) => context.registerHandle((el as { $el?: HTMLElement } | null)?.$el ?? null)"
variant="ghost"
size="icon-sm"
:class="cn('size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing', props.class)"
aria-label="Drag to reorder row"
role="button"
aria-roledescription="sortable"
:aria-pressed="context.isDragging"
tabindex="0"
@keydown="context.listeners.onKeydown"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="9" cy="12" r="1" /><circle cx="15" cy="12" r="1" /></svg>
</Button>
</template>
src/reui/data-grid/DataGridTableDndRows.vue
<script setup lang="ts" generic="TData extends object">
/**
* Порт `DataGridTableDndRows` (data-grid-table-dnd-rows.tsx) —
* перетаскиваемые строки. `@dnd-kit/*` -> `@atlaskit/pragmatic-drag-and-drop`
* (ADR-002), тот же рецепт "один monitor + draggable/dropTarget на элемент",
* что и `DataGridTableDnd.vue` (колонки) и `reui/sortable`/`reui/kanban`.
* Перестановка вычисляется ОДИН раз на `onDrop`, не на каждом наведении
* (см. комментарий у Kanban про дрожание живого предпросмотра).
*
* Осознанное отступление от сигнатуры оригинала: `dataIds` — обычный проп
* (не `defineModel`), а не внутреннее состояние компонента. TanStack не
* хранит "порядок строк" отдельно от `data` массива потребителя (в отличие
* от `columnOrder` у колонок), поэтому, как и в оригинале, коммит остаётся
* ЦЕЛИКОМ на стороне потребителя через обязательный `onDragEnd` — компонент
* только сообщает `{ active, over }`, не трогает данные сам. Это относится
* и к клавиатурному пути: каждая стрелка коммитит немедленно (тот же стиль,
* что у Sortable/Kanban), но "коммит" здесь — это тоже просто вызов
* `onDragEnd`, а не запись в `value` (компоненту нечего писать).
*
* `IconPlaceholder` (docs/PORTING.md §5) не задета этим файлом напрямую
* (иконка — в `DataGridTableDndRowHandle.vue`).
*/
import { computed, onBeforeUnmount, onMounted, provide, ref } from "vue"
import type { HTMLAttributes } from "vue"
import { FlexRender, type Column, type Header } from "@tanstack/vue-table"
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"
import { cn } from "@/lib/utils"
import {
DataGridRowDndInternalContextKey,
type DataGridRowDndEvent,
useDataGrid,
} from "./context"
import DataGridTableViewport from "./DataGridTableViewport.vue"
import DataGridTableFillCol from "./DataGridTableFillCol.vue"
import DataGridTableFillHeadCell from "./DataGridTableFillHeadCell.vue"
import DataGridTableHeadRowCellResize from "./DataGridTableHeadRowCellResize.vue"
import DataGridTableDndRow from "./DataGridTableDndRow.vue"
const props = defineProps<{
dataIds: Array<string | number>
onDragStart?: (event: DataGridRowDndEvent) => void
onDragEnd: (event: DataGridRowDndEvent) => void
onDragCancel?: (event: DataGridRowDndEvent) => void
class?: HTMLAttributes["class"]
}>()
const { table, props: gridProps } = useDataGrid<TData>()
const instanceId = Symbol("data-grid-row-dnd-instance")
const containerRef = ref<{ $el: HTMLElement } | null>(null)
const activeId = ref<string | null>(null)
function colStyle(column: Column<TData, unknown>) {
if (gridProps.tableLayout?.columnsResizable) return { width: `calc(var(--col-${column.id}-size) * 1px)` }
if (gridProps.tableLayout?.width === "fixed") return { width: `${column.getSize()}px` }
return undefined
}
function headCellStyle(header: Header<TData, unknown>) {
return {
...(gridProps.tableLayout?.width === "fixed" &&
!gridProps.tableLayout?.columnsResizable && { width: `${header.getSize()}px` }),
...(gridProps.tableLayout?.columnsResizable && { width: `calc(var(--header-${header.id}-size) * 1px)` }),
}
}
const headerCellSpacing = computed(() => (gridProps.tableLayout?.dense ? "px-2 h-8" : "px-3"))
function headCellClass(header: Header<TData, unknown>) {
const isLastVisible = header.column.getIndex() === table.getVisibleLeafColumns().length - 1
return cn(
"text-foreground relative h-10 text-left align-middle font-medium rtl:text-right [&:has([role=checkbox])]:pe-0",
headerCellSpacing.value,
gridProps.tableLayout?.headerBackground && "bg-muted",
gridProps.tableLayout?.cellBorder && "border-e",
gridProps.tableLayout?.columnsResizable && header.column.getCanResize() && isLastVisible && "pe-8",
header.column.columnDef.meta?.headerClassName
)
}
const columnSizeVars = computed<Record<string, number> | undefined>(() => {
if (!gridProps.tableLayout?.columnsResizable) return undefined
const headers = table.getFlatHeaders()
const colSizes: Record<string, number> = {}
for (const header of headers) {
colSizes[`--header-${header.id}-size`] = header.getSize()
colSizes[`--col-${header.column.id}-size`] = header.column.getSize()
}
return colSizes
})
const tableStyle = computed(() =>
gridProps.tableLayout?.columnsResizable
? { ...columnSizeVars.value, width: `calc(${table.getTotalSize()}px + var(--data-grid-fill-size, 0px))` }
: undefined
)
// --- регистрация строк + перестановка ---------------------------------------
const rowElements = new Map<string, HTMLElement>()
function registerRowElement(id: string, element: HTMLElement) {
rowElements.set(id, element)
}
function unregisterRowElement(id: string) {
rowElements.delete(id)
}
function commitDrop(activeValue: string, overValue: string | null) {
const wasActive = activeId.value === activeValue
activeId.value = null
const event: DataGridRowDndEvent = { active: { id: activeValue }, over: overValue ? { id: overValue } : null }
if (!wasActive) return
if (overValue && overValue !== activeValue) {
props.onDragEnd(event)
} else {
props.onDragCancel?.(event)
}
}
function moveByKeyboard(activeValue: string, direction: -1 | 1) {
const ids = props.dataIds.map(String)
const activeIndex = ids.indexOf(activeValue)
if (activeIndex === -1) return
const overIndex = activeIndex + direction
if (overIndex < 0 || overIndex >= ids.length) return
const overValue = ids[overIndex] as string
props.onDragEnd({ active: { id: activeValue }, over: { id: overValue } })
}
provide(DataGridRowDndInternalContextKey, {
activeId,
instanceId,
registerRowElement,
unregisterRowElement,
moveByKeyboard,
})
let stopEngine: (() => void) | undefined
onMounted(() => {
const container = containerRef.value?.$el as HTMLElement | undefined
const teardown = [
monitorForElements({
canMonitor: ({ source }) => source.data.dataGridRowDndInstance === instanceId,
onDragStart({ source }) {
const id = source.data.dataGridRowDndRowId as string
activeId.value = id
props.onDragStart?.({ active: { id }, over: null })
},
onDrop({ source, location }) {
const activeValue = source.data.dataGridRowDndRowId as string
const dropTargets = location.current.dropTargets.filter(
(target) => target.data.dataGridRowDndInstance === instanceId
)
const overValue = dropTargets[0]?.data.dataGridRowDndRowId as string | undefined
commitDrop(activeValue, overValue ?? null)
},
}),
container
? autoScrollForElements({
element: container,
canScroll: ({ source }) => source.data.dataGridRowDndInstance === instanceId,
})
: () => {},
]
stopEngine = combine(...teardown)
})
onBeforeUnmount(() => {
stopEngine?.()
})
</script>
<template>
<DataGridTableViewport ref="containerRef" :class="cn('relative', activeId && 'cursor-grabbing [&_*]:cursor-grabbing!', props.class)">
<table
data-slot="data-grid-table"
:class="
cn(
'text-foreground caption-bottom text-left align-middle text-sm font-normal rtl:text-right',
gridProps.tableLayout?.columnsResizable ? 'min-w-0' : 'w-full min-w-full',
gridProps.tableLayout?.width === 'auto' ? 'table-auto' : 'table-fixed',
'border-separate border-spacing-0',
gridProps.tableClassNames?.base
)
"
:style="tableStyle"
>
<colgroup>
<col v-for="column in table.getVisibleLeafColumns()" :key="column.id" :style="colStyle(column)" />
<DataGridTableFillCol />
</colgroup>
<thead :class="cn(gridProps.tableClassNames?.header, gridProps.tableLayout?.headerSticky && gridProps.tableClassNames?.headerSticky)">
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
:class="cn(gridProps.tableLayout?.headerBorder && '[&>th]:border-b', gridProps.tableClassNames?.headerRow)"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
scope="col"
:colspan="header.colSpan > 1 ? header.colSpan : undefined"
:style="headCellStyle(header)"
:class="headCellClass(header)"
>
<div :class="gridProps.tableLayout?.columnsResizable && header.column.getCanResize() ? 'truncate' : undefined">
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header" :props="header.getContext()" />
</div>
<DataGridTableHeadRowCellResize v-if="gridProps.tableLayout?.columnsResizable && header.column.getCanResize()" :header="header" />
</th>
<DataGridTableFillHeadCell />
</tr>
</thead>
<tbody v-if="gridProps.tableLayout?.stripped || !gridProps.tableLayout?.rowBorder" aria-hidden="true" class="h-2" data-slot="data-grid-table-body-spacer"></tbody>
<tbody data-slot="data-grid-table-body">
<tr v-if="table.getRowModel().rows.length === 0">
<td :colspan="Math.max(table.getVisibleFlatColumns().length + 1, 1)" class="text-muted-foreground py-6 text-center text-sm">
{{ gridProps.emptyMessage || "No data available" }}
</td>
</tr>
<DataGridTableDndRow v-for="row in table.getRowModel().rows" :key="row.id" :row="row" />
</tbody>
<tfoot v-if="$slots.footer" data-slot="data-grid-table-foot" :class="cn(gridProps.tableClassNames?.footer)">
<slot name="footer" />
</tfoot>
</table>
</DataGridTableViewport>
</template>
src/reui/data-grid/DataGridTableFillBodyCell.vue
<script setup lang="ts">
import { useDataGrid } from "./context"
const { props } = useDataGrid()
</script>
<template>
<td
v-if="props.tableLayout?.columnsResizable"
aria-hidden="true"
data-slot="data-grid-table-fill-body-cell"
style="width: var(--data-grid-fill-size, 0px)"
class="p-0"
/>
</template>
src/reui/data-grid/DataGridTableFillCol.vue
<script setup lang="ts">
import { useDataGrid } from "./context"
const { props } = useDataGrid()
</script>
<template>
<col
v-if="props.tableLayout?.columnsResizable"
data-slot="data-grid-table-fill-col"
style="width: var(--data-grid-fill-size, 0px)"
/>
</template>
src/reui/data-grid/DataGridTableFillFootCell.vue
<script setup lang="ts">
import { useDataGrid } from "./context"
const { props } = useDataGrid()
</script>
<template>
<td
v-if="props.tableLayout?.columnsResizable"
aria-hidden="true"
data-slot="data-grid-table-fill-foot-cell"
style="width: var(--data-grid-fill-size, 0px)"
class="p-0"
/>
</template>
src/reui/data-grid/DataGridTableFillHeadCell.vue
<script setup lang="ts">
import { cn } from "@/lib/utils"
import { useDataGrid } from "./context"
const { props } = useDataGrid()
</script>
<template>
<th
v-if="props.tableLayout?.columnsResizable"
aria-hidden="true"
data-slot="data-grid-table-fill-head-cell"
style="width: var(--data-grid-fill-size, 0px)"
:class="cn('p-0', props.tableLayout?.headerBackground && 'bg-muted')"
/>
</template>
src/reui/data-grid/DataGridTableFootRow.vue
<script setup lang="ts">
import { cn } from "@/lib/utils"
import { useDataGrid } from "./context"
import DataGridTableFillFootCell from "./DataGridTableFillFootCell.vue"
const { props: gridProps } = useDataGrid()
const footRowBottomBorderClasses = "[&:not(:last-child)>td]:border-b"
</script>
<template>
<tr
data-slot="data-grid-table-foot-row"
:class="
cn(
gridProps.tableLayout?.footerBackground && 'bg-muted/40 dark:bg-background',
gridProps.tableLayout?.rowBorder && footRowBottomBorderClasses,
gridProps.tableLayout?.cellBorder && '*:last:border-e-0'
)
"
>
<slot />
<DataGridTableFillFootCell />
</tr>
</template>
src/reui/data-grid/DataGridTableFootRowCell.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { useDataGrid } from "./context"
const props = defineProps<{
colSpan?: number
class?: HTMLAttributes["class"]
}>()
const { props: gridProps } = useDataGrid()
const spacing = (dense?: boolean) => (dense ? "px-2 py-1.5" : "px-3 py-2")
</script>
<template>
<td
:colspan="props.colSpan"
:class="
cn(
'text-secondary-foreground/80 align-middle font-medium',
spacing(gridProps.tableLayout?.dense),
gridProps.tableLayout?.footerBackground && 'bg-muted/40 dark:bg-background',
gridProps.tableLayout?.cellBorder && 'border-e',
props.class
)
"
>
<slot />
</td>
</template>
src/reui/data-grid/DataGridTableHeadRowCellResize.vue
<script setup lang="ts" generic="TData extends object">
import type { Header } from "@tanstack/vue-table"
import { computed, onBeforeUnmount } from "vue"
import { cn } from "@/lib/utils"
import { useDataGrid } from "./context"
import { startDataGridColumnResizeOnEnd } from "./resize"
const props = defineProps<{
header: Header<TData, unknown>
}>()
const { props: gridProps, table } = useDataGrid<TData>()
const { column } = props.header
const isPinned = computed(() => column.getIsPinned())
const isLastVisibleColumn = computed(
() => column.getIndex() === props.header.getContext().table.getVisibleLeafColumns().length - 1
)
const isResizeModeOnEnd = computed(
() => (gridProps.tableLayout?.columnsResizeMode ?? table.options.columnResizeMode) === "onEnd"
)
let stopResizeSession: (() => void) | undefined
onBeforeUnmount(() => {
stopResizeSession?.()
stopResizeSession = undefined
})
function handleMouseDown(event: MouseEvent) {
// Only the primary button starts a resize; guard before preventDefault so
// right-click still opens the context menu.
if (event.button !== 0) return
event.preventDefault()
event.stopPropagation()
if (isResizeModeOnEnd.value) {
stopResizeSession?.()
stopResizeSession = startDataGridColumnResizeOnEnd(event, props.header, table)
return
}
header_getResizeHandler(event)
}
function handleTouchStart(event: TouchEvent) {
event.preventDefault()
event.stopPropagation()
if (isResizeModeOnEnd.value) {
stopResizeSession?.()
stopResizeSession = startDataGridColumnResizeOnEnd(event, props.header, table)
return
}
header_getResizeHandler(event)
}
function header_getResizeHandler(event: MouseEvent | TouchEvent) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(props.header.getResizeHandler() as any)(event)
}
</script>
<template>
<div
@dblclick="column.resetSize()"
@mousedown="handleMouseDown"
@touchstart="handleTouchStart"
:class="
cn(
'absolute top-0 h-full cursor-col-resize user-select-none touch-none z-10 flex',
isLastVisibleColumn
? 'end-0 w-5 justify-end before:hidden'
: isPinned
? 'end-0 w-5 justify-end before:hidden'
: '-end-2 w-5 justify-center before:absolute before:inset-y-0 before:w-px before:-translate-x-px before:bg-border',
column.getIsResizing() &&
(isResizeModeOnEnd
? 'opacity-100'
: isLastVisibleColumn
? 'before:absolute before:end-0 before:block before:inset-y-0 before:w-0.5 before:bg-primary opacity-100'
: 'before:block before:bg-primary before:w-0.5 opacity-100')
)
"
/>
</template>
src/reui/data-grid/DataGridTableResizeIndicator.vue
<script setup lang="ts">
/**
* Порт `DataGridTableResizeIndicator` (data-grid-table.tsx). Оригинал
* позиционирует индикатор императивно в `useLayoutEffect` (снимок
* `columnSizingInfo` + прямой доступ к DOM избегают лишнего React-рендера
* на каждый мышемув драга). Здесь то же самое: `watchEffect` — реактивный
* аналог `useLayoutEffect` с зависимостями, тело эффекта пишет стили
* напрямую в DOM.
*/
import { ref, watchEffect } from "vue"
import { useDataGrid } from "./context"
const props = defineProps<{
viewportEl: HTMLDivElement | null
}>()
const { props: gridProps, table } = useDataGrid()
const indicatorRef = ref<HTMLDivElement | null>(null)
const indicatorHeadRef = ref<HTMLDivElement | null>(null)
const headerHeightCache = { key: false as string | false, value: 0 }
const isActive = () => {
const columnSizingInfo = table.getState().columnSizingInfo
const resizeMode =
gridProps.tableLayout?.columnsResizeMode ?? table.options.columnResizeMode
return !!(
gridProps.tableLayout?.columnsResizable &&
resizeMode === "onEnd" &&
columnSizingInfo.isResizingColumn
)
}
watchEffect(() => {
const columnSizingInfo = table.getState().columnSizingInfo
const resizingColumnId = columnSizingInfo.isResizingColumn
const indicator = indicatorRef.value
const indicatorHead = indicatorHeadRef.value
const viewportElement = props.viewportEl
if (!isActive() || !indicator || !indicatorHead || !resizingColumnId) return
const resizingHeader = table
.getFlatHeaders()
.find(
(header) =>
header.column.id === resizingColumnId || header.id === resizingColumnId
)
if (!resizingHeader) return
const directionMultiplier =
table.options.columnResizeDirection === "rtl" ? -1 : 1
const deltaOffset = (columnSizingInfo.deltaOffset ?? 0) * directionMultiplier
if (headerHeightCache.key !== resizingColumnId) {
headerHeightCache.key = resizingColumnId
headerHeightCache.value =
viewportElement
?.querySelector('[data-slot="data-grid-table"] thead')
?.getBoundingClientRect().height ?? 0
}
const headerHeight = headerHeightCache.value
const indicatorLeft =
typeof columnSizingInfo.startOffset === "number" && viewportElement
? columnSizingInfo.startOffset - viewportElement.getBoundingClientRect().left
: resizingHeader.getStart() + resizingHeader.getSize()
indicator.style.left = `${indicatorLeft}px`
indicator.style.transform = `translateX(${deltaOffset}px)`
indicatorHead.style.height = `${Math.max(headerHeight, 6)}px`
})
</script>
<template>
<div
v-if="isActive()"
ref="indicatorRef"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 z-20"
>
<div class="bg-primary/85 absolute inset-y-0 left-0 w-px -translate-x-1/2" />
<div
ref="indicatorHeadRef"
class="bg-primary style-vega:rounded-b-sm style-nova:rounded-b-sm style-maia:rounded-b-md style-lyra:rounded-b-none style-mira:rounded-b-sm style-luma:rounded-b-lg style-sera:rounded-b-none style-rhea:rounded-b-lg absolute top-0 left-0 -translate-x-1/2 shadow-xs"
style="width: 5px"
/>
</div>
</template>
src/reui/data-grid/DataGridTableRowPin.vue
<script setup lang="ts" generic="TData extends object">
import type { Row } from "@tanstack/vue-table"
import { computed } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
row: Row<TData>
}>()
const isPinned = computed(() => props.row.getIsPinned())
function handleClick(event: MouseEvent) {
// Pinning must not bubble into the row's onRowClick handler.
event.stopPropagation()
if (isPinned.value) {
props.row.pin(false)
} else {
props.row.pin("top")
}
}
</script>
<template>
<button
type="button"
:aria-label="isPinned ? 'Unpin row' : 'Pin row'"
@click="handleClick"
:class="
cn(
'text-muted-foreground hover:text-foreground style-vega:rounded-md style-nova:rounded-lg style-maia:rounded-full style-lyra:rounded-none style-mira:rounded-md style-luma:rounded-full style-sera:rounded-none style-rhea:rounded-full inline-flex size-7 items-center justify-center transition-colors',
isPinned && 'text-primary hover:text-primary/80'
)
"
>
<svg
v-if="isPinned"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
stroke="none"
>
<path
d="M16 2l4.585 4.586-2.122 2.121L17.05 7.293l-3.535 3.536 1.413 5.658-2.12 2.121-4.244-4.243L4.322 18.6l-1.414-1.41 4.242-4.244-4.243-4.243 2.122-2.121 5.656 1.414 3.536-3.536-1.414-1.414z"
/>
</svg>
<svg
v-else
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="12" y1="17" x2="12" y2="22" />
<path
d="M5 17h14v-1.76a2 2 0 00-1.11-1.79l-1.78-.9A2 2 0 0115 10.76V6h1a2 2 0 000-4H8a2 2 0 000 4h1v4.76a2 2 0 01-1.11 1.79l-1.78.9A2 2 0 005 15.24z"
/>
</svg>
</button>
</template>
src/reui/data-grid/DataGridTableRowSelect.vue
<script setup lang="ts" generic="TData extends object">
import type { Row } from "@tanstack/vue-table"
import { cn } from "@/lib/utils"
import { Checkbox } from "@/components/ui/checkbox"
const props = defineProps<{
row: Row<TData>
}>()
</script>
<template>
<div
:class="
cn(
'bg-primary absolute inset-s-0 top-0 bottom-0 hidden w-[2px]',
props.row.getIsSelected() && 'block'
)
"
></div>
<Checkbox
:model-value="props.row.getIsSelected()"
@update:model-value="(value) => props.row.toggleSelected(!!value)"
@click="(event: MouseEvent) => event.stopPropagation()"
aria-label="Select row"
class="align-[inherit]"
/>
</template>
src/reui/data-grid/DataGridTableRowSelectAll.vue
<script setup lang="ts">
import { Checkbox } from "@/components/ui/checkbox"
import { useDataGrid } from "./context"
const { table, recordCount, isLoading } = useDataGrid()
</script>
<template>
<Checkbox
:model-value="
table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()
? 'indeterminate'
: table.getIsAllPageRowsSelected()
"
:disabled="isLoading || recordCount === 0"
@update:model-value="(value) => table.toggleAllPageRowsSelected(!!value)"
aria-label="Select all"
class="align-[inherit]"
/>
</template>
src/reui/data-grid/DataGridTableViewport.vue
<script setup lang="ts" generic="TData extends object">
/**
* Порт `DataGridTableViewport` (data-grid-table.tsx). В оригинале свободное
* место записывается CSS-переменной прямо на DOM-узле (а не в React state),
* чтобы ресайз контейнера/коммит размера колонки не вызывал React-рендер
* всей сетки. Vue reactivity не платит за это так же дорого (сеттеры
* `columnSizing` уже реактивны и не переинициализируют дерево), но чтобы не
* распылять числовую логику (что именно 1:1 совпадает с апстримом) —
* порт оставляет тот же приём: `ResizeObserver` + прямой `style.setProperty`.
*/
import { computed, onBeforeUnmount, useTemplateRef, watchEffect } from "vue"
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { getDataGridScrollAreaViewport, useDataGrid } from "./context"
import DataGridTableResizeIndicator from "./DataGridTableResizeIndicator.vue"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const { props: gridProps, table, autoSize } = useDataGrid<TData>()
const viewportRef = useTemplateRef<HTMLDivElement>("viewportEl")
const isColumnsResizable = computed(() => !!gridProps.tableLayout?.columnsResizable)
const fillState = { containerWidth: 0, appliedFill: -1 }
let stopObserver: (() => void) | null = null
function syncFillWidth() {
const node = viewportRef.value
if (!node) return
const fillWidth = Math.max(0, fillState.containerWidth - table.getTotalSize())
if (fillState.appliedFill !== fillWidth) {
fillState.appliedFill = fillWidth
node.style.setProperty("--data-grid-fill-size", `${fillWidth}px`)
}
autoSize?.apply(fillWidth)
}
watchEffect(() => {
const node = viewportRef.value
// Пересоздаём наблюдатель при смене узла или переключении columnsResizable.
const resizable = isColumnsResizable.value
stopObserver?.()
stopObserver = null
if (!node) return
if (!resizable) {
fillState.appliedFill = -1
node.style.removeProperty("--data-grid-fill-size")
return
}
const scrollViewport = getDataGridScrollAreaViewport(node) ?? node.parentElement
const measurementTarget = scrollViewport ?? node
const measure = () => {
fillState.containerWidth = (measurementTarget as HTMLElement).clientWidth
syncFillWidth()
}
measure()
if (typeof ResizeObserver !== "undefined") {
const observer = new ResizeObserver(measure)
observer.observe(measurementTarget as HTMLElement)
stopObserver = () => observer.disconnect()
}
})
// Column sizing commits and visibility changes alter the table's total size
// without moving the container, so the fill var must re-sync after renders
// the ResizeObserver never sees.
watchEffect(() => {
if (!isColumnsResizable.value) return
table.getState().columnSizing
table.getState().columnVisibility
syncFillWidth()
})
onBeforeUnmount(() => stopObserver?.())
const style = computed(() =>
isColumnsResizable.value
? { width: `calc(${table.getTotalSize()}px + var(--data-grid-fill-size, 0px))` }
: undefined
)
</script>
<template>
<div
ref="viewportEl"
data-slot="data-grid-table-viewport"
:class="cn('relative min-w-full align-top', props.class)"
:style="style"
>
<slot />
<DataGridTableResizeIndicator :viewport-el="viewportRef" />
</div>
</template>
src/reui/data-grid/DataGridTableVirtual.vue
<script setup lang="ts" generic="TData extends object">
/**
* Порт `DataGridTableVirtual` (data-grid-table-virtual.tsx).
*
* `@tanstack/react-virtual` -> `@tanstack/vue-virtual`: тот же `virtual-core`
* движок (`Virtualizer`/`VirtualItem`), другой тонкий адаптер — `useVirtualizer`
* принимает `MaybeRef<Options>` вместо React-пропов и возвращает
* `Ref<Virtualizer>` вместо значения хука.
*
* Оригинал (~960 строк) собран из десятка мелких переиспользуемых
* подкомпонентов `data-grid-table.tsx` (`DataGridTableHeadRowCell`,
* `DataGridTableRenderedRow`, ...), которых у порта нет: `DataGridTable.vue`
* сознательно свернул их в один SFC (см. комментарий в файле, тот же выбор
* уже сделан для обычной таблицы). Здесь по той же причине голова/тело
* таблицы собраны заново одним SFC, а не через переиспользование
* `DataGridTable.vue` — виртуализация меняет структуру `<tbody>` достаточно,
* что делить код между обоими компонентами не с чем: единственное общее —
* несколько маленьких функций стилизации ячеек, продублированных из
* `DataGridTable.vue` (`headCellClass/Style`, `bodyCellClass/Style`,
* `bodyRowClass`) — по духу то же решение, что и дублирование `variants.ts`
* между отдельными компонентами вместо общего примитива.
*
* ponytail: сознательно не портированы (вне ядра функциональности —
* позиционирование строк через переставляемую таблицу без layout-трюков —
* которое и есть предмет этого файла):
* - императивный `scrollToRowIndex`/`scrollToRowAlign`/`scrollBehavior`
* (программная прокрутка к строке из потребителя);
* - произвольный `virtualizerOptions` passthrough и переключатель
* `enabled: false` (виртуализация всегда включена);
* - динамическое измерение высоты строки через `measureElement`/
* `ResizeObserver` на каждую строку (позиционирование только по
* `estimateSize`, как для сеток с фиксированной высотой строки).
* Добавить, если конкретный блок/кейс потребует одну из этих возможностей.
*/
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"
import type { HTMLAttributes } from "vue"
import { FlexRender, type Cell, type Column, type Header, type Row } from "@tanstack/vue-table"
import { useVirtualizer } from "@tanstack/vue-virtual"
import { cn } from "@/lib/utils"
import { Spinner } from "@/components/ui/spinner"
import {
getDataGridScrollAreaViewport,
getDataGridTableMergedHeaderGroups,
getDataGridTableRowSections,
getPinningStyles,
hasDataGridTableRightPinnedColumns,
useDataGrid,
type DataGridTablePinnedBoundary,
} from "./context"
import DataGridTableFillCol from "./DataGridTableFillCol.vue"
import DataGridTableFillHeadCell from "./DataGridTableFillHeadCell.vue"
import DataGridTableFillBodyCell from "./DataGridTableFillBodyCell.vue"
import DataGridTableHeadRowCellResize from "./DataGridTableHeadRowCellResize.vue"
const props = withDefaults(
defineProps<{
height?: number | string
estimateSize?: number
overscan?: number
renderHeader?: boolean
fetchMoreOffset?: number
isFetchingMore?: boolean
hasMore?: boolean
class?: HTMLAttributes["class"]
}>(),
{
estimateSize: 48,
overscan: 10,
renderHeader: true,
fetchMoreOffset: 0,
isFetchingMore: false,
}
)
const emit = defineEmits<{ (e: "fetch-more"): void }>()
const { table, props: gridProps, isLoading } = useDataGrid<TData>()
const containerEl = ref<HTMLDivElement | null>(null)
const scrollEl = ref<HTMLElement | null>(null)
const usesExternalScrollArea = ref(false)
onMounted(() => {
const node = containerEl.value
if (!node) return
const resolved = getDataGridScrollAreaViewport(node)
scrollEl.value = resolved ?? node
usesExternalScrollArea.value = resolved !== null && resolved !== node
})
const loadingMoreMessage = computed(
() => gridProps.fetchingMoreMessage || gridProps.loadingMessage || "Loading..."
)
const allRowsLoadedMessage = computed(() => gridProps.allRowsLoadedMessage || "All records loaded")
const sections = computed(() => getDataGridTableRowSections(table, gridProps.tableLayout?.rowsPinnable))
const centerRows = computed(() => sections.value.centerRows)
const topRows = computed(() => sections.value.topRows)
const bottomRows = computed(() => sections.value.bottomRows)
const mergedHeaderGroups = computed(() => getDataGridTableMergedHeaderGroups(table))
const hasRightPinnedColumns = computed(() => hasDataGridTableRightPinnedColumns(table))
const virtualizerOptions = computed(() => ({
count: centerRows.value.length,
getScrollElement: () => scrollEl.value,
getItemKey: (index: number) => centerRows.value[index]?.id ?? index,
estimateSize: () => props.estimateSize,
overscan: props.overscan,
}))
const virtualizer = useVirtualizer(virtualizerOptions)
const virtualItems = computed(() => virtualizer.value.getVirtualItems())
const totalSize = computed(() => virtualizer.value.getTotalSize())
const leadingSpacerHeight = computed(() =>
centerRows.value.length > 0 && virtualItems.value.length > 0 ? (virtualItems.value[0]?.start ?? 0) : 0
)
const trailingSpacerHeight = computed(() =>
centerRows.value.length > 0 && virtualItems.value.length > 0
? Math.max(0, totalSize.value - (virtualItems.value[virtualItems.value.length - 1]?.end ?? 0))
: 0
)
const resolvedVirtualRows = computed(() =>
virtualItems.value
.map((virtualRow) => ({ index: virtualRow.index, row: centerRows.value[virtualRow.index] }))
.filter((entry): entry is { index: number; row: Row<TData> } => !!entry.row)
)
const showFetchingRow = computed(() => props.isFetchingMore)
const showCompleteRow = computed(() => props.hasMore === false && centerRows.value.length > 0)
const hasMiddleSection = computed(
() => centerRows.value.length > 0 || showFetchingRow.value || showCompleteRow.value
)
const totalRows = computed(() => topRows.value.length + centerRows.value.length + bottomRows.value.length)
// Latch: fires at most once per row count, so a rapid scroll near the end
// cannot request the same page repeatedly before `isFetchingMore` flips.
let fetchMoreFiredAtCount: number | null = null
watch(
() => [virtualItems.value, centerRows.value.length, props.isFetchingMore, props.hasMore] as const,
([items, count, fetching, hasMore]) => {
if (hasMore === false || fetching) return
const lastItem = items[items.length - 1]
if (!lastItem) return
if (fetchMoreFiredAtCount === count) return
if (lastItem.index >= count - 1 - props.fetchMoreOffset) {
fetchMoreFiredAtCount = count
emit("fetch-more")
}
}
)
onBeforeUnmount(() => {
fetchMoreFiredAtCount = null
})
// --- разметка: те же вычисления, что в DataGridTable.vue (дублированы, см.
// комментарий вверху файла) -------------------------------------------------
const headerCellSpacing = computed(() => (gridProps.tableLayout?.dense ? "px-2 h-8" : "px-3"))
const bodyCellSpacing = computed(() => (gridProps.tableLayout?.dense ? "px-2 py-1.5" : "px-3 py-2"))
function colStyle(column: Column<TData, unknown>) {
if (gridProps.tableLayout?.columnsResizable) {
return { width: `calc(var(--col-${column.id}-size) * 1px)` }
}
if (gridProps.tableLayout?.width === "fixed") {
return { width: `${column.getSize()}px` }
}
return undefined
}
function headCellStyle(header: Header<TData, unknown>) {
const { column } = header
return {
...(gridProps.tableLayout?.width === "fixed" &&
!gridProps.tableLayout?.columnsResizable && { width: `${header.getSize()}px` }),
...(gridProps.tableLayout?.columnsPinnable && column.getCanPin() && getPinningStyles(column)),
...(gridProps.tableLayout?.columnsResizable && { width: `calc(var(--header-${header.id}-size) * 1px)` }),
}
}
function headCellClass(header: Header<TData, unknown>) {
const { column } = header
const isPinned = column.getIsPinned()
const isLastVisible = column.getIndex() === table.getVisibleLeafColumns().length - 1
return cn(
"text-foreground relative h-10 text-left align-middle font-medium rtl:text-right [&:has([role=checkbox])]:pe-0",
headerCellSpacing.value,
gridProps.tableLayout?.headerBackground && "bg-muted",
gridProps.tableLayout?.cellBorder && "border-e",
gridProps.tableLayout?.columnsResizable &&
column.getCanResize() &&
(isPinned ? "overflow-hidden" : "overflow-visible"),
gridProps.tableLayout?.columnsResizable && column.getCanResize() && isLastVisible && "pe-8",
gridProps.tableLayout?.columnsPinnable &&
column.getCanPin() &&
cn(
"data-pinned:bg-muted data-outer-pinned-col:bg-clip-padding data-pinned:isolate",
"[&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right]:last-child_div.cursor-col-resize:last-child]:opacity-0 [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]",
"[&:not([data-pinned]):has(+[data-pinned])_div.cursor-col-resize:last-child]:opacity-0 [&[data-last-col=left]_div.cursor-col-resize:last-child]:opacity-0"
),
header.column.columnDef.meta?.headerClassName,
column.getIndex() === 0 || isLastVisible ? gridProps.tableClassNames?.edgeCell : ""
)
}
function headOuterPinned(header: Header<TData, unknown>) {
const isPinned = header.column.getIsPinned()
const isFirstLeftPinned = isPinned === "left" && header.column.getIsFirstColumn("left")
const isLastRightPinned = isPinned === "right" && header.column.getIsLastColumn("right")
return isFirstLeftPinned ? "left" : isLastRightPinned ? "right" : undefined
}
function headLastCol(header: Header<TData, unknown>) {
const isPinned = header.column.getIsPinned()
const isLastLeftPinned = isPinned === "left" && header.column.getIsLastColumn("left")
const isFirstRightPinned = isPinned === "right" && header.column.getIsFirstColumn("right")
return isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
}
function bodyCellClass(cell: Cell<TData, unknown>) {
const { column, row } = cell
return cn(
"align-middle",
bodyCellSpacing.value,
gridProps.tableLayout?.cellBorder && "border-e",
gridProps.tableLayout?.columnsResizable && column.getCanResize() && "truncate",
cell.column.columnDef.meta?.cellClassName,
gridProps.tableLayout?.columnsPinnable &&
column.getCanPin() &&
cn(
"data-pinned:bg-background data-pinned:isolate",
"[&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)]",
"[&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
),
column.getIndex() === 0 || column.getIndex() === row.getVisibleCells().length - 1
? gridProps.tableClassNames?.edgeCell
: ""
)
}
function bodyCellStyle(cell: Cell<TData, unknown>) {
const { column } = cell
return {
...(gridProps.tableLayout?.columnsPinnable && column.getCanPin() && getPinningStyles(column)),
...(gridProps.tableLayout?.columnsResizable && { width: `calc(var(--col-${column.id}-size) * 1px)` }),
}
}
function bodyCellLastCol(cell: Cell<TData, unknown>) {
const isPinned = cell.column.getIsPinned()
const isLastLeftPinned = isPinned === "left" && cell.column.getIsLastColumn("left")
const isFirstRightPinned = isPinned === "right" && cell.column.getIsFirstColumn("right")
return isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
}
const bodyRowBottomBorderClasses =
"[&:not(:last-child)>td]:border-b [tbody:has(+tfoot)_&:last-child>td]:border-b [*:has(>[data-slot=data-grid]+[data-slot=data-grid-pagination])_[data-slot=data-grid]_&:last-child>td]:border-b"
function bodyRowClass(row: Row<TData>, pinnedBoundary?: DataGridTablePinnedBoundary, dataIndex?: number) {
const isRowPinned = row.getIsPinned()
return cn(
"hover:bg-muted/40 data-[state=selected]:bg-muted/50",
gridProps.onRowClick && "cursor-pointer",
!gridProps.tableLayout?.stripped && gridProps.tableLayout?.rowBorder && bodyRowBottomBorderClasses,
gridProps.tableLayout?.cellBorder && `*:last:border-e-0 ${bodyRowBottomBorderClasses}`,
// Virtualized rows stripe by absolute row index (CSS :nth-child parity
// flips as spacer rows resize while scrolling) — mirrors DataGridTable.vue.
gridProps.tableLayout?.stripped &&
(typeof dataIndex === "number"
? cn("hover:bg-transparent", dataIndex % 2 === 0 && "bg-muted/90 hover:bg-muted")
: "odd:bg-muted/90 odd:hover:bg-muted hover:bg-transparent"),
table.options.enableRowSelection && "*:first:relative",
gridProps.tableLayout?.rowsPinnable && isRowPinned && "bg-muted/30 hover:bg-muted/50",
(pinnedBoundary === "top" || pinnedBoundary === "bottom") &&
"[&>td]:shadow-[0_2px_0_rgba(0,0,0,0.03)] dark:[&>td]:shadow-[0_2px_0_rgba(255,255,255,0.06)]",
gridProps.tableClassNames?.bodyRow
)
}
function orderedVisibleCells(row: Row<TData>) {
return [...row.getLeftVisibleCells(), ...row.getCenterVisibleCells()]
}
function handleRowClick(row: Row<TData>) {
gridProps.onRowClick?.(row.original)
}
const columnSizeVars = computed<Record<string, number> | undefined>(() => {
if (!gridProps.tableLayout?.columnsResizable) return undefined
const headers = table.getFlatHeaders()
const colSizes: Record<string, number> = {}
for (const header of headers) {
colSizes[`--header-${header.id}-size`] = header.getSize()
colSizes[`--col-${header.column.id}-size`] = header.column.getSize()
}
return colSizes
})
const tableStyle = computed(() =>
gridProps.tableLayout?.columnsResizable
? { ...columnSizeVars.value, width: `calc(${table.getTotalSize()}px + var(--data-grid-fill-size, 0px))` }
: undefined
)
const leftVisibleColumns = computed(() => table.getLeftVisibleLeafColumns())
const centerVisibleColumns = computed(() => table.getCenterVisibleLeafColumns())
const rightVisibleColumns = computed(() => table.getRightVisibleLeafColumns())
const viewportStyle = computed(() =>
usesExternalScrollArea.value
? undefined
: { height: typeof props.height === "number" ? `${props.height}px` : props.height, overflow: "auto", position: "relative" as const }
)
</script>
<template>
<div
ref="containerEl"
data-slot="data-grid-table-viewport"
:class="cn('relative min-w-full align-top', !usesExternalScrollArea && 'block', props.class)"
:style="viewportStyle"
>
<table
data-slot="data-grid-table"
:class="
cn(
'text-foreground caption-bottom text-left align-middle text-sm font-normal rtl:text-right',
gridProps.tableLayout?.columnsResizable ? 'min-w-0' : 'w-full min-w-full',
gridProps.tableLayout?.width === 'auto' ? 'table-auto' : 'table-fixed',
!gridProps.tableLayout?.columnsDraggable && 'border-separate border-spacing-0',
gridProps.tableClassNames?.base
)
"
:style="tableStyle"
>
<colgroup>
<col v-for="column in [...leftVisibleColumns, ...centerVisibleColumns]" :key="column.id" :style="colStyle(column)" />
<DataGridTableFillCol v-if="hasRightPinnedColumns" />
<col v-for="column in rightVisibleColumns" :key="column.id" :style="colStyle(column)" />
<DataGridTableFillCol v-if="!hasRightPinnedColumns" />
</colgroup>
<thead
v-if="renderHeader"
:class="cn(gridProps.tableClassNames?.header, gridProps.tableLayout?.headerSticky && gridProps.tableClassNames?.headerSticky)"
>
<tr
v-for="headerGroup in mergedHeaderGroups"
:key="headerGroup.id"
:class="
cn(
gridProps.tableLayout?.headerBorder && '[&>th]:border-b',
gridProps.tableLayout?.cellBorder && '*:last:border-e-0',
gridProps.tableLayout?.headerBackground === false && 'bg-transparent',
gridProps.tableClassNames?.headerRow
)
"
>
<th
v-for="header in headerGroup.headers.filter((h) => h.column.getIsPinned() !== 'right')"
:key="header.id"
scope="col"
:colspan="header.colSpan > 1 ? header.colSpan : undefined"
:aria-sort="header.column.getIsSorted() === 'asc' ? 'ascending' : header.column.getIsSorted() === 'desc' ? 'descending' : undefined"
:style="{ ...headCellStyle(header) }"
:data-pinned="header.column.getIsPinned() || undefined"
:data-outer-pinned-col="headOuterPinned(header)"
:data-last-col="headLastCol(header)"
:class="headCellClass(header)"
>
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header" :props="header.getContext()" />
<DataGridTableHeadRowCellResize v-if="gridProps.tableLayout?.columnsResizable && header.column.getCanResize()" :header="header" />
</th>
<DataGridTableFillHeadCell v-if="gridProps.tableLayout?.columnsResizable && hasRightPinnedColumns" />
<th
v-for="header in headerGroup.headers.filter((h) => h.column.getIsPinned() === 'right')"
:key="header.id"
scope="col"
:colspan="header.colSpan > 1 ? header.colSpan : undefined"
:aria-sort="header.column.getIsSorted() === 'asc' ? 'ascending' : header.column.getIsSorted() === 'desc' ? 'descending' : undefined"
:style="{ ...headCellStyle(header) }"
:data-pinned="header.column.getIsPinned() || undefined"
:data-outer-pinned-col="headOuterPinned(header)"
:data-last-col="headLastCol(header)"
:class="headCellClass(header)"
>
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header" :props="header.getContext()" />
<DataGridTableHeadRowCellResize v-if="gridProps.tableLayout?.columnsResizable && header.column.getCanResize()" :header="header" />
</th>
<DataGridTableFillHeadCell v-if="gridProps.tableLayout?.columnsResizable && !hasRightPinnedColumns" />
</tr>
</thead>
<tbody v-if="renderHeader && (gridProps.tableLayout?.stripped || !gridProps.tableLayout?.rowBorder)" aria-hidden="true" class="h-2" data-slot="data-grid-table-body-spacer"></tbody>
<tbody data-slot="data-grid-table-body">
<!-- Пусто: начальная загрузка -->
<tr v-if="totalRows === 0 && isLoading">
<td :colspan="Math.max(table.getVisibleFlatColumns().length, 1)" class="text-muted-foreground py-4 text-center text-sm">
<div class="flex items-center justify-center gap-2">
<Spinner class="size-4 opacity-60" />
<component :is="loadingMoreMessage" v-if="typeof loadingMoreMessage !== 'string'" />
<template v-else>{{ loadingMoreMessage }}</template>
</div>
</td>
</tr>
<!-- Пусто: нет данных -->
<tr v-else-if="totalRows === 0">
<td :colspan="Math.max(table.getVisibleFlatColumns().length, 1)" class="text-muted-foreground py-6 text-center text-sm">
<component :is="gridProps.emptyMessage" v-if="gridProps.emptyMessage && typeof gridProps.emptyMessage !== 'string'" />
<template v-else>{{ gridProps.emptyMessage || "No data available" }}</template>
</td>
</tr>
<template v-else>
<!-- Закреплённые сверху строки -->
<tr
v-for="(row, index) in topRows"
:key="row.id"
:data-state="table.options.enableRowSelection && row.getIsSelected() ? 'selected' : undefined"
:data-row-id="row.id"
:data-row-pinned="row.getIsPinned() || undefined"
:data-row-pinned-boundary="index === topRows.length - 1 && hasMiddleSection ? 'top' : undefined"
:class="bodyRowClass(row, index === topRows.length - 1 && hasMiddleSection ? 'top' : undefined)"
@click="handleRowClick(row)"
>
<td v-for="cell in orderedVisibleCells(row)" :key="cell.id" :style="bodyCellStyle(cell)" :data-pinned="cell.column.getIsPinned() || undefined" :data-last-col="bodyCellLastCol(cell)" :class="bodyCellClass(cell)">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</td>
<DataGridTableFillBodyCell v-if="gridProps.tableLayout?.columnsResizable && hasRightPinnedColumns" />
<td v-for="cell in row.getRightVisibleCells()" :key="cell.id" :style="bodyCellStyle(cell)" :data-pinned="cell.column.getIsPinned() || undefined" :data-last-col="bodyCellLastCol(cell)" :class="bodyCellClass(cell)">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</td>
<DataGridTableFillBodyCell v-if="gridProps.tableLayout?.columnsResizable && !hasRightPinnedColumns" />
</tr>
<!-- Виртуализированные центральные строки -->
<tr v-if="leadingSpacerHeight > 0" aria-hidden="true" data-slot="data-grid-virtual-spacer">
<td :colspan="Math.max(table.getVisibleFlatColumns().length, 1)" class="p-0" :style="{ height: `${leadingSpacerHeight}px`, padding: 0 }" />
</tr>
<template v-for="entry in resolvedVirtualRows" :key="entry.row.id">
<tr
:data-index="entry.index"
:data-state="table.options.enableRowSelection && entry.row.getIsSelected() ? 'selected' : undefined"
:data-row-id="entry.row.id"
:class="bodyRowClass(entry.row, undefined, entry.index)"
@click="handleRowClick(entry.row)"
>
<td
v-for="cell in orderedVisibleCells(entry.row)"
:key="cell.id"
:style="bodyCellStyle(cell)"
:data-pinned="cell.column.getIsPinned() || undefined"
:data-last-col="bodyCellLastCol(cell)"
:class="bodyCellClass(cell)"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</td>
<DataGridTableFillBodyCell v-if="gridProps.tableLayout?.columnsResizable && hasRightPinnedColumns" />
<td
v-for="cell in entry.row.getRightVisibleCells()"
:key="cell.id"
:style="bodyCellStyle(cell)"
:data-pinned="cell.column.getIsPinned() || undefined"
:data-last-col="bodyCellLastCol(cell)"
:class="bodyCellClass(cell)"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</td>
<DataGridTableFillBodyCell v-if="gridProps.tableLayout?.columnsResizable && !hasRightPinnedColumns" />
</tr>
</template>
<tr v-if="trailingSpacerHeight > 0" aria-hidden="true" data-slot="data-grid-virtual-spacer">
<td :colspan="Math.max(table.getVisibleFlatColumns().length, 1)" class="p-0" :style="{ height: `${trailingSpacerHeight}px`, padding: 0 }" />
</tr>
<tr v-if="showFetchingRow">
<td :colspan="Math.max(table.getVisibleFlatColumns().length, 1)" class="text-muted-foreground py-4 text-center text-sm">
<div class="flex items-center justify-center gap-2">
<Spinner class="size-4 opacity-60" />
<component :is="loadingMoreMessage" v-if="typeof loadingMoreMessage !== 'string'" />
<template v-else>{{ loadingMoreMessage }}</template>
</div>
</td>
</tr>
<tr v-if="showCompleteRow">
<td :colspan="Math.max(table.getVisibleFlatColumns().length, 1)" class="text-muted-foreground py-3 text-center text-xs">
<component :is="allRowsLoadedMessage" v-if="typeof allRowsLoadedMessage !== 'string'" />
<template v-else>{{ allRowsLoadedMessage }}</template>
</td>
</tr>
<!-- Закреплённые снизу строки -->
<tr
v-for="(row, index) in bottomRows"
:key="row.id"
:data-state="table.options.enableRowSelection && row.getIsSelected() ? 'selected' : undefined"
:data-row-id="row.id"
:data-row-pinned="row.getIsPinned() || undefined"
:data-row-pinned-boundary="index === 0 && (topRows.length > 0 || hasMiddleSection) ? 'bottom' : undefined"
:class="bodyRowClass(row, index === 0 && (topRows.length > 0 || hasMiddleSection) ? 'bottom' : undefined)"
@click="handleRowClick(row)"
>
<td v-for="cell in orderedVisibleCells(row)" :key="cell.id" :style="bodyCellStyle(cell)" :data-pinned="cell.column.getIsPinned() || undefined" :data-last-col="bodyCellLastCol(cell)" :class="bodyCellClass(cell)">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</td>
<DataGridTableFillBodyCell v-if="gridProps.tableLayout?.columnsResizable && hasRightPinnedColumns" />
<td v-for="cell in row.getRightVisibleCells()" :key="cell.id" :style="bodyCellStyle(cell)" :data-pinned="cell.column.getIsPinned() || undefined" :data-last-col="bodyCellLastCol(cell)" :class="bodyCellClass(cell)">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</td>
<DataGridTableFillBodyCell v-if="gridProps.tableLayout?.columnsResizable && !hasRightPinnedColumns" />
</tr>
</template>
</tbody>
<tfoot v-if="$slots.footer" data-slot="data-grid-table-foot" :class="cn(gridProps.tableClassNames?.footer)">
<slot name="footer" />
</tfoot>
</table>
</div>
</template>
src/reui/data-grid/context.ts
/**
* Порт ReUI DataGrid (registry-reui/bases/radix/reui/data-grid/data-grid.tsx, MIT).
*
* `@tanstack/react-table` -> `@tanstack/vue-table`: тот же core-пакет,
* другой тонкий адаптер (тот же `Table`/`Column`/`Row`/`Header`/`Cell`,
* тот же `flexRender`, та же модель состояния/строк/колонок) — это
* главное упрощение всего порта: почти вся логика data-grid работает
* с `table` как с обычным объектом и переносится механически.
*
* Оригинал держит контекст на `createContext`/`useContext` с ручной
* мемоизацией (`useMemo` с длинным списком зависимостей + `propsRef` для
* "свежих" ReactNode/function пропов, чтобы не переинвалидировать контекст
* на каждый инлайновый JSX). Vue reactivity делает это бесплатно: `table`
* из `useVueTable` уже реактивен сам по себе (Proxy над состоянием), а
* `props` в `provide()` ниже — это реактивный `computed`/сами входные
* `props` компонента, без ручной мемоизации и без `propsRef`-обхода.
*/
import type { ComputedRef, InjectionKey, Ref } from "vue"
import { inject } from "vue"
import type {
Column,
ColumnFiltersState,
RowData,
SortingState,
Table,
} from "@tanstack/vue-table"
declare module "@tanstack/vue-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
headerTitle?: string
headerClassName?: string
cellClassName?: string
/** VNode/строка, показываемая в скелетон-ячейке (аналог ReactNode). */
skeleton?: unknown
expandedContent?: (row: TData) => unknown
autoSize?: boolean
}
}
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
export function getColumnHeaderLabel<TData, TValue>(
column: Column<TData, TValue>
): string {
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
if (typeof meta?.headerTitle === "string") return meta.headerTitle
const defHeader = column.columnDef.header
if (typeof defHeader === "string") return defHeader
return String(column.id)
}
export type DataGridApiFetchParams = {
pageIndex: number
pageSize: number
sorting?: SortingState
filters?: ColumnFiltersState
searchQuery?: string
}
export type DataGridApiResponse<T> = {
data: T[]
empty: boolean
pagination: {
total: number
page: number
}
}
export type DataGridRequestParams = {
pageIndex: number
pageSize: number
sorting?: SortingState
columnFilters?: ColumnFiltersState
}
export interface DataGridTableLayout {
dense?: boolean
cellBorder?: boolean
rowBorder?: boolean
rowRounded?: boolean
stripped?: boolean
headerBackground?: boolean
footerBackground?: boolean
headerBorder?: boolean
headerSticky?: boolean
width?: "auto" | "fixed"
columnsVisibility?: boolean
columnsResizable?: boolean
columnsResizeMode?: "onChange" | "onEnd"
columnsPinnable?: boolean
columnsMovable?: boolean
columnsDraggable?: boolean
rowsDraggable?: boolean
rowsPinnable?: boolean
}
export interface DataGridTableClassNames {
base?: string
header?: string
headerRow?: string
headerSticky?: string
body?: string
bodyRow?: string
footer?: string
edgeCell?: string
}
export interface DataGridProps<TData extends object> {
table?: Table<TData>
recordCount: number
onRowClick?: (row: TData) => void
isLoading?: boolean
loadingMode?: "skeleton" | "spinner"
/** ReactNode в оригинале -> строка или VNode/компонент (см. DataGrid.vue). */
loadingMessage?: unknown
fetchingMoreMessage?: unknown
allRowsLoadedMessage?: unknown
emptyMessage?: unknown
tableLayout?: DataGridTableLayout
tableClassNames?: DataGridTableClassNames
}
export interface DataGridContextProps<TData extends object> {
props: DataGridProps<TData>
table: Table<TData>
recordCount: number
isLoading: boolean
/**
* Internal coordinator for `meta.autoSize` columns. Lives at the core level
* so every table variant and viewport instance shares one application state.
*/
autoSize?: DataGridAutoSizeController
}
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.
*/
apply: (fillWidth: number) => boolean
}
export function createDataGridAutoSizeController<TData extends object>(
table: Table<TData>
): DataGridAutoSizeController {
let applied: { columnId: string; base: number; grown: number } | null = null
return {
apply(fillWidth: number) {
const columnSizing = table.getState().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 (fillWidth <= 0) return false
const autoSizeColumn = table
.getVisibleLeafColumns()
.find(
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
)
if (!autoSizeColumn || applied?.columnId === autoSizeColumn.id) {
return false
}
// Candidate switched (e.g. the grown column was hidden and another
// meta.autoSize column took over): revert the previous growth if the
// 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
: null
const base = columnSizing[autoSizeColumn.id] ?? autoSizeColumn.getSize()
const grown = base + fillWidth
applied = { columnId: autoSizeColumn.id, base, grown }
table.setColumnSizing((old) => {
const next = { ...old, [autoSizeColumn.id]: grown }
if (revert && next[revert.columnId] === revert.grown) {
next[revert.columnId] = revert.base
}
return next
})
return true
},
}
}
export const DataGridContextKey: InjectionKey<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
DataGridContextProps<any>
> = Symbol("DataGridContext")
/** Соответствует `useDataGrid()` оригинала — тоже бросает вне провайдера. */
export function useDataGrid<
TData extends object = // eslint-disable-next-line @typescript-eslint/no-explicit-any
any,
>(): DataGridContextProps<TData> {
const context = inject(DataGridContextKey)
if (!context) {
throw new Error("useDataGrid must be used within a DataGridProvider")
}
return context as DataGridContextProps<TData>
}
export type DataGridTablePinnedBoundary = "top" | "bottom"
export function getDataGridTableRowSections<TData>(
table: Table<TData>,
rowsPinnable?: boolean
) {
if (!rowsPinnable) {
return {
topRows: [] as ReturnType<Table<TData>["getRowModel"]>["rows"],
centerRows: table.getRowModel().rows,
bottomRows: [] as ReturnType<Table<TData>["getRowModel"]>["rows"],
}
}
return {
topRows: table.getTopRows(),
centerRows: table.getCenterRows(),
bottomRows: table.getBottomRows(),
}
}
export function getDataGridTableResolvedRows<TData>(
table: Table<TData>,
rowsPinnable?: boolean
) {
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
table,
rowsPinnable
)
const resolvedRows: Array<{
row: (typeof centerRows)[number]
pinnedBoundary?: DataGridTablePinnedBoundary
}> = []
topRows.forEach((row, index) => {
resolvedRows.push({
row,
pinnedBoundary:
index === topRows.length - 1 &&
(centerRows.length > 0 || bottomRows.length > 0)
? "top"
: undefined,
})
})
centerRows.forEach((row) => {
resolvedRows.push({ row })
})
bottomRows.forEach((row, index) => {
resolvedRows.push({
row,
pinnedBoundary:
index === 0 && (centerRows.length > 0 || topRows.length > 0)
? "bottom"
: undefined,
})
})
return resolvedRows
}
export function getDataGridTableOrderedVisibleColumns<TData>(
table: Table<TData>
) {
return [
...table.getLeftVisibleLeafColumns(),
...table.getCenterVisibleLeafColumns(),
...table.getRightVisibleLeafColumns(),
]
}
export function getDataGridTableMergedHeaderGroups<TData>(table: Table<TData>) {
const leftHeaderGroups = table.getLeftHeaderGroups()
const centerHeaderGroups = table.getCenterHeaderGroups()
const rightHeaderGroups = table.getRightHeaderGroups()
const headerGroupCount = Math.max(
leftHeaderGroups.length,
centerHeaderGroups.length,
rightHeaderGroups.length
)
return Array.from({ length: headerGroupCount }, (_, index) => {
const leftGroup = leftHeaderGroups[index]
const centerGroup = centerHeaderGroups[index]
const rightGroup = rightHeaderGroups[index]
return {
id:
[leftGroup?.id, centerGroup?.id, rightGroup?.id]
.filter(Boolean)
.join(":") || `header-group-${index}`,
headers: [
...(leftGroup?.headers ?? []),
...(centerGroup?.headers ?? []),
...(rightGroup?.headers ?? []),
],
}
})
}
export function hasDataGridTableRightPinnedColumns<TData>(table: Table<TData>) {
return (table.getState().columnPinning.right?.length ?? 0) > 0
}
export function getPinningStyles<TData>(
column: Column<TData, unknown>
): Record<string, string | number | undefined> {
const isPinned = column.getIsPinned()
return {
insetInlineStart:
isPinned === "left" ? `${column.getStart("left")}px` : undefined,
insetInlineEnd:
isPinned === "right" ? `${column.getAfter("right")}px` : undefined,
position: isPinned ? "sticky" : undefined,
transform: isPinned ? "translateZ(0)" : undefined,
contain: isPinned ? "paint" : undefined,
width: column.getSize(),
zIndex: isPinned ? 30 : undefined,
backgroundClip: isPinned ? "padding-box" : undefined,
}
}
/**
* Nearest scroll-area viewport that belongs to THIS grid. A viewport outside
* the grid's own container (e.g. a page-level ScrollArea) would make the
* width measurement - and the virtualizer - bind the wrong box.
*/
export function getDataGridScrollAreaViewport(
node: HTMLElement
): HTMLElement | null {
const scrollViewport = node.closest(
'[data-slot="scroll-area-viewport"]'
) as HTMLElement | null
if (!scrollViewport) return null
const gridContainer = node.closest('[data-slot="data-grid"]')
if (gridContainer && !gridContainer.contains(scrollViewport)) return null
return scrollViewport
}
export const dataGridDefaultTableLayout: Required<
Omit<DataGridTableLayout, "columnsResizeMode">
> &
Pick<DataGridTableLayout, "columnsResizeMode"> = {
dense: false,
cellBorder: false,
rowBorder: true,
rowRounded: false,
stripped: false,
headerSticky: false,
headerBackground: false,
footerBackground: false,
headerBorder: true,
width: "fixed",
columnsVisibility: false,
columnsResizable: false,
columnsResizeMode: undefined,
columnsPinnable: false,
columnsMovable: false,
columnsDraggable: false,
rowsDraggable: false,
rowsPinnable: false,
}
export const dataGridDefaultTableClassNames: Required<DataGridTableClassNames> = {
base: "",
header: "",
headerRow: "",
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs",
body: "",
bodyRow: "",
footer: "",
edgeCell: "",
}
/**
* Общие типы/контексты для `DataGridTableDndRows` (data-grid-table-dnd-rows.tsx,
* ADR-002: dnd-kit -> Pragmatic Drag and Drop). Тот же приём "внутренний
* контекст + контекст хендла", что и `SortableInternalContextKey`/
* `SortableItemContextKey` в `reui/sortable/context.ts` — см. комментарий в
* шапке `DataGridTableDndRows.vue`. В отличие от Sortable, здесь нет
* `defineModel`: порядок строк управляется данными потребителя (`data`
* массив `useVueTable`), а не состоянием, которым владеет сам компонент,
* поэтому внутренний контекст не хранит снимок/восстановление — коммит
* (мышью или клавиатурой) всегда идёт через `onDragEnd`, а не напрямую.
*/
export interface DataGridRowDndEvent {
active: { id: string }
over: { id: string } | null
}
export interface DataGridRowDndInternalContext {
activeId: Ref<string | null>
instanceId: symbol
registerRowElement: (id: string, element: HTMLElement) => void
unregisterRowElement: (id: string) => void
/** Клавиатурное перемещение на один шаг — коммитит через `onDragEnd` немедленно. */
moveByKeyboard: (id: string, direction: -1 | 1) => void
}
export interface DataGridRowDndHandleContext {
listeners: { onKeydown: (event: KeyboardEvent) => void } | undefined
isDragging: boolean
registerHandle: (element: HTMLElement | null) => void
}
export const DataGridRowDndInternalContextKey: InjectionKey<DataGridRowDndInternalContext> =
Symbol("DataGridRowDndInternalContext")
export const DataGridRowDndHandleContextKey: InjectionKey<DataGridRowDndHandleContext> =
Symbol("DataGridRowDndHandleContext")
const defaultRowDndHandleContext: DataGridRowDndHandleContext = {
listeners: undefined,
isDragging: false,
registerHandle: () => {},
}
/** Соответствует `useContext(SortableRowContext)` оригинала — не бросает, есть дефолт-фолбэк (см. data-grid-table-dnd-rows.tsx). */
export function useDataGridRowDndHandleContext(): DataGridRowDndHandleContext {
return inject(DataGridRowDndHandleContextKey, defaultRowDndHandleContext)
}
export type { ComputedRef }
src/reui/data-grid/index.ts
export { default as DataGrid } from "./DataGrid.vue"
export { default as DataGridContainer } from "./DataGridContainer.vue"
export { default as DataGridTable } from "./DataGridTable.vue"
export { default as DataGridTableViewport } from "./DataGridTableViewport.vue"
export { default as DataGridTableRowSelect } from "./DataGridTableRowSelect.vue"
export { default as DataGridTableRowSelectAll } from "./DataGridTableRowSelectAll.vue"
export { default as DataGridTableRowPin } from "./DataGridTableRowPin.vue"
export { default as DataGridTableFootRow } from "./DataGridTableFootRow.vue"
export { default as DataGridTableFootRowCell } from "./DataGridTableFootRowCell.vue"
export { default as DataGridColumnHeader } from "./DataGridColumnHeader.vue"
export { default as DataGridColumnVisibility } from "./DataGridColumnVisibility.vue"
export { default as DataGridColumnFilter } from "./DataGridColumnFilter.vue"
export { default as DataGridPagination } from "./DataGridPagination.vue"
export { default as DataGridScrollArea } from "./DataGridScrollArea.vue"
export { default as DataGridTableVirtual } from "./DataGridTableVirtual.vue"
export { default as DataGridTableDnd } from "./DataGridTableDnd.vue"
export { default as DataGridTableDndRows } from "./DataGridTableDndRows.vue"
export { default as DataGridTableDndRowHandle } from "./DataGridTableDndRowHandle.vue"
export {
getColumnHeaderLabel,
useDataGrid,
DataGridContextKey,
createDataGridAutoSizeController,
getDataGridScrollAreaViewport,
getDataGridTableMergedHeaderGroups,
getDataGridTableOrderedVisibleColumns,
getDataGridTableResolvedRows,
getDataGridTableRowSections,
getPinningStyles,
hasDataGridTableRightPinnedColumns,
type DataGridRowDndEvent,
type DataGridApiFetchParams,
type DataGridApiResponse,
type DataGridAutoSizeController,
type DataGridContextProps,
type DataGridProps,
type DataGridRequestParams,
type DataGridTableClassNames,
type DataGridTableLayout,
type DataGridTablePinnedBoundary,
} from "./context"
src/reui/data-grid/resize.ts
/**
* Порт `startDataGridColumnResizeOnEnd` (data-grid-table.tsx). Это ~чистый
* DOM-код (document/window listeners, координаты указателя), без единого
* React-хука внутри тела функции — переносится почти механически, меняются
* только типы (`Header`/`Table` из `@tanstack/vue-table` вместо
* `@tanstack/react-table`) и способ получения `currentTarget`
* (React-события несут `event.currentTarget` типизированно; здесь это
* обычный `EventTarget`, поэтому оборачивающий компонент передаёт элемент
* явным вторым позиционным доступом через `event.currentTarget as HTMLElement`).
*/
import type { Header, Table } from "@tanstack/vue-table"
export type DataGridResizeStartEvent = MouseEvent | TouchEvent
export type DataGridResizeDocumentEvent = MouseEvent | TouchEvent
function isDataGridTouchEvent(
event: DataGridResizeStartEvent | DataGridResizeDocumentEvent
): event is TouchEvent {
return "touches" in event
}
type DataGridTouchListLike = {
length: number
item: (index: number) => { identifier: number; clientX: number } | null
}
function findTouchClientX(list: DataGridTouchListLike, identifier: number) {
for (let i = 0; i < list.length; i++) {
const touch = list.item(i)
if (touch && touch.identifier === identifier) return touch.clientX
}
return undefined
}
function getDataGridResizeEventClientX(
event: DataGridResizeStartEvent | DataGridResizeDocumentEvent,
touchIdentifier?: number
) {
if (isDataGridTouchEvent(event)) {
if (typeof touchIdentifier === "number") {
return (
findTouchClientX(event.touches, touchIdentifier) ??
findTouchClientX(event.changedTouches, touchIdentifier)
)
}
return event.touches[0]?.clientX ?? event.changedTouches[0]?.clientX
}
return event.clientX
}
export function startDataGridColumnResizeOnEnd<TData>(
event: DataGridResizeStartEvent,
header: Header<TData, unknown>,
table: Table<TData>
): (() => void) | undefined {
const column = table.getColumn(header.column.id)
if (!column || !column.getCanResize()) return
const isTouchSession = isDataGridTouchEvent(event)
if (isTouchSession && event.touches.length > 1) return
const currentTarget = event.currentTarget as HTMLElement
const ownerDocument = currentTarget.ownerDocument
const ownerWindow = ownerDocument.defaultView
const previousBodyCursor = ownerDocument.body.style.cursor
const previousDocumentCursor = ownerDocument.documentElement.style.cursor
const startSize = header.getSize()
// Track the initiating finger so a second touch cannot move or commit the
// resize with the wrong clientX.
const touchIdentifier = isTouchSession ? event.touches[0]?.identifier : undefined
const dragStartClientX = getDataGridResizeEventClientX(event, touchIdentifier)
const headerCell = currentTarget.closest("th")
const headerRect = headerCell?.getBoundingClientRect()
const startOffset =
headerRect &&
Number.isFinite(
table.options.columnResizeDirection === "rtl" ? headerRect.left : headerRect.right
)
? table.options.columnResizeDirection === "rtl"
? headerRect.left
: headerRect.right
: dragStartClientX
if (typeof dragStartClientX !== "number" || typeof startOffset !== "number") {
return
}
ownerDocument.body.style.cursor = "col-resize"
ownerDocument.documentElement.style.cursor = "col-resize"
const columnSizingStart = header
.getLeafHeaders()
.map((leafHeader) => [leafHeader.column.id, leafHeader.column.getSize()] as [string, number])
const directionMultiplier = table.options.columnResizeDirection === "rtl" ? -1 : 1
// Clamp the drag to the leaf columns' min/max sizes so the preview
// indicator matches what the commit will produce (no overshoot followed by
// a snap-back on release). columnDef always carries resolved defaults.
let minDeltaPercentage = -0.999999
let maxDeltaPercentage = Number.POSITIVE_INFINITY
columnSizingStart.forEach(([columnId, headerSize]) => {
if (headerSize <= 0) return
const leafColumn = table.getColumn(columnId)
const minSize = leafColumn?.columnDef.minSize
const maxSize = leafColumn?.columnDef.maxSize
if (typeof minSize === "number") {
minDeltaPercentage = Math.max(minDeltaPercentage, minSize / headerSize - 1)
}
if (typeof maxSize === "number" && Number.isFinite(maxSize)) {
maxDeltaPercentage = Math.min(maxDeltaPercentage, maxSize / headerSize - 1)
}
})
let lastClientX = dragStartClientX
let ended = false
const stopListeners: Array<() => void> = []
const updateOffset = (clientXPos?: number, commit = false) => {
if (typeof clientXPos !== "number") return
lastClientX = clientXPos
const nextColumnSizing: Record<string, number> = {}
const deltaPercentage = Math.min(
Math.max(((clientXPos - dragStartClientX) * directionMultiplier) / startSize, minDeltaPercentage),
maxDeltaPercentage
)
const deltaOffset = deltaPercentage * startSize
columnSizingStart.forEach(([columnId, headerSize]) => {
nextColumnSizing[columnId] =
Math.round(Math.max(headerSize + headerSize * deltaPercentage, 0) * 100) / 100
})
table.setColumnSizingInfo((old) => ({
...old,
startOffset,
startSize,
deltaOffset,
deltaPercentage,
columnSizingStart,
isResizingColumn: column.id,
}))
if (commit) {
table.setColumnSizing((old) => ({ ...old, ...nextColumnSizing }))
}
}
// Single teardown path: commits at the given position, removes every
// document/window listener, and restores cursors. Safe to call more than
// once (blur + mouseup + unmount can race).
const endResize = (clientXPos?: number) => {
if (ended) return
ended = true
stopListeners.forEach((stop) => stop())
updateOffset(clientXPos, true)
table.setColumnSizingInfo((old) => ({
...old,
isResizingColumn: false,
startOffset: null,
startSize: null,
deltaOffset: null,
deltaPercentage: null,
columnSizingStart: [],
}))
ownerDocument.body.style.cursor = previousBodyCursor
ownerDocument.documentElement.style.cursor = previousDocumentCursor
}
const mouseMoveHandler = (moveEvent: globalThis.MouseEvent) => {
updateOffset(moveEvent.clientX)
}
const mouseUpHandler = (upEvent: globalThis.MouseEvent) => {
endResize(upEvent.clientX)
}
const touchMoveHandler = (moveEvent: globalThis.TouchEvent) => {
if (moveEvent.cancelable) {
moveEvent.preventDefault()
moveEvent.stopPropagation()
}
updateOffset(getDataGridResizeEventClientX(moveEvent, touchIdentifier))
}
const touchEndHandler = (endEvent: globalThis.TouchEvent) => {
// Ignore other fingers lifting; only the initiating touch ends the drag.
const clientXPos =
typeof touchIdentifier === "number"
? findTouchClientX(endEvent.changedTouches, touchIdentifier)
: getDataGridResizeEventClientX(endEvent)
if (typeof clientXPos !== "number") return
if (endEvent.cancelable) {
endEvent.preventDefault()
endEvent.stopPropagation()
}
endResize(clientXPos)
}
// System-interrupted gestures and window focus loss would otherwise leave
// the session (and its document listeners) live with no pointer held.
const touchCancelHandler = () => {
endResize(lastClientX)
}
const windowBlurHandler = () => {
endResize(lastClientX)
}
const passiveIfSupported = { passive: false } as const
if (isTouchSession) {
ownerDocument.addEventListener("touchmove", touchMoveHandler, passiveIfSupported)
ownerDocument.addEventListener("touchend", touchEndHandler, passiveIfSupported)
ownerDocument.addEventListener("touchcancel", touchCancelHandler)
stopListeners.push(() => {
ownerDocument.removeEventListener("touchmove", touchMoveHandler)
ownerDocument.removeEventListener("touchend", touchEndHandler)
ownerDocument.removeEventListener("touchcancel", touchCancelHandler)
})
} else {
ownerDocument.addEventListener("mousemove", mouseMoveHandler, passiveIfSupported)
ownerDocument.addEventListener("mouseup", mouseUpHandler, passiveIfSupported)
stopListeners.push(() => {
ownerDocument.removeEventListener("mousemove", mouseMoveHandler)
ownerDocument.removeEventListener("mouseup", mouseUpHandler)
})
}
if (ownerWindow) {
ownerWindow.addEventListener("blur", windowBlurHandler)
stopListeners.push(() => ownerWindow.removeEventListener("blur", windowBlurHandler))
}
table.setColumnSizingInfo((old) => ({
...old,
startOffset,
startSize,
deltaOffset: 0,
deltaPercentage: 0,
columnSizingStart,
isResizingColumn: column.id,
}))
return () => endResize(lastClientX)
}
Установка
npx shadcn-vue@latest add https://revueui.rootapi.dev/r/data-grid.jsonЗависимости реестра
npm-зависимости
- @atlaskit/pragmatic-drag-and-drop
- @atlaskit/pragmatic-drag-and-drop-auto-scroll
- @tanstack/vue-table
- @tanstack/vue-virtual
- reka-ui
Источник: порт из ReUI (Keenthemes, MIT)