├── .husky
├── .gitignore
└── pre-commit
├── .gitignore
├── .prettierignore
├── .prettierrc.json
├── index.html
├── vite.config.js
├── src
├── index.jsx
├── colors.js
├── Badge.jsx
├── scrollbarWidth.js
├── img
│ ├── Multi.jsx
│ ├── Text.jsx
│ ├── ArrowLeft.jsx
│ ├── ArrowUp.jsx
│ ├── ArrowDown.jsx
│ ├── ArrowRight.jsx
│ ├── Hash.jsx
│ ├── Plus.jsx
│ └── Trash.jsx
├── header
│ ├── DataTypeIcon.jsx
│ ├── AddColumnHeader.jsx
│ ├── TypesMenu.jsx
│ ├── Header.jsx
│ └── HeaderMenu.jsx
├── cells
│ ├── TextCell.jsx
│ ├── NumberCell.jsx
│ ├── Cell.jsx
│ └── SelectCell.jsx
├── utils.js
├── Table.jsx
├── style.css
└── App.jsx
├── .github
└── workflows
│ └── master.yaml
├── LICENSE.md
├── package.json
└── README.md
/.husky/.gitignore:
--------------------------------------------------------------------------------
1 | _
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
3 | build
4 | dist
5 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | build
2 | dist
3 | .prettierrc.json
4 | .husky
5 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npm run lint
5 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "avoid",
3 | "singleQuote": true,
4 | "semi": true
5 | }
6 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | setShowTypeMenu(true)}
64 | onMouseLeave={() => setShowTypeMenu(false)}
65 | {...popper.attributes.popper}
66 | style={{
67 | ...popper.styles.popper,
68 | width: 200,
69 | backgroundColor: 'white',
70 | zIndex: 4,
71 | }}
72 | >
73 | {types.map(type => (
74 |
78 | ))}
79 |
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/src/header/Header.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { usePopper } from 'react-popper';
3 | import { Constants } from '../utils';
4 | import AddColumnHeader from './AddColumnHeader';
5 | import DataTypeIcon from './DataTypeIcon';
6 | import HeaderMenu from './HeaderMenu';
7 |
8 | export default function Header({
9 | column: { id, created, label, dataType, getResizerProps, getHeaderProps },
10 | setSortBy,
11 | dataDispatch,
12 | }) {
13 | const [showHeaderMenu, setShowHeaderMenu] = useState(created || false);
14 | const [headerMenuAnchorRef, setHeaderMenuAnchorRef] = useState(null);
15 | const [headerMenuPopperRef, setHeaderMenuPopperRef] = useState(null);
16 | const headerMenuPopper = usePopper(headerMenuAnchorRef, headerMenuPopperRef, {
17 | placement: 'bottom',
18 | strategy: 'absolute',
19 | });
20 |
21 | /* when the column is newly created, set it to open */
22 | useEffect(() => {
23 | if (created) {
24 | setShowHeaderMenu(true);
25 | }
26 | }, [created]);
27 |
28 | function getHeader() {
29 | if (id === Constants.ADD_COLUMN_ID) {
30 | return (
31 | setShowHeaderMenu(false)} />
55 | )}
56 | {showHeaderMenu && (
57 |
67 | )}
68 | >
69 | );
70 | }
71 |
72 | return getHeader();
73 | }
74 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | import faker from 'faker';
2 |
3 | export function shortId() {
4 | return '_' + Math.random().toString(36).substr(2, 9);
5 | }
6 |
7 | export function randomColor() {
8 | return `hsl(${Math.floor(Math.random() * 360)}, 95%, 90%)`;
9 | }
10 |
11 | export function makeData(count) {
12 | let data = [];
13 | let options = [];
14 | for (let i = 0; i < count; i++) {
15 | let row = {
16 | ID: faker.mersenne.rand(),
17 | firstName: faker.name.firstName(),
18 | lastName: faker.name.lastName(),
19 | email: faker.internet.email(),
20 | age: Math.floor(20 + Math.random() * 20),
21 | music: faker.music.genre(),
22 | };
23 | options.push({ label: row.music, backgroundColor: randomColor() });
24 |
25 | data.push(row);
26 | }
27 |
28 | options = options.filter(
29 | (a, i, self) => self.findIndex(b => b.label === a.label) === i
30 | );
31 |
32 | let columns = [
33 | {
34 | id: 'firstName',
35 | label: 'First Name',
36 | accessor: 'firstName',
37 | minWidth: 100,
38 | dataType: DataTypes.TEXT,
39 | options: [],
40 | },
41 | {
42 | id: 'lastName',
43 | label: 'Last Name',
44 | accessor: 'lastName',
45 | minWidth: 100,
46 | dataType: DataTypes.TEXT,
47 | options: [],
48 | },
49 | {
50 | id: 'age',
51 | label: 'Age',
52 | accessor: 'age',
53 | width: 80,
54 | dataType: DataTypes.NUMBER,
55 | options: [],
56 | },
57 | {
58 | id: 'email',
59 | label: 'E-Mail',
60 | accessor: 'email',
61 | width: 300,
62 | dataType: DataTypes.TEXT,
63 | options: [],
64 | },
65 | {
66 | id: 'music',
67 | label: 'Music Preference',
68 | accessor: 'music',
69 | dataType: DataTypes.SELECT,
70 | width: 200,
71 | options: options,
72 | },
73 | {
74 | id: Constants.ADD_COLUMN_ID,
75 | width: 20,
76 | label: '+',
77 | disableResizing: true,
78 | dataType: 'null',
79 | },
80 | ];
81 | return { columns: columns, data: data, skipReset: false };
82 | }
83 |
84 | export const ActionTypes = Object.freeze({
85 | ADD_OPTION_TO_COLUMN: 'add_option_to_column',
86 | ADD_ROW: 'add_row',
87 | UPDATE_COLUMN_TYPE: 'update_column_type',
88 | UPDATE_COLUMN_HEADER: 'update_column_header',
89 | UPDATE_CELL: 'update_cell',
90 | ADD_COLUMN_TO_LEFT: 'add_column_to_left',
91 | ADD_COLUMN_TO_RIGHT: 'add_column_to_right',
92 | DELETE_COLUMN: 'delete_column',
93 | ENABLE_RESET: 'enable_reset',
94 | });
95 |
96 | export const DataTypes = Object.freeze({
97 | NUMBER: 'number',
98 | TEXT: 'text',
99 | SELECT: 'select',
100 | });
101 |
102 | export const Constants = Object.freeze({
103 | ADD_COLUMN_ID: 999999,
104 | });
105 |
--------------------------------------------------------------------------------
/src/Table.jsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from 'react';
2 | import clsx from 'clsx';
3 | import {
4 | useTable,
5 | useBlockLayout,
6 | useResizeColumns,
7 | useSortBy,
8 | } from 'react-table';
9 | import Cell from './cells/Cell';
10 | import Header from './header/Header';
11 | import PlusIcon from './img/Plus';
12 | import { ActionTypes } from './utils';
13 | import { FixedSizeList } from 'react-window';
14 | import scrollbarWidth from './scrollbarWidth';
15 |
16 | const defaultColumn = {
17 | minWidth: 50,
18 | width: 150,
19 | maxWidth: 400,
20 | Cell: Cell,
21 | Header: Header,
22 | sortType: 'alphanumericFalsyLast',
23 | };
24 |
25 | export default function Table({
26 | columns,
27 | data,
28 | dispatch: dataDispatch,
29 | skipReset,
30 | }) {
31 | const sortTypes = useMemo(
32 | () => ({
33 | alphanumericFalsyLast(rowA, rowB, columnId, desc) {
34 | if (!rowA.values[columnId] && !rowB.values[columnId]) {
35 | return 0;
36 | }
37 |
38 | if (!rowA.values[columnId]) {
39 | return desc ? -1 : 1;
40 | }
41 |
42 | if (!rowB.values[columnId]) {
43 | return desc ? 1 : -1;
44 | }
45 |
46 | return isNaN(rowA.values[columnId])
47 | ? rowA.values[columnId].localeCompare(rowB.values[columnId])
48 | : rowA.values[columnId] - rowB.values[columnId];
49 | },
50 | }),
51 | []
52 | );
53 |
54 | const {
55 | getTableProps,
56 | getTableBodyProps,
57 | headerGroups,
58 | rows,
59 | prepareRow,
60 | totalColumnsWidth,
61 | } = useTable(
62 | {
63 | columns,
64 | data,
65 | defaultColumn,
66 | dataDispatch,
67 | autoResetSortBy: !skipReset,
68 | autoResetFilters: !skipReset,
69 | autoResetRowState: !skipReset,
70 | sortTypes,
71 | },
72 | useBlockLayout,
73 | useResizeColumns,
74 | useSortBy
75 | );
76 |
77 | const RenderRow = React.useCallback(
78 | ({ index, style }) => {
79 | const row = rows[index];
80 | prepareRow(row);
81 | return (
82 |
83 | {row.cells.map(cell => (
84 |
85 | {cell.render('Cell')}
86 |
87 | ))}
88 |
89 | );
90 | },
91 | [prepareRow, rows]
92 | );
93 |
94 | function isTableResizing() {
95 | for (let headerGroup of headerGroups) {
96 | for (let column of headerGroup.headers) {
97 | if (column.isResizing) {
98 | return true;
99 | }
100 | }
101 | }
102 |
103 | return false;
104 | }
105 |
106 | return (
107 |
108 |
112 |
113 | {headerGroups.map(headerGroup => (
114 |
115 | {headerGroup.headers.map(column => column.render('Header'))}
116 |
117 | ))}
118 |
119 |
120 |
126 | {RenderRow}
127 |
128 |
dataDispatch({ type: ActionTypes.ADD_ROW })}
131 | >
132 |
133 |
134 |
135 | New
136 |
137 |
138 |
139 |
140 | );
141 | }
142 |
--------------------------------------------------------------------------------
/src/cells/SelectCell.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { createPortal } from 'react-dom';
3 | import { usePopper } from 'react-popper';
4 | import Badge from '../Badge';
5 | import { grey } from '../colors';
6 | import PlusIcon from '../img/Plus';
7 | import { ActionTypes, randomColor } from '../utils';
8 |
9 | export default function SelectCell({
10 | initialValue,
11 | options,
12 | columnId,
13 | rowIndex,
14 | dataDispatch,
15 | }) {
16 | const [selectRef, setSelectRef] = useState(null);
17 | const [selectPop, setSelectPop] = useState(null);
18 | const [showSelect, setShowSelect] = useState(false);
19 | const [showAdd, setShowAdd] = useState(false);
20 | const [addSelectRef, setAddSelectRef] = useState(null);
21 | const { styles, attributes } = usePopper(selectRef, selectPop, {
22 | placement: 'bottom-start',
23 | strategy: 'fixed',
24 | });
25 | const [value, setValue] = useState({ value: initialValue, update: false });
26 |
27 | useEffect(() => {
28 | setValue({ value: initialValue, update: false });
29 | }, [initialValue]);
30 |
31 | useEffect(() => {
32 | if (value.update) {
33 | dataDispatch({
34 | type: ActionTypes.UPDATE_CELL,
35 | columnId,
36 | rowIndex,
37 | value: value.value,
38 | });
39 | }
40 | // eslint-disable-next-line react-hooks/exhaustive-deps
41 | }, [value, columnId, rowIndex]);
42 |
43 | useEffect(() => {
44 | if (addSelectRef && showAdd) {
45 | addSelectRef.focus();
46 | }
47 | }, [addSelectRef, showAdd]);
48 |
49 | function getColor() {
50 | let match = options.find(option => option.label === value.value);
51 | return (match && match.backgroundColor) || grey(200);
52 | }
53 |
54 | function handleAddOption(e) {
55 | setShowAdd(true);
56 | }
57 |
58 | function handleOptionKeyDown(e) {
59 | if (e.key === 'Enter') {
60 | if (e.target.value !== '') {
61 | dataDispatch({
62 | type: ActionTypes.ADD_OPTION_TO_COLUMN,
63 | option: e.target.value,
64 | backgroundColor: randomColor(),
65 | columnId,
66 | });
67 | }
68 | setShowAdd(false);
69 | }
70 | }
71 |
72 | function handleOptionBlur(e) {
73 | if (e.target.value !== '') {
74 | dataDispatch({
75 | type: ActionTypes.ADD_OPTION_TO_COLUMN,
76 | option: e.target.value,
77 | backgroundColor: randomColor(),
78 | columnId,
79 | });
80 | }
81 | setShowAdd(false);
82 | }
83 |
84 | function handleOptionClick(option) {
85 | setValue({ value: option.label, update: true });
86 | setShowSelect(false);
87 | }
88 |
89 | useEffect(() => {
90 | if (addSelectRef && showAdd) {
91 | addSelectRef.focus();
92 | }
93 | }, [addSelectRef, showAdd]);
94 |
95 | return (
96 | <>
97 |
setShowSelect(true)}
101 | >
102 | {value.value && (
103 |
104 | )}
105 |
106 | {showSelect && (
107 |
setShowSelect(false)} />
108 | )}
109 | {showSelect &&
110 | createPortal(
111 |
125 |
129 | {options.map(option => (
130 |
handleOptionClick(option)}
133 | >
134 |
138 |
139 | ))}
140 | {showAdd && (
141 |
148 |
155 |
156 | )}
157 |
161 |
164 |
165 |
166 | }
167 | backgroundColor={grey(200)}
168 | />
169 |
170 |
171 |
,
172 | document.querySelector('#popper-portal')
173 | )}
174 | >
175 | );
176 | }
177 |
--------------------------------------------------------------------------------
/src/header/HeaderMenu.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import ArrowUpIcon from '../img/ArrowUp';
3 | import ArrowDownIcon from '../img/ArrowDown';
4 | import ArrowLeftIcon from '../img/ArrowLeft';
5 | import ArrowRightIcon from '../img/ArrowRight';
6 | import TrashIcon from '../img/Trash';
7 | import { grey } from '../colors';
8 | import TypesMenu from './TypesMenu';
9 | import { usePopper } from 'react-popper';
10 | import { ActionTypes, shortId } from '../utils';
11 | import DataTypeIcon from './DataTypeIcon';
12 |
13 | export default function HeaderMenu({
14 | label,
15 | dataType,
16 | columnId,
17 | setSortBy,
18 | popper,
19 | popperRef,
20 | dataDispatch,
21 | setShowHeaderMenu,
22 | }) {
23 | const [inputRef, setInputRef] = useState(null);
24 | const [header, setHeader] = useState(label);
25 | const [typeReferenceElement, setTypeReferenceElement] = useState(null);
26 | const [typePopperElement, setTypePopperElement] = useState(null);
27 | const typePopper = usePopper(typeReferenceElement, typePopperElement, {
28 | placement: 'right',
29 | strategy: 'fixed',
30 | });
31 | const [showTypeMenu, setShowTypeMenu] = useState(false);
32 |
33 | function onTypeMenuClose() {
34 | setShowTypeMenu(false);
35 | setShowHeaderMenu(false);
36 | }
37 |
38 | useEffect(() => {
39 | setHeader(label);
40 | }, [label]);
41 |
42 | useEffect(() => {
43 | if (inputRef) {
44 | inputRef.focus();
45 | inputRef.select();
46 | }
47 | }, [inputRef]);
48 |
49 | const buttons = [
50 | {
51 | onClick: e => {
52 | dataDispatch({
53 | type: ActionTypes.UPDATE_COLUMN_HEADER,
54 | columnId,
55 | label: header,
56 | });
57 | setSortBy([{ id: columnId, desc: false }]);
58 | setShowHeaderMenu(false);
59 | },
60 | icon:
,
61 | label: 'Sort ascending',
62 | },
63 | {
64 | onClick: e => {
65 | dataDispatch({
66 | type: ActionTypes.UPDATE_COLUMN_HEADER,
67 | columnId,
68 | label: header,
69 | });
70 | setSortBy([{ id: columnId, desc: true }]);
71 | setShowHeaderMenu(false);
72 | },
73 | icon:
,
74 | label: 'Sort descending',
75 | },
76 | {
77 | onClick: e => {
78 | dataDispatch({
79 | type: ActionTypes.UPDATE_COLUMN_HEADER,
80 | columnId,
81 | label: header,
82 | });
83 | dataDispatch({
84 | type: ActionTypes.ADD_COLUMN_TO_LEFT,
85 | columnId,
86 | focus: false,
87 | });
88 | setShowHeaderMenu(false);
89 | },
90 | icon:
,
91 | label: 'Insert left',
92 | },
93 | {
94 | onClick: e => {
95 | dataDispatch({
96 | type: ActionTypes.UPDATE_COLUMN_HEADER,
97 | columnId,
98 | label: header,
99 | });
100 | dataDispatch({
101 | type: ActionTypes.ADD_COLUMN_TO_RIGHT,
102 | columnId,
103 | focus: false,
104 | });
105 | setShowHeaderMenu(false);
106 | },
107 | icon:
,
108 | label: 'Insert right',
109 | },
110 | {
111 | onClick: e => {
112 | dataDispatch({ type: ActionTypes.DELETE_COLUMN, columnId });
113 | setShowHeaderMenu(false);
114 | },
115 | icon:
,
116 | label: 'Delete',
117 | },
118 | ];
119 |
120 | function handleColumnNameKeyDown(e) {
121 | if (e.key === 'Enter') {
122 | dataDispatch({
123 | type: ActionTypes.UPDATE_COLUMN_HEADER,
124 | columnId,
125 | label: header,
126 | });
127 | setShowHeaderMenu(false);
128 | }
129 | }
130 |
131 | function handleColumnNameChange(e) {
132 | setHeader(e.target.value);
133 | }
134 |
135 | function handleColumnNameBlur(e) {
136 | e.preventDefault();
137 | dataDispatch({
138 | type: ActionTypes.UPDATE_COLUMN_HEADER,
139 | columnId,
140 | label: header,
141 | });
142 | }
143 |
144 | return (
145 |
150 |
156 |
163 |
164 |
173 |
174 |
175 | Property Type
176 |
177 |
178 |
179 |
191 | {showTypeMenu && (
192 |
200 | )}
201 |
202 |
203 |
204 | {buttons.map(button => (
205 |
216 | ))}
217 |
218 |
219 |
220 | );
221 | }
222 |
--------------------------------------------------------------------------------
/src/style.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
2 | html {
3 | box-sizing: border-box;
4 | }
5 |
6 | *,
7 | *:before,
8 | *:after {
9 | box-sizing: inherit;
10 | }
11 |
12 | * {
13 | margin: 0px;
14 | padding: 0px;
15 | font-family: 'Inter', sans-serif;
16 | }
17 |
18 | #root {
19 | margin: 0px;
20 | padding: 0px;
21 | }
22 |
23 | .transition-fade-enter {
24 | opacity: 0;
25 | }
26 |
27 | .transition-fade-enter-active {
28 | opacity: 1;
29 | transition: opacity 300ms;
30 | }
31 |
32 | .transition-fade-exit {
33 | opacity: 1;
34 | }
35 |
36 | .transition-fade-exit-active {
37 | opacity: 0;
38 | transition: opacity 300ms;
39 | }
40 |
41 | .svg-icon svg {
42 | position: relative;
43 | height: 1.5em;
44 | width: 1.5em;
45 | top: 0.125rem;
46 | }
47 |
48 | .svg-text svg {
49 | stroke: #424242;
50 | }
51 |
52 | .svg-180 svg {
53 | transform: rotate(180deg);
54 | }
55 |
56 | .form-input {
57 | padding: 0.375rem;
58 | background-color: #eeeeee;
59 | border: none;
60 | border-radius: 4px;
61 | font-size: 0.875rem;
62 | color: #424242;
63 | }
64 |
65 | .form-input:focus {
66 | outline: none;
67 | box-shadow: 0 0 1px 2px #8ecae6;
68 | }
69 |
70 | .is-fullwidth {
71 | width: 100%;
72 | }
73 |
74 | .bg-white {
75 | background-color: white;
76 | }
77 |
78 | .data-input {
79 | white-space: pre-wrap;
80 | border: none;
81 | padding: 0.5rem;
82 | color: #424242;
83 | font-size: 1rem;
84 | border-radius: 4px;
85 | resize: none;
86 | background-color: white;
87 | box-sizing: border-box;
88 | flex: 1 1 auto;
89 | }
90 |
91 | .data-input:focus {
92 | outline: none;
93 | }
94 |
95 | .shadow-5 {
96 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.12),
97 | 0 4px 6px rgba(0, 0, 0, 0.12), 0 8px 16px rgba(0, 0, 0, 0.12),
98 | 0 16px 32px rgba(0, 0, 0, 0.12);
99 | }
100 |
101 | .svg-icon-sm svg {
102 | position: relative;
103 | height: 1rem;
104 | width: 1rem;
105 | top: 0.125rem;
106 | }
107 |
108 | .svg-gray svg {
109 | stroke: #9e9e9e;
110 | }
111 |
112 | .option-input {
113 | width: 100%;
114 | font-size: 1rem;
115 | border: none;
116 | background-color: transparent;
117 | }
118 |
119 | .option-input:focus {
120 | outline: none;
121 | }
122 |
123 | .noselect {
124 | -webkit-touch-callout: none;
125 | -webkit-user-select: none;
126 | -khtml-user-select: none;
127 | -moz-user-select: none;
128 | -ms-user-select: none;
129 | user-select: none;
130 | }
131 |
132 | .overlay {
133 | position: fixed;
134 | top: 0;
135 | left: 0;
136 | height: 100vh;
137 | width: 100vw;
138 | z-index: 2;
139 | overflow: hidden;
140 | }
141 |
142 | .sort-button {
143 | padding: 0.25rem 0.75rem;
144 | width: 100%;
145 | background-color: transparent;
146 | border: 0;
147 | font-size: 0.875rem;
148 | color: #757575;
149 | cursor: pointer;
150 | text-align: left;
151 | display: flex;
152 | align-items: center;
153 | }
154 |
155 | .sort-button:hover {
156 | background-color: #eeeeee;
157 | }
158 |
159 | .add-row {
160 | color: #9e9e9e;
161 | padding: 0.5rem;
162 | display: flex;
163 | align-items: center;
164 | font-size: 0.875rem;
165 | cursor: pointer;
166 | height: 50px;
167 | border: 1px solid #e0e0e0;
168 | }
169 |
170 | .add-row:hover {
171 | background-color: #f5f5f5;
172 | }
173 |
174 | .th {
175 | color: #9e9e9e;
176 | font-weight: 500;
177 | font-size: 0.875rem;
178 | cursor: pointer;
179 | }
180 |
181 | .th:hover {
182 | background-color: #f5f5f5;
183 | }
184 |
185 | .th-content {
186 | overflow-x: hidden;
187 | text-overflow: ellipsis;
188 | padding: 0.5rem;
189 | display: flex;
190 | align-items: center;
191 | height: 50px;
192 | }
193 |
194 | .td {
195 | overflow: hidden;
196 | color: #424242;
197 | align-items: stretch;
198 | padding: 0;
199 | display: flex;
200 | flex-direction: column;
201 | }
202 |
203 | .td-content {
204 | display: block;
205 | }
206 |
207 | .table {
208 | display: inline-block;
209 | border-spacing: 0;
210 | }
211 |
212 | .resizer {
213 | display: inline-block;
214 | background: transparent;
215 | width: 8px;
216 | height: 100%;
217 | position: absolute;
218 | right: 0;
219 | top: 0;
220 | transform: translateX(50%);
221 | z-index: 1;
222 | cursor: col-resize;
223 | touch-action: none;
224 | }
225 |
226 | .resizer:hover {
227 | background-color: #8ecae6;
228 | }
229 |
230 | .th,
231 | .td {
232 | white-space: nowrap;
233 | margin: 0;
234 | border-left: 1px solid #e0e0e0;
235 | border-top: 1px solid #e0e0e0;
236 | position: relative;
237 | }
238 |
239 | .th {
240 | border-bottom: 1px solid #e0e0e0;
241 | }
242 |
243 | .tr:last-child .td {
244 | border-bottom: 0;
245 | }
246 |
247 | .td:last-child {
248 | border-right: 1px solid #e0e0e0;
249 | }
250 |
251 | .th:last-child {
252 | border-right: 1px solid #e0e0e0;
253 | }
254 |
255 | .tr:first-child .td {
256 | border-top: 0;
257 | }
258 |
259 | .text-align-right {
260 | text-align: right;
261 | }
262 |
263 | .cell-padding {
264 | padding: 0.5rem;
265 | }
266 |
267 | .d-flex {
268 | display: flex;
269 | }
270 |
271 | .d-inline-block {
272 | display: inline-block;
273 | }
274 |
275 | .cursor-default {
276 | cursor: default;
277 | }
278 |
279 | .align-items-center {
280 | align-items: center;
281 | }
282 |
283 | .flex-wrap-wrap {
284 | flex-wrap: wrap;
285 | }
286 |
287 | .border-radius-md {
288 | border-radius: 5px;
289 | }
290 |
291 | .cursor-pointer {
292 | cursor: pointer;
293 | }
294 |
295 | .icon-margin {
296 | margin-right: 4px;
297 | }
298 |
299 | .font-weight-600 {
300 | font-weight: 600;
301 | }
302 |
303 | .font-weight-400 {
304 | font-weight: 400;
305 | }
306 |
307 | .font-size-75 {
308 | font-size: 0.75rem;
309 | }
310 |
311 | .flex-1 {
312 | flex: 1;
313 | }
314 |
315 | .mt-5 {
316 | margin-top: 0.5rem;
317 | }
318 |
319 | .mr-auto {
320 | margin-right: auto;
321 | }
322 |
323 | .ml-auto {
324 | margin-left: auto;
325 | }
326 |
327 | .mr-5 {
328 | margin-right: 0.5rem;
329 | }
330 |
331 | .justify-content-center {
332 | justify-content: center;
333 | }
334 |
335 | .flex-column {
336 | flex-direction: column;
337 | }
338 |
339 | .overflow-auto {
340 | overflow: auto;
341 | }
342 |
343 | .overflow-hidden {
344 | overflow: hidden;
345 | }
346 |
347 | .overflow-y-hidden {
348 | overflow-y: hidden;
349 | }
350 |
351 | .list-padding {
352 | padding: 4px 0px;
353 | }
354 |
355 | .bg-grey-200 {
356 | background-color: #eeeeee;
357 | }
358 |
359 | .color-grey-800 {
360 | color: #424242;
361 | }
362 |
363 | .color-grey-600 {
364 | color: #757575;
365 | }
366 |
367 | .color-grey-500 {
368 | color: #9e9e9e;
369 | }
370 |
371 | .border-radius-sm {
372 | border-radius: 4px;
373 | }
374 |
375 | .text-transform-uppercase {
376 | text-transform: uppercase;
377 | }
378 |
379 | .text-transform-capitalize {
380 | text-transform: capitalize;
381 | }
382 |
--------------------------------------------------------------------------------
/src/App.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useReducer } from 'react';
2 | import './style.css';
3 | import Table from './Table';
4 | import {
5 | randomColor,
6 | shortId,
7 | makeData,
8 | ActionTypes,
9 | DataTypes,
10 | } from './utils';
11 | import update from 'immutability-helper';
12 |
13 | function reducer(state, action) {
14 | switch (action.type) {
15 | case ActionTypes.ADD_OPTION_TO_COLUMN:
16 | const optionIndex = state.columns.findIndex(
17 | column => column.id === action.columnId
18 | );
19 | return update(state, {
20 | skipReset: { $set: true },
21 | columns: {
22 | [optionIndex]: {
23 | options: {
24 | $push: [
25 | {
26 | label: action.option,
27 | backgroundColor: action.backgroundColor,
28 | },
29 | ],
30 | },
31 | },
32 | },
33 | });
34 | case ActionTypes.ADD_ROW:
35 | return update(state, {
36 | skipReset: { $set: true },
37 | data: { $push: [{}] },
38 | });
39 | case ActionTypes.UPDATE_COLUMN_TYPE:
40 | const typeIndex = state.columns.findIndex(
41 | column => column.id === action.columnId
42 | );
43 | switch (action.dataType) {
44 | case DataTypes.NUMBER:
45 | if (state.columns[typeIndex].dataType === DataTypes.NUMBER) {
46 | return state;
47 | } else {
48 | return update(state, {
49 | skipReset: { $set: true },
50 | columns: { [typeIndex]: { dataType: { $set: action.dataType } } },
51 | data: {
52 | $apply: data =>
53 | data.map(row => ({
54 | ...row,
55 | [action.columnId]: isNaN(row[action.columnId])
56 | ? ''
57 | : Number.parseInt(row[action.columnId]),
58 | })),
59 | },
60 | });
61 | }
62 | case DataTypes.SELECT:
63 | if (state.columns[typeIndex].dataType === DataTypes.SELECT) {
64 | return state;
65 | } else {
66 | let options = [];
67 | state.data.forEach(row => {
68 | if (row[action.columnId]) {
69 | options.push({
70 | label: row[action.columnId],
71 | backgroundColor: randomColor(),
72 | });
73 | }
74 | });
75 | return update(state, {
76 | skipReset: { $set: true },
77 | columns: {
78 | [typeIndex]: {
79 | dataType: { $set: action.dataType },
80 | options: { $push: options },
81 | },
82 | },
83 | });
84 | }
85 | case DataTypes.TEXT:
86 | if (state.columns[typeIndex].dataType === DataTypes.TEXT) {
87 | return state;
88 | } else if (state.columns[typeIndex].dataType === DataTypes.SELECT) {
89 | return update(state, {
90 | skipReset: { $set: true },
91 | columns: { [typeIndex]: { dataType: { $set: action.dataType } } },
92 | });
93 | } else {
94 | return update(state, {
95 | skipReset: { $set: true },
96 | columns: { [typeIndex]: { dataType: { $set: action.dataType } } },
97 | data: {
98 | $apply: data =>
99 | data.map(row => ({
100 | ...row,
101 | [action.columnId]: row[action.columnId] + '',
102 | })),
103 | },
104 | });
105 | }
106 | default:
107 | return state;
108 | }
109 | case ActionTypes.UPDATE_COLUMN_HEADER:
110 | const index = state.columns.findIndex(
111 | column => column.id === action.columnId
112 | );
113 | return update(state, {
114 | skipReset: { $set: true },
115 | columns: { [index]: { label: { $set: action.label } } },
116 | });
117 | case ActionTypes.UPDATE_CELL:
118 | return update(state, {
119 | skipReset: { $set: true },
120 | data: {
121 | [action.rowIndex]: { [action.columnId]: { $set: action.value } },
122 | },
123 | });
124 | case ActionTypes.ADD_COLUMN_TO_LEFT:
125 | const leftIndex = state.columns.findIndex(
126 | column => column.id === action.columnId
127 | );
128 | let leftId = shortId();
129 | return update(state, {
130 | skipReset: { $set: true },
131 | columns: {
132 | $splice: [
133 | [
134 | leftIndex,
135 | 0,
136 | {
137 | id: leftId,
138 | label: 'Column',
139 | accessor: leftId,
140 | dataType: DataTypes.TEXT,
141 | created: action.focus && true,
142 | options: [],
143 | },
144 | ],
145 | ],
146 | },
147 | });
148 | case ActionTypes.ADD_COLUMN_TO_RIGHT:
149 | const rightIndex = state.columns.findIndex(
150 | column => column.id === action.columnId
151 | );
152 | const rightId = shortId();
153 | return update(state, {
154 | skipReset: { $set: true },
155 | columns: {
156 | $splice: [
157 | [
158 | rightIndex + 1,
159 | 0,
160 | {
161 | id: rightId,
162 | label: 'Column',
163 | accessor: rightId,
164 | dataType: DataTypes.TEXT,
165 | created: action.focus && true,
166 | options: [],
167 | },
168 | ],
169 | ],
170 | },
171 | });
172 | case ActionTypes.DELETE_COLUMN:
173 | const deleteIndex = state.columns.findIndex(
174 | column => column.id === action.columnId
175 | );
176 | return update(state, {
177 | skipReset: { $set: true },
178 | columns: { $splice: [[deleteIndex, 1]] },
179 | });
180 | case ActionTypes.ENABLE_RESET:
181 | return update(state, { skipReset: { $set: true } });
182 | default:
183 | return state;
184 | }
185 | }
186 |
187 | function App() {
188 | const [state, dispatch] = useReducer(reducer, makeData(1000));
189 |
190 | useEffect(() => {
191 | dispatch({ type: ActionTypes.ENABLE_RESET });
192 | }, [state.data, state.columns]);
193 |
194 | return (
195 |
203 |
204 |
Editable React Table - Demo
205 |
206 |
212 |
213 |
214 | );
215 | }
216 |
217 | export default App;
218 |
--------------------------------------------------------------------------------