data-grid

Data grid with remote infinite scroll

Data grid with remote infinite scroll

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

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

<!--
  Порт `c-data-grid-28.tsx` ("Remote infinite scroll").

  Оригинал эмулирует сетевой fetch: `window.setTimeout(..., 800)` перед
  дописыванием следующей страницы данных. Для детерминированного статичного
  рендера таймер убран целиком — `fetchMore` дописывает следующую страницу
  синхронно, без задержки (задание прямо требует "без setTimeout/промисов
  с задержкой, влияющей на первый рендер"; поскольку `fetchMore` не
  вызывается автоматически при монтировании — только из
  `DataGridTableVirtual`'s "конец списка"-детектора при реальном скролле
  или по клику "Start over", — начальное состояние компонента и так уже
  "первая порция данных загружена": `initialData` (20 из 200 строк),
  `isFetching = false`, `hasMore = true`).

  `Math.random()` в генераторе баланса заменён детерминированной функцией
  индекса (та же `balanceFor`, что в c-data-grid-27.vue). Сетевые
  `unsplash`-аватары заменены статичным 1x1 `data:` URI (`PIXEL`).
  `IconPlaceholder` заменена инлайновым `<svg>` (lucide-react v0.545.0:
  "cloud-download", "refresh-cw", "download").
-->
<script setup lang="ts">
import { computed, h, ref } from "vue"
import { Badge } from "@/components/reui/badge"
import { DataGrid, DataGridColumnHeader, DataGridScrollArea, DataGridTableVirtual } from "@/components/reui/data-grid"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardHeader } from "@/components/ui/card"
import { getCoreRowModel, getSortedRowModel, useVueTable } from "@tanstack/vue-table"
import type { ColumnDef, SortingState } from "@tanstack/vue-table"

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

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

const names = [
  "Alex Johnson",
  "Sarah Chen",
  "Michael Rodriguez",
  "Emma Wilson",
  "David Kim",
  "Aron Thompson",
  "James Brown",
  "Maria Garcia",
  "Nick Johnson",
  "Liam Thompson",
]

const statuses: IData["status"][] = ["Active", "Inactive", "Pending"]

const TOTAL_SERVER_RECORDS = 200
const PAGE_SIZE = 20

// ponytail: same deterministic stand-in for Math.random() as c-data-grid-27.
function balanceFor(index: number): number {
  return Math.round((((index * 137) % 9000) + 1000) * 100) / 100
}

function simulateRow(index: number): IData {
  const name = names[index % names.length]!
  return {
    id: String(index + 1),
    name,
    avatar: PIXEL,
    email: `${name.toLowerCase().replace(" ", ".")}${index}@company.com`,
    status: statuses[index % statuses.length]!,
    balance: balanceFor(index),
  }
}

function createInitialData(): IData[] {
  return Array.from({ length: PAGE_SIZE }, (_, index) => simulateRow(index))
}

const initialData = createInitialData()
const data = ref<IData[]>(initialData)
const isFetching = ref(false)
const sorting = ref<SortingState>([])
const hasMore = computed(() => data.value.length < TOTAL_SERVER_RECORDS)

function handleReset() {
  sorting.value = []
  data.value = initialData
  isFetching.value = false
}

// Synchronous, no network/timer: appends the next page immediately so the
// resulting state stays deterministic (see file header).
function fetchMore() {
  if (isFetching.value || !hasMore.value) return
  const prev = data.value
  const next = Array.from({ length: PAGE_SIZE }, (_, index) => simulateRow(prev.length + index))
  data.value = [...prev, ...next]
}

const columns: ColumnDef<IData>[] = [
  {
    accessorKey: "id",
    id: "id",
    header: (ctx) => h(DataGridColumnHeader, { column: ctx.column as never, title: "#" }),
    cell: (info) => h("span", { class: "text-muted-foreground tabular-nums" }, info.row.original.id),
    size: 70,
    enableSorting: 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-7" }, () => [
          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", {}, [
          h("div", { class: "text-foreground font-medium" }, info.row.original.name),
          h("div", { class: "text-muted-foreground text-xs" }, info.row.original.email),
        ]),
      ]),
    minSize: 200,
    meta: {
      autoSize: true,
    },
    enableSorting: true,
  },
  {
    accessorKey: "status",
    id: "status",
    header: (ctx) => h(DataGridColumnHeader, { column: ctx.column as never, title: "Status" }),
    cell: (info) => {
      const status = info.row.original.status
      if (status === "Active") return h(Badge, { variant: "success-outline" }, () => "Active")
      if (status === "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: "tabular-nums" }, `$${info.row.original.balance.toLocaleString("en-US", { minimumFractionDigits: 2 })}`),
    size: 140,
    enableSorting: true,
  },
]

const table = useVueTable({
  get data() {
    return data.value
  },
  columns,
  getRowId: (row: IData) => row.id,
  state: {
    get sorting() {
      return sorting.value
    },
  },
  onSortingChange: (updater) => {
    sorting.value = typeof updater === "function" ? updater(sorting.value) : updater
  },
  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
})
</script>

<template>
  <DataGrid
    :table="table"
    :record-count="data.length"
    :table-layout="{ columnsResizable: true, headerSticky: true }"
    :table-class-names="{ headerSticky: 'sticky top-0 z-10 bg-muted/90 backdrop-blur-xs' }"
  >
    <Card class="w-full gap-0 p-0">
      <CardHeader class="flex items-center justify-between gap-3 px-4 py-2">
        <div class="flex items-center gap-2">
          <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="text-muted-foreground size-4">
            <path d="M12 13v8l-4-4" />
            <path d="m12 21 4-4" />
            <path d="M4.393 15.269A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.436 8.284" />
          </svg>
          <span class="text-foreground text-sm font-medium">Remote Data</span>
          <Badge variant="secondary" size="sm">{{ data.length }} / {{ TOTAL_SERVER_RECORDS }}</Badge>
        </div>
        <CardAction class="flex items-center gap-2">
          <Button variant="outline" size="sm" @click="handleReset">
            <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>
            Start over
          </Button>
          <Button variant="ghost" size="icon" class="size-8" aria-label="Download snapshot" title="Download snapshot">
            <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="M12 15V3" />
              <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
              <path d="m7 10 5 5 5-5" />
            </svg>
          </Button>
        </CardAction>
      </CardHeader>
      <CardContent class="border-t p-0">
        <DataGridScrollArea class="h-[480px]">
          <DataGridTableVirtual
            :estimate-size="57"
            :is-fetching-more="isFetching"
            :has-more="hasMore"
            @fetch-more="fetchMore"
          />
        </DataGridScrollArea>
      </CardContent>
    </Card>
  </DataGrid>
</template>

Установка

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

Зависимости реестра

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

  • @tanstack/vue-table

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