data-grid
Data grid with CRUD features
Data grid with CRUD features
Загрузка превью…
src/data-grid/c-data-grid-22.vue
<!--
Порт `c-data-grid-22.tsx` ("Data grid with search/status filter and row actions").
Расхождения с апстримом:
- Аватарки (`images.unsplash.com`) и флаги (`flagcdn.com`) — реальные
сетевые запросы, недетерминированы в headless-стенде; заменены
локальным 1x1 data-URI (см. docs/PORTING.md §5 "picsum.photos").
- `useCopyToClipboard` (реестровый React-хук) не имеет прямого Vue-порта
в @revueui/ui — реализован тривиально прямо в блоке
(`navigator.clipboard.writeText`), как в `c-button-42.vue`.
- Оригинал зовёт `toast.success(...)` из `sonner` по клику "Copy ID" —
пакет (`vue-sonner`) не входит в зависимости воркспейса и не влияет на
статичный скриншот (тост никогда не монтируется в состоянии покоя,
см. `packages/blocks/src/sonner/index.ts`), поэтому вызов не перенесён.
- `IconPlaceholder` заменена инлайновым `<svg>` (lucide-react v0.545.0:
"search", "x", "funnel", "user-plus", "more-horizontal"/"ellipsis").
- Поиск/фильтр по статусу/дропдаун действий реализованы по оригиналу
(`ref`/`computed`), но статичный скриншот снимает состояние покоя:
пустой поиск, ни одного выбранного статуса, меню закрыты.
-->
<script setup lang="ts">
import { computed, h, ref } from "vue"
import { Badge } from "@/components/reui/badge"
import { DataGrid, DataGridColumnHeader, DataGridContainer, DataGridPagination, DataGridScrollArea, DataGridTable, DataGridTableRowSelect, DataGridTableRowSelectAll } from "@/components/reui/data-grid"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
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 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: true, columnsMovable: true, columnsVisibility: true }"
>
<Card class="w-full gap-3 py-0">
<CardHeader class="flex items-center justify-between px-3.5 py-2">
<div class="flex items-center gap-2.5">
<InputGroup class="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="Copy" title="Copy" 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"
:model-value="selectedStatuses.includes(status)"
@update:model-value="(checked) => handleStatusChange(checked === true, status)"
/>
<Label :for="status" 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>
</div>
<CardAction>
<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>
</CardAction>
</CardHeader>
<CardContent class="p-0">
<Card class="p-0">
<DataGridContainer>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
</DataGridContainer>
</Card>
</CardContent>
<CardFooter class="border-none bg-transparent! px-3.5 py-2">
<DataGridPagination />
</CardFooter>
</Card>
</DataGrid>
</template>
Установка
npx shadcn-vue@latest add https://revueui.rootapi.dev/r/c-data-grid-22.jsonЗависимости реестра
npm-зависимости
- @tanstack/vue-table
Источник: порт из ReUI (Keenthemes, MIT)