simple-table-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Outline.txt +63 -0
- package/README.md +12 -0
- package/package.json +38 -0
- package/public/favicon.ico +0 -0
- package/public/index.html +15 -0
- package/src/App.tsx +20 -0
- package/src/components/Animate.tsx +60 -0
- package/src/components/SimpleTable/SimpleTable.tsx +82 -0
- package/src/components/SimpleTable/TableCell.tsx +50 -0
- package/src/components/SimpleTable/TableHeader.tsx +38 -0
- package/src/components/SimpleTable/TableHeaderCell.tsx +77 -0
- package/src/components/SimpleTable/TableRow.tsx +54 -0
- package/src/consts/SampleData.ts +38 -0
- package/src/helpers/calculateBoundingBoxes.ts +29 -0
- package/src/helpers/shuffleArray.ts +6 -0
- package/src/hooks/usePrevious.ts +15 -0
- package/src/hooks/useSelection.ts +105 -0
- package/src/hooks/useTableHeaderCell.ts +72 -0
- package/src/index.tsx +16 -0
- package/src/react-app-env.d.ts +1 -0
- package/src/styles/index.css +7 -0
- package/src/styles/table.css +93 -0
- package/src/types/HeaderObject.ts +6 -0
- package/src/utils/performanceUtils.ts +17 -0
- package/src/utils/sortUtils.ts +30 -0
- package/tsconfig.json +26 -0
package/Outline.txt
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
Pros and cons of different react table libraries
|
|
2
|
+
|
|
3
|
+
ag-grid
|
|
4
|
+
pros:
|
|
5
|
+
- highly customizable
|
|
6
|
+
- performant
|
|
7
|
+
- feature rich
|
|
8
|
+
cons:
|
|
9
|
+
- crazy expensive ($1000 for a license)
|
|
10
|
+
|
|
11
|
+
react-table
|
|
12
|
+
pros:
|
|
13
|
+
- highly customizable
|
|
14
|
+
- performant
|
|
15
|
+
cons:
|
|
16
|
+
- not beginner friendly
|
|
17
|
+
- terrible syntax
|
|
18
|
+
- missing features
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
Handsontable
|
|
22
|
+
pros
|
|
23
|
+
- easy to set up
|
|
24
|
+
cons:
|
|
25
|
+
- terrible documentation
|
|
26
|
+
- not beginner friendly
|
|
27
|
+
- impossible to style
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
DevExtreme Data Grid
|
|
33
|
+
pros:
|
|
34
|
+
- cheap
|
|
35
|
+
cons:
|
|
36
|
+
- terrible UI
|
|
37
|
+
- slow
|
|
38
|
+
|
|
39
|
+
DataTables
|
|
40
|
+
cons:
|
|
41
|
+
- missing features
|
|
42
|
+
- not react specific
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
jqGrid
|
|
46
|
+
cons
|
|
47
|
+
- looks like the first website component ever made
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
Syncfusion Data Grid
|
|
52
|
+
pros:
|
|
53
|
+
- beautiful UI
|
|
54
|
+
cons:
|
|
55
|
+
- so slow
|
|
56
|
+
- few features
|
|
57
|
+
|
|
58
|
+
Tabulator
|
|
59
|
+
pros:
|
|
60
|
+
- feature rich
|
|
61
|
+
cons:
|
|
62
|
+
- ugly
|
|
63
|
+
- missing polish
|
package/README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Simple React Table Project
|
|
2
|
+
|
|
3
|
+
This project is a simple React table application created for fun
|
|
4
|
+
|
|
5
|
+
## Available Scripts
|
|
6
|
+
|
|
7
|
+
In the project directory, you can run:
|
|
8
|
+
|
|
9
|
+
### `npm start`
|
|
10
|
+
|
|
11
|
+
Runs the app in the development mode.\
|
|
12
|
+
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "simple-table-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"dependencies": {
|
|
5
|
+
"@types/node": "^16.18.111",
|
|
6
|
+
"@types/react": "^18.3.9",
|
|
7
|
+
"@types/react-dom": "^18.3.0",
|
|
8
|
+
"react": "^18.3.1",
|
|
9
|
+
"react-dom": "^18.3.1",
|
|
10
|
+
"react-scripts": "5.0.1",
|
|
11
|
+
"typescript": "^4.9.5",
|
|
12
|
+
"web-vitals": "^2.1.4"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "react-scripts start",
|
|
16
|
+
"build": "react-scripts build",
|
|
17
|
+
"test": "react-scripts test",
|
|
18
|
+
"eject": "react-scripts eject"
|
|
19
|
+
},
|
|
20
|
+
"eslintConfig": {
|
|
21
|
+
"extends": [
|
|
22
|
+
"react-app",
|
|
23
|
+
"react-app/jest"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
"browserslist": {
|
|
27
|
+
"production": [
|
|
28
|
+
">0.2%",
|
|
29
|
+
"not dead",
|
|
30
|
+
"not op_mini all"
|
|
31
|
+
],
|
|
32
|
+
"development": [
|
|
33
|
+
"last 1 chrome version",
|
|
34
|
+
"last 1 firefox version",
|
|
35
|
+
"last 1 safari version"
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="theme-color" content="#000000" />
|
|
7
|
+
<meta name="description" content="Simple React Table" />
|
|
8
|
+
<title>Simple React Table</title>
|
|
9
|
+
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<noscript>You need to enable JavaScript to run this app.</noscript>
|
|
13
|
+
<div id="root"></div>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import SimpleTable from "./components/SimpleTable/SimpleTable";
|
|
2
|
+
import { sampleData } from "./consts/SampleData";
|
|
3
|
+
|
|
4
|
+
const HEADERS = [
|
|
5
|
+
{ label: "id", accessor: "id" },
|
|
6
|
+
{ label: "name", accessor: "name" },
|
|
7
|
+
{ label: "age", accessor: "age" },
|
|
8
|
+
{ label: "email", accessor: "email" },
|
|
9
|
+
{ label: "address", accessor: "address" },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const App = () => {
|
|
13
|
+
return (
|
|
14
|
+
<div className="app" style={{ padding: "2rem" }}>
|
|
15
|
+
<SimpleTable headers={HEADERS} rows={sampleData} />
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default App;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React, { useState, useLayoutEffect, useEffect } from "react";
|
|
2
|
+
import usePrevious from "../hooks/usePrevious";
|
|
3
|
+
import calculateBoundingBoxes from "../helpers/calculateBoundingBoxes";
|
|
4
|
+
|
|
5
|
+
interface AnimateProps {
|
|
6
|
+
children: any;
|
|
7
|
+
animateRow?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const Animate = ({ children, animateRow }: AnimateProps) => {
|
|
11
|
+
const [boundingBox, setBoundingBox] = useState<any>({});
|
|
12
|
+
const [prevBoundingBox, setPrevBoundingBox] = useState<any>({});
|
|
13
|
+
const prevChildren = usePrevious(children);
|
|
14
|
+
|
|
15
|
+
useLayoutEffect(() => {
|
|
16
|
+
const newBoundingBox = calculateBoundingBoxes(children);
|
|
17
|
+
setBoundingBox(newBoundingBox);
|
|
18
|
+
}, [children]);
|
|
19
|
+
|
|
20
|
+
useLayoutEffect(() => {
|
|
21
|
+
const prevBoundingBox = calculateBoundingBoxes(prevChildren);
|
|
22
|
+
setPrevBoundingBox(prevBoundingBox);
|
|
23
|
+
}, [prevChildren]);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const hasPrevBoundingBox = Object.keys(prevBoundingBox).length;
|
|
27
|
+
|
|
28
|
+
if (hasPrevBoundingBox) {
|
|
29
|
+
React.Children.forEach(children, (child) => {
|
|
30
|
+
const domNode = child.ref.current;
|
|
31
|
+
const firstBox = prevBoundingBox[child.key];
|
|
32
|
+
const lastBox = boundingBox[child.key];
|
|
33
|
+
const changeInPosition = animateRow
|
|
34
|
+
? firstBox.top - lastBox.top
|
|
35
|
+
: firstBox.left - lastBox.left;
|
|
36
|
+
|
|
37
|
+
if (changeInPosition) {
|
|
38
|
+
requestAnimationFrame(() => {
|
|
39
|
+
// Before the DOM paints, invert child to old position
|
|
40
|
+
domNode.style.transform = animateRow
|
|
41
|
+
? `translateY(${changeInPosition}px)`
|
|
42
|
+
: `translateX(${changeInPosition}px)`;
|
|
43
|
+
domNode.style.transition = "transform 0s";
|
|
44
|
+
|
|
45
|
+
requestAnimationFrame(() => {
|
|
46
|
+
// After the previous frame, remove
|
|
47
|
+
// the transition to play the animation
|
|
48
|
+
domNode.style.transform = "";
|
|
49
|
+
domNode.style.transition = "transform 500ms";
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}, [boundingBox, prevBoundingBox, children, animateRow]);
|
|
56
|
+
|
|
57
|
+
return children;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export default Animate;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useState, createRef, useRef, useReducer } from "react";
|
|
2
|
+
import useSelection from "../../hooks/useSelection";
|
|
3
|
+
import TableHeader from "./TableHeader";
|
|
4
|
+
import { onSort } from "../../utils/sortUtils";
|
|
5
|
+
import Animate from "../Animate";
|
|
6
|
+
import TableRow from "./TableRow";
|
|
7
|
+
import HeaderObject from "../../types/HeaderObject";
|
|
8
|
+
|
|
9
|
+
interface SpreadsheetProps {
|
|
10
|
+
headers: HeaderObject[];
|
|
11
|
+
rows: { [key: string]: any }[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SimpleTable = ({ headers, rows }: SpreadsheetProps) => {
|
|
15
|
+
const headersRef = useRef(headers);
|
|
16
|
+
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
|
17
|
+
const [sortedRows, setSortedRows] = useState(rows);
|
|
18
|
+
const [sortConfig, setSortConfig] = useState<{
|
|
19
|
+
key: HeaderObject;
|
|
20
|
+
direction: string;
|
|
21
|
+
} | null>(null);
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
handleMouseDown,
|
|
25
|
+
handleMouseOver,
|
|
26
|
+
handleMouseUp,
|
|
27
|
+
isSelected,
|
|
28
|
+
getBorderClass,
|
|
29
|
+
isTopLeftCell,
|
|
30
|
+
} = useSelection(sortedRows, headers);
|
|
31
|
+
|
|
32
|
+
const handleSort = (columnIndex: number) => {
|
|
33
|
+
const { sortedData, newSortConfig } = onSort(
|
|
34
|
+
headers,
|
|
35
|
+
sortedRows,
|
|
36
|
+
sortConfig,
|
|
37
|
+
columnIndex
|
|
38
|
+
);
|
|
39
|
+
setSortedRows(sortedData);
|
|
40
|
+
setSortConfig(newSortConfig);
|
|
41
|
+
};
|
|
42
|
+
const onDragEnd = (newHeaders: HeaderObject[]) => {
|
|
43
|
+
headersRef.current = newHeaders;
|
|
44
|
+
forceUpdate();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="table-wrapper">
|
|
49
|
+
<table
|
|
50
|
+
className="simple-table"
|
|
51
|
+
onMouseUp={handleMouseUp}
|
|
52
|
+
onMouseLeave={handleMouseUp}
|
|
53
|
+
>
|
|
54
|
+
<TableHeader
|
|
55
|
+
headersRef={headersRef}
|
|
56
|
+
onSort={handleSort}
|
|
57
|
+
onDragEnd={onDragEnd}
|
|
58
|
+
/>
|
|
59
|
+
<tbody>
|
|
60
|
+
<Animate animateRow={true}>
|
|
61
|
+
{sortedRows.map((row, rowIndex) => (
|
|
62
|
+
<TableRow
|
|
63
|
+
getBorderClass={getBorderClass}
|
|
64
|
+
handleMouseDown={handleMouseDown}
|
|
65
|
+
handleMouseOver={handleMouseOver}
|
|
66
|
+
headers={headersRef.current}
|
|
67
|
+
isSelected={isSelected}
|
|
68
|
+
isTopLeftCell={isTopLeftCell}
|
|
69
|
+
key={row.id}
|
|
70
|
+
ref={createRef()}
|
|
71
|
+
row={row}
|
|
72
|
+
rowIndex={rowIndex}
|
|
73
|
+
/>
|
|
74
|
+
))}
|
|
75
|
+
</Animate>
|
|
76
|
+
</tbody>
|
|
77
|
+
</table>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export default SimpleTable;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { forwardRef, LegacyRef } from "react";
|
|
2
|
+
|
|
3
|
+
interface TableCellProps {
|
|
4
|
+
rowIndex: number;
|
|
5
|
+
colIndex: number;
|
|
6
|
+
content: any;
|
|
7
|
+
isSelected: boolean;
|
|
8
|
+
isTopLeftCell: boolean;
|
|
9
|
+
borderClass: string;
|
|
10
|
+
onMouseDown: (rowIndex: number, colIndex: number) => void;
|
|
11
|
+
onMouseOver: (rowIndex: number, colIndex: number) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const TableCell = forwardRef(
|
|
15
|
+
(
|
|
16
|
+
{
|
|
17
|
+
rowIndex,
|
|
18
|
+
colIndex,
|
|
19
|
+
content,
|
|
20
|
+
isSelected,
|
|
21
|
+
isTopLeftCell,
|
|
22
|
+
borderClass,
|
|
23
|
+
onMouseDown,
|
|
24
|
+
onMouseOver,
|
|
25
|
+
}: TableCellProps,
|
|
26
|
+
ref: LegacyRef<HTMLTableCellElement>
|
|
27
|
+
) => {
|
|
28
|
+
return (
|
|
29
|
+
<td
|
|
30
|
+
onMouseDown={() => onMouseDown(rowIndex, colIndex)}
|
|
31
|
+
onMouseOver={() => onMouseOver(rowIndex, colIndex)}
|
|
32
|
+
ref={ref}
|
|
33
|
+
>
|
|
34
|
+
<div
|
|
35
|
+
className={`table-cell ${
|
|
36
|
+
isSelected
|
|
37
|
+
? isTopLeftCell
|
|
38
|
+
? `selected-first-cell ${borderClass}`
|
|
39
|
+
: `selected ${borderClass}`
|
|
40
|
+
: ""
|
|
41
|
+
}`}
|
|
42
|
+
>
|
|
43
|
+
{content}
|
|
44
|
+
</div>
|
|
45
|
+
</td>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
export default TableCell;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createRef, useRef } from "react";
|
|
2
|
+
import Animate from "../Animate";
|
|
3
|
+
import TableHeaderCell from "./TableHeaderCell";
|
|
4
|
+
import HeaderObject from "../../types/HeaderObject";
|
|
5
|
+
|
|
6
|
+
interface TableHeaderProps {
|
|
7
|
+
headersRef: React.RefObject<HeaderObject[]>;
|
|
8
|
+
onSort: (columnIndex: number) => void;
|
|
9
|
+
onDragEnd: (newHeaders: HeaderObject[]) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const TableHeader = ({ headersRef, onSort, onDragEnd }: TableHeaderProps) => {
|
|
13
|
+
const draggedHeaderRef = useRef<HeaderObject | null>(null);
|
|
14
|
+
const hoveredHeaderRef = useRef<HeaderObject | null>(null);
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<thead className="table-header">
|
|
18
|
+
<tr>
|
|
19
|
+
<Animate>
|
|
20
|
+
{headersRef.current?.map((header, index) => (
|
|
21
|
+
<TableHeaderCell
|
|
22
|
+
draggedHeaderRef={draggedHeaderRef}
|
|
23
|
+
headersRef={headersRef}
|
|
24
|
+
hoveredHeaderRef={hoveredHeaderRef}
|
|
25
|
+
index={index}
|
|
26
|
+
key={header.accessor}
|
|
27
|
+
onDragEnd={onDragEnd}
|
|
28
|
+
onSort={onSort}
|
|
29
|
+
ref={createRef()}
|
|
30
|
+
/>
|
|
31
|
+
))}
|
|
32
|
+
</Animate>
|
|
33
|
+
</tr>
|
|
34
|
+
</thead>
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default TableHeader;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { forwardRef, LegacyRef, useState, useRef } from "react";
|
|
2
|
+
import useTableHeaderCell from "../../hooks/useTableHeaderCell";
|
|
3
|
+
import { throttle } from "../../utils/performanceUtils";
|
|
4
|
+
import HeaderObject from "../../types/HeaderObject";
|
|
5
|
+
|
|
6
|
+
interface TableHeaderCellProps {
|
|
7
|
+
draggedHeaderRef: React.MutableRefObject<HeaderObject | null>;
|
|
8
|
+
headersRef: React.RefObject<HeaderObject[]>;
|
|
9
|
+
hoveredHeaderRef: React.MutableRefObject<HeaderObject | null>;
|
|
10
|
+
index: number;
|
|
11
|
+
onDragEnd: (newHeaders: HeaderObject[]) => void;
|
|
12
|
+
onSort: (columnIndex: number) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const TableHeaderCell = forwardRef(
|
|
16
|
+
(
|
|
17
|
+
{
|
|
18
|
+
draggedHeaderRef,
|
|
19
|
+
headersRef,
|
|
20
|
+
hoveredHeaderRef,
|
|
21
|
+
index,
|
|
22
|
+
onDragEnd,
|
|
23
|
+
onSort,
|
|
24
|
+
}: TableHeaderCellProps,
|
|
25
|
+
ref: LegacyRef<HTMLTableCellElement>
|
|
26
|
+
) => {
|
|
27
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
28
|
+
const header = headersRef.current?.[index];
|
|
29
|
+
const { handleDragStart, handleDragOver, handleDragEnd } =
|
|
30
|
+
useTableHeaderCell({
|
|
31
|
+
draggedHeaderRef,
|
|
32
|
+
headersRef,
|
|
33
|
+
hoveredHeaderRef,
|
|
34
|
+
onDragEnd,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const handleDragStartWrapper = (header: HeaderObject) => {
|
|
38
|
+
setIsDragging(true);
|
|
39
|
+
handleDragStart(header);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const handleDragEndWrapper = () => {
|
|
43
|
+
setIsDragging(false);
|
|
44
|
+
handleDragEnd();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Throttle the handleDragOver function
|
|
48
|
+
const throttledHandleDragOver = useRef(
|
|
49
|
+
throttle((header: HeaderObject) => {
|
|
50
|
+
handleDragOver(header);
|
|
51
|
+
}, 50) // Adjust the delay as needed
|
|
52
|
+
).current;
|
|
53
|
+
if (!header) return null;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<th
|
|
57
|
+
className={`table-header-cell ${
|
|
58
|
+
header === hoveredHeaderRef.current ? "hovered" : ""
|
|
59
|
+
} ${isDragging ? "dragging" : ""}`}
|
|
60
|
+
key={header?.accessor}
|
|
61
|
+
draggable
|
|
62
|
+
onDragStart={() => handleDragStartWrapper(header)}
|
|
63
|
+
onDragOver={(event) => {
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
throttledHandleDragOver(header, event);
|
|
66
|
+
}}
|
|
67
|
+
onDragEnd={handleDragEndWrapper}
|
|
68
|
+
onClick={() => onSort(index)}
|
|
69
|
+
ref={ref}
|
|
70
|
+
>
|
|
71
|
+
{header?.label}
|
|
72
|
+
</th>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
export default TableHeaderCell;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createRef, forwardRef, LegacyRef } from "react";
|
|
2
|
+
import TableCell from "./TableCell";
|
|
3
|
+
import Animate from "../Animate";
|
|
4
|
+
import HeaderObject from "../../types/HeaderObject";
|
|
5
|
+
|
|
6
|
+
interface TableRowProps {
|
|
7
|
+
rowIndex: number;
|
|
8
|
+
row: { [key: string]: any };
|
|
9
|
+
headers: HeaderObject[];
|
|
10
|
+
isSelected: (rowIndex: number, columnIndex: number) => boolean;
|
|
11
|
+
isTopLeftCell: (rowIndex: number, columnIndex: number) => boolean;
|
|
12
|
+
getBorderClass: (rowIndex: number, columnIndex: number) => string;
|
|
13
|
+
handleMouseDown: (rowIndex: number, columnIndex: number) => void;
|
|
14
|
+
handleMouseOver: (rowIndex: number, columnIndex: number) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const TableRow = forwardRef(
|
|
18
|
+
(
|
|
19
|
+
{
|
|
20
|
+
rowIndex,
|
|
21
|
+
row,
|
|
22
|
+
headers,
|
|
23
|
+
isSelected,
|
|
24
|
+
isTopLeftCell,
|
|
25
|
+
getBorderClass,
|
|
26
|
+
handleMouseDown,
|
|
27
|
+
handleMouseOver,
|
|
28
|
+
}: TableRowProps,
|
|
29
|
+
ref: LegacyRef<HTMLTableRowElement>
|
|
30
|
+
) => {
|
|
31
|
+
return (
|
|
32
|
+
<tr key={row.id} ref={ref}>
|
|
33
|
+
<Animate>
|
|
34
|
+
{headers.map((header, columnIndex) => (
|
|
35
|
+
<TableCell
|
|
36
|
+
key={header.accessor}
|
|
37
|
+
rowIndex={rowIndex}
|
|
38
|
+
colIndex={columnIndex}
|
|
39
|
+
content={row[header.accessor]}
|
|
40
|
+
isSelected={isSelected(rowIndex, columnIndex)}
|
|
41
|
+
isTopLeftCell={isTopLeftCell(rowIndex, columnIndex)}
|
|
42
|
+
borderClass={getBorderClass(rowIndex, columnIndex)}
|
|
43
|
+
onMouseDown={() => handleMouseDown(rowIndex, columnIndex)}
|
|
44
|
+
onMouseOver={() => handleMouseOver(rowIndex, columnIndex)}
|
|
45
|
+
ref={createRef()}
|
|
46
|
+
/>
|
|
47
|
+
))}
|
|
48
|
+
</Animate>
|
|
49
|
+
</tr>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
export default TableRow;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface SpreadsheetRow {
|
|
2
|
+
id: number;
|
|
3
|
+
name: string;
|
|
4
|
+
age: number;
|
|
5
|
+
email: string;
|
|
6
|
+
address: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const sampleData: SpreadsheetRow[] = [
|
|
10
|
+
{
|
|
11
|
+
id: 1,
|
|
12
|
+
name: "John Doe",
|
|
13
|
+
age: 28,
|
|
14
|
+
email: "john.doe@example.com",
|
|
15
|
+
address: "123 Main St, Anytown, USA",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 2,
|
|
19
|
+
name: "Jane Smith",
|
|
20
|
+
age: 34,
|
|
21
|
+
email: "jane.smith@example.com",
|
|
22
|
+
address: "456 Oak St, Sometown, USA",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 3,
|
|
26
|
+
name: "Alice Johnson",
|
|
27
|
+
age: 45,
|
|
28
|
+
email: "alice.johnson@example.com",
|
|
29
|
+
address: "789 Pine St, Yourtown, USA",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 4,
|
|
33
|
+
name: "Bob Brown",
|
|
34
|
+
age: 23,
|
|
35
|
+
email: "bob.brown@example.com",
|
|
36
|
+
address: "101 Maple St, Mytown, USA",
|
|
37
|
+
},
|
|
38
|
+
];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Children, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
interface BoundingBox {
|
|
4
|
+
bottom: number;
|
|
5
|
+
height: number;
|
|
6
|
+
left: number;
|
|
7
|
+
right: number;
|
|
8
|
+
top: number;
|
|
9
|
+
width: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const calculateBoundingBoxes = (
|
|
13
|
+
children: ReactNode
|
|
14
|
+
): { [key: string]: BoundingBox } => {
|
|
15
|
+
const boundingBoxes: { [key: string]: BoundingBox } = {};
|
|
16
|
+
|
|
17
|
+
Children.forEach(children, (child: any) => {
|
|
18
|
+
if (child.ref && child.ref.current) {
|
|
19
|
+
const domNode = child.ref.current;
|
|
20
|
+
const nodeBoundingBox = domNode.getBoundingClientRect();
|
|
21
|
+
|
|
22
|
+
boundingBoxes[child.key] = nodeBoundingBox;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return boundingBoxes;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export default calculateBoundingBoxes;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
const usePrevious = (value: any) => {
|
|
4
|
+
const prevChildrenRef = useRef();
|
|
5
|
+
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
prevChildrenRef.current = value;
|
|
8
|
+
}, [value]);
|
|
9
|
+
|
|
10
|
+
return prevChildrenRef.current;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default usePrevious;
|
|
14
|
+
|
|
15
|
+
// https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback } from "react";
|
|
2
|
+
import HeaderObject from "../types/HeaderObject";
|
|
3
|
+
|
|
4
|
+
interface Cell {
|
|
5
|
+
row: number;
|
|
6
|
+
col: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const useSelection = (
|
|
10
|
+
rows: { [key: string]: any }[],
|
|
11
|
+
headers: HeaderObject[]
|
|
12
|
+
) => {
|
|
13
|
+
const [selectedCells, setSelectedCells] = useState<Cell[]>([]);
|
|
14
|
+
const isSelecting = useRef(false);
|
|
15
|
+
const startCell = useRef<Cell | null>(null);
|
|
16
|
+
|
|
17
|
+
const copyToClipboard = useCallback(() => {
|
|
18
|
+
const rowsText = selectedCells.reduce((acc, { row, col }) => {
|
|
19
|
+
if (!acc[row]) acc[row] = [];
|
|
20
|
+
acc[row][col] = rows[row][headers[col].accessor];
|
|
21
|
+
return acc;
|
|
22
|
+
}, {} as { [key: number]: { [key: number]: any } });
|
|
23
|
+
|
|
24
|
+
const text = Object.values(rowsText)
|
|
25
|
+
.map((row) => Object.values(row).join("\t"))
|
|
26
|
+
.join("\n");
|
|
27
|
+
|
|
28
|
+
navigator.clipboard.writeText(text);
|
|
29
|
+
}, [selectedCells, rows, headers]);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
33
|
+
if ((event.ctrlKey || event.metaKey) && event.key === "c") {
|
|
34
|
+
copyToClipboard();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
39
|
+
return () => {
|
|
40
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
41
|
+
};
|
|
42
|
+
}, [copyToClipboard, selectedCells]);
|
|
43
|
+
|
|
44
|
+
const handleMouseDown = (rowIndex: number, colIndex: number) => {
|
|
45
|
+
isSelecting.current = true;
|
|
46
|
+
startCell.current = { row: rowIndex, col: colIndex };
|
|
47
|
+
setSelectedCells([{ row: rowIndex, col: colIndex }]);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const handleMouseOver = (rowIndex: number, colIndex: number) => {
|
|
51
|
+
if (isSelecting.current && startCell.current) {
|
|
52
|
+
const newSelectedCells = [];
|
|
53
|
+
const startRow = Math.min(startCell.current.row, rowIndex);
|
|
54
|
+
const endRow = Math.max(startCell.current.row, rowIndex);
|
|
55
|
+
const startCol = Math.min(startCell.current.col, colIndex);
|
|
56
|
+
const endCol = Math.max(startCell.current.col, colIndex);
|
|
57
|
+
|
|
58
|
+
for (let row = startRow; row <= endRow; row++) {
|
|
59
|
+
for (let col = startCol; col <= endCol; col++) {
|
|
60
|
+
newSelectedCells.push({ row, col });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
setSelectedCells(newSelectedCells);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const handleMouseUp = () => {
|
|
68
|
+
isSelecting.current = false;
|
|
69
|
+
startCell.current = null;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const isSelected = (rowIndex: number, colIndex: number) => {
|
|
73
|
+
return selectedCells.some(
|
|
74
|
+
(cell) => cell.row === rowIndex && cell.col === colIndex
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const getBorderClass = (rowIndex: number, colIndex: number) => {
|
|
79
|
+
const classes = [];
|
|
80
|
+
if (!isSelected(rowIndex - 1, colIndex)) classes.push("border-top-blue");
|
|
81
|
+
if (!isSelected(rowIndex + 1, colIndex)) classes.push("border-bottom-blue");
|
|
82
|
+
if (!isSelected(rowIndex, colIndex - 1)) classes.push("border-left-blue");
|
|
83
|
+
if (!isSelected(rowIndex, colIndex + 1)) classes.push("border-right-blue");
|
|
84
|
+
return classes.join(" ");
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const isTopLeftCell = (rowIndex: number, colIndex: number) => {
|
|
88
|
+
return (
|
|
89
|
+
rowIndex === Math.min(...selectedCells.map((cell) => cell.row)) &&
|
|
90
|
+
colIndex === Math.min(...selectedCells.map((cell) => cell.col))
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
selectedCells,
|
|
96
|
+
handleMouseDown,
|
|
97
|
+
handleMouseOver,
|
|
98
|
+
handleMouseUp,
|
|
99
|
+
isSelected,
|
|
100
|
+
getBorderClass,
|
|
101
|
+
isTopLeftCell,
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export default useSelection;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import HeaderObject from "../types/HeaderObject";
|
|
2
|
+
|
|
3
|
+
interface UseTableHeaderCellProps {
|
|
4
|
+
draggedHeaderRef: React.MutableRefObject<HeaderObject | null>;
|
|
5
|
+
headersRef: React.RefObject<HeaderObject[]>;
|
|
6
|
+
hoveredHeaderRef: React.MutableRefObject<HeaderObject | null>;
|
|
7
|
+
onDragEnd: (newHeaders: HeaderObject[]) => void;
|
|
8
|
+
}
|
|
9
|
+
var isUpdating = false;
|
|
10
|
+
|
|
11
|
+
const useTableHeaderCell = ({
|
|
12
|
+
draggedHeaderRef,
|
|
13
|
+
headersRef,
|
|
14
|
+
hoveredHeaderRef,
|
|
15
|
+
onDragEnd,
|
|
16
|
+
}: UseTableHeaderCellProps) => {
|
|
17
|
+
const handleDragStart = (header: HeaderObject) => {
|
|
18
|
+
draggedHeaderRef.current = header;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const updateHeaders = (hoveredHeader: HeaderObject) => {
|
|
22
|
+
if (isUpdating) return;
|
|
23
|
+
hoveredHeaderRef.current = hoveredHeader;
|
|
24
|
+
|
|
25
|
+
if (
|
|
26
|
+
hoveredHeader.accessor !== draggedHeaderRef.current?.accessor &&
|
|
27
|
+
draggedHeaderRef.current !== null &&
|
|
28
|
+
!isUpdating
|
|
29
|
+
) {
|
|
30
|
+
isUpdating = true;
|
|
31
|
+
const newHeaders = [...(headersRef.current || [])];
|
|
32
|
+
const draggedHeaderIndex = headersRef.current?.findIndex(
|
|
33
|
+
(header) => header.accessor === draggedHeaderRef.current?.accessor
|
|
34
|
+
);
|
|
35
|
+
const hoveredHeaderIndex = headersRef.current?.findIndex(
|
|
36
|
+
(header) => header.accessor === hoveredHeader.accessor
|
|
37
|
+
);
|
|
38
|
+
if (draggedHeaderIndex === undefined || hoveredHeaderIndex === undefined)
|
|
39
|
+
return;
|
|
40
|
+
|
|
41
|
+
const [draggedHeader] = newHeaders.splice(draggedHeaderIndex, 1);
|
|
42
|
+
newHeaders.splice(hoveredHeaderIndex, 0, draggedHeader);
|
|
43
|
+
|
|
44
|
+
// Check if the newHeaders array is different from the original headers array
|
|
45
|
+
if (JSON.stringify(newHeaders) !== JSON.stringify(headersRef.current))
|
|
46
|
+
setTimeout(() => {
|
|
47
|
+
onDragEnd(newHeaders);
|
|
48
|
+
|
|
49
|
+
setTimeout(() => {
|
|
50
|
+
isUpdating = false;
|
|
51
|
+
}, 500);
|
|
52
|
+
}, 50);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handleDragOver = (hoveredHeader: HeaderObject) => {
|
|
57
|
+
updateHeaders(hoveredHeader);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const handleDragEnd = () => {
|
|
61
|
+
draggedHeaderRef.current = null;
|
|
62
|
+
hoveredHeaderRef.current = null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
handleDragStart,
|
|
67
|
+
handleDragOver,
|
|
68
|
+
handleDragEnd,
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export default useTableHeaderCell;
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import ReactDOM from "react-dom/client";
|
|
3
|
+
import App from "./App";
|
|
4
|
+
|
|
5
|
+
import "./styles/index.css";
|
|
6
|
+
import "./styles/table.css";
|
|
7
|
+
|
|
8
|
+
const root = ReactDOM.createRoot(
|
|
9
|
+
document.getElementById("root") as HTMLElement
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
root.render(
|
|
13
|
+
<React.StrictMode>
|
|
14
|
+
<App />
|
|
15
|
+
</React.StrictMode>
|
|
16
|
+
);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="react-scripts" />
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--blue-50: #ebf8ff;
|
|
3
|
+
--blue-100: #bee3f8;
|
|
4
|
+
--blue-200: #90cdf4;
|
|
5
|
+
--blue-300: #63b3ed;
|
|
6
|
+
--blue-400: #4299e1;
|
|
7
|
+
--blue-500: #3182ce;
|
|
8
|
+
--blue-600: #2b6cb0;
|
|
9
|
+
--blue-700: #2c5282;
|
|
10
|
+
--blue-800: #2a4365;
|
|
11
|
+
--blue-900: #1a365d;
|
|
12
|
+
--selected-first-cell: white;
|
|
13
|
+
--light-grey: #d3d3d3;
|
|
14
|
+
--border-radius: 4px;
|
|
15
|
+
--border-width: 1px;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
* {
|
|
19
|
+
box-sizing: border-box;
|
|
20
|
+
margin: 0;
|
|
21
|
+
padding: 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* Add a new class for the table wrapper */
|
|
25
|
+
.table-wrapper {
|
|
26
|
+
border: 1px solid var(--light-grey);
|
|
27
|
+
border-radius: var(--border-radius);
|
|
28
|
+
overflow: hidden; /* Ensures the table stays within the rounded corners */
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
table {
|
|
32
|
+
border-collapse: collapse;
|
|
33
|
+
width: 100%;
|
|
34
|
+
table-layout: fixed; /* Ensures even width for all th elements */
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
thead tr,
|
|
38
|
+
tr:not(:last-child) {
|
|
39
|
+
border-bottom: 1px solid var(--light-grey);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
tr {
|
|
43
|
+
transition: transform 0.4s, top 0.4s, opacity 0.2s;
|
|
44
|
+
transform: translateY(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.dragging {
|
|
48
|
+
background-color: var(--blue-100);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
th {
|
|
52
|
+
width: 100px; /* Set a specific width for all th elements */
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
th,
|
|
56
|
+
.table-cell {
|
|
57
|
+
user-select: none;
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
padding: 8px;
|
|
60
|
+
text-align: left;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.table-cell {
|
|
64
|
+
border: var(--border-width) solid transparent;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.selected {
|
|
68
|
+
background-color: var(--blue-200);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.selected-first-cell {
|
|
72
|
+
background-color: var(--selected-first-cell);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.border-top-blue {
|
|
76
|
+
border-top: var(--border-width) solid var(--blue-500);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.border-bottom-blue {
|
|
80
|
+
border-bottom: var(--border-width) solid var(--blue-500);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.border-left-blue {
|
|
84
|
+
border-left: var(--border-width) solid var(--blue-500);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.border-right-blue {
|
|
88
|
+
border-right: var(--border-width) solid var(--blue-500);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.border-top-white {
|
|
92
|
+
border-top: var(--border-width) solid white;
|
|
93
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const throttle = (func: (...args: any[]) => void, limit: number) => {
|
|
2
|
+
let isFirstCall = true;
|
|
3
|
+
let inThrottle = true;
|
|
4
|
+
|
|
5
|
+
return function (this: any, ...args: any[]) {
|
|
6
|
+
if (isFirstCall) {
|
|
7
|
+
isFirstCall = false;
|
|
8
|
+
setTimeout(() => (inThrottle = false), limit);
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
if (!inThrottle) {
|
|
12
|
+
func.apply(this, args);
|
|
13
|
+
inThrottle = true;
|
|
14
|
+
setTimeout(() => (inThrottle = false), limit);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import HeaderObject from "../types/HeaderObject";
|
|
2
|
+
|
|
3
|
+
export const onSort = (
|
|
4
|
+
headers: HeaderObject[],
|
|
5
|
+
rows: { [key: string]: any }[],
|
|
6
|
+
sortConfig: { key: HeaderObject; direction: string } | null,
|
|
7
|
+
columnIndex: number
|
|
8
|
+
) => {
|
|
9
|
+
const key = headers[columnIndex];
|
|
10
|
+
let direction = "ascending";
|
|
11
|
+
if (
|
|
12
|
+
sortConfig &&
|
|
13
|
+
sortConfig.key.accessor === key.accessor &&
|
|
14
|
+
sortConfig.direction === "ascending"
|
|
15
|
+
) {
|
|
16
|
+
direction = "descending";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sortedData = [...rows].sort((a, b) => {
|
|
20
|
+
if (a[key.accessor] < b[key.accessor]) {
|
|
21
|
+
return direction === "ascending" ? -1 : 1;
|
|
22
|
+
}
|
|
23
|
+
if (a[key.accessor] > b[key.accessor]) {
|
|
24
|
+
return direction === "ascending" ? 1 : -1;
|
|
25
|
+
}
|
|
26
|
+
return 0;
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return { sortedData, newSortConfig: { key, direction } };
|
|
30
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es5",
|
|
4
|
+
"lib": [
|
|
5
|
+
"dom",
|
|
6
|
+
"dom.iterable",
|
|
7
|
+
"esnext"
|
|
8
|
+
],
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"allowSyntheticDefaultImports": true,
|
|
13
|
+
"strict": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true,
|
|
15
|
+
"noFallthroughCasesInSwitch": true,
|
|
16
|
+
"module": "esnext",
|
|
17
|
+
"moduleResolution": "node",
|
|
18
|
+
"resolveJsonModule": true,
|
|
19
|
+
"isolatedModules": true,
|
|
20
|
+
"noEmit": true,
|
|
21
|
+
"jsx": "react-jsx"
|
|
22
|
+
},
|
|
23
|
+
"include": [
|
|
24
|
+
"src"
|
|
25
|
+
]
|
|
26
|
+
}
|