reui

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)