├── bun.lockb ├── public ├── og-image.png ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── src ├── app │ ├── favicon.ico │ ├── normal-2 │ │ └── page.tsx │ ├── globals.css │ ├── layout.tsx │ ├── normal │ │ └── page.tsx │ ├── page.tsx │ ├── sub-rows │ │ └── page.tsx │ └── add-rows │ │ └── page.tsx ├── lib │ └── utils.ts ├── components │ ├── theme-provider.tsx │ ├── ui │ │ ├── button.tsx │ │ └── table.tsx │ ├── header.tsx │ ├── sheet-table │ │ ├── utils.ts │ │ └── index.tsx │ └── backup │ │ └── sheet-table.tsx └── schemas │ └── row-data-schema.ts ├── next.config.ts ├── postcss.config.mjs ├── eslint.config.mjs ├── components.json ├── .gitignore ├── tsconfig.json ├── package.json ├── LICENSE ├── tailwind.config.ts └── README.md /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacksonkasi1/FlexiSheet/HEAD/bun.lockb -------------------------------------------------------------------------------- /public/og-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacksonkasi1/FlexiSheet/HEAD/public/og-image.png -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacksonkasi1/FlexiSheet/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ThemeProvider as NextThemesProvider } from "next-themes" 5 | 6 | export function ThemeProvider({ 7 | children, 8 | ...props 9 | }: React.ComponentProps) { 10 | return {children} 11 | } 12 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | 44 | logs.txt 45 | 46 | notes.todo -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "baseUrl": ".", 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flexisheet", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@radix-ui/react-slot": "^1.1.1", 13 | "@tanstack/react-table": "^8.20.6", 14 | "@types/nanoid": "^2.1.0", 15 | "class-variance-authority": "^0.7.1", 16 | "clsx": "^2.1.1", 17 | "lucide-react": "^0.469.0", 18 | "nanoid": "^5.0.9", 19 | "next": "15.1.3", 20 | "next-themes": "^0.4.4", 21 | "react": "^19.0.0", 22 | "react-dom": "^19.0.0", 23 | "tailwind-merge": "^2.6.0", 24 | "tailwindcss-animate": "^1.0.7", 25 | "zod": "^3.24.1" 26 | }, 27 | "devDependencies": { 28 | "@eslint/eslintrc": "^3", 29 | "@types/node": "^20", 30 | "@types/react": "^19", 31 | "@types/react-dom": "^19", 32 | "eslint": "^9", 33 | "eslint-config-next": "15.1.3", 34 | "postcss": "^8", 35 | "tailwindcss": "^3.4.1", 36 | "typescript": "^5" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/schemas/row-data-schema.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * row-data-schema.ts 3 | * 4 | * Defines the Zod schema and TypeScript types for table row data. 5 | */ 6 | 7 | import { z } from "zod"; 8 | 9 | /** 10 | * Zod schema for row data validation. 11 | */ 12 | export const rowDataZodSchema = z.object({ 13 | headerKey: z.string().optional(), // NOTE: Key to define grouping (optional). 14 | 15 | id: z.string(), // NOTE: Unique ID for each row. it's mandatory. 16 | 17 | materialName: z.string().min(1, "Material name cannot be empty."), 18 | cft: z.number().min(0, "CFT cannot be negative."), 19 | // .optional(), // BUG: Optional fields are not validated properly as number. 20 | rate: z.number().min(0, "Rate cannot be negative.").max(10000, "Rate cannot exceed 10000."), 21 | amount: z.number().min(0, "Amount cannot be negative."), 22 | }); 23 | 24 | /** 25 | * The inferred TypeScript type for row data based on the Zod schema. 26 | */ 27 | export type RowData = z.infer & { 28 | // Add any additional properties here. 29 | subRows?: RowData[]; // Add subRows for nested rows 30 | }; 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Jackson Kasi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/normal-2/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import SheetTable from "@/components/sheet-table" 3 | import { ExtendedColumnDef } from "@/components/sheet-table/utils" 4 | 5 | type MyData = { 6 | headerKey?: string 7 | id: string 8 | materialName: string 9 | quantity: number 10 | cost: number 11 | } 12 | 13 | const columns: ExtendedColumnDef[] = [ 14 | { 15 | header: "Material Name", 16 | accessorKey: "materialName", 17 | size: 120, 18 | minSize: 100, 19 | maxSize: 300, 20 | }, 21 | { 22 | header: "Quantity", 23 | accessorKey: "quantity", 24 | size: 80, 25 | minSize: 50, 26 | maxSize: 120, 27 | }, 28 | { 29 | header: "Cost", 30 | accessorKey: "cost", 31 | size: 100, 32 | }, 33 | ] 34 | 35 | const data: MyData[] = [ 36 | { id: "1", materialName: "Pine Wood", quantity: 5, cost: 100 }, 37 | { id: "2", materialName: "Rubber Wood", quantity: 3, cost: 75 }, 38 | // ... 39 | ] 40 | 41 | export default function MyPage() { 42 | return ( 43 | 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 47.4% 11.2%; 9 | --muted: 210 40% 96.1%; 10 | --muted-foreground: 215.4 16.3% 46.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 222.2 47.4% 11.2%; 13 | --border: 214.3 31.8% 91.4%; 14 | --input: 214.3 31.8% 91.4%; 15 | --card: 0 0% 100%; 16 | --card-foreground: 222.2 47.4% 11.2%; 17 | --primary: 222.2 47.4% 11.2%; 18 | --primary-foreground: 210 40% 98%; 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | --accent: 210 40% 96.1%; 22 | --accent-foreground: 222.2 47.4% 11.2%; 23 | --destructive: 0 100% 50%; 24 | --destructive-foreground: 210 40% 98%; 25 | --ring: 215 20.2% 65.1%; 26 | --radius: 0.5rem; 27 | } 28 | 29 | .dark { 30 | --background: 224 71% 4%; 31 | --foreground: 213 31% 91%; 32 | --muted: 223 47% 11%; 33 | --muted-foreground: 215.4 16.3% 56.9%; 34 | --accent: 216 34% 17%; 35 | --accent-foreground: 210 40% 98%; 36 | --popover: 224 71% 4%; 37 | --popover-foreground: 215 20.2% 65.1%; 38 | --border: 216 34% 17%; 39 | --input: 216 34% 17%; 40 | --card: 224 71% 4%; 41 | --card-foreground: 213 31% 91%; 42 | --primary: 210 40% 98%; 43 | --primary-foreground: 222.2 47.4% 1.2%; 44 | --secondary: 222.2 47.4% 11.2%; 45 | --secondary-foreground: 210 40% 98%; 46 | --destructive: 0 63% 31%; 47 | --destructive-foreground: 210 40% 98%; 48 | --ring: 216 34% 17%; 49 | } 50 | } 51 | 52 | @layer base { 53 | * { 54 | @apply border-border; 55 | } 56 | body { 57 | @apply font-sans antialiased bg-background text-foreground; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | darkMode: ["class"], 5 | content: [ 6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | theme: { 11 | extend: { 12 | colors: { 13 | border: "hsl(var(--border))", 14 | input: "hsl(var(--input))", 15 | ring: "hsl(var(--ring))", 16 | background: "hsl(var(--background))", 17 | foreground: "hsl(var(--foreground))", 18 | primary: { 19 | DEFAULT: "hsl(var(--primary))", 20 | foreground: "hsl(var(--primary-foreground))", 21 | }, 22 | secondary: { 23 | DEFAULT: "hsl(var(--secondary))", 24 | foreground: "hsl(var(--secondary-foreground))", 25 | }, 26 | destructive: { 27 | DEFAULT: "hsl(var(--destructive))", 28 | foreground: "hsl(var(--destructive-foreground))", 29 | }, 30 | muted: { 31 | DEFAULT: "hsl(var(--muted))", 32 | foreground: "hsl(var(--muted-foreground))", 33 | }, 34 | accent: { 35 | DEFAULT: "hsl(var(--accent))", 36 | foreground: "hsl(var(--accent-foreground))", 37 | }, 38 | popover: { 39 | DEFAULT: "hsl(var(--popover))", 40 | foreground: "hsl(var(--popover-foreground))", 41 | }, 42 | card: { 43 | DEFAULT: "hsl(var(--card))", 44 | foreground: "hsl(var(--card-foreground))", 45 | }, 46 | }, 47 | borderRadius: { 48 | lg: `var(--radius)`, 49 | md: `calc(var(--radius) - 2px)`, 50 | sm: "calc(var(--radius) - 4px)", 51 | }, 52 | }, 53 | }, 54 | plugins: [require("tailwindcss-animate")], 55 | } satisfies Config; 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | 4 | import { ThemeProvider } from "@/components/theme-provider"; 5 | import Header from "@/components/header"; 6 | 7 | import "./globals.css"; 8 | 9 | const geistSans = Geist({ 10 | variable: "--font-geist-sans", 11 | subsets: ["latin"], 12 | }); 13 | 14 | const geistMono = Geist_Mono({ 15 | variable: "--font-geist-mono", 16 | subsets: ["latin"], 17 | }); 18 | 19 | export const metadata: Metadata = { 20 | title: "FlexiSheet - Reusable and Editable Tables", 21 | description: 22 | "Discover FlexiSheet: A dynamic, reusable table component with features like editable cells, row disabling, Zod validation, and more.", 23 | keywords: 24 | "FlexiSheet, React Table, Editable Cells, Zod Validation, Dynamic Tables, Reusable Components", 25 | authors: [{ name: "Jackson Kasi", url: "https://github.com/jacksonkasi1" }], 26 | openGraph: { 27 | title: "FlexiSheet - Reusable and Editable Tables", 28 | description: 29 | "FlexiSheet provides advanced table features for React, including grouping, validation, and customization.", 30 | url: "https://flexisheet.vercel.app/", 31 | siteName: "FlexiSheet", 32 | images: [ 33 | { 34 | url: "https://flexisheet.vercel.app/og-image.png", 35 | width: 1200, 36 | height: 630, 37 | alt: "FlexiSheet - Reusable and Editable Tables", 38 | }, 39 | ], 40 | type: "website", 41 | }, 42 | twitter: { 43 | card: "summary_large_image", 44 | title: "FlexiSheet - Reusable and Editable Tables", 45 | description: 46 | "Discover FlexiSheet: A dynamic, reusable table component for React applications.", 47 | images: ["https://flexisheet.vercel.app/og-image.png"], 48 | }, 49 | }; 50 | 51 | export default function RootLayout({ 52 | children, 53 | }: Readonly<{ 54 | children: React.ReactNode; 55 | }>) { 56 | return ( 57 | 58 | 61 | 67 |
68 | {children} 69 | 70 | 71 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Table = React.forwardRef< 6 | HTMLTableElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
10 | 15 | 16 | )) 17 | Table.displayName = "Table" 18 | 19 | const TableHeader = React.forwardRef< 20 | HTMLTableSectionElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )) 25 | TableHeader.displayName = "TableHeader" 26 | 27 | const TableBody = React.forwardRef< 28 | HTMLTableSectionElement, 29 | React.HTMLAttributes 30 | >(({ className, ...props }, ref) => ( 31 | 36 | )) 37 | TableBody.displayName = "TableBody" 38 | 39 | const TableFooter = React.forwardRef< 40 | HTMLTableSectionElement, 41 | React.HTMLAttributes 42 | >(({ className, ...props }, ref) => ( 43 | tr]:last:border-b-0", 47 | className 48 | )} 49 | {...props} 50 | /> 51 | )) 52 | TableFooter.displayName = "TableFooter" 53 | 54 | const TableRow = React.forwardRef< 55 | HTMLTableRowElement, 56 | React.HTMLAttributes 57 | >(({ className, ...props }, ref) => ( 58 | 66 | )) 67 | TableRow.displayName = "TableRow" 68 | 69 | const TableHead = React.forwardRef< 70 | HTMLTableCellElement, 71 | React.ThHTMLAttributes 72 | >(({ className, ...props }, ref) => ( 73 |
[role=checkbox]]:translate-y-[2px]", 77 | className 78 | )} 79 | {...props} 80 | /> 81 | )) 82 | TableHead.displayName = "TableHead" 83 | 84 | const TableCell = React.forwardRef< 85 | HTMLTableCellElement, 86 | React.TdHTMLAttributes 87 | >(({ className, ...props }, ref) => ( 88 | [role=checkbox]]:translate-y-[2px]", 92 | className 93 | )} 94 | {...props} 95 | /> 96 | )) 97 | TableCell.displayName = "TableCell" 98 | 99 | const TableCaption = React.forwardRef< 100 | HTMLTableCaptionElement, 101 | React.HTMLAttributes 102 | >(({ className, ...props }, ref) => ( 103 |
108 | )) 109 | TableCaption.displayName = "TableCaption" 110 | 111 | export { 112 | Table, 113 | TableHeader, 114 | TableBody, 115 | TableFooter, 116 | TableHead, 117 | TableRow, 118 | TableCell, 119 | TableCaption, 120 | } 121 | -------------------------------------------------------------------------------- /src/components/header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTheme } from "next-themes"; // Handles theme switching 4 | 5 | import Link from "next/link"; // For navigation links 6 | import { usePathname } from "next/navigation"; // To determine the current path 7 | import { Sun, Moon, Menu, X } from "lucide-react"; // Icons for theme and mobile menu 8 | import { useState } from "react"; // For local state management 9 | import { Button } from "@/components/ui/button"; // ShadCN button component 10 | 11 | const Header = () => { 12 | const { theme, setTheme } = useTheme(); // Theme state and handler 13 | const pathname = usePathname(); // Current route path 14 | const [isMenuOpen, setIsMenuOpen] = useState(false); // State for mobile menu toggle 15 | 16 | // Navigation links 17 | const navigation = [ 18 | { name: "Complex", href: "/" }, 19 | { name: "Simple", href: "/normal" }, 20 | { name: "Simple 2", href: "/normal-2" }, 21 | { name: "Sub Rows", href: "/sub-rows" }, 22 | { name: "Add Rows", href: "/add-rows" }, 23 | ]; 24 | 25 | // Toggle between light and dark themes 26 | const toggleTheme = () => { 27 | setTheme(theme === "dark" ? "light" : "dark"); 28 | }; 29 | 30 | return ( 31 |
32 |
33 |
34 | {/* Logo */} 35 |
36 | 40 | FlexiSheet 41 | 42 |
43 | 44 | {/* Desktop Navigation */} 45 | 60 | 61 | {/* Theme Toggle, ShadCN Button, and Mobile Menu Button */} 62 |
63 | {/* ShadCN Button for Theme Toggle */} 64 | 71 | 72 | {/* Mobile Menu Toggle Button */} 73 | 85 |
86 |
87 | 88 | {/* Mobile Navigation */} 89 | {isMenuOpen && ( 90 |
91 |
92 | {navigation.map((item) => ( 93 | setIsMenuOpen(false)} 102 | > 103 | {item.name} 104 | 105 | ))} 106 |
107 |
108 | )} 109 |
110 |
111 | ); 112 | }; 113 | 114 | export default Header; 115 | -------------------------------------------------------------------------------- /src/app/normal/page.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * normal/page.tsx 3 | * 4 | * Demonstration of using the extended SheetTable with Zod-based validation. 5 | */ 6 | 7 | "use client"; 8 | 9 | import React, { useState } from "react"; 10 | 11 | // ** import 3rd party lib 12 | import { z } from "zod"; 13 | 14 | // ** import ui components 15 | import { Button } from "@/components/ui/button"; 16 | 17 | // ** import components 18 | import SheetTable from "@/components/sheet-table"; 19 | import { ExtendedColumnDef } from "@/components/sheet-table/utils"; 20 | 21 | // ** import zod schema for row data 22 | import { rowDataZodSchema, RowData } from "@/schemas/row-data-schema"; 23 | 24 | const materialNameSchema = rowDataZodSchema.shape.materialName; // required string 25 | const cftSchema = rowDataZodSchema.shape.cft; // optional number >= 0 26 | const rateSchema = rowDataZodSchema.shape.rate; // required number >= 0 27 | const amountSchema = rowDataZodSchema.shape.amount; // required number >= 0 28 | 29 | /** 30 | * Initial data for demonstration. 31 | */ 32 | const initialData: RowData[] = [ 33 | { id: "1", materialName: "Ultra Nitro Sealer", cft: 0.03, rate: 164, amount: 5.17 }, 34 | { id: "2", materialName: "NC Thinner (Spl)", cft: 0.202, rate: 93, amount: 19.73 }, 35 | { id: "3", materialName: "Ultra Nitro Sealer 2", cft: 0.072, rate: 164, amount: 12.4 }, 36 | { id: "4", materialName: "Ultra Nitro Matt 2", cft: 0.051, rate: 209, amount: 11.19 }, 37 | { id: "5", materialName: "Ultra Nitro Glossy 2", cft: 0.045, rate: 215, amount: 9.68 }, 38 | ]; 39 | 40 | /** 41 | * Extended column definitions, each with a validationSchema. 42 | * We rely on 'accessorKey' instead of 'id'. This is fine now 43 | * because we manually allowed 'accessorKey?: string'. 44 | */ 45 | const columns: ExtendedColumnDef[] = [ 46 | { 47 | accessorKey: "materialName", 48 | header: "Material Name", 49 | validationSchema: materialNameSchema, 50 | size: 120, 51 | minSize: 50, 52 | maxSize: 100, 53 | }, 54 | { 55 | accessorKey: "cft", 56 | header: "CFT", 57 | validationSchema: cftSchema, 58 | maxSize: 20, 59 | }, 60 | { 61 | accessorKey: "rate", 62 | header: "Rate", 63 | validationSchema: rateSchema, 64 | size: 80, 65 | minSize: 50, 66 | maxSize: 120, 67 | }, 68 | { 69 | accessorKey: "amount", 70 | header: "Amount", 71 | validationSchema: amountSchema, 72 | size: 80, 73 | minSize: 50, 74 | maxSize: 120, 75 | }, 76 | ]; 77 | 78 | /** 79 | * HomePage - shows how to integrate the SheetTable with per-column Zod validation. 80 | */ 81 | export default function HomePage() { 82 | const [data, setData] = useState(initialData); 83 | 84 | 85 | /** 86 | * onEdit callback: updates local state if the new value is valid. (Normal usage) 87 | */ 88 | const handleEdit = ( 89 | rowId: string, // Unique identifier for the row 90 | columnId: K, // Column key 91 | value: RowData[K], // New value for the cell 92 | ) => { 93 | setData((prevData) => 94 | prevData.map((row) => 95 | String(row.id) === rowId 96 | ? { ...row, [columnId]: value } // Update the row if the ID matches 97 | : row // Otherwise, return the row unchanged 98 | ) 99 | ); 100 | 101 | console.log( 102 | `State updated [row id=${rowId}, column=${columnId}, value=${value}]`, 103 | value, 104 | ); 105 | }; 106 | 107 | /** 108 | * Validate entire table on submit. 109 | * If any row fails, we log the errors. Otherwise, we log the data. 110 | */ 111 | const handleSubmit = () => { 112 | const arraySchema = z.array(rowDataZodSchema); 113 | const result = arraySchema.safeParse(data); 114 | 115 | if (!result.success) { 116 | console.error("Table data is invalid:", result.error.issues); 117 | } else { 118 | console.log("Table data is valid! Submitting:", data); 119 | } 120 | }; 121 | 122 | return ( 123 |
124 |

Home Page with Zod Validation

125 | 126 | 127 | columns={columns} 128 | data={data} 129 | onEdit={handleEdit} 130 | disabledColumns={["materialName"]} // e.g. ["materialName"] 131 | disabledRows={[2]} 132 | showHeader={true} // First header visibility 133 | showSecondHeader={true} // Second header visibility 134 | secondHeaderTitle="Custom Title Example" // Title for the second header 135 | 136 | enableColumnSizing 137 | /> 138 | 139 | 140 |
141 | ); 142 | } 143 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * page.tsx 3 | * 4 | * Demonstration of using the extended SheetTable with Zod-based validation. 5 | */ 6 | 7 | "use client"; 8 | 9 | import React, { useState } from "react"; 10 | 11 | // ** import 3rd party lib 12 | import { z } from "zod"; 13 | 14 | // ** import ui components 15 | import { Button } from "@/components/ui/button"; 16 | import { TableCell, TableRow } from "@/components/ui/table"; 17 | 18 | // ** import component 19 | import SheetTable from "@/components/sheet-table"; 20 | import { ExtendedColumnDef } from "@/components/sheet-table/utils"; 21 | 22 | // ** import zod schema for row data 23 | import { rowDataZodSchema, RowData } from "@/schemas/row-data-schema"; 24 | 25 | const materialNameSchema = rowDataZodSchema.shape.materialName; // required string 26 | const cftSchema = rowDataZodSchema.shape.cft; // optional number >= 0 27 | const rateSchema = rowDataZodSchema.shape.rate; // required number >= 0 28 | const amountSchema = rowDataZodSchema.shape.amount; // required number >= 0 29 | 30 | /** 31 | * Initial data for demonstration. 32 | */ 33 | const initialData: RowData[] = [ 34 | { 35 | headerKey: "Dipping - 2 times", 36 | id: "1", 37 | materialName: "Ultra Nitro Sealer", 38 | cft: 0.03, 39 | rate: 164, 40 | amount: 5.17 41 | }, 42 | { 43 | headerKey: "Dipping - 2 times", 44 | id: "2", 45 | materialName: "NC Thinner (Spl)", 46 | cft: 0.202, 47 | rate: 93, 48 | amount: 101.73, 49 | }, 50 | { 51 | headerKey: "Spraying", 52 | id: "3", 53 | materialName: "Ultra Nitro Sealer 2", 54 | cft: 0.072, 55 | rate: 164, 56 | amount: 12.4, 57 | }, 58 | { 59 | headerKey: "Spraying", 60 | id: "4", 61 | materialName: "Ultra Nitro Matt 2", 62 | cft: 0.051, 63 | rate: 209, 64 | amount: 11.19, 65 | }, 66 | { 67 | headerKey: "Spraying", 68 | id: "5", 69 | materialName: "Ultra Nitro Glossy 2", 70 | cft: 0.045, 71 | rate: 215, 72 | amount: 120, 73 | 74 | }, 75 | ]; 76 | 77 | /** 78 | * Extended column definitions, each with a validationSchema. 79 | * We rely on 'accessorKey' instead of 'id'. This is fine now 80 | * because we manually allowed 'accessorKey?: string'. 81 | */ 82 | const columns: ExtendedColumnDef[] = [ 83 | { 84 | accessorKey: "materialName", 85 | header: "Material Name", 86 | validationSchema: materialNameSchema, 87 | className: "text-center font-bold bg-yellow-100 dark:bg-yellow-800 dark:text-yellow-100", // Static styling 88 | }, 89 | { 90 | accessorKey: "cft", 91 | header: "CFT", 92 | validationSchema: cftSchema, 93 | }, 94 | { 95 | accessorKey: "rate", 96 | header: "Rate", 97 | validationSchema: rateSchema, 98 | }, 99 | { 100 | accessorKey: "amount", 101 | header: "Amount", 102 | validationSchema: amountSchema, 103 | className: (row) => (row.amount > 100 ? "text-green-500" : "text-red-500"), // Dynamic styling based on row data 104 | }, 105 | ]; 106 | 107 | /** 108 | * HomePage - shows how to integrate the SheetTable with per-column Zod validation. 109 | */ 110 | export default function HomePage() { 111 | const [data, setData] = useState(initialData); 112 | 113 | 114 | /** 115 | * onEdit callback: updates local state if the new value is valid. (Normal usage) 116 | */ 117 | const handleEdit = ( 118 | rowId: string, // Unique identifier for the row 119 | columnId: K, // Column key 120 | value: RowData[K], // New value for the cell 121 | ) => { 122 | setData((prevData) => 123 | prevData.map((row) => 124 | String(row.id) === rowId 125 | ? { ...row, [columnId]: value } // Update the row if the ID matches 126 | : row // Otherwise, return the row unchanged 127 | ) 128 | ); 129 | 130 | console.log( 131 | `State updated [row id=${rowId}, column=${columnId}, value=${value}]`, 132 | value, 133 | ); 134 | }; 135 | 136 | /** 137 | * Validate entire table on submit. 138 | * If any row fails, we log the errors. Otherwise, we log the data. 139 | */ 140 | const handleSubmit = () => { 141 | const arraySchema = z.array(rowDataZodSchema); 142 | const result = arraySchema.safeParse(data); 143 | 144 | if (!result.success) { 145 | console.error("Table data is invalid:", result.error.issues); 146 | } else { 147 | console.log("Table data is valid! Submitting:", data); 148 | } 149 | }; 150 | 151 | return ( 152 |
153 |

Home Page with Zod Validation

154 | 155 | 156 | columns={columns} 157 | data={data} 158 | onEdit={handleEdit} 159 | disabledColumns={["materialName"]} // e.g. ["materialName"] 160 | disabledRows={{ 161 | // optional: disable specific rows 162 | "Dipping - 2 times": [0], // Disable the second row in this group 163 | Spraying: [1], // Disable the first row in this group 164 | }} 165 | // Grouping & header props 166 | showHeader={true} // First header visibility 167 | showSecondHeader={true} // Second header visibility 168 | secondHeaderTitle="Custom Title Example" // Title for the second header 169 | // Footer props 170 | totalRowValues={{ 171 | // cft: 0.4, 172 | rate: 560, 173 | amount: 38.17, 174 | }} 175 | totalRowLabel="Total" 176 | totalRowTitle="Summary (Footer Total Title)" 177 | footerElement={ 178 | 179 | 180 | Custom Footer Note 181 | 182 | Misc 183 | Extra Info 184 | 185 | } 186 | /> 187 | 188 | 189 |
190 | ); 191 | } 192 | -------------------------------------------------------------------------------- /src/app/sub-rows/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState } from "react"; 4 | 5 | // ** import ui components 6 | import { Button } from "@/components/ui/button"; 7 | 8 | // ** import component 9 | import SheetTable from "@/components/sheet-table"; 10 | import { ExtendedColumnDef } from "@/components/sheet-table/utils"; 11 | 12 | // ** import zod schema for row data 13 | import { rowDataZodSchema, RowData } from "@/schemas/row-data-schema"; 14 | 15 | const materialNameSchema = rowDataZodSchema.shape.materialName; // required string 16 | const cftSchema = rowDataZodSchema.shape.cft; // optional number >= 0 17 | const rateSchema = rowDataZodSchema.shape.rate; // required number >= 0 18 | const amountSchema = rowDataZodSchema.shape.amount; // required number >= 0 19 | 20 | /** 21 | * Initial data for demonstration. 22 | * All `id` values must be *unique strings* across all nested subRows. 23 | */ 24 | const initialData: RowData[] = [ 25 | { 26 | id: "1", 27 | materialName: "Ultra Nitro Sealer", 28 | cft: 0.03, 29 | rate: 164, 30 | amount: 5.17, 31 | }, 32 | { 33 | id: "2", 34 | materialName: "NC Thinner (Spl)", 35 | cft: 0.202, 36 | rate: 93, 37 | amount: 19.73, 38 | subRows: [ 39 | { 40 | id: "2.1", 41 | materialName: "NC Thinner (Spl) 1", 42 | cft: 0.203, 43 | rate: 94, 44 | amount: 20.0, 45 | }, 46 | { 47 | id: "2.2", 48 | materialName: "NC Thinner (Spl) 2", 49 | cft: 0.204, 50 | rate: 95, 51 | amount: 20.3, 52 | }, 53 | ], 54 | }, 55 | { 56 | id: "3", 57 | materialName: "Ultra Nitro Sealer 2", 58 | cft: 0.072, 59 | rate: 165, 60 | amount: 12.4, 61 | }, 62 | { 63 | id: "4", 64 | materialName: "Ultra Nitro Matt 2", 65 | cft: 0.051, 66 | rate: 209, 67 | amount: 11.19, 68 | subRows: [ 69 | { 70 | id: "4.1", 71 | materialName: "Ultra Nitro Matt 2 1", 72 | cft: 0.052, 73 | rate: 210, 74 | amount: 11.2, 75 | subRows: [ 76 | { 77 | id: "4.1.1", 78 | materialName: "Ultra Nitro Matt 2 1 1", 79 | cft: 0.053, 80 | rate: 211, 81 | amount: 11.3, 82 | }, 83 | { 84 | id: "4.1.2", 85 | materialName: "Ultra Nitro Matt 2 1 2", 86 | cft: 0.054, 87 | rate: 212, 88 | amount: 11.4, 89 | }, 90 | ], 91 | }, 92 | { 93 | id: "4.2", 94 | materialName: "Ultra Nitro Matt 2 2", 95 | cft: 0.055, 96 | rate: 213, 97 | amount: 11.5, 98 | }, 99 | ], 100 | }, 101 | { 102 | id: "5", 103 | materialName: "Ultra Nitro Glossy 2", 104 | cft: 0.045, 105 | rate: 215, 106 | amount: 9.68, 107 | }, 108 | ]; 109 | 110 | /** 111 | * Extended column definitions, each with a validationSchema. 112 | */ 113 | const columns: ExtendedColumnDef[] = [ 114 | { 115 | accessorKey: "materialName", 116 | header: "Material Name", 117 | validationSchema: materialNameSchema, 118 | size: 120, 119 | minSize: 50, 120 | maxSize: 100, 121 | }, 122 | { 123 | accessorKey: "cft", 124 | header: "CFT", 125 | validationSchema: cftSchema, 126 | maxSize: 20, 127 | }, 128 | { 129 | accessorKey: "rate", 130 | header: "Rate", 131 | validationSchema: rateSchema, 132 | size: 80, 133 | minSize: 50, 134 | maxSize: 120, 135 | }, 136 | { 137 | accessorKey: "amount", 138 | header: "Amount", 139 | validationSchema: amountSchema, 140 | size: 80, 141 | minSize: 50, 142 | maxSize: 120, 143 | }, 144 | ]; 145 | 146 | /** 147 | * Recursively update a row in nested data by matching rowId with strict equality. 148 | * Logs when it finds a match, so we can see exactly what's updated. 149 | */ 150 | function updateNestedRow( 151 | rows: RowData[], 152 | rowId: string, 153 | colKey: K, 154 | newValue: RowData[K], 155 | ): RowData[] { 156 | return rows.map((row) => { 157 | // If this row's ID matches rowId exactly, update it 158 | if (row.id === rowId) { 159 | console.log("updateNestedRow -> Found exact match:", rowId); 160 | return { ...row, [colKey]: newValue }; 161 | } 162 | 163 | // Otherwise, if the row has subRows, recurse 164 | if (row.subRows && row.subRows.length > 0) { 165 | // We only log if we are actually diving into them 166 | console.log("updateNestedRow -> Checking subRows for row:", row.id); 167 | return { 168 | ...row, 169 | subRows: updateNestedRow(row.subRows, rowId, colKey, newValue), 170 | }; 171 | } 172 | 173 | // If no match and no subRows, return row unchanged 174 | return row; 175 | }); 176 | } 177 | 178 | /** 179 | * HomePage - shows how to integrate the SheetTable with per-column Zod validation. 180 | */ 181 | export default function HomePage() { 182 | const [data, setData] = useState(initialData); 183 | 184 | /** 185 | * onEdit callback: updates local state if the new value is valid. 186 | */ 187 | const handleEdit = ( 188 | rowId: string, // Unique identifier for the row 189 | columnId: K, // Column key 190 | value: RowData[K], // New value for the cell 191 | ) => { 192 | setData((prevData) => { 193 | const newRows = updateNestedRow(prevData, rowId, columnId, value); 194 | // optional logging 195 | console.log( 196 | `State updated [row id=${rowId}, column=${columnId}, value=${value}]` 197 | ); 198 | return newRows; 199 | }); 200 | }; 201 | 202 | /** 203 | * Validate entire table (including subRows) on submit. 204 | */ 205 | const handleSubmit = () => { 206 | const validateRows = (rows: RowData[]): boolean => { 207 | for (const row of rows) { 208 | // Validate this row 209 | const result = rowDataZodSchema.safeParse(row); 210 | if (!result.success) { 211 | console.error("Row validation failed:", result.error.issues, row); 212 | return false; 213 | } 214 | // Recursively validate subRows if present 215 | if (row.subRows && row.subRows.length > 0) { 216 | if (!validateRows(row.subRows)) return false; 217 | } 218 | } 219 | return true; 220 | }; 221 | 222 | if (validateRows(data)) { 223 | console.log("Table data is valid! Submitting:", data); 224 | } else { 225 | console.error("Table data is invalid. Check the logged errors."); 226 | } 227 | }; 228 | 229 | return ( 230 |
231 |

Home Page with Zod Validation

232 | 233 | 234 | columns={columns} 235 | data={data} 236 | onEdit={handleEdit} 237 | showHeader={true} 238 | showSecondHeader={true} 239 | secondHeaderTitle="Custom Title Example" 240 | totalRowTitle="Total" 241 | totalRowValues={{ 242 | materialName: "Total", 243 | cft: data.reduce((sum, row) => sum + (row.cft || 0), 0), 244 | rate: data.reduce((sum, row) => sum + row.rate, 0), 245 | amount: data.reduce((sum, row) => sum + row.amount, 0), 246 | }} 247 | enableColumnSizing 248 | /> 249 | 250 | 251 |
252 | ); 253 | } -------------------------------------------------------------------------------- /src/app/add-rows/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState } from "react"; 4 | import { nanoid } from "nanoid"; 5 | 6 | // ** import ui components 7 | import { Button } from "@/components/ui/button"; 8 | 9 | // ** import your reusable table 10 | import SheetTable from "@/components/sheet-table"; 11 | import { ExtendedColumnDef } from "@/components/sheet-table/utils"; 12 | 13 | // ** import zod schema for row data 14 | import { rowDataZodSchema, RowData } from "@/schemas/row-data-schema"; 15 | 16 | const materialNameSchema = rowDataZodSchema.shape.materialName; // required string 17 | const cftSchema = rowDataZodSchema.shape.cft; // optional number >= 0 18 | const rateSchema = rowDataZodSchema.shape.rate; // required number >= 0 19 | const amountSchema = rowDataZodSchema.shape.amount; // required number >= 0 20 | 21 | /** 22 | * Initial data for demonstration. 23 | * We can still provide some initial IDs manually, but they must be unique. 24 | */ 25 | const initialData: RowData[] = [ 26 | { 27 | headerKey: "Group 1", 28 | id: "1", 29 | materialName: "Ultra Nitro Sealer", 30 | cft: 0.03, 31 | rate: 164, 32 | amount: 5.17 33 | }, 34 | { 35 | headerKey: "Group 1", 36 | id: "2", 37 | materialName: "NC Thinner (Spl)", 38 | cft: 0.202, 39 | rate: 93, 40 | amount: 101.73, 41 | }, 42 | { 43 | headerKey: "Group 2", 44 | id: "row-1", 45 | materialName: "Ultra Nitro Sealer", 46 | cft: 0.03, 47 | rate: 164, 48 | amount: 5.17, 49 | }, 50 | { 51 | headerKey: "Group 2", 52 | id: "row-2", 53 | materialName: "NC Thinner (Spl)", 54 | cft: 0.202, 55 | rate: 93, 56 | amount: 19.73, 57 | subRows: [ 58 | { 59 | id: "row-2.1", 60 | materialName: "NC Thinner (Spl) 1", 61 | cft: 0.203, 62 | rate: 94, 63 | amount: 20.0, 64 | }, 65 | { 66 | id: "row-2.2", 67 | materialName: "NC Thinner (Spl) 2", 68 | cft: 0.204, 69 | rate: 95, 70 | amount: 20.3, 71 | }, 72 | ], 73 | }, 74 | { 75 | id: "row-3", 76 | materialName: "Ultra Nitro Sealer 2", 77 | cft: 0.072, 78 | rate: 165, 79 | amount: 12.4, 80 | }, 81 | ]; 82 | 83 | /** 84 | * Extended column definitions, each with a validationSchema. 85 | */ 86 | const columns: ExtendedColumnDef[] = [ 87 | { 88 | accessorKey: "materialName", 89 | header: "Material Name", 90 | validationSchema: materialNameSchema, 91 | size: 120, 92 | minSize: 50, 93 | maxSize: 100, 94 | }, 95 | { 96 | accessorKey: "cft", 97 | header: "CFT", 98 | validationSchema: cftSchema, 99 | maxSize: 20, 100 | }, 101 | { 102 | accessorKey: "rate", 103 | header: "Rate", 104 | validationSchema: rateSchema, 105 | size: 80, 106 | minSize: 50, 107 | maxSize: 120, 108 | }, 109 | { 110 | accessorKey: "amount", 111 | header: "Amount", 112 | validationSchema: amountSchema, 113 | size: 80, 114 | minSize: 50, 115 | maxSize: 120, 116 | }, 117 | ]; 118 | 119 | /** 120 | * Recursively update a row in nested data by matching rowId. 121 | */ 122 | function updateNestedRow( 123 | rows: RowData[], 124 | rowId: string, 125 | colKey: K, 126 | newValue: RowData[K], 127 | ): RowData[] { 128 | return rows.map((row) => { 129 | if (row.id === rowId) { 130 | return { ...row, [colKey]: newValue }; 131 | } 132 | if (row.subRows?.length) { 133 | return { 134 | ...row, 135 | subRows: updateNestedRow(row.subRows, rowId, colKey, newValue), 136 | }; 137 | } 138 | return row; 139 | }); 140 | } 141 | 142 | /** 143 | * Recursively add a sub-row under a given parent (by rowId). 144 | * Always generate a brand-new ID via nanoid(). 145 | */ 146 | function addSubRowToRow(rows: RowData[], parentId: string): RowData[] { 147 | return rows.map((row) => { 148 | if (row.id === parentId) { 149 | const newSubRow: RowData = { 150 | id: nanoid(), // <-- Generate a guaranteed unique ID 151 | materialName: "New SubRow", 152 | cft: 0, 153 | rate: 0, 154 | amount: 0, 155 | }; 156 | return { 157 | ...row, 158 | subRows: [...(row.subRows ?? []), newSubRow], 159 | }; 160 | } else if (row.subRows?.length) { 161 | return { ...row, subRows: addSubRowToRow(row.subRows, parentId) }; 162 | } 163 | return row; 164 | }); 165 | } 166 | 167 | /** 168 | * Remove the row with the given rowId, recursively if in subRows. 169 | */ 170 | function removeRowRecursively(rows: RowData[], rowId: string): RowData[] { 171 | return rows 172 | .filter((row) => row.id !== rowId) 173 | .map((row) => { 174 | if (row.subRows?.length) { 175 | return { ...row, subRows: removeRowRecursively(row.subRows, rowId) }; 176 | } 177 | return row; 178 | }); 179 | } 180 | 181 | /** 182 | * HomePage - shows how to integrate the SheetTable with dynamic row addition, 183 | * guaranteed unique IDs, sub-row removal, and validation on submit. 184 | */ 185 | export default function HomePage() { 186 | const [data, setData] = useState(initialData); 187 | 188 | /** 189 | * onEdit callback: updates local state if the new value is valid. 190 | */ 191 | const handleEdit = ( 192 | rowId: string, 193 | columnId: K, 194 | value: RowData[K], 195 | ) => { 196 | setData((prevData) => updateNestedRow(prevData, rowId, columnId, value)); 197 | }; 198 | 199 | /** 200 | * Validate entire table on submit. 201 | */ 202 | const handleSubmit = () => { 203 | const validateRows = (rows: RowData[]): boolean => { 204 | for (const row of rows) { 205 | const result = rowDataZodSchema.safeParse(row); 206 | if (!result.success) { 207 | console.error("Row validation failed:", result.error.issues, row); 208 | return false; 209 | } 210 | if (row.subRows?.length) { 211 | if (!validateRows(row.subRows)) return false; 212 | } 213 | } 214 | return true; 215 | }; 216 | 217 | if (validateRows(data)) { 218 | console.log("Table data is valid! Submitting:", data); 219 | } else { 220 | console.error("Table data is invalid."); 221 | } 222 | }; 223 | 224 | /** 225 | * Add a brand-new main row (non-sub-row). 226 | * Also generate a unique ID for it via nanoid(). 227 | */ 228 | const addMainRow = () => { 229 | const newRow: RowData = { 230 | id: nanoid(), // Unique ID 231 | materialName: "New Row", 232 | cft: 0, 233 | rate: 0, 234 | amount: 0, 235 | }; 236 | setData((prev) => [...prev, newRow]); 237 | }; 238 | 239 | /** 240 | * Add a sub-row to a row with the given rowId. 241 | */ 242 | const handleAddRowFunction = (parentId: string) => { 243 | console.log("Adding sub-row under row:", parentId); 244 | setData((old) => addSubRowToRow(old, parentId)); 245 | }; 246 | 247 | /** 248 | * Remove row (and subRows) by rowId. 249 | */ 250 | const handleRemoveRowFunction = (rowId: string) => { 251 | console.log("Removing row:", rowId); 252 | setData((old) => removeRowRecursively(old, rowId)); 253 | }; 254 | 255 | return ( 256 |
257 |

Home Page with Dynamic Rows & Unique IDs

258 | 259 |
260 | 261 |
262 | 263 | 264 | columns={columns} 265 | data={data} 266 | onEdit={handleEdit} 267 | enableColumnSizing 268 | // Show both icons on the "left" 269 | rowActions={{ add: "left", remove: "right" }} 270 | handleAddRowFunction={handleAddRowFunction} 271 | handleRemoveRowFunction={handleRemoveRowFunction} 272 | secondHeaderTitle="Custom Title Example" 273 | totalRowTitle="Total" 274 | totalRowValues={{ 275 | materialName: "Total", 276 | cft: data.reduce((sum, row) => sum + (row.cft || 0), 0), 277 | rate: data.reduce((sum, row) => sum + row.rate, 0), 278 | amount: data.reduce((sum, row) => sum + row.amount, 0), 279 | }} 280 | /> 281 | 282 |
283 | 286 |
287 |
288 | ); 289 | } -------------------------------------------------------------------------------- /src/components/sheet-table/utils.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars */ 2 | 3 | /** 4 | * components/sheet-table/utils.ts 5 | * 6 | * Utility functions, types, and helpers used by the SheetTable component. 7 | * 8 | * We include: 9 | * - ExtendedColumnDef and SheetTableProps 10 | * - parseAndValidate function 11 | * - getColumnKey function 12 | * - handleKeyDown, handlePaste 13 | * 14 | * This is purely for organization: the code is identical in functionality 15 | * to what was previously in sheet-table.tsx (just split out). 16 | */ 17 | 18 | import type { ColumnDef, TableOptions } from "@tanstack/react-table"; 19 | import type { ZodType, ZodTypeDef } from "zod"; 20 | import React from "react"; 21 | 22 | /** 23 | * ExtendedColumnDef: 24 | * - Inherits everything from TanStack's ColumnDef 25 | * - Forces existence of optional `accessorKey?: string` and `id?: string` 26 | * - Adds our optional `validationSchema` property (for column-level Zod). 27 | * - Adds optional `className` and `style` properties for custom styling. 28 | */ 29 | export type ExtendedColumnDef< 30 | TData extends object, 31 | TValue = unknown 32 | > = Omit, "id" | "accessorKey"> & { 33 | id?: string; 34 | accessorKey?: string; 35 | validationSchema?: ZodType; 36 | className?: string | ((row: TData) => string); // Allows static or dynamic class names 37 | style?: React.CSSProperties; // style for inline styles 38 | }; 39 | 40 | 41 | /** 42 | * Extended props for footer functionality. 43 | */ 44 | interface FooterProps { 45 | /** 46 | * totalRowValues: 47 | * - Object mapping column ID/accessorKey => any 48 | * - If provided, we render a special totals row at the bottom of the table. 49 | */ 50 | totalRowValues?: Record; 51 | 52 | /** 53 | * totalRowLabel: 54 | * - A string label used to fill empty cells in the totals row. 55 | * - Defaults to "" if omitted. 56 | */ 57 | totalRowLabel?: string; 58 | 59 | /** 60 | * totalRowTitle: 61 | * - A string displayed on a separate row above the totals row. 62 | * - Shown only if totalRowValues is provided as well. 63 | */ 64 | totalRowTitle?: string; 65 | 66 | /** 67 | * footerElement: 68 | * - A React node rendered below the totals row. 69 | * - If omitted, no extra footer node is rendered. 70 | */ 71 | footerElement?: React.ReactNode; 72 | } 73 | 74 | /** 75 | * Props for the SheetTable component. 76 | * Includes footer props and additional TanStack table configurations. 77 | */ 78 | export interface SheetTableProps extends FooterProps { 79 | /** 80 | * Column definitions for the table. 81 | */ 82 | columns: ExtendedColumnDef[]; 83 | 84 | /** 85 | * Data to be displayed in the table. 86 | */ 87 | data: T[]; 88 | 89 | /** 90 | * Callback for handling cell edits. 91 | */ 92 | onEdit?: (rowIndex: string, columnId: K, value: T[K]) => void; 93 | 94 | 95 | /** 96 | * Callback for when a cell is focused. 97 | */ 98 | onCellFocus?: (rowId: string) => void; 99 | 100 | /** 101 | * Columns that are disabled for editing. 102 | */ 103 | disabledColumns?: string[]; 104 | 105 | /** 106 | * Rows that are disabled for editing. 107 | * Can be an array of row indices or a record mapping column IDs to row indices. 108 | */ 109 | disabledRows?: number[] | Record; 110 | 111 | /** 112 | * Whether to show the table header. 113 | */ 114 | showHeader?: boolean; 115 | 116 | /** 117 | * Whether to show a secondary header below the main header. 118 | */ 119 | showSecondHeader?: boolean; 120 | 121 | /** 122 | * Title for the secondary header, if enabled. 123 | */ 124 | secondHeaderTitle?: string; 125 | 126 | /** 127 | * If true, column sizing is enabled. Sizes are tracked in local state. 128 | */ 129 | enableColumnSizing?: boolean; 130 | 131 | /** 132 | * Additional table options to be passed directly to `useReactTable`. 133 | * Examples: initialState, columnResizeMode, etc. 134 | */ 135 | tableOptions?: Partial>; 136 | 137 | /** 138 | * Configuration for Add/Remove row icons: 139 | * { add?: "left" | "right"; remove?: "left" | "right"; } 140 | * Example: { add: "left", remove: "right" } 141 | */ 142 | rowActions?: { 143 | add?: "left" | "right"; 144 | remove?: "left" | "right"; 145 | }; 146 | 147 | /** 148 | * Optional function to handle adding a sub-row to a given row (by rowId). 149 | */ 150 | handleAddRowFunction?: (parentRowId: string) => void; 151 | 152 | /** 153 | * Optional function to handle removing a given row (by rowId), 154 | * including all of its sub-rows. 155 | */ 156 | handleRemoveRowFunction?: (rowId: string) => void; 157 | } 158 | 159 | 160 | /** 161 | * Returns a stable string key for each column (id > accessorKey > ""). 162 | */ 163 | export function getColumnKey(colDef: ExtendedColumnDef): string { 164 | return colDef.id ?? colDef.accessorKey ?? ""; 165 | } 166 | 167 | /** 168 | * Parse & validate helper: 169 | * - If colDef is numeric and empty => undefined (if optional) 170 | * - If colDef is numeric and invalid => produce error 171 | */ 172 | export function parseAndValidate( 173 | rawValue: string, 174 | colDef: ExtendedColumnDef 175 | ): { parsedValue: unknown; errorMessage: string | null } { 176 | const schema = colDef.validationSchema; 177 | if (!schema) { 178 | // No validation => no error 179 | return { parsedValue: rawValue, errorMessage: null }; 180 | } 181 | 182 | let parsedValue: unknown = rawValue; 183 | let errorMessage: string | null = null; 184 | 185 | const schemaType = (schema as any)?._def?.typeName; 186 | if (schemaType === "ZodNumber") { 187 | // If empty => undefined (if optional this is okay, otherwise error) 188 | if (rawValue.trim() === "") { 189 | parsedValue = undefined; 190 | } else { 191 | // Try parse to float 192 | const maybeNum = parseFloat(rawValue); 193 | // If the user typed something that parseFloat sees as NaN, it's an error 194 | parsedValue = Number.isNaN(maybeNum) ? rawValue : maybeNum; 195 | } 196 | } 197 | 198 | const result = schema.safeParse(parsedValue); 199 | if (!result.success) { 200 | errorMessage = result.error.issues[0].message; 201 | } 202 | 203 | return { parsedValue, errorMessage }; 204 | } 205 | 206 | /** 207 | * BLOCK non-numeric characters in numeric columns, including paste. 208 | * (We keep these separate so they're easy to import and use in the main component.) 209 | */ 210 | 211 | export function handleKeyDown( 212 | e: React.KeyboardEvent, 213 | colDef: ExtendedColumnDef 214 | ) { 215 | if (!colDef.validationSchema) return; 216 | 217 | const schemaType = (colDef.validationSchema as any)?._def?.typeName; 218 | if (schemaType === "ZodNumber") { 219 | // Allowed keys for numeric input: 220 | const allowedKeys = [ 221 | "Backspace", 222 | "Delete", 223 | "ArrowLeft", 224 | "ArrowRight", 225 | "Tab", 226 | "Home", 227 | "End", 228 | ".", 229 | "-", 230 | ]; 231 | const isDigit = /^[0-9]$/.test(e.key); 232 | 233 | if (!allowedKeys.includes(e.key) && !isDigit) { 234 | e.preventDefault(); 235 | } 236 | } 237 | } 238 | 239 | export function handlePaste( 240 | e: React.ClipboardEvent, 241 | colDef: ExtendedColumnDef 242 | ) { 243 | if (!colDef.validationSchema) return; 244 | const schemaType = (colDef.validationSchema as any)?._def?.typeName; 245 | if (schemaType === "ZodNumber") { 246 | const text = e.clipboardData.getData("text"); 247 | // If the pasted text is not a valid float, block it. 248 | if (!/^-?\d*\.?\d*$/.test(text)) { 249 | e.preventDefault(); 250 | } 251 | } 252 | } 253 | 254 | 255 | /** 256 | * Helper function to determine if a row is disabled based on the provided 257 | * disabledRows prop. This prop can be either a simple array of row indices 258 | * or a record keyed by groupKey mapped to arrays of row indices. 259 | */ 260 | export function isRowDisabled( 261 | rows: number[] | Record | undefined, 262 | groupKey: string, 263 | rowIndex: number 264 | ): boolean { 265 | if (!rows) return false; 266 | if (Array.isArray(rows)) { 267 | return rows.includes(rowIndex); 268 | } 269 | return rows[groupKey]?.includes(rowIndex) ?? false; 270 | } -------------------------------------------------------------------------------- /src/components/backup/sheet-table.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | /** 4 | * sheet-table.tsx 5 | * 6 | * A reusable table component with editable cells, row/column disabling, 7 | * and custom data support. Integrates with Zod validation per column 8 | * using an optional validationSchema property in the column definition. 9 | * 10 | * Key differences from previous versions: 11 | * - We do NOT re-render the cell content on every keystroke, so the cursor won't jump. 12 | * - We only call `onEdit` (and thus update parent state) onBlur, not on every keystroke. 13 | * - We still do real-time validation & highlighting by storing errors in component state. 14 | * - We block disallowed characters in numeric columns (letters, etc.). 15 | */ 16 | 17 | import React, { useState, useCallback } from "react"; 18 | import { 19 | useReactTable, 20 | getCoreRowModel, 21 | flexRender, 22 | ColumnDef, 23 | TableOptions, 24 | } from "@tanstack/react-table"; 25 | import type { ZodType, ZodTypeDef } from "zod"; 26 | 27 | /** 28 | * ExtendedColumnDef: 29 | * - Inherits everything from TanStack's ColumnDef 30 | * - Forces existence of optional `accessorKey?: string` and `id?: string` 31 | * - Adds our optional `validationSchema` property (for column-level Zod). 32 | */ 33 | export type ExtendedColumnDef< 34 | TData extends object, 35 | TValue = unknown 36 | > = Omit, "id" | "accessorKey"> & { 37 | id?: string; 38 | accessorKey?: string; 39 | validationSchema?: ZodType; 40 | }; 41 | 42 | /** 43 | * Props for the SheetTable component. 44 | */ 45 | interface SheetTableProps { 46 | columns: ExtendedColumnDef[]; 47 | data: T[]; 48 | onEdit?: (rowIndex: number, columnId: K, value: T[K]) => void; 49 | disabledColumns?: string[]; 50 | disabledRows?: number[]; 51 | } 52 | 53 | /** 54 | * A reusable table component with: 55 | * - Editable cells 56 | * - Optional per-column Zod validation 57 | * - Row/column disabling 58 | * - Real-time error highlighting 59 | * - Only final updates to parent onBlur 60 | */ 61 | function SheetTable({ 62 | columns, 63 | data, 64 | onEdit, 65 | disabledColumns = [], 66 | disabledRows = [], 67 | }: SheetTableProps) { 68 | /** 69 | * We track errors by row/column, but NOT the content of each cell. 70 | * The DOM itself (contentEditable) holds the user-typed text until blur. 71 | */ 72 | const [cellErrors, setCellErrors] = useState 75 | >>({}); 76 | 77 | /** 78 | * Initialize the table using TanStack Table 79 | */ 80 | const table = useReactTable({ 81 | data, 82 | columns, 83 | getCoreRowModel: getCoreRowModel(), 84 | } as TableOptions); 85 | 86 | /** 87 | * Returns a stable string key for each column (id > accessorKey > ""). 88 | */ 89 | const getColumnKey = (colDef: ExtendedColumnDef) => { 90 | return colDef.id ?? colDef.accessorKey ?? ""; 91 | }; 92 | 93 | /** 94 | * Real-time validation (but we do NOT call onEdit here). 95 | * This helps us show error highlighting and console logs 96 | * without resetting the DOM text or cursor position. 97 | */ 98 | const handleCellInput = useCallback( 99 | ( 100 | e: React.FormEvent, 101 | rowIndex: number, 102 | colDef: ExtendedColumnDef 103 | ) => { 104 | const colKey = getColumnKey(colDef); 105 | if (disabledRows.includes(rowIndex) || disabledColumns.includes(colKey)) { 106 | return; 107 | } 108 | 109 | const rawValue = e.currentTarget.textContent ?? ""; 110 | const { errorMessage } = parseAndValidate(rawValue, colDef); 111 | 112 | setCellError(rowIndex, colKey, errorMessage); 113 | 114 | if (errorMessage) { 115 | console.error(`Row ${rowIndex}, Column "${colKey}" error: ${errorMessage}`); 116 | } else { 117 | console.log(`Row ${rowIndex}, Column "${colKey}" is valid (typing)...`); 118 | } 119 | }, 120 | [disabledColumns, disabledRows, getColumnKey, parseAndValidate] 121 | ); 122 | 123 | /** 124 | * Final check onBlur. If there's no error, we call onEdit to update parent state. 125 | * This means we do NOT lose the user’s cursor during typing, but still keep 126 | * the parent data in sync once the user finishes editing the cell. 127 | */ 128 | const handleCellBlur = useCallback( 129 | ( 130 | e: React.FocusEvent, 131 | rowIndex: number, 132 | colDef: ExtendedColumnDef 133 | ) => { 134 | const colKey = getColumnKey(colDef); 135 | if (disabledRows.includes(rowIndex) || disabledColumns.includes(colKey)) { 136 | return; 137 | } 138 | 139 | const rawValue = e.currentTarget.textContent ?? ""; 140 | const { parsedValue, errorMessage } = parseAndValidate(rawValue, colDef); 141 | 142 | setCellError(rowIndex, colKey, errorMessage); 143 | 144 | if (errorMessage) { 145 | console.error( 146 | `Row ${rowIndex}, Column "${colKey}" final error: ${errorMessage}` 147 | ); 148 | } else { 149 | console.log( 150 | `Row ${rowIndex}, Column "${colKey}" final valid:`, 151 | parsedValue 152 | ); 153 | // If no error, update parent state 154 | if (onEdit) { 155 | onEdit(rowIndex, colKey as keyof T, parsedValue as T[keyof T]); 156 | } 157 | } 158 | }, 159 | [disabledColumns, disabledRows, onEdit, getColumnKey, parseAndValidate] 160 | ); 161 | 162 | /** 163 | * BLOCK non-numeric characters in numeric columns, including paste. 164 | */ 165 | const handleKeyDown = useCallback( 166 | (e: React.KeyboardEvent, colDef: ExtendedColumnDef) => { 167 | if (!colDef.validationSchema) return; 168 | 169 | const schemaType = (colDef.validationSchema as any)?._def?.typeName; 170 | if (schemaType === "ZodNumber") { 171 | // Allowed keys for numeric input: 172 | const allowedKeys = [ 173 | "Backspace", 174 | "Delete", 175 | "ArrowLeft", 176 | "ArrowRight", 177 | "Tab", 178 | "Home", 179 | "End", 180 | ".", 181 | "-", 182 | ]; 183 | const isDigit = /^[0-9]$/.test(e.key); 184 | 185 | if (!allowedKeys.includes(e.key) && !isDigit) { 186 | e.preventDefault(); 187 | } 188 | } 189 | }, 190 | [] 191 | ); 192 | 193 | /** 194 | * If user tries to paste in a numeric field, we check if it's valid digits. 195 | * If not, we block the paste. Alternatively, you can let them paste 196 | * then parse after, but that might cause partial invalid text mid-paste. 197 | */ 198 | const handlePaste = useCallback( 199 | (e: React.ClipboardEvent, colDef: ExtendedColumnDef) => { 200 | if (!colDef.validationSchema) return; 201 | const schemaType = (colDef.validationSchema as any)?._def?.typeName; 202 | if (schemaType === "ZodNumber") { 203 | const text = e.clipboardData.getData("text"); 204 | // If the pasted text is not a valid float, block it. 205 | if (!/^-?\d*\.?\d*$/.test(text)) { 206 | e.preventDefault(); 207 | } 208 | } 209 | }, 210 | [] 211 | ); 212 | 213 | /** 214 | * Parse & validate helper: 215 | * - If colDef is numeric and empty => undefined (if optional) 216 | * - If colDef is numeric and invalid => produce error 217 | */ 218 | function parseAndValidate( 219 | rawValue: string, 220 | colDef: ExtendedColumnDef 221 | ): { parsedValue: unknown; errorMessage: string | null } { 222 | const schema = colDef.validationSchema; 223 | if (!schema) { 224 | // No validation => no error 225 | return { parsedValue: rawValue, errorMessage: null }; 226 | } 227 | 228 | let parsedValue: unknown = rawValue; 229 | let errorMessage: string | null = null; 230 | 231 | const schemaType = (schema as any)?._def?.typeName; 232 | if (schemaType === "ZodNumber") { 233 | // If empty => undefined (if optional this is okay, otherwise error) 234 | if (rawValue.trim() === "") { 235 | parsedValue = undefined; 236 | } else { 237 | // Try parse to float 238 | const maybeNum = parseFloat(rawValue); 239 | // If the user typed something that parseFloat sees as NaN, it's an error 240 | parsedValue = Number.isNaN(maybeNum) ? rawValue : maybeNum; 241 | } 242 | } 243 | 244 | const result = schema.safeParse(parsedValue); 245 | if (!result.success) { 246 | errorMessage = result.error.issues[0].message; 247 | } 248 | 249 | return { parsedValue, errorMessage }; 250 | } 251 | 252 | /** 253 | * Set or clear an error for a specific [rowIndex, colKey]. 254 | */ 255 | function setCellError(rowIndex: number, colKey: string, errorMsg: string | null) { 256 | setCellErrors((prev) => { 257 | const rowErrors = { ...prev[rowIndex] }; 258 | rowErrors[colKey] = errorMsg; 259 | return { ...prev, [rowIndex]: rowErrors }; 260 | }); 261 | } 262 | 263 | return ( 264 |
265 | 266 | 267 | {table.getHeaderGroups().map((headerGroup) => ( 268 | 269 | {headerGroup.headers.map((header) => ( 270 | 276 | ))} 277 | 278 | ))} 279 | 280 | 281 | {table.getRowModel().rows.map((row) => ( 282 | 286 | {row.getVisibleCells().map((cell) => { 287 | const colDef = cell.column.columnDef as ExtendedColumnDef; 288 | const colKey = getColumnKey(colDef); 289 | 290 | // Determine if cell is disabled 291 | const isDisabled = 292 | disabledRows.includes(row.index) || disabledColumns.includes(colKey); 293 | 294 | // Check for error 295 | const errorMsg = cellErrors[row.index]?.[colKey] || null; 296 | 297 | return ( 298 | 317 | ); 318 | })} 319 | 320 | ))} 321 | 322 |
274 | {flexRender(header.column.columnDef.header, header.getContext())} 275 |
handleKeyDown(e, colDef)} 309 | onPaste={(e) => handlePaste(e, colDef)} 310 | // Real-time check => highlight errors or success logs 311 | onInput={(e) => handleCellInput(e, row.index, colDef)} 312 | // Final check => if valid => onEdit => updates parent 313 | onBlur={(e) => handleCellBlur(e, row.index, colDef)} 314 | > 315 | {flexRender(cell.column.columnDef.cell, cell.getContext())} 316 |
323 |
324 | ); 325 | } 326 | 327 | export default SheetTable; 328 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FlexiSheet 2 | 3 | **FlexiSheet** is a powerful, reusable table component for React applications. It supports features like editable cells, row/column disabling, Zod-based validation, grouping rows by headers, and configurable footers. 4 | 5 | --- 6 | 7 | ## Table of Contents 8 | 9 | 1. [Features](#features) 10 | 2. [Demo](#demo) 11 | 3. [Installation](#installation) 12 | - [Prerequisites](#prerequisites) 13 | 4. [Basic Usage](#basic-usage) 14 | - [Define Your Data](#1-define-your-data) 15 | - [Define Column Schema with Validation](#2-define-column-schema-with-validation) 16 | - [Render the Table](#3-render-the-table) 17 | 5. [Advanced Options](#advanced-options) 18 | - [Grouped Rows Example](#grouped-rows-example) 19 | - [Group Specific Disabled Rows](#group-specific-disabled-rows) 20 | - [Footer Example](#footer-example) 21 | 6. [FAQ](#faq) 22 | 7. [Development](#development) 23 | 8. [License](#license) 24 | 25 | --- 26 | 27 | ## Features 28 | 29 | - **Editable Cells**: Supports real-time editing with validation. 30 | - **Zod Validation**: Per-column validation using Zod schemas. 31 | - **Row/Column Disabling**: Disable specific rows or columns. 32 | - **Grouping Rows**: Group data using a `headerKey` field. 33 | - **Footer Support**: Add totals rows and custom footer elements. 34 | 35 | --- 36 | 37 | ## Demo 38 | 39 | **Link**: 40 | 41 | ![FlexiSheet Demo](https://flexisheet.vercel.app/og-image.png) 42 | 43 | --- 44 | 45 | ## Installation 46 | 47 | ### Prerequisites 48 | 49 | Ensure you have the following installed in your project: 50 | 51 | 1. **Zod** for validation: 52 | 53 | ```bash 54 | bun install zod 55 | ``` 56 | 57 | 2. **TanStack Table** for table functionality: 58 | 59 | ```bash 60 | bun install @tanstack/react-table 61 | ``` 62 | 63 | 3. **ShadCN/UI** for UI components: 64 | 65 | - 66 | 67 | ```bash 68 | bunx --bun shadcn@latest add table 69 | ``` 70 | 71 | 4. **Tailwind CSS** for styling: 72 | 73 | ```bash 74 | bun install tailwindcss postcss autoprefixer 75 | ``` 76 | 77 | --- 78 | 79 | ## Basic Usage 80 | 81 | ### 1. Define Your Data 82 | 83 | **👀 NOTE:** The `id` field is required for each row. It should be unique for each row. 84 | 85 | ```ts 86 | const initialData = [ 87 | { id: 1, materialName: "Material A", cft: 0.1, rate: 100, amount: 10 }, 88 | { id: 2, materialName: "Material B", cft: 0.2, rate: 200, amount: 40 }, 89 | ]; 90 | ``` 91 | 92 | ### 2. Define Column Schema with Validation 93 | 94 | ```ts 95 | import { z } from "zod"; 96 | 97 | const materialNameSchema = z.string().min(1, "Required"); 98 | const cftSchema = z.number().nonnegative().optional(); 99 | const rateSchema = z.number().min(0, "Must be >= 0"); 100 | const amountSchema = z.number().min(0, "Must be >= 0"); 101 | 102 | const columns = [ 103 | { accessorKey: "materialName", header: "Material Name", validationSchema: materialNameSchema }, 104 | { accessorKey: "cft", header: "CFT", validationSchema: cftSchema }, 105 | { accessorKey: "rate", header: "Rate", validationSchema: rateSchema }, 106 | { accessorKey: "amount", header: "Amount", validationSchema: amountSchema }, 107 | ]; 108 | ``` 109 | 110 | ### 3. Render the Table 111 | 112 | ```tsx 113 | import React, { useState } from "react"; 114 | import SheetTable from "./components/sheet-table"; 115 | 116 | const App = () => { 117 | const [data, setData] = useState(initialData); 118 | 119 | /** 120 | * onEdit callback: updates local state if the new value is valid. (Normal usage) 121 | */ 122 | const handleEdit = ( 123 | rowId: string, // Unique identifier for the row 124 | columnId: K, // Column key 125 | value: RowData[K], // New value for the cell 126 | ) => { 127 | setData((prevData) => 128 | prevData.map( 129 | (row) => 130 | String(row.id) === rowId 131 | ? { ...row, [columnId]: value } // Update the row if the ID matches 132 | : row, // Otherwise, return the row unchanged 133 | ), 134 | ); 135 | 136 | console.log( 137 | `State updated [row id=${rowId}, column=${columnId}, value=${value}]`, 138 | value, 139 | ); 140 | }; 141 | 142 | return ( 143 | 150 | ); 151 | }; 152 | 153 | export default App; 154 | ``` 155 | 156 | --- 157 | 158 | ## Advanced Options 159 | 160 | ### Grouped Rows Example 161 | 162 | ```ts 163 | const groupedData = [ 164 | { 165 | id: 1, 166 | headerKey: "Group A", 167 | materialName: "Material A", 168 | cft: 0.1, 169 | rate: 100, 170 | amount: 10, 171 | }, 172 | { 173 | id: 2, 174 | headerKey: "Group A", 175 | materialName: "Material B", 176 | cft: 0.2, 177 | rate: 200, 178 | amount: 40, 179 | }, 180 | { 181 | id: 3, 182 | headerKey: "Group B", 183 | materialName: "Material C", 184 | cft: 0.3, 185 | rate: 300, 186 | amount: 90, 187 | }, 188 | ]; 189 | ``` 190 | 191 | ### Group Specific Disabled Rows 192 | 193 | ```tsx 194 | 203 | ``` 204 | 205 | ### Footer Example 206 | 207 | ```tsx 208 | Custom Footer Content} 215 | /> 216 | ``` 217 | 218 | --- 219 | 220 | ## FAQ 221 | 222 | ### **1. How do I disable editing for specific columns or rows?** 223 | 224 | You can disable specific rows and columns by using the `disabledColumns` and `disabledRows` props in the `SheetTable` component. 225 | 226 | - **Disable Columns**: 227 | 228 | ```tsx 229 | 232 | ``` 233 | 234 | - **Disable Rows(normal)**: 235 | 236 | ```tsx 237 | 240 | ``` 241 | 242 | - **Disable Rows(group)**: 243 | 244 | ```tsx 245 | 251 | ``` 252 | 253 | --- 254 | 255 | ### **2. Can I add custom validation for columns?** 256 | 257 | Yes, you can use **Zod schemas** to define validation rules for each column using the `validationSchema` property. 258 | 259 | Example: 260 | 261 | ```ts 262 | const rateSchema = z.number().min(0, "Rate must be greater than or equal to 0"); 263 | const columns = [ 264 | { 265 | accessorKey: "rate", 266 | header: "Rate", 267 | validationSchema: rateSchema, 268 | }, 269 | ]; 270 | ``` 271 | 272 | --- 273 | 274 | ### **3. What happens if validation fails?** 275 | 276 | If validation fails while editing a cell, the cell will: 277 | 278 | - Display an error class (e.g., `bg-destructive/25` by default). 279 | - Not trigger the `onEdit` callback until the value is valid. 280 | 281 | --- 282 | 283 | ### **4. How do I group rows?** 284 | 285 | To group rows, provide a `headerKey` field in your data and the `SheetTable` will automatically group rows based on this key. 286 | 287 | Example: 288 | 289 | ```ts 290 | const groupedData = [ 291 | { headerKey: "Group A", materialName: "Material A", cft: 0.1 }, 292 | { headerKey: "Group B", materialName: "Material B", cft: 0.2 }, 293 | ]; 294 | ``` 295 | 296 | --- 297 | 298 | ### **5. Can I dynamically resize columns?** 299 | 300 | Yes, you can enable column resizing by setting `enableColumnSizing` to `true` and providing column size properties (`size`, `minSize`, and `maxSize`) in the column definitions. 301 | 302 | Example: 303 | 304 | ```tsx 305 | const columns = [ 306 | { 307 | accessorKey: "materialName", 308 | header: "Material Name", 309 | size: 200, 310 | minSize: 100, 311 | maxSize: 300, 312 | }, 313 | ]; 314 | ; 315 | ``` 316 | 317 | --- 318 | 319 | ### **6. How do I add a footer with totals or custom elements?** 320 | 321 | Use the `totalRowValues`, `totalRowLabel`, and `footerElement` props to define footer content. 322 | 323 | Example: 324 | 325 | ```tsx 326 | Custom Footer Content} 330 | /> 331 | ``` 332 | 333 | --- 334 | 335 | ### **7. Does FlexiSheet support large datasets?** 336 | 337 | Yes, but for optimal performance: 338 | 339 | - Use **memoization** for `columns` and `data` to prevent unnecessary re-renders. 340 | - Consider integrating virtualization (e.g., `react-window`) for very large datasets. 341 | 342 | --- 343 | 344 | ### **8. Can I hide columns dynamically?** 345 | 346 | Yes, you can control column visibility using the `tableOptions.initialState.columnVisibility` configuration. 347 | 348 | Example: 349 | 350 | ```tsx 351 | 356 | ``` 357 | 358 | --- 359 | 360 | ### **9. How do I handle user actions like copy/paste or undo?** 361 | 362 | FlexiSheet supports common keyboard actions like copy (`Ctrl+C`), paste (`Ctrl+V`), and undo (`Ctrl+Z`). You don’t need to configure anything to enable these actions. 363 | 364 | --- 365 | 366 | ### **10. How do I validate the entire table before submission?** 367 | 368 | Use Zod's `array` schema to validate the entire dataset on form submission. 369 | 370 | Example: 371 | 372 | ```tsx 373 | const handleSubmit = () => { 374 | const tableSchema = z.array(rowDataZodSchema); 375 | const result = tableSchema.safeParse(data); 376 | if (!result.success) { 377 | console.error("Invalid data:", result.error.issues); 378 | } else { 379 | console.log("Valid data:", data); 380 | } 381 | }; 382 | ``` 383 | 384 | ### **11. How does the sub-row data structure look, and how can I handle sub-row editing?** 385 | 386 | Sub-rows are supported using a `subRows` field within each row object. The `subRows` field is an array of child rows, where each child row can have its own data and even further sub-rows (nested structure). 387 | 388 | **Example Sub-row Data Structure:** 389 | 390 | ```ts 391 | const dataWithSubRows = [ 392 | { 393 | id: 1, 394 | materialName: "Material A", 395 | cft: 0.1, 396 | rate: 100, 397 | amount: 10, 398 | subRows: [ 399 | { 400 | id: 1.1, 401 | materialName: "Sub-Material A1", 402 | cft: 0.05, 403 | rate: 50, 404 | amount: 5, 405 | }, 406 | { 407 | id: 1.2, 408 | materialName: "Sub-Material A2", 409 | cft: 0.05, 410 | rate: 50, 411 | amount: 5, 412 | }, 413 | ], 414 | }, 415 | { 416 | id: 2, 417 | materialName: "Material B", 418 | cft: 0.2, 419 | rate: 200, 420 | amount: 40, 421 | }, 422 | ]; 423 | ``` 424 | 425 | **How to Handle Sub-row Editing:** 426 | 427 | To handle editing for sub-rows, ensure that your `onEdit` callback can traverse the `subRows` array and update the appropriate row. 428 | 429 | **Example:** 430 | 431 | ```tsx 432 | function updateNestedRow( 433 | rows: RowData[], 434 | rowId: string, 435 | colKey: K, 436 | newValue: RowData[K], 437 | ): RowData[] { 438 | return rows.map((row) => { 439 | if (row.id === rowId) { 440 | return { ...row, [colKey]: newValue }; 441 | } 442 | if (row.subRows && row.subRows.length > 0) { 443 | return { 444 | ...row, 445 | subRows: updateNestedRow(row.subRows, rowId, colKey, newValue), 446 | }; 447 | } 448 | return row; 449 | }); 450 | } 451 | 452 | export default function HomePage() { 453 | const [data, setData] = useState(initialData); 454 | 455 | const handleEdit = ( 456 | rowId: string, 457 | columnId: K, 458 | value: RowData[K], 459 | ) => { 460 | setData((prevData) => { 461 | const newRows = updateNestedRow(prevData, rowId, columnId, value); 462 | return newRows; 463 | }); 464 | }; 465 | } 466 | ``` 467 | 468 | --- 469 | 470 | ## Development 471 | 472 | 1. Clone the repository: 473 | 474 | ```bash 475 | git clone https://github.com/jacksonkasi1/FlexiSheet.git 476 | ``` 477 | 478 | 2. Install dependencies: 479 | 480 | ```bash 481 | bun install 482 | ``` 483 | 484 | 3. Run the development server: 485 | 486 | ```bash 487 | bun dev 488 | ``` 489 | 490 | --- 491 | 492 | ## License 493 | 494 | This project is licensed under the MIT License. See the LICENSE file for details. 495 | -------------------------------------------------------------------------------- /src/components/sheet-table/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | /** 4 | * sheet-table/index.tsx 5 | * 6 | * A reusable table component with editable cells, row/column disabling, 7 | * custom data support, and Zod validation. Supports: 8 | * - Grouping rows by a `headerKey` 9 | * - A configurable footer (totals row + custom element) 10 | * - TanStack Table column sizing (size, minSize, maxSize) 11 | * - Forwarding other TanStack Table configuration via tableOptions 12 | * - Sub-rows (nested rows) with expand/collapse 13 | * - Hover-based Add/Remove row actions 14 | * - Custom styling for cells and columns 15 | * - Real-time validation with Zod schemas 16 | * - Keyboard shortcuts (Ctrl+Z, Ctrl+V, etc.) 17 | */ 18 | 19 | import React, { useState, useCallback } from "react"; 20 | import { 21 | useReactTable, 22 | getCoreRowModel, 23 | getExpandedRowModel, 24 | flexRender, 25 | TableOptions, 26 | Row as TanStackRow, 27 | ColumnSizingState, 28 | } from "@tanstack/react-table"; 29 | 30 | // ** import icons 31 | import { ChevronDown, ChevronRight, Plus, Trash2 } from "lucide-react"; 32 | 33 | // ** import ui components 34 | import { 35 | Table, 36 | TableBody, 37 | TableCaption, 38 | TableCell, 39 | TableHead, 40 | TableHeader, 41 | TableRow, 42 | TableFooter, 43 | } from "@/components/ui/table"; 44 | 45 | // ** import utils 46 | import { 47 | ExtendedColumnDef, 48 | SheetTableProps, 49 | parseAndValidate, 50 | getColumnKey, 51 | handleKeyDown, 52 | handlePaste, 53 | isRowDisabled, 54 | } from "./utils"; 55 | 56 | // ** import lib 57 | import { cn } from "@/lib/utils"; 58 | 59 | /** 60 | * The main SheetTable component, now with optional column sizing support, 61 | * sub-row expansions, and hover-based Add/Remove row actions. 62 | */ 63 | function SheetTable< 64 | T extends { 65 | // Common properties for each row 66 | id?: string; // Each row should have a unique string/number ID 67 | headerKey?: string; 68 | subRows?: T[]; 69 | }, 70 | >(props: SheetTableProps) { 71 | const { 72 | columns, 73 | data, 74 | onEdit, 75 | disabledColumns = [], 76 | disabledRows = [], 77 | showHeader = true, 78 | showSecondHeader = false, 79 | secondHeaderTitle = "", 80 | 81 | // Footer props 82 | totalRowValues, 83 | totalRowLabel = "", 84 | totalRowTitle, 85 | footerElement, 86 | 87 | // Additional TanStack config 88 | enableColumnSizing = false, 89 | tableOptions = {}, 90 | 91 | // Add/Remove Dynamic Row Actions 92 | rowActions, 93 | handleAddRowFunction, 94 | handleRemoveRowFunction, 95 | } = props; 96 | 97 | /** 98 | * If column sizing is enabled, we track sizes in state. 99 | * This allows the user to define 'size', 'minSize', 'maxSize' in the column definitions. 100 | */ 101 | const [columnSizing, setColumnSizing] = useState({}); 102 | 103 | /** 104 | * Expanded state for sub-rows. Keyed by row.id in TanStack Table. 105 | */ 106 | const [expanded, setExpanded] = useState>({}); 107 | 108 | /** 109 | * Track errors/original content keyed by (groupKey, rowId) for editing. 110 | */ 111 | const [cellErrors, setCellErrors] = useState< 112 | Record>> 113 | >({}); 114 | const [cellOriginalContent, setCellOriginalContent] = useState< 115 | Record>> 116 | >({}); 117 | 118 | /** 119 | * Track the currently hovered row ID (or null if none). 120 | */ 121 | const [hoveredRowId, setHoveredRowId] = useState(null); 122 | 123 | /** 124 | * Build the final table options. Merge user-provided tableOptions with ours. 125 | */ 126 | const mergedOptions: TableOptions = { 127 | data, 128 | columns, 129 | getRowId: (row) => row.id ?? String(Math.random()), // fallback if row.id is missing 130 | getCoreRowModel: getCoreRowModel(), 131 | // Provide subRows if you have them: 132 | getSubRows: (row) => row.subRows ?? undefined, 133 | // Add expansions 134 | getExpandedRowModel: getExpandedRowModel(), 135 | enableExpanding: true, 136 | // External expanded state 137 | state: { 138 | // If user also provided tableOptions.state, merge them 139 | ...(tableOptions.state ?? {}), 140 | expanded, 141 | ...(enableColumnSizing 142 | ? { 143 | columnSizing, 144 | } 145 | : {}), 146 | }, 147 | onExpandedChange: setExpanded, // keep expansions in local state 148 | 149 | // If sizing is enabled, pass sizing states: 150 | ...(enableColumnSizing 151 | ? { 152 | onColumnSizingChange: setColumnSizing, 153 | columnResizeMode: tableOptions.columnResizeMode ?? "onChange", 154 | } 155 | : {}), 156 | 157 | // Spread any other user-provided table options 158 | ...tableOptions, 159 | } as TableOptions; 160 | 161 | /** 162 | * Initialize the table using TanStack Table. 163 | */ 164 | const table = useReactTable(mergedOptions); 165 | 166 | /** 167 | * Find a TanStack row by matching rowData.id. 168 | */ 169 | const findTableRow = useCallback( 170 | (rowData: T): TanStackRow | undefined => { 171 | if (!rowData.id) return undefined; 172 | // NOTE: Because we have expansions, rowData might be in subRows. 173 | // We can do a quick flatten search across all rows. We use table.getRowModel().flatRows 174 | return table 175 | .getRowModel() 176 | .flatRows.find((r) => r.original.id === rowData.id); 177 | }, 178 | [table], 179 | ); 180 | 181 | /** 182 | * Store a cell's original value on focus, for detecting changes on blur. 183 | */ 184 | const handleCellFocus = useCallback( 185 | ( 186 | e: React.FocusEvent, 187 | groupKey: string, 188 | rowData: T, 189 | colDef: ExtendedColumnDef, 190 | ) => { 191 | const tanStackRow = findTableRow(rowData); 192 | if (!tanStackRow) return; 193 | 194 | const rowId = tanStackRow.id; 195 | const colKey = getColumnKey(colDef); 196 | const initialText = e.currentTarget.textContent ?? ""; 197 | 198 | setCellOriginalContent((prev) => { 199 | const groupContent = prev[groupKey] || {}; 200 | const rowContent = { 201 | ...(groupContent[rowId] || {}), 202 | [colKey]: initialText, 203 | }; 204 | return { 205 | ...prev, 206 | [groupKey]: { ...groupContent, [rowId]: rowContent }, 207 | }; 208 | }); 209 | }, 210 | [findTableRow], 211 | ); 212 | 213 | /** 214 | * Real-time validation on each keystroke (but no onEdit call here). 215 | */ 216 | const handleCellInput = useCallback( 217 | ( 218 | e: React.FormEvent, 219 | groupKey: string, 220 | rowData: T, 221 | colDef: ExtendedColumnDef, 222 | ) => { 223 | const tanStackRow = findTableRow(rowData); 224 | if (!tanStackRow) return; 225 | 226 | const rowId = tanStackRow.id; 227 | const rowIndex = tanStackRow.index; 228 | const colKey = getColumnKey(colDef); 229 | 230 | if ( 231 | isRowDisabled(disabledRows, groupKey, rowIndex) || 232 | disabledColumns.includes(colKey) 233 | ) { 234 | return; 235 | } 236 | 237 | const rawValue = e.currentTarget.textContent ?? ""; 238 | const { errorMessage } = parseAndValidate(rawValue, colDef); 239 | 240 | setCellErrors((prev) => { 241 | const groupErrors = prev[groupKey] || {}; 242 | const rowErrors = { 243 | ...(groupErrors[rowId] || {}), 244 | [colKey]: errorMessage, 245 | }; 246 | return { ...prev, [groupKey]: { ...groupErrors, [rowId]: rowErrors } }; 247 | }); 248 | }, 249 | [disabledColumns, disabledRows, findTableRow], 250 | ); 251 | 252 | /** 253 | * OnBlur: if content changed from the original, parse/validate. If valid => onEdit(rowId, colKey, parsedValue). 254 | */ 255 | const handleCellBlur = useCallback( 256 | ( 257 | e: React.FocusEvent, 258 | groupKey: string, 259 | rowData: T, 260 | colDef: ExtendedColumnDef, 261 | ) => { 262 | const tanStackRow = findTableRow(rowData); 263 | if (!tanStackRow) return; 264 | 265 | const rowId = tanStackRow.id; 266 | const rowIndex = tanStackRow.index; 267 | const colKey = getColumnKey(colDef); 268 | 269 | if ( 270 | isRowDisabled(disabledRows, groupKey, rowIndex) || 271 | disabledColumns.includes(colKey) 272 | ) { 273 | return; 274 | } 275 | 276 | const rawValue = e.currentTarget.textContent ?? ""; 277 | const originalValue = 278 | cellOriginalContent[groupKey]?.[rowId]?.[colKey] ?? ""; 279 | 280 | if (rawValue === originalValue) { 281 | return; // No change 282 | } 283 | 284 | const { parsedValue, errorMessage } = parseAndValidate(rawValue, colDef); 285 | 286 | setCellErrors((prev) => { 287 | const groupErrors = prev[groupKey] || {}; 288 | const rowErrors = { 289 | ...(groupErrors[rowId] || {}), 290 | [colKey]: errorMessage, 291 | }; 292 | return { ...prev, [groupKey]: { ...groupErrors, [rowId]: rowErrors } }; 293 | }); 294 | 295 | if (errorMessage) { 296 | console.error(`Row "${rowId}", Col "${colKey}" error: ${errorMessage}`); 297 | } else if (onEdit) { 298 | // Instead of rowIndex, we pass the row's unique ID from TanStack 299 | onEdit(rowId, colKey as keyof T, parsedValue as T[keyof T]); 300 | } 301 | }, 302 | [disabledColumns, disabledRows, findTableRow, cellOriginalContent, onEdit], 303 | ); 304 | 305 | /** 306 | * Group data by `headerKey` (top-level only). 307 | * Sub-rows are handled by TanStack expansions. 308 | */ 309 | const groupedData = React.useMemo(() => { 310 | const out: Record = {}; 311 | data.forEach((row) => { 312 | const key = row.headerKey || "ungrouped"; 313 | if (!out[key]) out[key] = []; 314 | out[key].push(row); 315 | }); 316 | return out; 317 | }, [data]); 318 | 319 | /** 320 | * Attempt removing the row with the given rowId via handleRemoveRowFunction. 321 | * You can also do the "recursive removal" in your parent with a similar approach to `updateNestedRow`. 322 | */ 323 | const removeRow = useCallback( 324 | (rowId: string) => { 325 | if (handleRemoveRowFunction) { 326 | handleRemoveRowFunction(rowId); 327 | } 328 | }, 329 | [handleRemoveRowFunction], 330 | ); 331 | 332 | /** 333 | * Attempt adding a sub-row to the row with given rowId (the "parentRowId"). 334 | */ 335 | const addSubRow = useCallback( 336 | (parentRowId: string) => { 337 | if (handleAddRowFunction) { 338 | handleAddRowFunction(parentRowId); 339 | } 340 | }, 341 | [handleAddRowFunction], 342 | ); 343 | 344 | // rowActions config 345 | const addPos = rowActions?.add ?? null; // "left" | "right" | null 346 | const removePos = rowActions?.remove ?? null; // "left" | "right" | null 347 | 348 | const rowActionCellStyle: React.CSSProperties = { 349 | width: "5px", 350 | maxWidth: "5px", 351 | outline: "none", 352 | }; 353 | const rowActionCellClassName = "p-0 border-none bg-transparent"; 354 | 355 | /** 356 | * Recursively renders a row and its sub-rows, handling: 357 | * - Row content and cell editing 358 | * - Hover-activated action icons (Add/Remove) 359 | * - Sub-row indentation and expansion 360 | * - Row-level error tracking and validation 361 | * - Disabled state management 362 | * 363 | * @param row - TanStack row instance containing the data and state 364 | * @param groupKey - Identifier for the row's group, used for validation and disabled state 365 | * @param level - Nesting depth (0 = top-level), used for sub-row indentation 366 | * @returns JSX element containing the row and its sub-rows 367 | */ 368 | 369 | const renderRow = (row: TanStackRow, groupKey: string, level = 0) => { 370 | const rowId = row.id; 371 | const rowIndex = row.index; 372 | const rowData = row.original; 373 | 374 | // Determine if this row or its group is disabled 375 | const disabled = isRowDisabled(disabledRows, groupKey, rowIndex); 376 | 377 | // TanStack expansion logic 378 | const hasSubRows = row.getCanExpand(); 379 | const isExpanded = row.getIsExpanded(); 380 | 381 | // Determine if we show the rowAction icons on hover 382 | const showRowActions = hoveredRowId === rowId; // only for hovered row 383 | 384 | return ( 385 | 386 | setHoveredRowId(rowId)} 393 | onMouseLeave={() => 394 | setHoveredRowId((prev) => (prev === rowId ? null : prev)) 395 | } 396 | > 397 | {/* Left icon cells */} 398 | {addPos === "left" && handleAddRowFunction && ( 399 | 403 | {showRowActions && ( 404 | 410 | )} 411 | 412 | )} 413 | {removePos === "left" && handleRemoveRowFunction && ( 414 | 418 | {showRowActions && ( 419 | 425 | )} 426 | 427 | )} 428 | 429 | {/** 430 | * If the "Add" or "Remove" icons are on the left, we can render an extra for them, 431 | * or overlay them. 432 | * We'll do an approach that overlays them. For clarity, let's keep it simple: 433 | * we'll just overlay or absolutely position them, or place them in the first cell. 434 | */} 435 | {row.getVisibleCells().map((cell, cellIndex) => { 436 | const colDef = cell.column.columnDef as ExtendedColumnDef; 437 | const colKey = getColumnKey(colDef); 438 | const isDisabled = disabled || disabledColumns.includes(colKey); 439 | const errorMsg = cellErrors[groupKey]?.[rowId]?.[colKey] || null; 440 | 441 | // Apply sizing logic & indentation 442 | const style: React.CSSProperties = {}; 443 | if (enableColumnSizing) { 444 | const size = cell.column.getSize(); 445 | if (size) style.width = `${size}px`; 446 | if (colDef.minSize) style.minWidth = `${colDef.minSize}px`; 447 | if (colDef.maxSize) style.maxWidth = `${colDef.maxSize}px`; 448 | } 449 | if (cellIndex === 0) { 450 | style.paddingLeft = `${level * 20}px`; 451 | } 452 | 453 | // Render cell content with customizations for the first cell 454 | const rawCellContent = flexRender( 455 | cell.column.columnDef.cell, 456 | cell.getContext(), 457 | ); 458 | 459 | let cellContent: React.ReactNode = rawCellContent; 460 | 461 | // If first cell, show expand arrow if subRows exist 462 | if (cellIndex === 0) { 463 | cellContent = ( 464 |
468 | {hasSubRows && ( 469 | 483 | )} 484 |
490 | handleCellFocus(e, groupKey, rowData, colDef) 491 | } 492 | onKeyDown={(e) => { 493 | if ( 494 | (e.ctrlKey || e.metaKey) && 495 | ["a", "c", "x", "z", "v"].includes(e.key.toLowerCase()) 496 | ) { 497 | return; 498 | } 499 | handleKeyDown(e, colDef); 500 | }} 501 | onPaste={(e) => handlePaste(e, colDef)} 502 | onInput={(e) => 503 | handleCellInput(e, groupKey, rowData, colDef) 504 | } 505 | onBlur={(e) => handleCellBlur(e, groupKey, rowData, colDef)} 506 | > 507 | {rawCellContent} 508 |
509 |
510 | ); 511 | } 512 | 513 | return ( 514 | { 530 | if (cellIndex > 0 && !isDisabled) { 531 | handleCellFocus(e, groupKey, rowData, colDef); 532 | } 533 | }} 534 | onKeyDown={(e) => { 535 | if (cellIndex > 0 && !isDisabled) { 536 | if ( 537 | (e.ctrlKey || e.metaKey) && 538 | // Let user do Ctrl+A, C, X, Z, V, etc. 539 | ["a", "c", "x", "z", "v"].includes(e.key.toLowerCase()) 540 | ) { 541 | return; // do not block copy/paste 542 | } 543 | handleKeyDown(e, colDef); 544 | } 545 | }} 546 | onPaste={(e) => { 547 | if (cellIndex > 0 && !isDisabled) { 548 | handlePaste(e, colDef); 549 | } 550 | }} 551 | onInput={(e) => { 552 | if (cellIndex > 0 && !isDisabled) { 553 | handleCellInput(e, groupKey, rowData, colDef); 554 | } 555 | }} 556 | onBlur={(e) => { 557 | if (cellIndex > 0 && !isDisabled) { 558 | handleCellBlur(e, groupKey, rowData, colDef); 559 | } 560 | }} 561 | > 562 | {/** The actual content */} 563 | {cellContent} 564 | 565 | ); 566 | })} 567 | 568 | {/* Right icon cells */} 569 | {addPos === "right" && handleAddRowFunction && ( 570 | 574 | {showRowActions && ( 575 | 581 | )} 582 | 583 | )} 584 | 585 | {removePos === "right" && handleRemoveRowFunction && ( 586 | 590 | {showRowActions && ( 591 | 597 | )} 598 | 599 | )} 600 |
601 | 602 | {/* If expanded, render each subRows recursively */} 603 | {isExpanded && 604 | row.subRows.map((subRow) => renderRow(subRow, groupKey, level + 1))} 605 |
606 | ); 607 | }; 608 | 609 | /** 610 | * Renders optional footer (totals row + optional custom element) inside a . 611 | */ 612 | function renderFooter() { 613 | if (!totalRowValues && !footerElement) return null; 614 | 615 | return ( 616 | 617 | {/* If there's a totalRowTitle, show it in a single row */} 618 | {totalRowTitle && ( 619 | 620 | {/* Right icon - empty cells */} 621 | {addPos === "left" && ( 622 | 626 | )} 627 | 628 | {removePos === "left" && ( 629 | 633 | )} 634 | 635 | 639 | {totalRowTitle} 640 | 641 | 642 | {/* Left icon - empty cells */} 643 | {addPos === "right" && ( 644 | 648 | )} 649 | 650 | {removePos === "right" && ( 651 | 655 | )} 656 | 657 | )} 658 | 659 | {/* The totals row */} 660 | {totalRowValues && ( 661 | 662 | {/* Right icon - empty cells */} 663 | {addPos === "left" && ( 664 | 668 | )} 669 | 670 | {removePos === "left" && ( 671 | 675 | )} 676 | 677 | {columns.map((colDef, index) => { 678 | const colKey = getColumnKey(colDef); 679 | const cellValue = totalRowValues[colKey]; 680 | 681 | // Provide a default string if totalRowLabel is not passed and this is the first cell 682 | const displayValue = 683 | cellValue !== undefined 684 | ? cellValue 685 | : index === 0 686 | ? totalRowLabel || "" 687 | : ""; 688 | 689 | // Always apply the border to the first cell or any cell that has a displayValue 690 | const applyBorder = index === 0 || displayValue !== ""; 691 | 692 | return ( 693 | 697 | {displayValue} 698 | 699 | ); 700 | })} 701 | 702 | )} 703 | 704 | {/* If a footerElement is provided, render it after the totals row */} 705 | {footerElement} 706 | 707 | ); 708 | } 709 | 710 | return ( 711 |
712 | 713 | 714 | Dynamic, editable data table with grouping & nested sub-rows. 715 | 716 | {/* Primary header */} 717 | {showHeader && ( 718 | 719 | 720 | {/* Right icon cells empty headers */} 721 | {addPos === "left" && ( 722 | 726 | )} 727 | 728 | {removePos === "left" && ( 729 | 733 | )} 734 | 735 | {table.getHeaderGroups().map((headerGroup) => 736 | headerGroup.headers.map((header) => { 737 | const style: React.CSSProperties = {}; 738 | if (enableColumnSizing) { 739 | const col = header.column.columnDef; 740 | const size = header.getSize(); 741 | if (size) style.width = `${size}px`; 742 | if (col.minSize) style.minWidth = `${col.minSize}px`; 743 | if (col.maxSize) style.maxWidth = `${col.maxSize}px`; 744 | } 745 | 746 | return ( 747 | 752 | {flexRender( 753 | header.column.columnDef.header, 754 | header.getContext(), 755 | )} 756 | 757 | ); 758 | }), 759 | )} 760 | 761 | {/* Left icon cells empty headers */} 762 | 763 | {addPos === "right" && ( 764 | 768 | )} 769 | 770 | {removePos === "right" && ( 771 | 775 | )} 776 | 777 | 778 | )} 779 | {/* Optional second header */}{" "} 780 | {showSecondHeader && secondHeaderTitle && ( 781 | 782 | 783 | {/* Right icon cells empty headers */} 784 | {addPos === "left" && ( 785 | 789 | )} 790 | 791 | {removePos === "left" && ( 792 | 796 | )} 797 | 798 | 799 | {secondHeaderTitle} 800 | 801 | 802 | {/* Left icon cells empty headers */} 803 | {addPos === "right" && ( 804 | 808 | )} 809 | 810 | {removePos === "right" && ( 811 | 815 | )} 816 | 817 | 818 | )} 819 | 820 | {Object.entries(groupedData).map(([groupKey, topRows]) => ( 821 | 822 | {/* Group label row (if not ungrouped) */} 823 | {groupKey !== "ungrouped" && ( 824 | 825 | 826 | {/* Right icon cells empty headers */} 827 | {addPos === "left" && ( 828 | 832 | )} 833 | 834 | {removePos === "left" && ( 835 | 839 | )} 840 | 841 | 845 | {groupKey} 846 | 847 | 848 | {/* Left icon cells empty headers */} 849 | {addPos === "right" && ( 850 | 854 | )} 855 | 856 | {removePos === "right" && ( 857 | 861 | )} 862 | 863 | 864 | )} 865 | {/* For each top-level row in this group, find the actual row in table. 866 | Then recursively render it with renderRow() */}{" "} 867 | {topRows.map((rowData) => { 868 | const row = table 869 | .getRowModel() 870 | .flatRows.find((r) => r.original === rowData); 871 | if (!row) return null; 872 | 873 | return renderRow(row, groupKey, 0); 874 | })} 875 | 876 | ))} 877 | 878 | {/* Render footer (totals row + custom footer) */} 879 | {renderFooter()} 880 |
881 |
882 | ); 883 | } 884 | 885 | export default SheetTable; 886 | --------------------------------------------------------------------------------