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 @@
1
+ yarn lint-staged
@@ -0,0 +1,3 @@
1
+ dist
2
+ node_modules
3
+ coverage
package/.prettierrc ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "singleQuote": true,
3
+ "semi": true,
4
+ "trailingComma": "es5",
5
+ "printWidth": 100
6
+ }
package/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # EMS Grid
2
+
3
+ React grid demo with TanStack Table, virtualization, grouping, and exports.
4
+
5
+ ## Run locally
6
+
7
+ ```
8
+ yarn
9
+ yarn dev
10
+ ```
11
+
12
+ ## Create a new project
13
+
14
+ ```
15
+ yarn create emsgrid my-app
16
+ ```
17
+
18
+ ## Publish
19
+
20
+ Package:
21
+
22
+ ```
23
+ npm login
24
+ npm publish --access public
25
+ ```
26
+
27
+ Creator:
28
+
29
+ ```
30
+ cd create-emsgrid
31
+ npm login
32
+ npm publish --access public
33
+ ```
@@ -0,0 +1,44 @@
1
+ import js from '@eslint/js';
2
+ import tseslint from '@typescript-eslint/eslint-plugin';
3
+ import tsParser from '@typescript-eslint/parser';
4
+ import react from 'eslint-plugin-react';
5
+ import reactHooks from 'eslint-plugin-react-hooks';
6
+ import reactRefresh from 'eslint-plugin-react-refresh';
7
+ import globals from 'globals';
8
+
9
+ export default [
10
+ { ignores: ['dist', 'node_modules'] },
11
+ js.configs.recommended,
12
+ {
13
+ files: ['**/*.{ts,tsx}'],
14
+ languageOptions: {
15
+ parser: tsParser,
16
+ parserOptions: {
17
+ ecmaVersion: 'latest',
18
+ sourceType: 'module',
19
+ ecmaFeatures: { jsx: true },
20
+ },
21
+ globals: {
22
+ ...globals.browser,
23
+ ...globals.es2021,
24
+ },
25
+ },
26
+ settings: { react: { version: 'detect' } },
27
+ plugins: {
28
+ '@typescript-eslint': tseslint,
29
+ react,
30
+ 'react-hooks': reactHooks,
31
+ 'react-refresh': reactRefresh,
32
+ },
33
+ rules: {
34
+ ...tseslint.configs['flat/recommended'].rules,
35
+ ...react.configs.flat.recommended.rules,
36
+ ...reactHooks.configs.flat.recommended.rules,
37
+ ...reactRefresh.configs.recommended.rules,
38
+ 'no-unused-vars': 'off',
39
+ '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
40
+ 'react-hooks/incompatible-library': 'off',
41
+ 'react-hooks/refs': 'off',
42
+ },
43
+ },
44
+ ];
package/index.html ADDED
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="ru">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>TanStack Grid Mini</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "create-emsgrid",
3
+ "private": false,
4
+ "version": "0.1.0",
5
+ "description": "Create an EMS Grid project",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "create-emsgrid": "index.js"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/Resg/table.git"
13
+ },
14
+ "homepage": "https://github.com/Resg/table",
15
+ "bugs": {
16
+ "url": "https://github.com/Resg/table/issues"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "type": "module",
22
+ "scripts": {
23
+ "dev": "vite",
24
+ "build": "tsc -b && vite build",
25
+ "preview": "vite preview",
26
+ "lint": "eslint \"src/**/*.{ts,tsx}\"",
27
+ "format": "prettier --check .",
28
+ "format:write": "prettier --write .",
29
+ "lint-staged": "lint-staged",
30
+ "prepare": "husky"
31
+ },
32
+ "lint-staged": {
33
+ "*.{ts,tsx}": [
34
+ "eslint --fix",
35
+ "prettier --write"
36
+ ],
37
+ "*.{js,jsx,cjs,mjs,css,md,json,yml,yaml}": [
38
+ "prettier --write"
39
+ ]
40
+ },
41
+ "dependencies": {
42
+ "@reduxjs/toolkit": "^2.2.7",
43
+ "@tanstack/react-table": "^8.20.5",
44
+ "@tanstack/react-virtual": "^3.10.8",
45
+ "react": "^18.3.1",
46
+ "react-dom": "^18.3.1",
47
+ "react-redux": "^9.1.2",
48
+ "xlsx": "^0.18.5"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^25.0.9",
52
+ "@types/react": "^18.3.3",
53
+ "@types/react-dom": "^18.3.0",
54
+ "@typescript-eslint/eslint-plugin": "^8.53.1",
55
+ "@typescript-eslint/parser": "^8.53.1",
56
+ "@vitejs/plugin-react": "^4.3.1",
57
+ "eslint": "^9.39.2",
58
+ "eslint-plugin-react": "^7.37.5",
59
+ "eslint-plugin-react-hooks": "^7.0.1",
60
+ "eslint-plugin-react-refresh": "^0.4.26",
61
+ "husky": "^9.1.7",
62
+ "lint-staged": "^16.2.7",
63
+ "prettier": "^3.8.0",
64
+ "typescript": "^5.5.4",
65
+ "vite": "^5.4.2"
66
+ }
67
+ }
package/plan.md ADDED
@@ -0,0 +1,51 @@
1
+ # План разработки (TanStack Grid Mini)
2
+
3
+ Цель: разработать новый компонент таблицы на базе **@tanstack/react-table** + **@tanstack/react-virtual**.
4
+ Проект автономный (Vite + React + TS). Данные и настройки — через RTK Query (моки).
5
+
6
+ ## Итерация 0 — инфраструктура (сделано)
7
+
8
+ - Vite + React + TS
9
+ - Redux store
10
+ - RTK Query mock API (данные + настройки)
11
+ - Базовый Grid с виртуализацией, сортировкой, selection, ресайзом колонок
12
+
13
+ ## Итерация 1 — “MVP таблицы”
14
+
15
+ - Виртуализация строк (фиксированный rowHeight)
16
+ - Sticky header
17
+ - Сортировка (client)
18
+ - Выбор строк (multi, checkbox)
19
+ - Column sizing (ресайз)
20
+ - Column reordering (dnd)
21
+ - Сохранение настроек: columnSizing, columnVisibility, sorting, rowSelection
22
+
23
+ ## Итерация 2 — фильтры
24
+
25
+ - Text filter (debounce)
26
+ - Set/exclude filter
27
+ - UI для фильтров в header или popover
28
+
29
+ ## Итерация 3 — дерево Parent
30
+
31
+ - Построение дерева по parentId -> subRows
32
+ - Expand/collapse + expand all/collapse all
33
+ - Selection propagation (родители/дети)
34
+
35
+ ## Итерация 4 — grouping tree
36
+
37
+ - Grouping по выбранным колонкам
38
+ - Групповые строки + счетчики
39
+
40
+ ## Итерация 5 — контекстное меню, экспорт, хоткеи
41
+
42
+ - Контекстное меню (на строке/хедере)
43
+ - Экспорт (CSV)
44
+ - Клавиатурная навигация (базовая)
45
+
46
+ ## Правила производительности
47
+
48
+ - columns/data — стабилизировать (useMemo)
49
+ - Row/Cell — memo
50
+ - тяжелые вычисления (tree/group) — memo + кеши
51
+ - overscan 10–20, rowHeight фиксированный по умолчанию
package/src/App.tsx ADDED
@@ -0,0 +1,230 @@
1
+ import React from 'react';
2
+ import type { ColumnDef, Table } from '@tanstack/react-table';
3
+ import { Grid } from '@/components/Grid/core/Grid';
4
+ import type { GridMode, GridSettings } from '@/components/Grid/core/types';
5
+ import { createSelectColumn } from '@/components/Grid/ui';
6
+ import { useGetPeopleQuery, useGetSettingsQuery, useSaveSettingsMutation } from '@/store/gridApi';
7
+ import type { PersonRow } from '@/mocks/people';
8
+ import { exportTableToXlsx } from '@/components/Grid/features/export/exportXlsx';
9
+
10
+ const GRID_ID = 'people-grid';
11
+
12
+ const DEFAULT_SETTINGS: GridSettings = {
13
+ version: 1,
14
+ columnSizing: { select: 46, name: 220, age: 80, country: 100, department: 140 },
15
+ columnOrder: [],
16
+ columnVisibility: {},
17
+ sorting: [],
18
+ rowSelection: {},
19
+ columnFilters: [],
20
+ expanded: {},
21
+ grouping: [],
22
+ pagination: { pageIndex: 0, pageSize: 100 },
23
+ mode: 'flat',
24
+ };
25
+
26
+ function useDebounced<T extends (..._args: any[]) => void>(fn: T, wait = 250) {
27
+ const fnRef = React.useRef(fn);
28
+ const timeoutRef = React.useRef<number | undefined>(undefined);
29
+ React.useEffect(() => {
30
+ fnRef.current = fn;
31
+ }, [fn]);
32
+
33
+ return React.useMemo(() => {
34
+ return (...args: Parameters<T>) => {
35
+ window.clearTimeout(timeoutRef.current);
36
+ timeoutRef.current = window.setTimeout(() => fnRef.current(...args), wait);
37
+ };
38
+ }, [wait]);
39
+ }
40
+
41
+ export function App() {
42
+ const [localSettings, setLocalSettings] = React.useState<GridSettings>(DEFAULT_SETTINGS);
43
+ const { data: peopleData, isFetching: isFetchingPeople } = useGetPeopleQuery({
44
+ page: localSettings.pagination.pageIndex,
45
+ size: localSettings.pagination.pageSize,
46
+ });
47
+
48
+ const { data: settingsData, isFetching: isFetchingSettings } = useGetSettingsQuery({
49
+ gridId: GRID_ID,
50
+ });
51
+ const [saveSettings] = useSaveSettingsMutation();
52
+
53
+ React.useEffect(() => {
54
+ if (settingsData) {
55
+ setLocalSettings({
56
+ ...DEFAULT_SETTINGS,
57
+ ...settingsData,
58
+ columnSizing: settingsData.columnSizing ?? DEFAULT_SETTINGS.columnSizing,
59
+ columnOrder: settingsData.columnOrder ?? DEFAULT_SETTINGS.columnOrder,
60
+ columnVisibility: settingsData.columnVisibility ?? DEFAULT_SETTINGS.columnVisibility,
61
+ sorting: settingsData.sorting ?? DEFAULT_SETTINGS.sorting,
62
+ rowSelection: settingsData.rowSelection ?? DEFAULT_SETTINGS.rowSelection,
63
+ columnFilters: settingsData.columnFilters ?? DEFAULT_SETTINGS.columnFilters,
64
+ expanded: settingsData.expanded ?? DEFAULT_SETTINGS.expanded,
65
+ grouping: settingsData.grouping ?? DEFAULT_SETTINGS.grouping,
66
+ pagination: settingsData.pagination ?? DEFAULT_SETTINGS.pagination,
67
+ mode: settingsData.mode ?? DEFAULT_SETTINGS.mode,
68
+ });
69
+ } else {
70
+ setLocalSettings(DEFAULT_SETTINGS);
71
+ }
72
+ }, [settingsData]);
73
+
74
+ const debouncedSave = useDebounced((next: GridSettings) => {
75
+ saveSettings({ gridId: GRID_ID, settings: next });
76
+ }, 300);
77
+
78
+ const settings = localSettings;
79
+
80
+ const columns = React.useMemo<ColumnDef<PersonRow, any>[]>(() => {
81
+ return [
82
+ createSelectColumn(),
83
+ { accessorKey: 'name', id: 'name', header: 'Name', size: 220 },
84
+ {
85
+ accessorKey: 'age',
86
+ id: 'age',
87
+ header: 'Age',
88
+ size: 80,
89
+ filterFn: (row, columnId, value) =>
90
+ String(row.getValue(columnId) ?? '').includes(String(value ?? '')),
91
+ },
92
+ { accessorKey: 'country', id: 'country', header: 'Country', size: 100 },
93
+ { accessorKey: 'department', id: 'department', header: 'Department', size: 140 },
94
+ ];
95
+ }, []);
96
+
97
+ const people = peopleData?.rows ?? [];
98
+ const totalRows = peopleData?.total ?? 0;
99
+ const isLoading = isFetchingPeople || isFetchingSettings;
100
+
101
+ const onSettingsChange = React.useCallback(
102
+ (next: GridSettings) => {
103
+ setLocalSettings(next);
104
+ debouncedSave(next);
105
+ },
106
+ [debouncedSave]
107
+ );
108
+ const tableRef = React.useRef<Table<PersonRow> | null>(null);
109
+ const handleTableReady = React.useCallback((table: Table<PersonRow>) => {
110
+ tableRef.current = table;
111
+ }, []);
112
+ const handleExport = React.useCallback((scope: 'page' | 'allFiltered') => {
113
+ const table = tableRef.current;
114
+ if (!table) return;
115
+ exportTableToXlsx({ table, fileName: `people-${scope}`, scope });
116
+ }, []);
117
+
118
+ const [showFilters, setShowFilters] = React.useState(true);
119
+ const [expandAllByDefault, setExpandAllByDefault] = React.useState(false);
120
+ const enableExportXlsx = true;
121
+ const handleModeChange = React.useCallback(
122
+ (e: React.ChangeEvent<HTMLSelectElement>) => {
123
+ const nextMode = e.target.value as GridMode;
124
+ const nextSettings = { ...settings, mode: nextMode, expanded: {} };
125
+ if (settings.mode === 'group' && nextMode !== 'group') {
126
+ nextSettings.grouping = [];
127
+ }
128
+ onSettingsChange(nextSettings);
129
+ },
130
+ [onSettingsChange, settings]
131
+ );
132
+ const handleGroupingToggle = React.useCallback(
133
+ (columnId: string) => {
134
+ const hasColumn = settings.grouping.includes(columnId);
135
+ const next = hasColumn
136
+ ? settings.grouping.filter((id) => id !== columnId)
137
+ : [...settings.grouping, columnId];
138
+ onSettingsChange({ ...settings, grouping: next, expanded: {} });
139
+ },
140
+ [onSettingsChange, settings]
141
+ );
142
+ const handleClearGrouping = React.useCallback(() => {
143
+ onSettingsChange({ ...settings, grouping: [], expanded: {} });
144
+ }, [onSettingsChange, settings]);
145
+
146
+ return (
147
+ <div className="container">
148
+ <div className="card">
149
+ <h1 className="h1">TanStack Grid Mini</h1>
150
+ <div style={{ opacity: 0.75, marginBottom: 10 }}>
151
+ rows: {people.length.toLocaleString()} • loading: {String(isLoading)}
152
+ </div>
153
+ <button
154
+ className="filter-toggle"
155
+ type="button"
156
+ onClick={() => setShowFilters((prev) => !prev)}
157
+ >
158
+ {showFilters ? 'Hide filters' : 'Show filters'}
159
+ </button>
160
+ <label className="expand-toggle">
161
+ <input
162
+ type="checkbox"
163
+ checked={expandAllByDefault}
164
+ onChange={(e) => setExpandAllByDefault(e.target.checked)}
165
+ />
166
+ Expand all
167
+ </label>
168
+ <select className="mode-toggle" value={settings.mode} onChange={handleModeChange}>
169
+ <option value="flat">Flat</option>
170
+ <option value="parent">Parent</option>
171
+ <option value="group">Group</option>
172
+ </select>
173
+ <button className="export-toggle" type="button" onClick={() => handleExport('page')}>
174
+ Export page (xlsx)
175
+ </button>
176
+ <button className="export-toggle" type="button" onClick={() => handleExport('allFiltered')}>
177
+ Export all (xlsx)
178
+ </button>
179
+ {settings.mode === 'group' && (
180
+ <div className="grouping-panel">
181
+ <span className="grouping-label">Group by:</span>
182
+ <label className="grouping-item">
183
+ <input
184
+ type="checkbox"
185
+ checked={settings.grouping.includes('country')}
186
+ onChange={() => handleGroupingToggle('country')}
187
+ />
188
+ Country
189
+ </label>
190
+ <label className="grouping-item">
191
+ <input
192
+ type="checkbox"
193
+ checked={settings.grouping.includes('department')}
194
+ onChange={() => handleGroupingToggle('department')}
195
+ />
196
+ Department
197
+ </label>
198
+ <button className="grouping-clear" type="button" onClick={handleClearGrouping}>
199
+ Clear grouping
200
+ </button>
201
+ </div>
202
+ )}
203
+
204
+ <Grid<PersonRow>
205
+ gridId={GRID_ID}
206
+ data={people}
207
+ columns={columns}
208
+ isLoading={isLoading}
209
+ totalRows={totalRows}
210
+ settings={settings}
211
+ onSettingsChange={onSettingsChange}
212
+ showFilters={showFilters}
213
+ expandAllByDefault={expandAllByDefault}
214
+ enableExportXlsx={enableExportXlsx}
215
+ onTableReady={handleTableReady}
216
+ getRowContextMenuItems={({ row, close }) => [
217
+ <button key="details" type="button" onClick={() => close()}>
218
+ Details for {String(row.original?.id ?? row.id)}
219
+ </button>,
220
+ <button key="remove" type="button" onClick={() => close()}>
221
+ Remove row
222
+ </button>,
223
+ ]}
224
+ rowHeight={36}
225
+ overscan={14}
226
+ />
227
+ </div>
228
+ </div>
229
+ );
230
+ }
@@ -0,0 +1,151 @@
1
+ import React from 'react';
2
+ import type { GridProps } from './types';
3
+ import { useGridTable } from './createTable';
4
+ import { useRowVirtualizer } from '../features/virtualization/useRowVirtualizer';
5
+ import { useColumnReorder } from '../features/columns/useColumnReorder';
6
+ import { Header } from '../ui/Header';
7
+ import { Row } from '../ui/Row';
8
+ import { Pagination } from '../ui/Pagination';
9
+ import { buildParentTree } from '../features/tree/buildParentTree';
10
+ import { ContextMenu } from '../ui/ContextMenu';
11
+
12
+ export function Grid<TData>(props: GridProps<TData>) {
13
+ const {
14
+ data,
15
+ totalRows,
16
+ columns,
17
+ settings,
18
+ onSettingsChange,
19
+ isLoading = false,
20
+ rowHeight = 36,
21
+ overscan = 12,
22
+ showFilters = true,
23
+ renderAggregatedCell,
24
+ expandAllByDefault = false,
25
+ enableExportXlsx = true,
26
+ getRowContextMenuItems,
27
+ onTableReady,
28
+ } = props;
29
+
30
+ const isParentMode = settings.mode === 'parent';
31
+ const isGroupMode = settings.mode === 'group';
32
+ const tableData = React.useMemo(() => {
33
+ if (!isParentMode) return data;
34
+ return buildParentTree(
35
+ data,
36
+ (row) => String((row as { id: string }).id),
37
+ (row) => (row as { parentId?: string | null }).parentId
38
+ );
39
+ }, [data, isParentMode]);
40
+
41
+ const getSubRows = React.useCallback((row: TData) => {
42
+ const node = row as { subRows?: TData[] };
43
+ return node.subRows;
44
+ }, []);
45
+
46
+ const expandedState = React.useMemo(() => {
47
+ if (!expandAllByDefault || !isParentMode) return settings.expanded;
48
+ if (settings.expanded === true) return settings.expanded;
49
+ const expandedMap = settings.expanded as Record<string, boolean> | undefined;
50
+ const isEmpty = !expandedMap || Object.keys(expandedMap).length === 0;
51
+ return isEmpty ? true : settings.expanded;
52
+ }, [expandAllByDefault, isParentMode, settings.expanded]);
53
+
54
+ const table = useGridTable<TData>({
55
+ data: tableData,
56
+ columns,
57
+ settings,
58
+ onSettingsChange,
59
+ showFilters,
60
+ getSubRows: isParentMode ? getSubRows : undefined,
61
+ expandedStateOverride: expandedState,
62
+ rowCount: totalRows,
63
+ });
64
+ React.useEffect(() => {
65
+ if (!enableExportXlsx || !onTableReady) return;
66
+ onTableReady(table);
67
+ }, [enableExportXlsx, onTableReady, table]);
68
+
69
+ const { handleHeaderDragStart, handleHeaderDrop, isReorderable } = useColumnReorder(table, {
70
+ disabledColumnIds: ['select'],
71
+ });
72
+ const { parentRef, rows, totalSize, virtualItems } = useRowVirtualizer({
73
+ table,
74
+ rowHeight,
75
+ overscan,
76
+ });
77
+ const firstContentColumnId = table.getVisibleLeafColumns().find((col) => col.id !== 'select')?.id;
78
+ const [menu, setMenu] = React.useState<{
79
+ open: boolean;
80
+ x: number;
81
+ y: number;
82
+ rowId?: string;
83
+ }>({ open: false, x: 0, y: 0 });
84
+ const closeMenu = React.useCallback(() => {
85
+ setMenu({ open: false, x: 0, y: 0 });
86
+ }, []);
87
+ const menuRow = React.useMemo(() => {
88
+ if (!menu.open || !menu.rowId) return undefined;
89
+ return table.getRowModel().rows.find((row) => row.id === menu.rowId);
90
+ }, [menu.open, menu.rowId, table]);
91
+ const menuItems = React.useMemo(() => {
92
+ if (!menu.open || !menuRow || !getRowContextMenuItems) return null;
93
+ return getRowContextMenuItems({ table, row: menuRow, close: closeMenu });
94
+ }, [closeMenu, getRowContextMenuItems, menu.open, menuRow, table]);
95
+
96
+ return (
97
+ <div className="grid-shell">
98
+ <Header
99
+ table={table}
100
+ showFilters={showFilters}
101
+ onHeaderDragStart={handleHeaderDragStart}
102
+ onHeaderDrop={handleHeaderDrop}
103
+ isReorderableColumn={isReorderable}
104
+ />
105
+
106
+ <div ref={parentRef} className="scroll">
107
+ <div style={{ height: totalSize, position: 'relative' }}>
108
+ {isLoading ? (
109
+ <div style={{ padding: 12, opacity: 0.8 }}>Загрузка…</div>
110
+ ) : (
111
+ virtualItems.map((vi) => {
112
+ const row = rows[vi.index];
113
+ if (!row) return null;
114
+ return (
115
+ <Row
116
+ key={row.id}
117
+ row={row}
118
+ top={vi.start}
119
+ rowHeight={rowHeight}
120
+ firstContentColumnId={firstContentColumnId}
121
+ renderAggregatedCell={renderAggregatedCell}
122
+ isGroupMode={isGroupMode}
123
+ onContextMenu={(event) => {
124
+ if (!getRowContextMenuItems) return;
125
+ event.preventDefault();
126
+ setMenu({
127
+ open: true,
128
+ x: event.clientX,
129
+ y: event.clientY,
130
+ rowId: row.id,
131
+ });
132
+ }}
133
+ />
134
+ );
135
+ })
136
+ )}
137
+ </div>
138
+ </div>
139
+ {settings.mode === 'flat' ? <Pagination table={table} /> : null}
140
+ {menuItems && menuItems.length > 0 ? (
141
+ <ContextMenu open={menu.open} x={menu.x} y={menu.y} onClose={closeMenu}>
142
+ {menuItems.map((node, index) => (
143
+ <div key={index} className="menu-item">
144
+ {node}
145
+ </div>
146
+ ))}
147
+ </ContextMenu>
148
+ ) : null}
149
+ </div>
150
+ );
151
+ }