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.
- package/.husky/pre-commit +1 -0
- package/.prettierignore +3 -0
- package/.prettierrc +6 -0
- package/README.md +33 -0
- package/eslint.config.js +44 -0
- package/index.html +12 -0
- package/package.json +67 -0
- package/plan.md +51 -0
- package/src/App.tsx +230 -0
- package/src/components/Grid/core/Grid.tsx +151 -0
- package/src/components/Grid/core/createTable.ts +152 -0
- package/src/components/Grid/core/settings.ts +5 -0
- package/src/components/Grid/core/types.ts +56 -0
- package/src/components/Grid/features/columns/useColumnReorder.ts +48 -0
- package/src/components/Grid/features/contextMenu/index.ts +1 -0
- package/src/components/Grid/features/export/exportXlsx.ts +55 -0
- package/src/components/Grid/features/filtering/FilterInput.tsx +34 -0
- package/src/components/Grid/features/pagination/index.ts +1 -0
- package/src/components/Grid/features/selection/index.ts +1 -0
- package/src/components/Grid/features/sorting/SortIndicator.tsx +11 -0
- package/src/components/Grid/features/toolbar/index.ts +1 -0
- package/src/components/Grid/features/tree/buildParentTree.ts +38 -0
- package/src/components/Grid/features/tree/index.ts +1 -0
- package/src/components/Grid/features/virtualization/useRowVirtualizer.ts +40 -0
- package/src/components/Grid/ui/Cell.tsx +93 -0
- package/src/components/Grid/ui/ContextMenu.tsx +43 -0
- package/src/components/Grid/ui/Header.tsx +68 -0
- package/src/components/Grid/ui/Pagination.tsx +65 -0
- package/src/components/Grid/ui/Panels/ColumnsPanel.tsx +3 -0
- package/src/components/Grid/ui/Panels/FiltersPanel.tsx +3 -0
- package/src/components/Grid/ui/Panels/GroupingPanel.tsx +3 -0
- package/src/components/Grid/ui/Row.tsx +50 -0
- package/src/components/Grid/ui/columns/SelectColumn.tsx +54 -0
- package/src/components/Grid/ui/index.ts +4 -0
- package/src/main.tsx +14 -0
- package/src/mocks/people.ts +34 -0
- package/src/store/gridApi.ts +76 -0
- package/src/store/store.ts +12 -0
- package/src/styles.css +259 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +13 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getCoreRowModel,
|
|
3
|
+
getExpandedRowModel,
|
|
4
|
+
getFilteredRowModel,
|
|
5
|
+
getGroupedRowModel,
|
|
6
|
+
getSortedRowModel,
|
|
7
|
+
useReactTable,
|
|
8
|
+
} from '@tanstack/react-table';
|
|
9
|
+
import type { ColumnDef, ExpandedState, Table } from '@tanstack/react-table';
|
|
10
|
+
import type { GridSettings } from './types';
|
|
11
|
+
import { updateSettings } from './settings';
|
|
12
|
+
|
|
13
|
+
type CreateTableArgs<TData> = {
|
|
14
|
+
data: TData[];
|
|
15
|
+
columns: ColumnDef<TData, any>[];
|
|
16
|
+
settings: GridSettings;
|
|
17
|
+
onSettingsChange: (next: GridSettings) => void;
|
|
18
|
+
showFilters: boolean;
|
|
19
|
+
getSubRows?: (row: TData) => TData[] | undefined;
|
|
20
|
+
expandedStateOverride?: GridSettings['expanded'];
|
|
21
|
+
rowCount?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function normalizeColumnOrder(order: string[]) {
|
|
25
|
+
if (!order.length) return order;
|
|
26
|
+
const next = order.filter((id) => id !== 'select');
|
|
27
|
+
return ['select', ...next];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isExpandedEqual(a: ExpandedState, b: ExpandedState) {
|
|
31
|
+
if (a === b) return true;
|
|
32
|
+
if (typeof a === 'boolean' || typeof b === 'boolean') return a === b;
|
|
33
|
+
const aKeys = Object.keys(a);
|
|
34
|
+
const bKeys = Object.keys(b);
|
|
35
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
36
|
+
for (const key of aKeys) {
|
|
37
|
+
if (a[key] !== b[key]) return false;
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useGridTable<TData>(args: CreateTableArgs<TData>): Table<TData> {
|
|
43
|
+
const {
|
|
44
|
+
data,
|
|
45
|
+
columns,
|
|
46
|
+
settings,
|
|
47
|
+
onSettingsChange,
|
|
48
|
+
showFilters,
|
|
49
|
+
getSubRows,
|
|
50
|
+
expandedStateOverride,
|
|
51
|
+
rowCount,
|
|
52
|
+
} = args;
|
|
53
|
+
const columnOrder = normalizeColumnOrder(settings.columnOrder);
|
|
54
|
+
const isGroupMode = settings.mode === 'group';
|
|
55
|
+
const isExpandableMode = settings.mode !== 'flat';
|
|
56
|
+
const currentExpanded = expandedStateOverride ?? settings.expanded;
|
|
57
|
+
const safeRowCount = Number.isFinite(rowCount) ? Math.max(rowCount ?? 0, 0) : undefined;
|
|
58
|
+
const pageCount =
|
|
59
|
+
safeRowCount !== undefined
|
|
60
|
+
? Math.max(1, Math.ceil(safeRowCount / Math.max(settings.pagination.pageSize, 1)))
|
|
61
|
+
: undefined;
|
|
62
|
+
|
|
63
|
+
return useReactTable({
|
|
64
|
+
data,
|
|
65
|
+
columns,
|
|
66
|
+
getRowId: (row) => String((row as any).id),
|
|
67
|
+
state: {
|
|
68
|
+
sorting: settings.sorting,
|
|
69
|
+
columnVisibility: settings.columnVisibility,
|
|
70
|
+
columnSizing: settings.columnSizing,
|
|
71
|
+
columnOrder,
|
|
72
|
+
rowSelection: settings.rowSelection,
|
|
73
|
+
columnFilters: settings.columnFilters,
|
|
74
|
+
expanded: isExpandableMode ? currentExpanded : {},
|
|
75
|
+
grouping: isGroupMode ? settings.grouping : [],
|
|
76
|
+
pagination: settings.pagination,
|
|
77
|
+
},
|
|
78
|
+
manualPagination: true,
|
|
79
|
+
pageCount,
|
|
80
|
+
rowCount: safeRowCount,
|
|
81
|
+
enableColumnFilters: showFilters,
|
|
82
|
+
enableColumnResizing: true,
|
|
83
|
+
enableGrouping: true,
|
|
84
|
+
groupedColumnMode: false,
|
|
85
|
+
enableMultiSort: true,
|
|
86
|
+
autoResetAll: false,
|
|
87
|
+
autoResetExpanded: false,
|
|
88
|
+
filterFromLeafRows: true,
|
|
89
|
+
maxLeafRowFilterDepth: 99,
|
|
90
|
+
isMultiSortEvent: (e) => {
|
|
91
|
+
if (!e || typeof e !== 'object') return false;
|
|
92
|
+
return 'ctrlKey' in e && Boolean((e as MouseEvent).ctrlKey);
|
|
93
|
+
},
|
|
94
|
+
enableRowSelection: true,
|
|
95
|
+
onSortingChange: (updater) => {
|
|
96
|
+
const next = typeof updater === 'function' ? updater(settings.sorting) : updater;
|
|
97
|
+
onSettingsChange(updateSettings(settings, { sorting: next }));
|
|
98
|
+
},
|
|
99
|
+
onColumnVisibilityChange: (updater) => {
|
|
100
|
+
const next = typeof updater === 'function' ? updater(settings.columnVisibility) : updater;
|
|
101
|
+
onSettingsChange(updateSettings(settings, { columnVisibility: next }));
|
|
102
|
+
},
|
|
103
|
+
onColumnSizingChange: (updater) => {
|
|
104
|
+
const next = typeof updater === 'function' ? updater(settings.columnSizing) : updater;
|
|
105
|
+
onSettingsChange(updateSettings(settings, { columnSizing: next }));
|
|
106
|
+
},
|
|
107
|
+
onColumnOrderChange: (updater) => {
|
|
108
|
+
const next = typeof updater === 'function' ? updater(settings.columnOrder) : updater;
|
|
109
|
+
onSettingsChange(updateSettings(settings, { columnOrder: normalizeColumnOrder(next) }));
|
|
110
|
+
},
|
|
111
|
+
onRowSelectionChange: (updater) => {
|
|
112
|
+
const next = typeof updater === 'function' ? updater(settings.rowSelection) : updater;
|
|
113
|
+
onSettingsChange(updateSettings(settings, { rowSelection: next }));
|
|
114
|
+
},
|
|
115
|
+
onColumnFiltersChange: (updater) => {
|
|
116
|
+
const next = typeof updater === 'function' ? updater(settings.columnFilters) : updater;
|
|
117
|
+
onSettingsChange(updateSettings(settings, { columnFilters: next }));
|
|
118
|
+
},
|
|
119
|
+
onGroupingChange: (updater) => {
|
|
120
|
+
const next = typeof updater === 'function' ? updater(settings.grouping) : updater;
|
|
121
|
+
onSettingsChange(
|
|
122
|
+
updateSettings(settings, {
|
|
123
|
+
grouping: next,
|
|
124
|
+
pagination: { ...settings.pagination, pageIndex: 0 },
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
},
|
|
128
|
+
onExpandedChange: isExpandableMode
|
|
129
|
+
? (updater) => {
|
|
130
|
+
const next = typeof updater === 'function' ? updater(currentExpanded) : updater;
|
|
131
|
+
if (isExpandedEqual(next, currentExpanded)) return;
|
|
132
|
+
onSettingsChange(
|
|
133
|
+
updateSettings(settings, {
|
|
134
|
+
expanded: next,
|
|
135
|
+
pagination: { ...settings.pagination, pageIndex: 0 },
|
|
136
|
+
})
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
: undefined,
|
|
140
|
+
onPaginationChange: (updater) => {
|
|
141
|
+
const next = typeof updater === 'function' ? updater(settings.pagination) : updater;
|
|
142
|
+
onSettingsChange(updateSettings(settings, { pagination: next }));
|
|
143
|
+
},
|
|
144
|
+
columnResizeMode: 'onChange',
|
|
145
|
+
getCoreRowModel: getCoreRowModel(),
|
|
146
|
+
getExpandedRowModel: isExpandableMode ? getExpandedRowModel() : undefined,
|
|
147
|
+
getFilteredRowModel: getFilteredRowModel(),
|
|
148
|
+
getGroupedRowModel: getGroupedRowModel(),
|
|
149
|
+
getSortedRowModel: getSortedRowModel(),
|
|
150
|
+
getSubRows,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ColumnDef,
|
|
3
|
+
SortingState,
|
|
4
|
+
VisibilityState,
|
|
5
|
+
ColumnSizingState,
|
|
6
|
+
RowSelectionState,
|
|
7
|
+
ColumnOrderState,
|
|
8
|
+
ColumnFiltersState,
|
|
9
|
+
ExpandedState,
|
|
10
|
+
GroupingState,
|
|
11
|
+
PaginationState,
|
|
12
|
+
Cell as TableCell,
|
|
13
|
+
Table,
|
|
14
|
+
Row,
|
|
15
|
+
} from '@tanstack/react-table';
|
|
16
|
+
import type { ReactNode } from 'react';
|
|
17
|
+
|
|
18
|
+
export type GridId = string;
|
|
19
|
+
|
|
20
|
+
export type GridMode = 'flat' | 'parent' | 'group';
|
|
21
|
+
|
|
22
|
+
export type GridSettings = {
|
|
23
|
+
version: number;
|
|
24
|
+
columnSizing: ColumnSizingState;
|
|
25
|
+
columnOrder: ColumnOrderState;
|
|
26
|
+
columnVisibility: VisibilityState;
|
|
27
|
+
sorting: SortingState;
|
|
28
|
+
rowSelection: RowSelectionState;
|
|
29
|
+
columnFilters: ColumnFiltersState;
|
|
30
|
+
expanded: ExpandedState;
|
|
31
|
+
grouping: GroupingState;
|
|
32
|
+
pagination: PaginationState;
|
|
33
|
+
mode: GridMode;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type GridProps<TData> = {
|
|
37
|
+
gridId: GridId;
|
|
38
|
+
data: TData[];
|
|
39
|
+
totalRows?: number;
|
|
40
|
+
columns: ColumnDef<TData, any>[];
|
|
41
|
+
isLoading?: boolean;
|
|
42
|
+
|
|
43
|
+
settings: GridSettings;
|
|
44
|
+
onSettingsChange: (_next: GridSettings) => void;
|
|
45
|
+
|
|
46
|
+
showFilters?: boolean;
|
|
47
|
+
renderAggregatedCell?: (cell: TableCell<TData, unknown>) => ReactNode;
|
|
48
|
+
expandAllByDefault?: boolean;
|
|
49
|
+
enableExportXlsx?: boolean;
|
|
50
|
+
rowHeight?: number;
|
|
51
|
+
overscan?: number;
|
|
52
|
+
getRowContextMenuItems?: (ctx: RowMenuCtx<TData>) => ReactNode[];
|
|
53
|
+
onTableReady?: (table: Table<TData>) => void;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type RowMenuCtx<TData> = { table: Table<TData>; row: Row<TData>; close: () => void };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { Table } from '@tanstack/react-table';
|
|
3
|
+
|
|
4
|
+
type ColumnReorderOptions = {
|
|
5
|
+
disabledColumnIds?: string[];
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function useColumnReorder<TData>(table: Table<TData>, options: ColumnReorderOptions = {}) {
|
|
9
|
+
const disabledColumnIds = React.useMemo(
|
|
10
|
+
() => new Set(options.disabledColumnIds ?? []),
|
|
11
|
+
[options.disabledColumnIds]
|
|
12
|
+
);
|
|
13
|
+
const isReorderable = React.useCallback(
|
|
14
|
+
(columnId: string) => !disabledColumnIds.has(columnId),
|
|
15
|
+
[disabledColumnIds]
|
|
16
|
+
);
|
|
17
|
+
const handleHeaderDragStart = React.useCallback(
|
|
18
|
+
(e: React.DragEvent<HTMLDivElement>, columnId: string) => {
|
|
19
|
+
if (!isReorderable(columnId)) return;
|
|
20
|
+
e.dataTransfer.setData('text/plain', columnId);
|
|
21
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
22
|
+
},
|
|
23
|
+
[isReorderable]
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const handleHeaderDrop = React.useCallback(
|
|
27
|
+
(e: React.DragEvent<HTMLDivElement>, targetId: string) => {
|
|
28
|
+
if (!isReorderable(targetId)) return;
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
const sourceId = e.dataTransfer.getData('text/plain');
|
|
31
|
+
if (!sourceId || sourceId === targetId) return;
|
|
32
|
+
if (!isReorderable(sourceId)) return;
|
|
33
|
+
|
|
34
|
+
const orderedIds = table.getAllLeafColumns().map((col) => col.id);
|
|
35
|
+
const fromIndex = orderedIds.indexOf(sourceId);
|
|
36
|
+
const toIndex = orderedIds.indexOf(targetId);
|
|
37
|
+
if (fromIndex === -1 || toIndex === -1) return;
|
|
38
|
+
|
|
39
|
+
const next = [...orderedIds];
|
|
40
|
+
next.splice(fromIndex, 1);
|
|
41
|
+
next.splice(toIndex, 0, sourceId);
|
|
42
|
+
table.setColumnOrder(next);
|
|
43
|
+
},
|
|
44
|
+
[isReorderable, table]
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return { handleHeaderDragStart, handleHeaderDrop, isReorderable };
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import * as XLSX from 'xlsx';
|
|
2
|
+
import type { Table } from '@tanstack/react-table';
|
|
3
|
+
|
|
4
|
+
type ExportScope = 'page' | 'allFiltered';
|
|
5
|
+
|
|
6
|
+
type ExportArgs<TData> = {
|
|
7
|
+
table: Table<TData>;
|
|
8
|
+
fileName: string;
|
|
9
|
+
scope: ExportScope;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function exportTableToXlsx<TData>({ table, fileName, scope }: ExportArgs<TData>) {
|
|
13
|
+
const columns = table.getVisibleLeafColumns().filter((col) => col.id !== 'select');
|
|
14
|
+
const headerRow = columns.map((col) => {
|
|
15
|
+
const header = col.columnDef.header;
|
|
16
|
+
return typeof header === 'string' ? header : col.id;
|
|
17
|
+
});
|
|
18
|
+
const firstContentColumnId = columns[0]?.id;
|
|
19
|
+
const rowsSource =
|
|
20
|
+
scope === 'page' ? table.getRowModel().rows : table.getPrePaginationRowModel().rows;
|
|
21
|
+
|
|
22
|
+
const bodyRows = rowsSource.map((row) => {
|
|
23
|
+
return columns.map((col) => {
|
|
24
|
+
const value = row.getValue(col.id);
|
|
25
|
+
if (col.id === firstContentColumnId) {
|
|
26
|
+
const prefix = ' '.repeat(row.depth ?? 0);
|
|
27
|
+
if (row.getIsGrouped?.()) {
|
|
28
|
+
const groupingId = row.groupingColumnId;
|
|
29
|
+
const groupValue = groupingId ? row.getValue(groupingId) : '';
|
|
30
|
+
const count = row.getLeafRows?.().length ?? 0;
|
|
31
|
+
return `${prefix}${groupingId ?? 'group'}: ${String(groupValue ?? '')} (${count})`;
|
|
32
|
+
}
|
|
33
|
+
return `${prefix}${String(value ?? '')}`;
|
|
34
|
+
}
|
|
35
|
+
return String(value ?? '');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const worksheet = XLSX.utils.aoa_to_sheet([headerRow, ...bodyRows]);
|
|
40
|
+
const workbook = XLSX.utils.book_new();
|
|
41
|
+
XLSX.utils.book_append_sheet(workbook, worksheet, 'Sheet1');
|
|
42
|
+
|
|
43
|
+
const data = XLSX.write(workbook, { type: 'array', bookType: 'xlsx' });
|
|
44
|
+
const blob = new Blob([data], {
|
|
45
|
+
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
46
|
+
});
|
|
47
|
+
const url = URL.createObjectURL(blob);
|
|
48
|
+
const link = document.createElement('a');
|
|
49
|
+
link.href = url;
|
|
50
|
+
link.download = fileName.endsWith('.xlsx') ? fileName : `${fileName}.xlsx`;
|
|
51
|
+
document.body.appendChild(link);
|
|
52
|
+
link.click();
|
|
53
|
+
link.remove();
|
|
54
|
+
URL.revokeObjectURL(url);
|
|
55
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { Column } from '@tanstack/react-table';
|
|
3
|
+
|
|
4
|
+
type FilterInputProps<TData> = {
|
|
5
|
+
column: Column<TData, unknown>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function FilterInput<TData>({ column }: FilterInputProps<TData>) {
|
|
9
|
+
const rawValue = column.getFilterValue();
|
|
10
|
+
const [localValue, setLocalValue] = React.useState(String(rawValue ?? ''));
|
|
11
|
+
|
|
12
|
+
React.useEffect(() => {
|
|
13
|
+
setLocalValue(String(rawValue ?? ''));
|
|
14
|
+
}, [rawValue]);
|
|
15
|
+
|
|
16
|
+
React.useEffect(() => {
|
|
17
|
+
const handle = window.setTimeout(() => {
|
|
18
|
+
if (localValue !== String(rawValue ?? '')) {
|
|
19
|
+
column.setFilterValue(localValue);
|
|
20
|
+
}
|
|
21
|
+
}, 200);
|
|
22
|
+
|
|
23
|
+
return () => window.clearTimeout(handle);
|
|
24
|
+
}, [column, localValue, rawValue]);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<input
|
|
28
|
+
className="filter-input"
|
|
29
|
+
value={localValue}
|
|
30
|
+
onChange={(e) => setLocalValue(e.target.value)}
|
|
31
|
+
placeholder="Filter…"
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
type SortIndicatorProps = {
|
|
4
|
+
sort: false | 'asc' | 'desc';
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function SortIndicator({ sort }: SortIndicatorProps) {
|
|
8
|
+
if (sort === 'asc') return <span> ▲</span>;
|
|
9
|
+
if (sort === 'desc') return <span> ▼</span>;
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type TreeNode<TData> = TData & { subRows?: TreeNode<TData>[] };
|
|
2
|
+
|
|
3
|
+
type GetId<TData> = (row: TData) => string;
|
|
4
|
+
|
|
5
|
+
type GetParentId<TData> = (row: TData) => string | undefined | null;
|
|
6
|
+
|
|
7
|
+
export function buildParentTree<TData>(
|
|
8
|
+
rows: TData[],
|
|
9
|
+
getId: GetId<TData>,
|
|
10
|
+
getParentId: GetParentId<TData>
|
|
11
|
+
): TreeNode<TData>[] {
|
|
12
|
+
const nodeById = new Map<string, TreeNode<TData>>();
|
|
13
|
+
const roots: TreeNode<TData>[] = [];
|
|
14
|
+
|
|
15
|
+
for (const row of rows) {
|
|
16
|
+
const id = getId(row);
|
|
17
|
+
nodeById.set(id, { ...row });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
for (const row of rows) {
|
|
21
|
+
const id = getId(row);
|
|
22
|
+
const parentId = getParentId(row);
|
|
23
|
+
const node = nodeById.get(id);
|
|
24
|
+
if (!node) continue;
|
|
25
|
+
|
|
26
|
+
if (parentId && nodeById.has(parentId)) {
|
|
27
|
+
const parent = nodeById.get(parentId);
|
|
28
|
+
if (parent) {
|
|
29
|
+
parent.subRows ??= [];
|
|
30
|
+
parent.subRows.push(node);
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
roots.push(node);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return roots;
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
3
|
+
import type { VirtualItem } from '@tanstack/react-virtual';
|
|
4
|
+
import type { Table, Row } from '@tanstack/react-table';
|
|
5
|
+
|
|
6
|
+
type RowVirtualizerArgs<TData> = {
|
|
7
|
+
table: Table<TData>;
|
|
8
|
+
rowHeight: number;
|
|
9
|
+
overscan: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type RowVirtualizerResult<TData> = {
|
|
13
|
+
parentRef: React.RefObject<HTMLDivElement>;
|
|
14
|
+
rows: Row<TData>[];
|
|
15
|
+
totalSize: number;
|
|
16
|
+
virtualItems: VirtualItem[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function useRowVirtualizer<TData>(
|
|
20
|
+
args: RowVirtualizerArgs<TData>
|
|
21
|
+
): RowVirtualizerResult<TData> {
|
|
22
|
+
const { table, rowHeight, overscan } = args;
|
|
23
|
+
const parentRef = React.useRef<HTMLDivElement | null>(null);
|
|
24
|
+
const rows = table.getRowModel().rows;
|
|
25
|
+
|
|
26
|
+
const rowVirtualizer = useVirtualizer({
|
|
27
|
+
count: rows.length,
|
|
28
|
+
getScrollElement: () => parentRef.current,
|
|
29
|
+
estimateSize: () => rowHeight,
|
|
30
|
+
overscan,
|
|
31
|
+
});
|
|
32
|
+
const virtualItems = rowVirtualizer.getVirtualItems();
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
parentRef,
|
|
36
|
+
rows,
|
|
37
|
+
totalSize: rowVirtualizer.getTotalSize(),
|
|
38
|
+
virtualItems,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { flexRender } from '@tanstack/react-table';
|
|
3
|
+
import type { Cell as TableCell } from '@tanstack/react-table';
|
|
4
|
+
|
|
5
|
+
type CellProps<TData> = {
|
|
6
|
+
cell: TableCell<TData, unknown>;
|
|
7
|
+
isExpanderCell?: boolean;
|
|
8
|
+
renderAggregatedCell?: (cell: TableCell<TData, unknown>) => React.ReactNode;
|
|
9
|
+
isGroupMode?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function Cell<TData>({
|
|
13
|
+
cell,
|
|
14
|
+
isExpanderCell,
|
|
15
|
+
renderAggregatedCell,
|
|
16
|
+
isGroupMode = false,
|
|
17
|
+
}: CellProps<TData>) {
|
|
18
|
+
const depth = cell.row.depth;
|
|
19
|
+
const canExpand = isExpanderCell && cell.row.getCanExpand();
|
|
20
|
+
const isExpanded = canExpand ? cell.row.getIsExpanded() : false;
|
|
21
|
+
const paddingLeft = isExpanderCell ? 10 + depth * 14 : undefined;
|
|
22
|
+
const isGroupRow = isGroupMode ? (cell.row.getIsGrouped?.() ?? false) : false;
|
|
23
|
+
const isAggregatedCell = isGroupMode ? (cell.getIsAggregated?.() ?? false) : false;
|
|
24
|
+
const isPlaceholder = cell.getIsPlaceholder?.() ?? false;
|
|
25
|
+
const groupingColumnId = isGroupMode && isGroupRow ? cell.row.groupingColumnId : undefined;
|
|
26
|
+
const groupValue =
|
|
27
|
+
groupingColumnId !== undefined ? cell.row.getValue(groupingColumnId) : undefined;
|
|
28
|
+
const groupCount =
|
|
29
|
+
isGroupMode && isGroupRow && isExpanderCell ? (cell.row.subRows?.length ?? 0) : 0;
|
|
30
|
+
|
|
31
|
+
const aggregatedRenderer =
|
|
32
|
+
renderAggregatedCell ??
|
|
33
|
+
((cellValue: TableCell<TData, unknown>) =>
|
|
34
|
+
flexRender(cellValue.column.columnDef.cell, cellValue.getContext()));
|
|
35
|
+
const defaultRenderer = flexRender(cell.column.columnDef.cell, cell.getContext());
|
|
36
|
+
|
|
37
|
+
let content: React.ReactNode = defaultRenderer;
|
|
38
|
+
if (isGroupRow) {
|
|
39
|
+
if (isExpanderCell) {
|
|
40
|
+
content = (
|
|
41
|
+
<>
|
|
42
|
+
{canExpand && (
|
|
43
|
+
<button
|
|
44
|
+
className="expander"
|
|
45
|
+
onClick={(e) => {
|
|
46
|
+
e.stopPropagation();
|
|
47
|
+
cell.row.getToggleExpandedHandler()(e);
|
|
48
|
+
}}
|
|
49
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
50
|
+
>
|
|
51
|
+
{isExpanded ? '▾' : '▸'}
|
|
52
|
+
</button>
|
|
53
|
+
)}
|
|
54
|
+
<span className="group-label">
|
|
55
|
+
{groupValue !== undefined ? String(groupValue) : 'Group'} ({groupCount})
|
|
56
|
+
</span>
|
|
57
|
+
</>
|
|
58
|
+
);
|
|
59
|
+
} else if (isAggregatedCell) {
|
|
60
|
+
content = aggregatedRenderer(cell);
|
|
61
|
+
} else {
|
|
62
|
+
content = null;
|
|
63
|
+
}
|
|
64
|
+
} else if (isPlaceholder) {
|
|
65
|
+
content = null;
|
|
66
|
+
} else if (isExpanderCell && canExpand) {
|
|
67
|
+
content = (
|
|
68
|
+
<>
|
|
69
|
+
<button
|
|
70
|
+
className="expander"
|
|
71
|
+
onClick={(e) => {
|
|
72
|
+
e.stopPropagation();
|
|
73
|
+
cell.row.getToggleExpandedHandler()(e);
|
|
74
|
+
}}
|
|
75
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
76
|
+
>
|
|
77
|
+
{isExpanded ? '▾' : '▸'}
|
|
78
|
+
</button>
|
|
79
|
+
{defaultRenderer}
|
|
80
|
+
</>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
className="cell"
|
|
87
|
+
style={{ width: cell.column.getSize(), paddingLeft }}
|
|
88
|
+
title={String(cell.getValue() ?? '')}
|
|
89
|
+
>
|
|
90
|
+
{content}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
|
|
4
|
+
type ContextMenuProps = {
|
|
5
|
+
open: boolean;
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
children: React.ReactNode;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function ContextMenu({ open, x, y, onClose, children }: ContextMenuProps) {
|
|
13
|
+
const menuRef = React.useRef<HTMLDivElement | null>(null);
|
|
14
|
+
|
|
15
|
+
React.useEffect(() => {
|
|
16
|
+
if (!open) return;
|
|
17
|
+
const handleMouseDown = (event: MouseEvent) => {
|
|
18
|
+
const target = event.target as Node | null;
|
|
19
|
+
if (!menuRef.current || !target) return;
|
|
20
|
+
if (!menuRef.current.contains(target)) {
|
|
21
|
+
onClose();
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
25
|
+
if (event.key === 'Escape') onClose();
|
|
26
|
+
};
|
|
27
|
+
document.addEventListener('mousedown', handleMouseDown);
|
|
28
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
29
|
+
return () => {
|
|
30
|
+
document.removeEventListener('mousedown', handleMouseDown);
|
|
31
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
32
|
+
};
|
|
33
|
+
}, [onClose, open]);
|
|
34
|
+
|
|
35
|
+
if (!open) return null;
|
|
36
|
+
|
|
37
|
+
return createPortal(
|
|
38
|
+
<div ref={menuRef} className="context-menu" style={{ left: x, top: y }}>
|
|
39
|
+
{children}
|
|
40
|
+
</div>,
|
|
41
|
+
document.body
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { flexRender } from '@tanstack/react-table';
|
|
3
|
+
import type { Table } from '@tanstack/react-table';
|
|
4
|
+
import { FilterInput } from '../features/filtering/FilterInput';
|
|
5
|
+
import { SortIndicator } from '../features/sorting/SortIndicator';
|
|
6
|
+
|
|
7
|
+
type HeaderProps<TData> = {
|
|
8
|
+
table: Table<TData>;
|
|
9
|
+
showFilters: boolean;
|
|
10
|
+
onHeaderDragStart: (e: React.DragEvent<HTMLDivElement>, columnId: string) => void;
|
|
11
|
+
onHeaderDrop: (e: React.DragEvent<HTMLDivElement>, targetId: string) => void;
|
|
12
|
+
isReorderableColumn: (columnId: string) => boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function Header<TData>(props: HeaderProps<TData>) {
|
|
16
|
+
const { table, showFilters, onHeaderDragStart, onHeaderDrop, isReorderableColumn } = props;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="grid-header">
|
|
20
|
+
{table.getHeaderGroups().map((hg) => (
|
|
21
|
+
<div key={hg.id} className="row">
|
|
22
|
+
{hg.headers.map((header) => {
|
|
23
|
+
const col = header.column;
|
|
24
|
+
const size = col.getSize();
|
|
25
|
+
const canSort = col.getCanSort();
|
|
26
|
+
const sort = col.getIsSorted();
|
|
27
|
+
const canReorder = !header.isPlaceholder && isReorderableColumn(col.id);
|
|
28
|
+
const isResizing = col.getIsResizing?.() ?? false;
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
key={header.id}
|
|
33
|
+
className="cell header-cell"
|
|
34
|
+
style={{ width: size, position: 'relative' }}
|
|
35
|
+
onDragOver={(e) => e.preventDefault()}
|
|
36
|
+
onDrop={(e) => onHeaderDrop(e, col.id)}
|
|
37
|
+
draggable={canReorder && !isResizing}
|
|
38
|
+
onDragStart={(e) => onHeaderDragStart(e, col.id)}
|
|
39
|
+
>
|
|
40
|
+
<button onClick={canSort ? col.getToggleSortingHandler() : undefined}>
|
|
41
|
+
{header.isPlaceholder
|
|
42
|
+
? null
|
|
43
|
+
: flexRender(col.columnDef.header, header.getContext())}
|
|
44
|
+
<SortIndicator sort={sort} />
|
|
45
|
+
</button>
|
|
46
|
+
{showFilters && col.getCanFilter() && <FilterInput column={col} />}
|
|
47
|
+
|
|
48
|
+
{col.getCanResize() && (
|
|
49
|
+
<div
|
|
50
|
+
className="resizer"
|
|
51
|
+
onMouseDown={(e) => {
|
|
52
|
+
e.stopPropagation();
|
|
53
|
+
header.getResizeHandler()(e);
|
|
54
|
+
}}
|
|
55
|
+
onTouchStart={(e) => {
|
|
56
|
+
e.stopPropagation();
|
|
57
|
+
header.getResizeHandler()(e);
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
</div>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|