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,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,5 @@
1
+ import type { GridSettings } from './types';
2
+
3
+ export function updateSettings(settings: GridSettings, patch: Partial<GridSettings>): GridSettings {
4
+ return { ...settings, ...patch };
5
+ }
@@ -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
+ }