data-grid

Data grid with CRUD in frame container

Data grid with CRUD in frame container

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

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

<!--
  Порт `c-data-grid-23.tsx` ("Data grid with search/status filter in a Frame
  container") — тот же паттерн, что и `c-data-grid-22.vue`, обёрнутый в
  `Frame`/`FrameHeader`/`FramePanel`/`FrameFooter` вместо `Card`, без
  `DataGridContainer` (`FramePanel` оборачивает `DataGridScrollArea`
  напрямую, как в оригинале).

  Те же расхождения с апстримом, что и в `c-data-grid-22.vue`:
  - Аватарки/флаги — сетевые URL заменены локальным 1x1 data-URI (PIXEL).
  - `useCopyToClipboard` реализован тривиально в блоке.
  - `toast.success(...)` из `sonner` не перенесён (нет vue-sonner порта,
    не влияет на статичный скриншот).
  - `IconPlaceholder` заменена инлайновым `<svg>` (lucide-react v0.545.0:
    "search", "x", "funnel", "user-plus", "more-horizontal"/"ellipsis").
-->
<script setup lang="ts">
import { computed, h, ref } from "vue"
import { Badge } from "@/components/reui/badge"
import { DataGrid, DataGridColumnHeader, DataGridPagination, DataGridScrollArea, DataGridTable, DataGridTableRowSelect, DataGridTableRowSelectAll } from "@/components/reui/data-grid"
import { Frame, FrameFooter, FrameHeader, FramePanel, FrameTitle } from "@/components/reui/frame"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"
import { Label } from "@/components/ui/label"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import {
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useVueTable,
} from "@tanstack/vue-table"
import type { ColumnDef, PaginationState, Row, SortingState } from "@tanstack/vue-table"

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

type Status = "Active" | "Inactive" | "Pending" | "Blocked"

interface IData {
  id: string
  name: string
  availability: "online" | "away" | "busy" | "offline"
  avatar: string
  status: Status
  flag: string
  email: string
  company: string
  role: string
  joined: string
  location: string
  balance: number
}

const demoData: IData[] = [
  { id: "1", name: "Alex Johnson", availability: "online", avatar: PIXEL, status: "Active", flag: "us", email: "alex@apple.com", company: "Apple", role: "CEO", joined: "Jan, 2024", location: "United States", balance: 5143.03 },
  { id: "2", name: "Sarah Chen", availability: "away", avatar: PIXEL, status: "Inactive", flag: "gb", email: "sarah@openai.com", company: "OpenAI", role: "CTO", joined: "Mar, 2023", location: "United Kingdom", balance: 4321.87 },
  { id: "3", name: "Michael Rodriguez", availability: "busy", avatar: PIXEL, status: "Blocked", flag: "ca", email: "michael@meta.com", company: "Meta", role: "Designer", joined: "Jun, 2022", location: "Canada", balance: 7654.98 },
  { id: "4", name: "Emma Wilson", availability: "offline", avatar: PIXEL, status: "Inactive", flag: "au", email: "emma@tesla.com", company: "Tesla", role: "Developer", joined: "Sep, 2024", location: "Australia", balance: 3456.45 },
  { id: "5", name: "David Kim", availability: "online", avatar: PIXEL, status: "Active", flag: "de", email: "david@sap.com", company: "SAP", role: "Lawyer", joined: "Nov, 2023", location: "Germany", balance: 9876.54 },
  { id: "6", name: "Aron Thompson", availability: "away", avatar: PIXEL, status: "Pending", flag: "my", email: "aron@keenthemes.com", company: "Keenthemes", role: "Director", joined: "Feb, 2022", location: "Malaysia", balance: 6214.22 },
  { id: "7", name: "James Brown", availability: "busy", avatar: PIXEL, status: "Inactive", flag: "es", email: "james@bbva.es", company: "BBVA", role: "Product Manager", joined: "Aug, 2024", location: "Spain", balance: 5321.77 },
]

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

function MoreHorizontalIcon() {
  return 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",
      class: "lucide lucide-ellipsis",
    },
    [
      h("circle", { cx: "12", cy: "12", r: "1" }),
      h("circle", { cx: "19", cy: "12", r: "1" }),
      h("circle", { cx: "5", cy: "12", r: "1" }),
    ]
  )
}

function renderActionsCell(row: Row<IData>) {
  return h(DropdownMenu, {}, () => [
    h(DropdownMenuTrigger, { asChild: true }, () =>
      h(Button, { class: "size-7", size: "icon", variant: "ghost" }, () => h(MoreHorizontalIcon))
    ),
    h(DropdownMenuContent, { side: "bottom", align: "start" }, () => [
      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: 5 })
const sorting = ref<SortingState>([{ id: "name", desc: true }])
const searchQuery = ref("")
const selectedStatuses = ref<string[]>([])

const filteredData = computed(() =>
  demoData.filter((item) => {
    const matchesStatus = !selectedStatuses.value.length || selectedStatuses.value.includes(item.status)
    const searchLower = searchQuery.value.toLowerCase()
    const matchesSearch =
      !searchQuery.value ||
      Object.values(item).join(" ").toLowerCase().includes(searchLower)
    return matchesStatus && matchesSearch
  })
)

const statusCounts = computed(() =>
  demoData.reduce<Record<string, number>>((acc, item) => {
    acc[item.status] = (acc[item.status] || 0) + 1
    return acc
  }, {})
)

function handleStatusChange(checked: boolean, value: string) {
  selectedStatuses.value = checked
    ? [...selectedStatuses.value, value]
    : selectedStatuses.value.filter((v) => v !== value)
}

const columns: ColumnDef<IData>[] = [
  {
    accessorKey: "id",
    id: "id",
    header: () => h(DataGridTableRowSelectAll),
    cell: (ctx) => h(DataGridTableRowSelect, { row: ctx.row }),
    enableSorting: false,
    size: 35,
    meta: { headerClassName: "", cellClassName: "" },
    enableResizing: false,
  },
  {
    accessorKey: "name",
    id: "name",
    header: (ctx) => h(DataGridColumnHeader, { title: "User", visibility: true, column: ctx.column as never }),
    cell: (ctx) =>
      h("div", { class: "flex items-center gap-3" }, [
        h(Avatar, { class: "size-8" }, () => [
          h(AvatarImage, { src: ctx.row.original.avatar, alt: ctx.row.original.name }),
          h(AvatarFallback, () => ctx.row.original.name.split(" ").map((n) => n[0]).join("")),
        ]),
        h("div", { class: "space-y-px" }, [
          h("div", { class: "text-foreground font-medium" }, ctx.row.original.name),
          h("div", { class: "text-muted-foreground" }, ctx.row.original.email),
        ]),
      ]),
    size: 260,
    meta: { autoSize: true },
    enableSorting: true,
    enableHiding: false,
    enableResizing: true,
  },
  {
    accessorKey: "location",
    id: "location",
    header: (ctx) => h(DataGridColumnHeader, { title: "Location", visibility: true, column: ctx.column as never }),
    cell: (ctx) =>
      h("div", { class: "flex items-center gap-1.5" }, [
        h("img", { src: PIXEL, alt: ctx.row.original.flag, class: "size-4 rounded-full object-cover" }),
        h("div", { class: "text-foreground min-w-0 truncate font-medium" }, ctx.row.original.location),
      ]),
    size: 150,
    meta: { headerClassName: "", cellClassName: "text-start" },
    enableSorting: true,
    enableHiding: true,
    enableResizing: true,
  },
  {
    accessorKey: "role",
    id: "role",
    header: (ctx) => h(DataGridColumnHeader, { title: "Role", visibility: true, column: ctx.column as never }),
    cell: (ctx) => h("div", { class: "text-foreground font-medium" }, ctx.row.original.role),
    size: 150,
    enableSorting: true,
    enableHiding: true,
    enableResizing: true,
  },
  {
    accessorKey: "joined",
    id: "joined",
    header: (ctx) => h(DataGridColumnHeader, { title: "Joined", visibility: true, column: ctx.column as never }),
    cell: (ctx) => h("div", { class: "text-foreground font-medium" }, ctx.row.original.joined),
    size: 150,
    enableSorting: true,
    enableHiding: true,
    enableResizing: true,
  },
  {
    accessorKey: "status",
    id: "status",
    header: (ctx) => h(DataGridColumnHeader, { title: "Status", visibility: true, column: ctx.column as never }),
    cell: (ctx) => {
      const status = ctx.row.original.status
      if (status === "Active") return h(Badge, { variant: "success-outline" }, () => "Approved")
      if (status === "Blocked") return h(Badge, { variant: "destructive-outline" }, () => "Blocked")
      if (status === "Inactive") return h(Badge, { variant: "info-outline" }, () => "Inactive")
      return h(Badge, { variant: "warning-outline" }, () => "Pending")
    },
    size: 100,
    enableSorting: true,
    enableHiding: true,
    enableResizing: true,
  },
  {
    id: "actions",
    header: "",
    cell: (ctx) => renderActionsCell(ctx.row),
    size: 60,
    enableSorting: false,
    enableHiding: false,
    enableResizing: false,
  },
]

const table = useVueTable({
  get data() {
    return filteredData.value
  },
  columns,
  get pageCount() {
    return Math.ceil((filteredData.value?.length || 0) / pagination.value.pageSize)
  },
  getRowId: (row: IData) => row.id,
  state: {
    get pagination() {
      return pagination.value
    },
    get sorting() {
      return sorting.value
    },
    get columnOrder() {
      return columns.map((column) => column.id as string)
    },
  },
  onColumnOrderChange: () => {},
  onPaginationChange: (updater) => {
    pagination.value = typeof updater === "function" ? updater(pagination.value) : updater
  },
  onSortingChange: (updater) => {
    sorting.value = typeof updater === "function" ? updater(sorting.value) : updater
  },
  getCoreRowModel: getCoreRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
  getSortedRowModel: getSortedRowModel(),
})
</script>

<template>
  <DataGrid
    :table="table"
    :record-count="filteredData?.length || 0"
    :table-layout="{ columnsPinnable: true, columnsResizable: false, columnsMovable: true, columnsVisibility: true }"
  >
    <Frame class="w-full" stacked dense>
      <FrameHeader class="flex w-full flex-row flex-wrap items-center justify-between gap-3">
        <FrameTitle>Team members</FrameTitle>
        <div class="flex items-center gap-2.5">
          <InputGroup class="bg-background w-48">
            <InputGroupAddon align="inline-start">
              <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="lucide lucide-search">
                <path d="m21 21-4.34-4.34" />
                <circle cx="11" cy="11" r="8" />
              </svg>
            </InputGroupAddon>

            <InputGroupInput placeholder="Search..." v-model="searchQuery" />

            <InputGroupAddon v-if="searchQuery.length > 0" align="inline-end">
              <InputGroupButton aria-label="Clear search" title="Clear" size="icon-xs" @click="searchQuery = ''">
                <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="lucide lucide-x">
                  <path d="M18 6 6 18" />
                  <path d="m6 6 12 12" />
                </svg>
              </InputGroupButton>
            </InputGroupAddon>
          </InputGroup>
          <Popover>
            <PopoverTrigger as-child>
              <Button variant="outline">
                <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="lucide lucide-funnel">
                  <path d="M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z" />
                </svg>
                Status
                <Badge v-if="selectedStatuses.length > 0" size="sm" variant="info-outline">{{ selectedStatuses.length }}</Badge>
              </Button>
            </PopoverTrigger>
            <PopoverContent class="w-40" align="start">
              <div class="space-y-3">
                <div class="text-muted-foreground text-xs font-medium">Filters</div>
                <div class="space-y-3">
                  <div v-for="status in Object.keys(statusCounts)" :key="status" class="flex items-center gap-2.5">
                    <Checkbox
                      :id="`${status}-23`"
                      :model-value="selectedStatuses.includes(status)"
                      @update:model-value="(checked) => handleStatusChange(checked === true, status)"
                    />
                    <Label :for="`${status}-23`" class="flex grow items-center justify-between gap-1.5 font-normal">
                      {{ status }}
                      <span class="text-muted-foreground">{{ statusCounts[status] }}</span>
                    </Label>
                  </div>
                </div>
              </div>
            </PopoverContent>
          </Popover>
          <Button>
            <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="lucide lucide-user-plus">
              <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
              <circle cx="9" cy="7" r="4" />
              <line x1="19" x2="19" y1="8" y2="14" />
              <line x1="22" x2="16" y1="11" y2="11" />
            </svg>
            Add new
          </Button>
        </div>
      </FrameHeader>
      <FramePanel class="p-0 shadow-none">
        <DataGridScrollArea>
          <DataGridTable />
        </DataGridScrollArea>
      </FramePanel>
      <FrameFooter class="py-1.5 pr-2 pl-2.5">
        <DataGridPagination />
      </FrameFooter>
    </Frame>
  </DataGrid>
</template>

Установка

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

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

  • @tanstack/vue-table

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