data-grid

Data grid with row pinning support

Data grid with row pinning support

Загрузка превью…

src/data-grid/c-data-grid-29.vue

<!--
  Порт `c-data-grid-29.tsx` ("Row pinning support").

  Расхождения с апстримом:
  - Аватарки (`images.unsplash.com`) — реальные сетевые запросы,
    недетерминированы в headless-стенде; заменены локальным 1x1 data-URI
    (см. docs/PORTING.md §5).
  - `useCopyToClipboard` (реестровый React-хук) не имеет прямого Vue-порта
    в @revueui/ui — реализован тривиально прямо в блоке
    (`navigator.clipboard.writeText`), как в c-data-grid-22.vue/c-button-42.vue.
  - Оригинал зовёт `toast.success(...)` из `sonner` по клику "Copy ID" —
    пакет (`vue-sonner`) не входит в зависимости воркспейса и не влияет на
    статичный скриншот (тост никогда не монтируется в состоянии покоя, см.
    `packages/blocks/src/sonner/index.ts`), поэтому вызов не перенесён.
  - `IconPlaceholder` заменена инлайновым `<svg>` (lucide-react v0.545.0:
    "refresh-cw", "ellipsis" — путь по умолчанию для алиаса
    "MoreHorizontalIcon" в оригинале).
  - Демо-данные урезаны с 15 до 7 строк.
  - `DataGridTableRowPin` уже полностью портирован (см.
    packages/ui/src/reui/data-grid/DataGridTableRowPin.vue) — pin/unpin
    работает через `row.pin(...)`, без правок примитива.
  - Состояние покоя для скриншота: `rowPinning = { top: [], bottom: [] }`
    (ни одна строка не закреплена), dropdown-меню действий закрыты.
-->
<script setup lang="ts">
import { h, ref } from "vue"
import { Badge } from "@/components/reui/badge"
import { DataGrid, DataGridColumnHeader, DataGridContainer, DataGridPagination, DataGridScrollArea, DataGridTable, DataGridTableRowPin } from "@/components/reui/data-grid"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import {
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useVueTable,
} from "@tanstack/vue-table"
import type { ColumnDef, PaginationState, Row, RowPinningState, SortingState } from "@tanstack/vue-table"

const PIXEL =
  "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACwAAAAAAQABAAACAkQBADs="

interface IData {
  id: string
  name: string
  avatar: string
  role: string
  status: "Active" | "Inactive" | "Pending" | "Blocked"
  balance: number
}

const demoData: IData[] = [
  { id: "1", name: "Alex Johnson", avatar: PIXEL, role: "CEO", status: "Active", balance: 5143.03 },
  { id: "2", name: "Sarah Chen", avatar: PIXEL, role: "CTO", status: "Active", balance: 4321.87 },
  { id: "3", name: "Michael Rodriguez", avatar: PIXEL, role: "Designer", status: "Blocked", balance: 7654.98 },
  { id: "4", name: "Emma Wilson", avatar: PIXEL, role: "Developer", status: "Inactive", balance: 3456.45 },
  { id: "5", name: "David Kim", avatar: PIXEL, role: "Lawyer", status: "Active", balance: 9876.54 },
  { id: "6", name: "Aron Thompson", avatar: PIXEL, role: "Director", status: "Pending", balance: 6214.22 },
  { id: "7", name: "James Brown", avatar: PIXEL, role: "Product Manager", status: "Inactive", balance: 5321.77 },
]

function copyToClipboard(text: string) {
  if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) return
  navigator.clipboard.writeText(text)
}

function renderActionsCell(row: Row<IData>) {
  const isPinned = row.getIsPinned()

  return h(DropdownMenu, {}, () => [
    h(DropdownMenuTrigger, { asChild: true }, () =>
      h(Button, { class: "size-7", size: "icon", variant: "ghost" }, () =>
        h(
          "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",
          },
          [
            h("circle", { cx: "12", cy: "12", r: "1" }),
            h("circle", { cx: "19", cy: "12", r: "1" }),
            h("circle", { cx: "5", cy: "12", r: "1" }),
          ]
        )
      )
    ),
    h(DropdownMenuContent, { side: "bottom", align: "start" }, () => [
      h(DropdownMenuItem, { onClick: () => row.pin(isPinned ? false : "top") }, () => (isPinned ? "Unpin row" : "Pin to top")),
      h(DropdownMenuItem, { onClick: () => {} }, () => "Edit"),
      h(DropdownMenuItem, { onClick: () => copyToClipboard(row.original.id) }, () => "Copy ID"),
      h(DropdownMenuSeparator),
      h(DropdownMenuItem, { variant: "destructive", onClick: () => {} }, () => "Delete"),
    ]),
  ])
}

const pagination = ref<PaginationState>({ pageIndex: 0, pageSize: 10 })
const sorting = ref<SortingState>([{ id: "name", desc: false }])
const rowPinning = ref<RowPinningState>({ top: [], bottom: [] })

const columns: ColumnDef<IData>[] = [
  {
    id: "pin",
    header: "",
    cell: (info) => h(DataGridTableRowPin, { row: info.row }),
    size: 40,
    enableSorting: false,
    enableHiding: false,
    enableResizing: false,
  },
  {
    accessorKey: "name",
    id: "name",
    header: (ctx) => h(DataGridColumnHeader, { column: ctx.column as never, title: "User" }),
    cell: (info) =>
      h("div", { class: "flex items-center gap-3" }, [
        h(Avatar, { class: "size-8" }, () => [
          h(AvatarImage, { src: info.row.original.avatar, alt: info.row.original.name }),
          h(AvatarFallback, () => info.row.original.name.split(" ").map((n) => n[0]).join("")),
        ]),
        h("div", { class: "text-foreground min-w-0 truncate font-medium" }, info.row.original.name),
      ]),
    minSize: 150,
    meta: {
      autoSize: true,
    },
    enableSorting: true,
    enableHiding: false,
  },
  {
    accessorKey: "role",
    id: "role",
    header: (ctx) => h(DataGridColumnHeader, { column: ctx.column as never, title: "Role" }),
    cell: (info) => h("span", { class: "text-foreground" }, info.row.original.role),
    size: 160,
    enableSorting: true,
  },
  {
    accessorKey: "status",
    id: "status",
    header: (ctx) => h(DataGridColumnHeader, { column: ctx.column as never, title: "Status" }),
    cell: (info) => {
      const s = info.row.original.status
      if (s === "Active") return h(Badge, { variant: "success-outline" }, () => "Active")
      if (s === "Blocked") return h(Badge, { variant: "destructive-outline" }, () => "Blocked")
      if (s === "Inactive") return h(Badge, { variant: "info-outline" }, () => "Inactive")
      return h(Badge, { variant: "warning-outline" }, () => "Pending")
    },
    size: 120,
    enableSorting: true,
  },
  {
    accessorKey: "balance",
    id: "balance",
    header: (ctx) => h(DataGridColumnHeader, { column: ctx.column as never, title: "Balance" }),
    cell: (info) =>
      h("span", { class: "text-foreground tabular-nums" }, `$${info.row.original.balance.toLocaleString("en-US", { minimumFractionDigits: 2 })}`),
    size: 140,
    enableSorting: true,
  },
  {
    id: "actions",
    header: "",
    cell: (info) => renderActionsCell(info.row),
    size: 60,
    enableSorting: false,
    enableHiding: false,
    enableResizing: false,
  },
]

const table = useVueTable({
  get data() {
    return demoData
  },
  columns,
  get pageCount() {
    return Math.ceil(demoData.length / pagination.value.pageSize)
  },
  getRowId: (row: IData) => row.id,
  enableRowPinning: true,
  keepPinnedRows: true,
  state: {
    get pagination() {
      return pagination.value
    },
    get sorting() {
      return sorting.value
    },
    get rowPinning() {
      return rowPinning.value
    },
  },
  onPaginationChange: (updater) => {
    pagination.value = typeof updater === "function" ? updater(pagination.value) : updater
  },
  onSortingChange: (updater) => {
    sorting.value = typeof updater === "function" ? updater(sorting.value) : updater
  },
  onRowPinningChange: (updater) => {
    rowPinning.value = typeof updater === "function" ? updater(rowPinning.value) : updater
  },
  getCoreRowModel: getCoreRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
  getSortedRowModel: getSortedRowModel(),
})
</script>

<template>
  <DataGrid
    :table="table"
    :record-count="demoData.length"
    :table-layout="{ rowsPinnable: true, columnsResizable: true }"
  >
    <Card class="w-full gap-0 p-0">
      <CardHeader class="flex items-center justify-between px-3 py-2">
        <div class="flex items-center gap-2">
          <span class="text-foreground text-sm font-medium">Team Members</span>
          <Badge v-if="(rowPinning.top?.length ?? 0) > 0" variant="primary-outline" size="sm">
            {{ rowPinning.top?.length }} pinned
          </Badge>
        </div>
        <div class="flex items-center gap-1">
          <Button v-if="(rowPinning.top?.length ?? 0) > 0" variant="ghost" size="sm" @click="rowPinning = { top: [], bottom: [] }">
            Unpin all
          </Button>
          <Button variant="ghost" size="icon" class="size-8">
            <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="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
              <path d="M21 3v5h-5" />
              <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
              <path d="M8 16H3v5" />
            </svg>
          </Button>
        </div>
      </CardHeader>
      <CardContent class="p-0">
        <Card class="p-0">
          <DataGridContainer>
            <DataGridScrollArea>
              <DataGridTable />
            </DataGridScrollArea>
          </DataGridContainer>
        </Card>
      </CardContent>
      <CardFooter class="border-none bg-transparent! px-3 py-2">
        <DataGridPagination />
      </CardFooter>
    </Card>
  </DataGrid>
</template>

Установка

npx shadcn-vue@latest add https://revueui.rootapi.dev/r/c-data-grid-29.json

npm-зависимости

  • @tanstack/vue-table

Источник: порт из ReUI (Keenthemes, MIT)