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 |
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 | |
274 | {flexRender(header.column.columnDef.header, header.getContext())}
275 | |
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 | | 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 | |
317 | );
318 | })}
319 |
320 | ))}
321 |
322 |
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 | 
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 |
--------------------------------------------------------------------------------
|