From bf5b4e42b2bceb52ca4b6216e8e17d293f0d0e35 Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Thu, 25 Sep 2025 02:35:25 +0600 Subject: [PATCH 001/161] feat: add virtual --- src/components/dynamic-container.tsx | 93 ---------------------------- 1 file changed, 93 deletions(-) delete mode 100644 src/components/dynamic-container.tsx diff --git a/src/components/dynamic-container.tsx b/src/components/dynamic-container.tsx deleted file mode 100644 index 0d1743c0a..000000000 --- a/src/components/dynamic-container.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client"; - -/** - * @see https://github.com/dubinc/dub/blob/main/packages/ui/src/animated-size-container.tsx - */ - -import { motion, type TargetAndTransition } from "motion/react"; -import * as React from "react"; -import { cn } from "@/lib/utils"; - -interface Dimensions extends TargetAndTransition { - width: string | number; - height: string | number; -} - -interface DynamicContainerProps - extends React.ComponentProps { - width?: boolean; - height?: boolean; - children?: React.ReactNode; -} - -function DynamicContainer({ - width, - height, - transition = { - type: "spring", - duration: 0.3, - stiffness: 100, - damping: 15, - }, - className, - children, - ...props -}: DynamicContainerProps) { - const containerRef = React.useRef(null); - const [dimensions, setDimensions] = React.useState({ - width: "auto", - height: "auto", - }); - const rafRef = React.useRef(null); - - React.useEffect(() => { - const node = containerRef?.current; - if (!node) return; - - function updateDimensions([entry]: ResizeObserverEntry[]) { - if (rafRef.current) { - cancelAnimationFrame(rafRef.current); - } - - rafRef.current = requestAnimationFrame(() => { - setDimensions({ - width: width ? (entry?.contentRect?.width ?? "auto") : "auto", - height: height ? (entry?.contentRect?.height ?? "auto") : "auto", - }); - }); - } - - const observer = new ResizeObserver(updateDimensions); - observer.observe(node); - - return () => { - observer.disconnect(); - if (rafRef.current) { - cancelAnimationFrame(rafRef.current); - } - }; - }, [width, height]); - - const containerStyle = React.useMemo( - () => ({ - height: height ? "max-content" : "auto", - width: width ? "max-content" : "auto", - }), - [height, width], - ); - - return ( - -
- {children} -
-
- ); -} - -export { DynamicContainer }; From 7e7e2c9a7f962fee2686e750e75ae553c170c04b Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Thu, 25 Sep 2025 03:09:26 +0600 Subject: [PATCH 002/161] feat: add data-grid --- package.json | 1 + pnpm-lock.yaml | 20 ++ src/app/editable/page.tsx | 10 + src/app/page.tsx | 1 - src/components/data-grid/data-grid-cell.tsx | 56 ++++ src/components/data-grid/data-grid-demo.tsx | 142 ++++++++++ src/components/data-grid/data-grid.tsx | 207 ++++++++++++++ src/components/data-grid/example.tsx | 142 ++++++++++ .../data-grid/infinte-data-grid-demo.tsx | 255 ++++++++++++++++++ src/components/layouts/site-header.tsx | 6 + src/hooks/use-data-grid.ts | 98 +++++++ 11 files changed, 937 insertions(+), 1 deletion(-) create mode 100644 src/app/editable/page.tsx create mode 100644 src/components/data-grid/data-grid-cell.tsx create mode 100644 src/components/data-grid/data-grid-demo.tsx create mode 100644 src/components/data-grid/data-grid.tsx create mode 100644 src/components/data-grid/example.tsx create mode 100644 src/components/data-grid/infinte-data-grid-demo.tsx create mode 100644 src/hooks/use-data-grid.ts diff --git a/package.json b/package.json index 659c82ba1..8ed648a89 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@t3-oss/env-nextjs": "^0.13.8", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.12", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86a927668..6bbb65f33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@tanstack/react-virtual': + specifier: ^3.13.12 + version: 3.13.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1606,10 +1609,19 @@ packages: react: '>=16.8' react-dom: '>=16.8' + '@tanstack/react-virtual@3.13.12': + resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/table-core@8.21.3': resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} + '@tanstack/virtual-core@3.13.12': + resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} + '@total-typescript/ts-reset@0.6.1': resolution: {integrity: sha512-cka47fVSo6lfQDIATYqb/vO1nvFfbPw7uWLayIXIhGETj0wcOOlrlkobOMDNQOFr9QOafegUPq13V2+6vtD7yg==} @@ -4522,8 +4534,16 @@ snapshots: react: 19.1.1 react-dom: 19.1.1(react@19.1.1) + '@tanstack/react-virtual@3.13.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@tanstack/virtual-core': 3.13.12 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + '@tanstack/table-core@8.21.3': {} + '@tanstack/virtual-core@3.13.12': {} + '@total-typescript/ts-reset@0.6.1': {} '@ts-morph/common@0.27.0': diff --git a/src/app/editable/page.tsx b/src/app/editable/page.tsx new file mode 100644 index 000000000..c19658875 --- /dev/null +++ b/src/app/editable/page.tsx @@ -0,0 +1,10 @@ +import { DataGridDemo } from "@/components/data-grid/data-grid-demo"; +import { Shell } from "@/components/shell"; + +export default async function EditablePage() { + return ( + + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 7364247ba..800653dcb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,7 +3,6 @@ import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; import { Shell } from "@/components/shell"; import { getValidFilters } from "@/lib/data-table"; import type { SearchParams } from "@/types"; - import { FeatureFlagsProvider } from "./_components/feature-flags-provider"; import { TasksTable } from "./_components/tasks-table"; import { diff --git a/src/components/data-grid/data-grid-cell.tsx b/src/components/data-grid/data-grid-cell.tsx new file mode 100644 index 000000000..10e68bae8 --- /dev/null +++ b/src/components/data-grid/data-grid-cell.tsx @@ -0,0 +1,56 @@ +"use client"; + +import type { Cell, RowData, Table } from "@tanstack/react-table"; +import * as React from "react"; + +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +declare module "@tanstack/react-table" { + interface TableMeta { + updateData: (rowIndex: number, columnId: string, value: unknown) => void; + } +} + +interface DataGridCellProps { + cell: Cell; + table: Table; + className?: string; +} + +export function DataGridCell({ + cell, + table, + className, +}: DataGridCellProps) { + const initialValue = cell.getValue(); + const [value, setValue] = React.useState(initialValue); + + const onBlur = React.useCallback(() => { + table.options.meta?.updateData(cell.row.index, cell.column.id, value); + }, [table.options.meta, cell.row.index, cell.column.id, value]); + + const onKeyDown = React.useCallback((event: React.KeyboardEvent) => { + if (event.key === "Enter") { + (event.currentTarget as HTMLInputElement).blur(); + } + }, []); + + // Sync external changes + React.useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + return ( + setValue(e.target.value)} + onBlur={onBlur} + onKeyDown={onKeyDown} + className={cn( + "h-8 border-none bg-transparent p-1 focus-visible:ring-1 focus-visible:ring-ring", + className, + )} + /> + ); +} diff --git a/src/components/data-grid/data-grid-demo.tsx b/src/components/data-grid/data-grid-demo.tsx new file mode 100644 index 000000000..2bcd1a993 --- /dev/null +++ b/src/components/data-grid/data-grid-demo.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { faker } from "@faker-js/faker"; +import type { ColumnDef } from "@tanstack/react-table"; +import * as React from "react"; + +import { DataGrid } from "@/components/data-grid/data-grid"; + +interface Person { + id: string; + firstName: string; + lastName: string; + age: number; + email: string; + phone: string; + company: string; + jobTitle: string; + city: string; + country: string; +} + +// Set seed for consistent data across reloads +faker.seed(12345); + +// Generate realistic sample data using Faker +const generatePerson = (id: number): Person => { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + return { + id: id.toString(), + firstName, + lastName, + age: faker.number.int({ min: 22, max: 65 }), + email: faker.internet.email({ firstName, lastName }).toLowerCase(), + phone: faker.phone.number(), + company: faker.company.name(), + jobTitle: faker.person.jobTitle(), + city: faker.location.city(), + country: faker.location.country(), + }; +}; + +// Create 1000 rows to test virtualization +const initialData: Person[] = Array.from({ length: 1000 }, (_, i) => + generatePerson(i + 1), +); + +export function DataGridDemo() { + const [data, setData] = React.useState(initialData); + + const columns = React.useMemo[]>( + () => [ + { + id: "firstName", + accessorKey: "firstName", + header: "First Name", + size: 120, + }, + { + id: "lastName", + accessorKey: "lastName", + header: "Last Name", + size: 120, + }, + { + id: "age", + accessorKey: "age", + header: "Age", + size: 80, + }, + { + id: "email", + accessorKey: "email", + header: "Email", + size: 220, + }, + { + id: "phone", + accessorKey: "phone", + header: "Phone", + size: 140, + }, + { + id: "company", + accessorKey: "company", + header: "Company", + size: 180, + }, + { + id: "jobTitle", + accessorKey: "jobTitle", + header: "Job Title", + size: 160, + }, + { + id: "city", + accessorKey: "city", + header: "City", + size: 120, + }, + { + id: "country", + accessorKey: "country", + header: "Country", + size: 120, + }, + ], + [], + ); + + const handleDataChange = React.useCallback((newData: Person[]) => { + setData(newData); + }, []); + + const handleAddRow = React.useCallback(() => { + const newId = data.length + 1; + const newRow: Person = generatePerson(newId); + setData((prev) => [...prev, newRow]); + }, [data.length]); + + return ( +
+

Editable Data Grid Example

+
+ 📊 {data.length} realistic employee records • ⚡ Virtualized rendering • + 🖱️ Click any cell to edit • 📱 Horizontal scroll for more columns +
+ row.id} + onAddRow={handleAddRow} + height={500} + /> +
+ 💡 Tip: Scroll to see virtualization in action - only visible rows are + rendered in the DOM +
+
+ ); +} diff --git a/src/components/data-grid/data-grid.tsx b/src/components/data-grid/data-grid.tsx new file mode 100644 index 000000000..f0807f1f7 --- /dev/null +++ b/src/components/data-grid/data-grid.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { type ColumnDef, flexRender } from "@tanstack/react-table"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import * as React from "react"; + +import { DataGridCell } from "@/components/data-grid/data-grid-cell"; +import { useDataGrid } from "@/hooks/use-data-grid"; +import { cn } from "@/lib/utils"; + +interface DataGridProps { + data: TData[]; + columns: ColumnDef[]; + onDataChange?: (data: TData[]) => void; + getRowId?: (row: TData, index: number) => string; + className?: string; + height?: number; + estimateRowSize?: number; + overscan?: number; + enableSorting?: boolean; + onAddRow?: () => void; +} + +export function DataGrid({ + data, + columns, + onDataChange, + getRowId, + className, + height = 600, + estimateRowSize = 35, + overscan = 5, + enableSorting = true, + onAddRow, +}: DataGridProps) { + const tableContainerRef = React.useRef(null); + + // Default column with editable cell + const defaultColumn: Partial> = React.useMemo( + () => ({ + cell: ({ cell, table }) => , + }), + [], + ); + + const { table, rows } = useDataGrid({ + data, + columns, + onDataChange, + getRowId, + enableSorting, + defaultColumn, + }); + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => tableContainerRef.current, + estimateSize: () => estimateRowSize, + overscan, + measureElement: + typeof window !== "undefined" && + navigator.userAgent.indexOf("Firefox") === -1 + ? (element) => element?.getBoundingClientRect().height + : undefined, + }); + + return ( +
+
+ {/* Table with CSS Grid for virtualization */} +
+ {/* Header */} +
+ {table.getHeaderGroups().map((headerGroup) => ( +
+ {headerGroup.headers.map((header) => ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + header.column.getToggleSortingHandler()?.(e); + } + }} + onClick={header.column.getToggleSortingHandler()} + role="button" + tabIndex={header.column.getCanSort() ? 0 : -1} + > + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {enableSorting && ( + + {{ + asc: "🔼", + desc: "🔽", + }[header.column.getIsSorted() as string] ?? null} + + )} +
+ ))} +
+ ))} +
+ + {/* Body */} +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const row = rows[virtualRow.index]; + if (!row) return null; + + return ( +
virtualizer.measureElement(node)} + style={{ + display: "flex", + position: "absolute", + transform: `translateY(${virtualRow.start}px)`, + width: "100%", + borderBottom: "1px solid hsl(var(--border))", + }} + > + {row.getVisibleCells().map((cell) => ( +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+ ))} +
+ ); + })} +
+
+
+ + {/* Add row button */} + {onAddRow && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/data-grid/example.tsx b/src/components/data-grid/example.tsx new file mode 100644 index 000000000..15d1c726a --- /dev/null +++ b/src/components/data-grid/example.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { faker } from "@faker-js/faker"; +import type { ColumnDef } from "@tanstack/react-table"; +import * as React from "react"; + +import { EditableDataGrid } from "@/components/data-grid/editable-data-grid"; + +interface Person { + id: string; + firstName: string; + lastName: string; + age: number; + email: string; + phone: string; + company: string; + jobTitle: string; + city: string; + country: string; +} + +// Set seed for consistent data across reloads +faker.seed(12345); + +// Generate realistic sample data using Faker +const generatePerson = (id: number): Person => { + const firstName = faker.person.firstName(); + const lastName = faker.person.lastName(); + return { + id: id.toString(), + firstName, + lastName, + age: faker.number.int({ min: 22, max: 65 }), + email: faker.internet.email({ firstName, lastName }).toLowerCase(), + phone: faker.phone.number(), + company: faker.company.name(), + jobTitle: faker.person.jobTitle(), + city: faker.location.city(), + country: faker.location.country(), + }; +}; + +// Create 1000 rows to test virtualization +const initialData: Person[] = Array.from({ length: 1000 }, (_, i) => + generatePerson(i + 1), +); + +export function EditableDataGridExample() { + const [data, setData] = React.useState(initialData); + + const columns = React.useMemo[]>( + () => [ + { + id: "firstName", + accessorKey: "firstName", + header: "First Name", + size: 120, + }, + { + id: "lastName", + accessorKey: "lastName", + header: "Last Name", + size: 120, + }, + { + id: "age", + accessorKey: "age", + header: "Age", + size: 80, + }, + { + id: "email", + accessorKey: "email", + header: "Email", + size: 220, + }, + { + id: "phone", + accessorKey: "phone", + header: "Phone", + size: 140, + }, + { + id: "company", + accessorKey: "company", + header: "Company", + size: 180, + }, + { + id: "jobTitle", + accessorKey: "jobTitle", + header: "Job Title", + size: 160, + }, + { + id: "city", + accessorKey: "city", + header: "City", + size: 120, + }, + { + id: "country", + accessorKey: "country", + header: "Country", + size: 120, + }, + ], + [], + ); + + const handleDataChange = React.useCallback((newData: Person[]) => { + setData(newData); + }, []); + + const handleAddRow = React.useCallback(() => { + const newId = data.length + 1; + const newRow: Person = generatePerson(newId); + setData((prev) => [...prev, newRow]); + }, [data.length]); + + return ( +
+

Editable Data Grid Example

+
+ 📊 {data.length} realistic employee records • ⚡ Virtualized rendering • + 🖱️ Click any cell to edit • 📱 Horizontal scroll for more columns +
+ row.id} + onAddRow={handleAddRow} + height={500} + /> +
+ 💡 Tip: Scroll to see virtualization in action - only visible rows are + rendered in the DOM +
+
+ ); +} diff --git a/src/components/data-grid/infinte-data-grid-demo.tsx b/src/components/data-grid/infinte-data-grid-demo.tsx new file mode 100644 index 000000000..b90bddd87 --- /dev/null +++ b/src/components/data-grid/infinte-data-grid-demo.tsx @@ -0,0 +1,255 @@ +"use client"; + +import { type ColumnDef, flexRender } from "@tanstack/react-table"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import * as React from "react"; + +import { DataGridCell } from "@/components/data-grid/data-grid-cell"; +import { useDataGrid } from "@/hooks/use-data-grid"; +import { cn } from "@/lib/utils"; + +interface InfiniteDataGridProps { + data: TData[]; + columns: ColumnDef[]; + onDataChange?: (data: TData[]) => void; + getRowId?: (row: TData, index: number) => string; + className?: string; + height?: number; + estimateRowSize?: number; + overscan?: number; + enableSorting?: boolean; + onAddRow?: () => void; + hasNextPage?: boolean; + isFetchingNextPage?: boolean; + fetchNextPage?: () => void; + totalCount?: number; +} + +export function InfiniteDataGrid({ + data, + columns, + onDataChange, + getRowId, + className, + height = 600, + estimateRowSize = 35, + overscan = 5, + enableSorting = true, + onAddRow, + hasNextPage = false, + isFetchingNextPage = false, + fetchNextPage, + totalCount, +}: InfiniteDataGridProps) { + const tableContainerRef = React.useRef(null); + + // Default column with editable cell + const defaultColumn: Partial> = React.useMemo( + () => ({ + cell: ({ cell, table }) => , + }), + [], + ); + + const { table, rows } = useDataGrid({ + data, + columns, + onDataChange, + getRowId, + enableSorting, + defaultColumn, + }); + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => tableContainerRef.current, + estimateSize: () => estimateRowSize, + overscan, + measureElement: + typeof window !== "undefined" && + navigator.userAgent.indexOf("Firefox") === -1 + ? (element) => element?.getBoundingClientRect().height + : undefined, + }); + + // Infinite scrolling logic + const fetchMoreOnBottomReached = React.useCallback( + (containerRefElement?: HTMLDivElement | null) => { + if (containerRefElement && hasNextPage && !isFetchingNextPage) { + const { scrollHeight, scrollTop, clientHeight } = containerRefElement; + // Fetch more data when user scrolls within 500px of the bottom + if (scrollHeight - scrollTop - clientHeight < 500) { + fetchNextPage?.(); + } + } + }, + [fetchNextPage, isFetchingNextPage, hasNextPage], + ); + + // Check if we need to fetch more data on mount or after data changes + React.useEffect(() => { + fetchMoreOnBottomReached(tableContainerRef.current); + }, [fetchMoreOnBottomReached]); + + const handleScroll = React.useCallback( + (e: React.UIEvent) => { + fetchMoreOnBottomReached(e.currentTarget); + }, + [fetchMoreOnBottomReached], + ); + + return ( +
+ {/* Data info */} +
+ {data.length} of {totalCount ?? "unknown"} rows loaded + {isFetchingNextPage && " (loading more...)"} +
+ +
+ {/* Table with CSS Grid for virtualization */} +
+ {/* Header */} +
+ {table.getHeaderGroups().map((headerGroup) => ( +
+ {headerGroup.headers.map((header) => ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + header.column.getToggleSortingHandler()?.(e); + } + }} + onClick={header.column.getToggleSortingHandler()} + role="button" + tabIndex={header.column.getCanSort() ? 0 : -1} + > + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {enableSorting && ( + + {{ + asc: "🔼", + desc: "🔽", + }[header.column.getIsSorted() as string] ?? null} + + )} +
+ ))} +
+ ))} +
+ + {/* Body */} +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const row = rows[virtualRow.index]; + if (!row) return null; + + return ( +
virtualizer.measureElement(node)} + style={{ + display: "flex", + position: "absolute", + transform: `translateY(${virtualRow.start}px)`, + width: "100%", + borderBottom: "1px solid hsl(var(--border))", + }} + > + {row.getVisibleCells().map((cell) => ( +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+ ))} +
+ ); + })} +
+
+
+ + {/* Add row button */} + {onAddRow && ( +
+ +
+ )} + + {/* Loading indicator */} + {isFetchingNextPage && ( +
+ Loading more rows... +
+ )} +
+ ); +} diff --git a/src/components/layouts/site-header.tsx b/src/components/layouts/site-header.tsx index ec5fd32f4..136051408 100644 --- a/src/components/layouts/site-header.tsx +++ b/src/components/layouts/site-header.tsx @@ -25,6 +25,12 @@ export function SiteHeader() { > Docs + + Editable +