create-emsgrid 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.
Files changed (41) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.prettierignore +3 -0
  3. package/.prettierrc +6 -0
  4. package/README.md +33 -0
  5. package/eslint.config.js +44 -0
  6. package/index.html +12 -0
  7. package/package.json +67 -0
  8. package/plan.md +51 -0
  9. package/src/App.tsx +230 -0
  10. package/src/components/Grid/core/Grid.tsx +151 -0
  11. package/src/components/Grid/core/createTable.ts +152 -0
  12. package/src/components/Grid/core/settings.ts +5 -0
  13. package/src/components/Grid/core/types.ts +56 -0
  14. package/src/components/Grid/features/columns/useColumnReorder.ts +48 -0
  15. package/src/components/Grid/features/contextMenu/index.ts +1 -0
  16. package/src/components/Grid/features/export/exportXlsx.ts +55 -0
  17. package/src/components/Grid/features/filtering/FilterInput.tsx +34 -0
  18. package/src/components/Grid/features/pagination/index.ts +1 -0
  19. package/src/components/Grid/features/selection/index.ts +1 -0
  20. package/src/components/Grid/features/sorting/SortIndicator.tsx +11 -0
  21. package/src/components/Grid/features/toolbar/index.ts +1 -0
  22. package/src/components/Grid/features/tree/buildParentTree.ts +38 -0
  23. package/src/components/Grid/features/tree/index.ts +1 -0
  24. package/src/components/Grid/features/virtualization/useRowVirtualizer.ts +40 -0
  25. package/src/components/Grid/ui/Cell.tsx +93 -0
  26. package/src/components/Grid/ui/ContextMenu.tsx +43 -0
  27. package/src/components/Grid/ui/Header.tsx +68 -0
  28. package/src/components/Grid/ui/Pagination.tsx +65 -0
  29. package/src/components/Grid/ui/Panels/ColumnsPanel.tsx +3 -0
  30. package/src/components/Grid/ui/Panels/FiltersPanel.tsx +3 -0
  31. package/src/components/Grid/ui/Panels/GroupingPanel.tsx +3 -0
  32. package/src/components/Grid/ui/Row.tsx +50 -0
  33. package/src/components/Grid/ui/columns/SelectColumn.tsx +54 -0
  34. package/src/components/Grid/ui/index.ts +4 -0
  35. package/src/main.tsx +14 -0
  36. package/src/mocks/people.ts +34 -0
  37. package/src/store/gridApi.ts +76 -0
  38. package/src/store/store.ts +12 -0
  39. package/src/styles.css +259 -0
  40. package/tsconfig.json +24 -0
  41. package/vite.config.ts +13 -0
@@ -0,0 +1,65 @@
1
+ import React from 'react';
2
+ import type { Table } from '@tanstack/react-table';
3
+
4
+ type PaginationProps<TData> = {
5
+ table: Table<TData>;
6
+ };
7
+
8
+ const PAGE_SIZES = [10, 25, 50, 100, 200];
9
+
10
+ export function Pagination<TData>({ table }: PaginationProps<TData>) {
11
+ const { pageIndex, pageSize } = table.getState().pagination;
12
+ const pageCount = Math.max(table.getPageCount(), 1);
13
+
14
+ return (
15
+ <div className="pagination">
16
+ <button
17
+ className="pagination-button"
18
+ type="button"
19
+ onClick={() => table.previousPage()}
20
+ disabled={!table.getCanPreviousPage()}
21
+ >
22
+ Prev
23
+ </button>
24
+ <button
25
+ className="pagination-button"
26
+ type="button"
27
+ onClick={() => table.nextPage()}
28
+ disabled={!table.getCanNextPage()}
29
+ >
30
+ Next
31
+ </button>
32
+ <span className="pagination-info">
33
+ Page {pageIndex + 1} of {pageCount}
34
+ </span>
35
+ <label className="pagination-size">
36
+ Page size
37
+ <select
38
+ value={pageSize}
39
+ onChange={(event) => table.setPageSize(Number(event.target.value))}
40
+ >
41
+ {PAGE_SIZES.map((size) => (
42
+ <option key={size} value={size}>
43
+ {size}
44
+ </option>
45
+ ))}
46
+ </select>
47
+ </label>
48
+ <label className="pagination-jump">
49
+ Go to
50
+ <input
51
+ type="number"
52
+ min={1}
53
+ max={pageCount}
54
+ value={pageIndex + 1}
55
+ onChange={(event) => {
56
+ const value = Number(event.target.value);
57
+ if (!Number.isFinite(value)) return;
58
+ const nextIndex = Math.min(Math.max(value - 1, 0), pageCount - 1);
59
+ table.setPageIndex(nextIndex);
60
+ }}
61
+ />
62
+ </label>
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,3 @@
1
+ export function ColumnsPanel() {
2
+ return null;
3
+ }
@@ -0,0 +1,3 @@
1
+ export function FiltersPanel() {
2
+ return null;
3
+ }
@@ -0,0 +1,3 @@
1
+ export function GroupingPanel() {
2
+ return null;
3
+ }
@@ -0,0 +1,50 @@
1
+ import React from 'react';
2
+ import type { Row as TableRow, Cell as TableCell } from '@tanstack/react-table';
3
+ import { Cell } from './Cell';
4
+
5
+ type RowProps<TData> = {
6
+ row: TableRow<TData>;
7
+ top: number;
8
+ rowHeight: number;
9
+ firstContentColumnId?: string;
10
+ renderAggregatedCell?: (cell: TableCell<TData, unknown>) => React.ReactNode;
11
+ isGroupMode?: boolean;
12
+ onContextMenu?: (event: React.MouseEvent<HTMLDivElement>) => void;
13
+ };
14
+
15
+ export function Row<TData>({
16
+ row,
17
+ top,
18
+ rowHeight,
19
+ firstContentColumnId,
20
+ renderAggregatedCell,
21
+ isGroupMode,
22
+ onContextMenu,
23
+ }: RowProps<TData>) {
24
+ return (
25
+ <div
26
+ className="row"
27
+ style={{
28
+ position: 'absolute',
29
+ top,
30
+ left: 0,
31
+ right: 0,
32
+ height: rowHeight,
33
+ }}
34
+ onContextMenu={onContextMenu}
35
+ >
36
+ {row.getVisibleCells().map((cell) => {
37
+ const isExpanderCell = firstContentColumnId === cell.column.id;
38
+ return (
39
+ <Cell
40
+ key={cell.id}
41
+ cell={cell}
42
+ isExpanderCell={isExpanderCell}
43
+ renderAggregatedCell={renderAggregatedCell}
44
+ isGroupMode={isGroupMode}
45
+ />
46
+ );
47
+ })}
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,54 @@
1
+ /* eslint-disable react-refresh/only-export-components */
2
+ import React from 'react';
3
+ import type { ColumnDef, Row, Table } from '@tanstack/react-table';
4
+
5
+ type SelectColumnOptions = {
6
+ id?: string;
7
+ size?: number;
8
+ };
9
+
10
+ function SelectAllCheckbox<TData>({ table }: { table: Table<TData> }) {
11
+ return (
12
+ <input
13
+ className="checkbox"
14
+ type="checkbox"
15
+ checked={table.getIsAllRowsSelected()}
16
+ ref={(el) => {
17
+ if (!el) return;
18
+ el.indeterminate = table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected();
19
+ }}
20
+ onChange={table.getToggleAllRowsSelectedHandler()}
21
+ />
22
+ );
23
+ }
24
+
25
+ function SelectRowCheckbox<TData>({ row }: { row: Row<TData> }) {
26
+ return (
27
+ <input
28
+ className="checkbox"
29
+ type="checkbox"
30
+ checked={row.getIsSelected()}
31
+ ref={(el) => {
32
+ if (!el) return;
33
+ el.indeterminate = row.getIsSomeSelected() && !row.getIsSelected();
34
+ }}
35
+ onChange={row.getToggleSelectedHandler()}
36
+ />
37
+ );
38
+ }
39
+
40
+ export function createSelectColumn<TData>(
41
+ options: SelectColumnOptions = {}
42
+ ): ColumnDef<TData, unknown> {
43
+ const { id = 'select', size = 46 } = options;
44
+
45
+ return {
46
+ id,
47
+ header: ({ table }) => <SelectAllCheckbox table={table} />,
48
+ cell: ({ row }) => <SelectRowCheckbox row={row} />,
49
+ size,
50
+ enableSorting: false,
51
+ enableResizing: false,
52
+ enableColumnFilter: false,
53
+ };
54
+ }
@@ -0,0 +1,4 @@
1
+ export { Header } from './Header';
2
+ export { Row } from './Row';
3
+ export { Cell } from './Cell';
4
+ export { createSelectColumn } from './columns/SelectColumn';
package/src/main.tsx ADDED
@@ -0,0 +1,14 @@
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import { Provider } from 'react-redux';
4
+ import { store } from './store/store';
5
+ import { App } from './App';
6
+ import './styles.css';
7
+
8
+ ReactDOM.createRoot(document.getElementById('root')!).render(
9
+ <React.StrictMode>
10
+ <Provider store={store}>
11
+ <App />
12
+ </Provider>
13
+ </React.StrictMode>
14
+ );
@@ -0,0 +1,34 @@
1
+ export type PersonRow = {
2
+ id: string;
3
+ parentId?: string;
4
+ name: string;
5
+ age: number;
6
+ country: string;
7
+ department: string;
8
+ };
9
+
10
+ const countries = ['DE', 'PL', 'FR', 'ES', 'IT', 'US', 'CA'] as const;
11
+ const depts = ['Ops', 'Dev', 'QA', 'HR', 'Finance'] as const;
12
+
13
+ function mulberry32(a: number) {
14
+ return function () {
15
+ let t = (a += 0x6d2b79f5);
16
+ t = Math.imul(t ^ (t >>> 15), t | 1);
17
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
18
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
19
+ };
20
+ }
21
+
22
+ export function makePeople(count = 5000, seed = 42): PersonRow[] {
23
+ const rnd = mulberry32(seed);
24
+ const rows: PersonRow[] = [];
25
+ for (let i = 0; i < count; i++) {
26
+ const id = String(i + 1);
27
+ const age = Math.floor(18 + rnd() * 48);
28
+ const country = countries[Math.floor(rnd() * countries.length)];
29
+ const department = depts[Math.floor(rnd() * depts.length)];
30
+ const parentId = i > 0 && i % 5 === 0 ? String(i) : undefined;
31
+ rows.push({ id, parentId, name: `User ${id}`, age, country, department });
32
+ }
33
+ return rows;
34
+ }
@@ -0,0 +1,76 @@
1
+ import { createApi } from '@reduxjs/toolkit/query/react';
2
+ import type { BaseQueryFn } from '@reduxjs/toolkit/query';
3
+ import type { GridSettings } from '@/components/Grid/core/types';
4
+ import { makePeople, type PersonRow } from '@/mocks/people';
5
+
6
+ type PeopleArgs = { page: number; size: number };
7
+ type SettingsArgs = { gridId: string };
8
+ type SaveSettingsArgs = { gridId: string; settings: GridSettings };
9
+ type BaseQueryArgs = {
10
+ url: string;
11
+ method?: 'GET' | 'POST';
12
+ body?: unknown;
13
+ params?: Record<string, unknown>;
14
+ };
15
+ type BaseQueryError = { status: number; data: unknown };
16
+
17
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
18
+
19
+ const mockDb = {
20
+ people: makePeople(20000),
21
+ settingsByGridId: new Map<string, GridSettings>(),
22
+ };
23
+
24
+ export const gridApi = createApi({
25
+ reducerPath: 'gridApi',
26
+ baseQuery: (async (arg: BaseQueryArgs) => {
27
+ const { url, method = 'GET', body, params } = arg ?? {};
28
+ await sleep(120);
29
+
30
+ try {
31
+ if (url === '/people' && method === 'GET') {
32
+ const page = Number(params?.page ?? 0);
33
+ const size = Number(params?.size ?? 100);
34
+ const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 0;
35
+ const safeSize = Number.isFinite(size) && size > 0 ? Math.floor(size) : 100;
36
+ const start = safePage * safeSize;
37
+ const end = start + safeSize;
38
+ const rows = mockDb.people.slice(start, end) as PersonRow[];
39
+ return { data: { rows, total: mockDb.people.length } };
40
+ }
41
+
42
+ if (url === '/settings' && method === 'GET') {
43
+ const gridId = String(params?.gridId ?? '');
44
+ const data = mockDb.settingsByGridId.get(gridId) ?? null;
45
+ return { data };
46
+ }
47
+
48
+ if (url === '/settings' && method === 'POST') {
49
+ const { gridId, settings } = body as SaveSettingsArgs;
50
+ mockDb.settingsByGridId.set(gridId, settings);
51
+ return { data: { ok: true } };
52
+ }
53
+
54
+ return { error: { status: 404, data: 'Not found' } };
55
+ } catch (e) {
56
+ return { error: { status: 500, data: String((e as Error)?.message ?? e) } };
57
+ }
58
+ }) as BaseQueryFn<BaseQueryArgs, unknown, BaseQueryError>,
59
+ endpoints: (build) => ({
60
+ getPeople: build.query<{ rows: PersonRow[]; total: number }, PeopleArgs>({
61
+ query: ({ page, size }) => ({ url: '/people', method: 'GET', params: { page, size } }),
62
+ }),
63
+ getSettings: build.query<GridSettings | null, SettingsArgs>({
64
+ query: ({ gridId }) => ({ url: '/settings', method: 'GET', params: { gridId } }),
65
+ }),
66
+ saveSettings: build.mutation<{ ok: true }, SaveSettingsArgs>({
67
+ query: ({ gridId, settings }) => ({
68
+ url: '/settings',
69
+ method: 'POST',
70
+ body: { gridId, settings },
71
+ }),
72
+ }),
73
+ }),
74
+ });
75
+
76
+ export const { useGetPeopleQuery, useGetSettingsQuery, useSaveSettingsMutation } = gridApi;
@@ -0,0 +1,12 @@
1
+ import { configureStore } from '@reduxjs/toolkit';
2
+ import { gridApi } from './gridApi';
3
+
4
+ export const store = configureStore({
5
+ reducer: {
6
+ [gridApi.reducerPath]: gridApi.reducer,
7
+ },
8
+ middleware: (getDefault) => getDefault().concat(gridApi.middleware),
9
+ });
10
+
11
+ export type RootState = ReturnType<typeof store.getState>;
12
+ export type AppDispatch = typeof store.dispatch;
package/src/styles.css ADDED
@@ -0,0 +1,259 @@
1
+ :root {
2
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
3
+ line-height: 1.4;
4
+ font-weight: 400;
5
+ }
6
+
7
+ body {
8
+ margin: 0;
9
+ background: #0b0d12;
10
+ color: #e8ecf3;
11
+ }
12
+
13
+ .container {
14
+ padding: 16px;
15
+ }
16
+
17
+ .card {
18
+ background: #121624;
19
+ border: 1px solid #242b3d;
20
+ border-radius: 12px;
21
+ padding: 12px;
22
+ }
23
+
24
+ .h1 {
25
+ font-size: 18px;
26
+ margin: 0 0 12px 0;
27
+ }
28
+
29
+ .filter-toggle {
30
+ margin: 0 0 10px 0;
31
+ padding: 6px 10px;
32
+ border: 1px solid #2a334a;
33
+ border-radius: 8px;
34
+ background: #0f1320;
35
+ color: inherit;
36
+ cursor: pointer;
37
+ }
38
+
39
+ .expand-toggle {
40
+ display: inline-flex;
41
+ align-items: center;
42
+ gap: 6px;
43
+ margin: 0 0 10px 8px;
44
+ font-size: 12px;
45
+ }
46
+
47
+ .mode-toggle {
48
+ margin: 0 0 10px 8px;
49
+ padding: 6px 10px;
50
+ border: 1px solid #2a334a;
51
+ border-radius: 8px;
52
+ background: #0f1320;
53
+ color: inherit;
54
+ }
55
+
56
+ .export-toggle {
57
+ margin: 0 0 10px 8px;
58
+ padding: 6px 10px;
59
+ border: 1px solid #2a334a;
60
+ border-radius: 8px;
61
+ background: #0f1320;
62
+ color: inherit;
63
+ cursor: pointer;
64
+ }
65
+
66
+ .grid-shell {
67
+ border: 1px solid #242b3d;
68
+ border-radius: 10px;
69
+ overflow: hidden;
70
+ }
71
+
72
+ .grid-header {
73
+ position: sticky;
74
+ top: 0;
75
+ z-index: 2;
76
+ background: #151a2a;
77
+ border-bottom: 1px solid #242b3d;
78
+ }
79
+
80
+ .grid-header .cell {
81
+ height: auto;
82
+ padding-top: 6px;
83
+ padding-bottom: 6px;
84
+ align-items: stretch;
85
+ }
86
+
87
+ .row {
88
+ display: flex;
89
+ align-items: center;
90
+ border-bottom: 1px solid #1b2233;
91
+ }
92
+
93
+ .cell {
94
+ display: flex;
95
+ align-items: center;
96
+ padding: 0 10px;
97
+ overflow: hidden;
98
+ white-space: nowrap;
99
+ text-overflow: ellipsis;
100
+ height: 36px;
101
+ box-sizing: border-box;
102
+ border-right: 1px solid #1b2233;
103
+ }
104
+
105
+ .cell:last-child {
106
+ border-right: none;
107
+ }
108
+
109
+ .header-cell {
110
+ font-weight: 600;
111
+ user-select: none;
112
+ flex-direction: column;
113
+ align-items: flex-start;
114
+ gap: 4px;
115
+ }
116
+ .header-cell button {
117
+ all: unset;
118
+ cursor: pointer;
119
+ width: 100%;
120
+ }
121
+
122
+ .resizer {
123
+ position: absolute;
124
+ right: 0;
125
+ top: 0;
126
+ width: 6px;
127
+ height: 100%;
128
+ cursor: col-resize;
129
+ }
130
+
131
+ .filter-input {
132
+ width: 100%;
133
+ height: 24px;
134
+ padding: 0 6px;
135
+ border: 1px solid #2a334a;
136
+ border-radius: 6px;
137
+ background: #0f1320;
138
+ color: inherit;
139
+ box-sizing: border-box;
140
+ }
141
+
142
+ .expander {
143
+ margin-right: 6px;
144
+ border: none;
145
+ background: transparent;
146
+ color: inherit;
147
+ cursor: pointer;
148
+ width: 16px;
149
+ height: 16px;
150
+ padding: 0;
151
+ }
152
+
153
+ .grouping-panel {
154
+ display: inline-flex;
155
+ align-items: center;
156
+ gap: 10px;
157
+ margin: 0 0 10px 10px;
158
+ }
159
+
160
+ .grouping-label {
161
+ opacity: 0.8;
162
+ }
163
+
164
+ .grouping-item {
165
+ display: inline-flex;
166
+ align-items: center;
167
+ gap: 6px;
168
+ font-size: 12px;
169
+ }
170
+
171
+ .grouping-clear {
172
+ padding: 4px 8px;
173
+ border: 1px solid #2a334a;
174
+ border-radius: 6px;
175
+ background: #0f1320;
176
+ color: inherit;
177
+ cursor: pointer;
178
+ }
179
+
180
+ .group-label {
181
+ font-weight: 600;
182
+ }
183
+
184
+ .scroll {
185
+ height: 520px;
186
+ overflow: auto;
187
+ position: relative;
188
+ }
189
+
190
+ .checkbox {
191
+ width: 14px;
192
+ height: 14px;
193
+ }
194
+
195
+ .context-menu {
196
+ position: fixed;
197
+ z-index: 10;
198
+ min-width: 160px;
199
+ padding: 6px;
200
+ border: 1px solid #2a334a;
201
+ border-radius: 8px;
202
+ background: #0f1320;
203
+ box-shadow: 0 8px 20px rgba(5, 8, 16, 0.4);
204
+ }
205
+
206
+ .menu-item {
207
+ padding: 6px 8px;
208
+ border-radius: 6px;
209
+ cursor: pointer;
210
+ }
211
+
212
+ .menu-item:hover {
213
+ background: #1b2233;
214
+ }
215
+
216
+ .pagination {
217
+ display: flex;
218
+ align-items: center;
219
+ gap: 8px;
220
+ padding: 8px 10px;
221
+ border-top: 1px solid #242b3d;
222
+ background: #111626;
223
+ font-size: 12px;
224
+ }
225
+
226
+ .pagination-button {
227
+ padding: 4px 8px;
228
+ border: 1px solid #2a334a;
229
+ border-radius: 6px;
230
+ background: #0f1320;
231
+ color: inherit;
232
+ cursor: pointer;
233
+ }
234
+
235
+ .pagination-button:disabled {
236
+ opacity: 0.5;
237
+ cursor: default;
238
+ }
239
+
240
+ .pagination-info {
241
+ opacity: 0.8;
242
+ }
243
+
244
+ .pagination-size,
245
+ .pagination-jump {
246
+ display: inline-flex;
247
+ align-items: center;
248
+ gap: 6px;
249
+ }
250
+
251
+ .pagination select,
252
+ .pagination input {
253
+ height: 26px;
254
+ padding: 0 6px;
255
+ border: 1px solid #2a334a;
256
+ border-radius: 6px;
257
+ background: #0f1320;
258
+ color: inherit;
259
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+
9
+ "moduleResolution": "Bundler",
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true,
12
+ "noEmit": true,
13
+
14
+ "jsx": "react-jsx",
15
+ "strict": true,
16
+ "types": ["vite/client"],
17
+
18
+ "baseUrl": ".",
19
+ "paths": {
20
+ "@/*": ["src/*"]
21
+ }
22
+ },
23
+ "include": ["src"]
24
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import { fileURLToPath, URL } from 'node:url';
4
+
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ server: { open: true },
8
+ resolve: {
9
+ alias: {
10
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
11
+ },
12
+ },
13
+ });