@alaarab/ogrid-react 2.0.23 → 2.1.1
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/dist/esm/components/ColumnHeaderFilterContent.js +0 -2
- package/dist/esm/components/MarchingAntsOverlay.js +2 -3
- package/dist/esm/components/SideBar.js +8 -7
- package/dist/esm/components/createOGrid.js +1 -4
- package/dist/esm/hooks/index.js +10 -0
- package/dist/esm/hooks/useActiveCell.js +2 -4
- package/dist/esm/hooks/useCellSelection.js +85 -52
- package/dist/esm/hooks/useClipboard.js +15 -54
- package/dist/esm/hooks/useColumnChooserState.js +25 -13
- package/dist/esm/hooks/useColumnHeaderFilterState.js +22 -11
- package/dist/esm/hooks/useColumnHeaderMenuState.js +1 -1
- package/dist/esm/hooks/useColumnMeta.js +61 -0
- package/dist/esm/hooks/useColumnPinning.js +11 -12
- package/dist/esm/hooks/useColumnReorder.js +8 -1
- package/dist/esm/hooks/useColumnResize.js +6 -2
- package/dist/esm/hooks/useDataGridContextMenu.js +24 -0
- package/dist/esm/hooks/useDataGridEditing.js +56 -0
- package/dist/esm/hooks/useDataGridInteraction.js +109 -0
- package/dist/esm/hooks/useDataGridLayout.js +172 -0
- package/dist/esm/hooks/useDataGridState.js +83 -318
- package/dist/esm/hooks/useDataGridTableOrchestration.js +2 -4
- package/dist/esm/hooks/useFillHandle.js +60 -55
- package/dist/esm/hooks/useFilterOptions.js +21 -6
- package/dist/esm/hooks/useInlineCellEditorState.js +7 -13
- package/dist/esm/hooks/useKeyboardNavigation.js +19 -132
- package/dist/esm/hooks/useMultiSelectFilterState.js +1 -1
- package/dist/esm/hooks/useOGrid.js +158 -301
- package/dist/esm/hooks/useOGridDataFetching.js +74 -0
- package/dist/esm/hooks/useOGridFilters.js +59 -0
- package/dist/esm/hooks/useOGridPagination.js +24 -0
- package/dist/esm/hooks/useOGridSorting.js +24 -0
- package/dist/esm/hooks/usePaginationControls.js +2 -5
- package/dist/esm/hooks/usePeopleFilterState.js +6 -1
- package/dist/esm/hooks/useRichSelectState.js +7 -5
- package/dist/esm/hooks/useRowSelection.js +6 -26
- package/dist/esm/hooks/useSelectState.js +2 -5
- package/dist/esm/hooks/useShallowEqualMemo.js +14 -0
- package/dist/esm/hooks/useTableLayout.js +3 -11
- package/dist/esm/hooks/useUndoRedo.js +16 -10
- package/dist/esm/index.js +1 -1
- package/dist/esm/utils/index.js +1 -1
- package/dist/types/components/ColumnChooserProps.d.ts +2 -0
- package/dist/types/components/ColumnHeaderFilterContent.d.ts +0 -2
- package/dist/types/hooks/index.d.ts +19 -0
- package/dist/types/hooks/useClipboard.d.ts +0 -1
- package/dist/types/hooks/useColumnChooserState.d.ts +2 -0
- package/dist/types/hooks/useColumnHeaderFilterState.d.ts +0 -2
- package/dist/types/hooks/useColumnHeaderMenuState.d.ts +0 -2
- package/dist/types/hooks/useColumnMeta.d.ts +34 -0
- package/dist/types/hooks/useDataGridContextMenu.d.ts +20 -0
- package/dist/types/hooks/useDataGridEditing.d.ts +39 -0
- package/dist/types/hooks/useDataGridInteraction.d.ts +95 -0
- package/dist/types/hooks/useDataGridLayout.d.ts +45 -0
- package/dist/types/hooks/useDataGridState.d.ts +7 -1
- package/dist/types/hooks/useDataGridTableOrchestration.d.ts +1 -2
- package/dist/types/hooks/useOGrid.d.ts +4 -2
- package/dist/types/hooks/useOGridDataFetching.d.ts +28 -0
- package/dist/types/hooks/useOGridFilters.d.ts +24 -0
- package/dist/types/hooks/useOGridPagination.d.ts +18 -0
- package/dist/types/hooks/useOGridSorting.d.ts +23 -0
- package/dist/types/hooks/usePaginationControls.d.ts +1 -1
- package/dist/types/hooks/useRichSelectState.d.ts +2 -0
- package/dist/types/hooks/useShallowEqualMemo.d.ts +7 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/utils/index.d.ts +2 -2
- package/package.json +12 -4
|
@@ -5,8 +5,6 @@ DateFilterContent.displayName = 'DateFilterContent';
|
|
|
5
5
|
export function getColumnHeaderFilterStateParams(props) {
|
|
6
6
|
return {
|
|
7
7
|
filterType: props.filterType,
|
|
8
|
-
isSorted: props.isSorted ?? false,
|
|
9
|
-
isSortedDescending: props.isSortedDescending ?? false,
|
|
10
8
|
onSort: props.onSort,
|
|
11
9
|
selectedValues: props.selectedValues,
|
|
12
10
|
onFilterChange: props.onFilterChange,
|
|
@@ -24,8 +24,7 @@ export function MarchingAntsOverlay({ containerRef, selectionRange, copyRange, c
|
|
|
24
24
|
}
|
|
25
25
|
setSelRect(selectionRange ? measureRange(container, selectionRange, colOffset) : null);
|
|
26
26
|
setClipRect(clipRange ? measureRange(container, clipRange, colOffset) : null);
|
|
27
|
-
|
|
28
|
-
}, [selectionRange, clipRange, containerRef, colOffset, items, visibleColumns, columnSizingOverrides, columnOrder]);
|
|
27
|
+
}, [selectionRange, clipRange, containerRef, colOffset]);
|
|
29
28
|
// Inject keyframes on mount
|
|
30
29
|
useEffect(() => {
|
|
31
30
|
injectGlobalStyles('ogrid-marching-ants-keyframes', '@keyframes ogrid-marching-ants{to{stroke-dashoffset:-8}}');
|
|
@@ -49,7 +48,7 @@ export function MarchingAntsOverlay({ containerRef, selectionRange, copyRange, c
|
|
|
49
48
|
cancelAnimationFrame(rafRef.current);
|
|
50
49
|
ro?.disconnect();
|
|
51
50
|
};
|
|
52
|
-
}, [selectionRange, clipRange, measureAll, containerRef]);
|
|
51
|
+
}, [selectionRange, clipRange, measureAll, containerRef, items, visibleColumns, columnSizingOverrides, columnOrder]);
|
|
53
52
|
if (!selRect && !clipRect)
|
|
54
53
|
return null;
|
|
55
54
|
// When clipboard range matches the selection range, hide the solid selection border
|
|
@@ -69,7 +69,7 @@ export function SideBar(props) {
|
|
|
69
69
|
const tabStripStyle = position === 'right' ? tabStripBorderLeft : tabStripBorderRight;
|
|
70
70
|
const panelContainerStyle = position === 'right' ? panelContainerBorderLeft : panelContainerBorderRight;
|
|
71
71
|
const tabStrip = (_jsx("div", { style: tabStripStyle, role: "tablist", "aria-label": "Side bar tabs", children: panels.map((panel) => (_jsx("button", { role: "tab", "aria-selected": activePanel === panel, "aria-label": PANEL_LABELS[panel], onClick: () => handleTabClick(panel), title: PANEL_LABELS[panel], style: activePanel === panel ? tabButtonActive : tabButtonInactive, children: panel === 'columns' ? '\u2261' : '\u2A65' }, panel))) }));
|
|
72
|
-
const panelContent = isOpen ? (_jsxs("div", { role: "tabpanel", "aria-label": PANEL_LABELS[activePanel], style: panelContainerStyle, children: [_jsxs("div", { style: panelHeaderStyle, children: [_jsx("span", { children: PANEL_LABELS[activePanel] }), _jsx("button", { onClick: () => onPanelChange(null), style: closeButtonStyle, "aria-label": "Close panel", children: "\u00D7" })] }), _jsxs("div", { style: panelBodyStyle, children: [activePanel === 'columns' && (_jsx(ColumnsPanel, { columns: columns, visibleColumns: visibleColumns, onVisibilityChange: onVisibilityChange, onSetVisibleColumns: onSetVisibleColumns })), activePanel === 'filters' && (_jsx(FiltersPanel, { filterableColumns: filterableColumns, filters: filters, onFilterChange: onFilterChange, filterOptions: filterOptions }))] })] })) : null;
|
|
72
|
+
const panelContent = isOpen && activePanel ? (_jsxs("div", { role: "tabpanel", "aria-label": PANEL_LABELS[activePanel], style: panelContainerStyle, children: [_jsxs("div", { style: panelHeaderStyle, children: [_jsx("span", { children: PANEL_LABELS[activePanel] }), _jsx("button", { onClick: () => onPanelChange(null), style: closeButtonStyle, "aria-label": "Close panel", children: "\u00D7" })] }), _jsxs("div", { style: panelBodyStyle, children: [activePanel === 'columns' && (_jsx(ColumnsPanel, { columns: columns, visibleColumns: visibleColumns, onVisibilityChange: onVisibilityChange, onSetVisibleColumns: onSetVisibleColumns })), activePanel === 'filters' && (_jsx(FiltersPanel, { filterableColumns: filterableColumns, filters: filters, onFilterChange: onFilterChange, filterOptions: filterOptions }))] })] })) : null;
|
|
73
73
|
return (_jsxs("div", { style: sideBarRootStyle, role: "complementary", "aria-label": "Side bar", children: [position === 'left' && tabStrip, position === 'left' && panelContent, position === 'right' && panelContent, position === 'right' && tabStrip] }));
|
|
74
74
|
}
|
|
75
75
|
// --- Internal sub-components ---
|
|
@@ -98,20 +98,21 @@ function FiltersPanel(props) {
|
|
|
98
98
|
}
|
|
99
99
|
return (_jsx(_Fragment, { children: filterableColumns.map((col) => {
|
|
100
100
|
const filterKey = col.filterField;
|
|
101
|
-
|
|
101
|
+
const fv = filters[filterKey];
|
|
102
|
+
return (_jsxs("div", { style: filterGroupStyle, children: [_jsx("div", { style: filterLabelStyle, children: col.name }), col.filterType === 'text' && (_jsx("input", { type: "text", value: fv?.type === 'text' ? fv.value : '', onChange: (e) => onFilterChange(filterKey, e.target.value ? { type: 'text', value: e.target.value } : undefined), placeholder: `Filter ${col.name}...`, "aria-label": `Filter ${col.name}`, style: textInputStyle })), col.filterType === 'date' && (_jsxs("div", { style: dateContainerStyle, children: [_jsxs("label", { style: dateLabelStyle, children: ["From:", _jsx("input", { type: "date", value: fv?.type === 'date' ? (fv.value.from ?? '') : '', onChange: (e) => {
|
|
102
103
|
const from = e.target.value || undefined;
|
|
103
|
-
const existingValue =
|
|
104
|
+
const existingValue = fv?.type === 'date' ? fv.value : {};
|
|
104
105
|
const to = existingValue.to;
|
|
105
106
|
onFilterChange(filterKey, from || to ? { type: 'date', value: { from, to } } : undefined);
|
|
106
|
-
}, "aria-label": `${col.name} from date`, style: dateInputStyle })] }), _jsxs("label", { style: dateLabelStyle, children: ["To:", _jsx("input", { type: "date", value:
|
|
107
|
+
}, "aria-label": `${col.name} from date`, style: dateInputStyle })] }), _jsxs("label", { style: dateLabelStyle, children: ["To:", _jsx("input", { type: "date", value: fv?.type === 'date' ? (fv.value.to ?? '') : '', onChange: (e) => {
|
|
107
108
|
const to = e.target.value || undefined;
|
|
108
|
-
const existingValue =
|
|
109
|
+
const existingValue = fv?.type === 'date' ? fv.value : {};
|
|
109
110
|
const from = existingValue.from;
|
|
110
111
|
onFilterChange(filterKey, from || to ? { type: 'date', value: { from, to } } : undefined);
|
|
111
112
|
}, "aria-label": `${col.name} to date`, style: dateInputStyle })] })] })), col.filterType === 'multiSelect' && (_jsx("div", { style: multiSelectContainerStyle, role: "group", "aria-label": `${col.name} options`, children: (filterOptions[filterKey] ?? []).map((opt) => {
|
|
112
|
-
const selected =
|
|
113
|
+
const selected = fv?.type === 'multiSelect' ? fv.value.includes(opt) : false;
|
|
113
114
|
return (_jsxs("label", { style: multiSelectLabelStyle, children: [_jsx("input", { type: "checkbox", checked: selected, onChange: (e) => {
|
|
114
|
-
const current =
|
|
115
|
+
const current = fv?.type === 'multiSelect' ? fv.value : [];
|
|
115
116
|
const next = e.target.checked
|
|
116
117
|
? [...current, opt]
|
|
117
118
|
: current.filter((v) => v !== opt);
|
|
@@ -12,10 +12,7 @@ export function createOGrid(components) {
|
|
|
12
12
|
const { DataGridTable, ColumnChooser, PaginationControls, containerComponent, containerProps, } = components;
|
|
13
13
|
const OGridInner = forwardRef(function OGridInner(props, ref) {
|
|
14
14
|
const { dataGridProps, pagination, columnChooser, layout } = useOGrid(props, ref);
|
|
15
|
-
return (_jsx(OGridLayout, { containerComponent: containerComponent, containerProps: containerProps, className: layout.className, sideBar: layout.sideBarProps, toolbar: layout.toolbar, toolbarBelow: layout.toolbarBelow, toolbarEnd: columnChooser.placement === 'toolbar' ? (_jsx(ColumnChooser, { columns: columnChooser.columns, visibleColumns: columnChooser.visibleColumns, onVisibilityChange: columnChooser.onVisibilityChange })) : undefined, pagination: _jsx(PaginationControls, { currentPage: pagination.page, pageSize: pagination.pageSize, totalCount: pagination.displayTotalCount, onPageChange: pagination.setPage, onPageSizeChange:
|
|
16
|
-
pagination.setPageSize(size);
|
|
17
|
-
pagination.setPage(1);
|
|
18
|
-
}, pageSizeOptions: pagination.pageSizeOptions, entityLabelPlural: pagination.entityLabelPlural }), children: _jsx(DataGridTable, { ...dataGridProps }) }));
|
|
15
|
+
return (_jsx(OGridLayout, { containerComponent: containerComponent, containerProps: containerProps, className: layout.className, sideBar: layout.sideBarProps, toolbar: layout.toolbar, toolbarBelow: layout.toolbarBelow, toolbarEnd: columnChooser.placement === 'toolbar' ? (_jsx(ColumnChooser, { columns: columnChooser.columns, visibleColumns: columnChooser.visibleColumns, onVisibilityChange: columnChooser.onVisibilityChange, onSetVisibleColumns: columnChooser.onSetVisibleColumns })) : undefined, pagination: _jsx(PaginationControls, { currentPage: pagination.page, pageSize: pagination.pageSize, totalCount: pagination.displayTotalCount, onPageChange: pagination.setPage, onPageSizeChange: pagination.setPageSize, pageSizeOptions: pagination.pageSizeOptions, entityLabelPlural: pagination.entityLabelPlural }), children: _jsx(DataGridTable, { ...dataGridProps }) }));
|
|
19
16
|
});
|
|
20
17
|
OGridInner.displayName = 'OGrid';
|
|
21
18
|
return React.memo(OGridInner);
|
package/dist/esm/hooks/index.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
export { useFilterOptions } from './useFilterOptions';
|
|
2
2
|
export { useOGrid } from './useOGrid';
|
|
3
|
+
export { useOGridPagination } from './useOGridPagination';
|
|
4
|
+
export { useOGridSorting } from './useOGridSorting';
|
|
5
|
+
export { useOGridFilters as useOGridFiltersState } from './useOGridFilters';
|
|
6
|
+
export { useOGridDataFetching } from './useOGridDataFetching';
|
|
3
7
|
export { useActiveCell } from './useActiveCell';
|
|
4
8
|
export { useCellEditing } from './useCellEditing';
|
|
5
9
|
export { useContextMenu } from './useContextMenu';
|
|
@@ -11,6 +15,10 @@ export { useUndoRedo } from './useUndoRedo';
|
|
|
11
15
|
export { useDebounce } from './useDebounce';
|
|
12
16
|
export { useFillHandle } from './useFillHandle';
|
|
13
17
|
export { useDataGridState } from './useDataGridState';
|
|
18
|
+
export { useDataGridLayout } from './useDataGridLayout';
|
|
19
|
+
export { useDataGridEditing } from './useDataGridEditing';
|
|
20
|
+
export { useDataGridInteraction } from './useDataGridInteraction';
|
|
21
|
+
export { useDataGridContextMenu } from './useDataGridContextMenu';
|
|
14
22
|
export { useColumnHeaderFilterState } from './useColumnHeaderFilterState';
|
|
15
23
|
export { useTextFilterState } from './useTextFilterState';
|
|
16
24
|
export { useMultiSelectFilterState } from './useMultiSelectFilterState';
|
|
@@ -29,5 +37,7 @@ export { useColumnReorder } from './useColumnReorder';
|
|
|
29
37
|
export { useVirtualScroll } from './useVirtualScroll';
|
|
30
38
|
export { useListVirtualizer } from './useListVirtualizer';
|
|
31
39
|
export { useLatestRef } from './useLatestRef';
|
|
40
|
+
export { useShallowEqualMemo } from './useShallowEqualMemo';
|
|
32
41
|
export { usePaginationControls } from './usePaginationControls';
|
|
33
42
|
export { useDataGridTableOrchestration } from './useDataGridTableOrchestration';
|
|
43
|
+
export { useColumnMeta } from './useColumnMeta';
|
|
@@ -31,8 +31,7 @@ export function useActiveCell(wrapperRef, editingCell) {
|
|
|
31
31
|
if (cell && document.activeElement !== cell && typeof cell.focus === 'function') {
|
|
32
32
|
cell.focus({ preventScroll: true });
|
|
33
33
|
}
|
|
34
|
-
|
|
35
|
-
}, [activeCell, editingCell]); // wrapperRef excluded — refs are stable across renders
|
|
34
|
+
}, [activeCell, editingCell, wrapperRef]);
|
|
36
35
|
// Batch scroll-into-view via RAF so rapid keyboard navigation only scrolls once
|
|
37
36
|
useEffect(() => {
|
|
38
37
|
if (activeCell == null || wrapperRef?.current == null || editingCell != null)
|
|
@@ -67,8 +66,7 @@ export function useActiveCell(wrapperRef, editingCell) {
|
|
|
67
66
|
}
|
|
68
67
|
}
|
|
69
68
|
});
|
|
70
|
-
|
|
71
|
-
}, [activeCell, editingCell]); // wrapperRef excluded — refs are stable across renders
|
|
69
|
+
}, [activeCell, editingCell, wrapperRef]);
|
|
72
70
|
// Clean up pending RAF on unmount
|
|
73
71
|
useEffect(() => {
|
|
74
72
|
return () => cancelAnimationFrame(scrollRafRef.current);
|
|
@@ -78,9 +78,7 @@ export function useCellSelection(params) {
|
|
|
78
78
|
// even before the first mousemove. This ensures instant visual feedback.
|
|
79
79
|
setTimeout(() => applyDragAttrsRef.current?.(initial), 0);
|
|
80
80
|
}
|
|
81
|
-
},
|
|
82
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- setSelectionRange is stable; colOffsetRef is a ref
|
|
83
|
-
[setActiveCell]);
|
|
81
|
+
}, [setActiveCell, colOffsetRef, setSelectionRange]);
|
|
84
82
|
const handleSelectAllCells = useCallback(() => {
|
|
85
83
|
if (rowCount === 0 || visibleColCount === 0)
|
|
86
84
|
return;
|
|
@@ -91,8 +89,7 @@ export function useCellSelection(params) {
|
|
|
91
89
|
endCol: visibleColCount - 1,
|
|
92
90
|
});
|
|
93
91
|
setActiveCell({ rowIndex: 0, columnIndex: colOffsetRef.current });
|
|
94
|
-
|
|
95
|
-
}, [rowCount, visibleColCount, setActiveCell]);
|
|
92
|
+
}, [rowCount, visibleColCount, setActiveCell, colOffsetRef, setSelectionRange]);
|
|
96
93
|
/** Last known mouse position during drag — used by mouseUp to flush pending RAF work. */
|
|
97
94
|
const lastMousePosRef = useRef(null);
|
|
98
95
|
// Ref to expose applyDragAttrs outside useEffect so it can be called from mouseDown
|
|
@@ -101,7 +98,57 @@ export function useCellSelection(params) {
|
|
|
101
98
|
// Performance: during drag, we update a ref + toggle DOM attributes via rAF.
|
|
102
99
|
// React state is only committed on mouseup (single re-render instead of 60-120/s).
|
|
103
100
|
useEffect(() => {
|
|
101
|
+
/** Set of currently drag-marked HTMLElements — avoids O(n) full DOM scan on each frame. */
|
|
102
|
+
const markedCells = new Set();
|
|
103
|
+
/** Cell lookup index built on drag start — O(1) lookups per frame instead of querySelectorAll. */
|
|
104
|
+
let cellIndex = null;
|
|
105
|
+
/** Build cell lookup index from a single querySelectorAll scan. */
|
|
106
|
+
const buildCellIndex = () => {
|
|
107
|
+
const wrapper = wrapperRef.current;
|
|
108
|
+
if (!wrapper)
|
|
109
|
+
return;
|
|
110
|
+
cellIndex = new Map();
|
|
111
|
+
const cells = wrapper.querySelectorAll('[data-row-index][data-col-index]');
|
|
112
|
+
for (let i = 0; i < cells.length; i++) {
|
|
113
|
+
const el = cells[i];
|
|
114
|
+
const r = el.getAttribute('data-row-index') ?? '';
|
|
115
|
+
const c = el.getAttribute('data-col-index') ?? '';
|
|
116
|
+
cellIndex.set(`${r},${c}`, el);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
/** Apply styling to a single in-range cell (attrs + box-shadow). */
|
|
120
|
+
const styleCellInRange = (el, r, c, minR, maxR, minC, maxC, anchor) => {
|
|
121
|
+
if (!el.hasAttribute(DRAG_ATTR))
|
|
122
|
+
el.setAttribute(DRAG_ATTR, '');
|
|
123
|
+
const isAnchor = anchor && r === anchor.row && c === anchor.col;
|
|
124
|
+
if (isAnchor) {
|
|
125
|
+
if (!el.hasAttribute(DRAG_ANCHOR_ATTR))
|
|
126
|
+
el.setAttribute(DRAG_ANCHOR_ATTR, '');
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
if (el.hasAttribute(DRAG_ANCHOR_ATTR))
|
|
130
|
+
el.removeAttribute(DRAG_ANCHOR_ATTR);
|
|
131
|
+
}
|
|
132
|
+
const shadows = [];
|
|
133
|
+
if (r === minR)
|
|
134
|
+
shadows.push('inset 0 2px 0 0 var(--ogrid-selection, #217346)');
|
|
135
|
+
if (r === maxR)
|
|
136
|
+
shadows.push('inset 0 -2px 0 0 var(--ogrid-selection, #217346)');
|
|
137
|
+
if (c === minC)
|
|
138
|
+
shadows.push('inset 2px 0 0 0 var(--ogrid-selection, #217346)');
|
|
139
|
+
if (c === maxC)
|
|
140
|
+
shadows.push('inset -2px 0 0 0 var(--ogrid-selection, #217346)');
|
|
141
|
+
el.style.boxShadow = shadows.length > 0 ? shadows.join(', ') : '';
|
|
142
|
+
markedCells.add(el);
|
|
143
|
+
};
|
|
144
|
+
/** Remove drag styling from a single cell. */
|
|
145
|
+
const unstyleCell = (el) => {
|
|
146
|
+
el.removeAttribute(DRAG_ATTR);
|
|
147
|
+
el.removeAttribute(DRAG_ANCHOR_ATTR);
|
|
148
|
+
el.style.boxShadow = '';
|
|
149
|
+
};
|
|
104
150
|
/** Toggle DRAG_ATTR on cells to show the range highlight via CSS.
|
|
151
|
+
* Uses a cell index Map for O(1) lookups per cell in the range instead of scanning all cells.
|
|
105
152
|
* Also sets edge box-shadows for a green border around the selection range,
|
|
106
153
|
* and marks the anchor cell with DRAG_ANCHOR_ATTR (white background). */
|
|
107
154
|
const applyDragAttrs = (range) => {
|
|
@@ -113,60 +160,45 @@ export function useCellSelection(params) {
|
|
|
113
160
|
const minC = Math.min(range.startCol, range.endCol);
|
|
114
161
|
const maxC = Math.max(range.startCol, range.endCol);
|
|
115
162
|
const anchor = dragStartRef.current;
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const r = parseInt(el.getAttribute('data-row-index'), 10);
|
|
120
|
-
const c = parseInt(el.getAttribute('data-col-index'), 10) -
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
163
|
+
const colOff = colOffsetRef.current;
|
|
164
|
+
// 1. Un-mark cells that are no longer in the new range (iterate the small set, not all DOM)
|
|
165
|
+
for (const el of markedCells) {
|
|
166
|
+
const r = parseInt(el.getAttribute('data-row-index') ?? '', 10);
|
|
167
|
+
const c = parseInt(el.getAttribute('data-col-index') ?? '', 10) - colOff;
|
|
168
|
+
const stillInRange = r >= minR && r <= maxR && c >= minC && c <= maxC;
|
|
169
|
+
if (!stillInRange) {
|
|
170
|
+
unstyleCell(el);
|
|
171
|
+
markedCells.delete(el);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Build index on first call if not yet initialized
|
|
175
|
+
if (!cellIndex)
|
|
176
|
+
buildCellIndex();
|
|
177
|
+
// 2. Look up only the cells in the new range — O(range size) via Map lookup.
|
|
178
|
+
for (let r = minR; r <= maxR; r++) {
|
|
179
|
+
for (let c = minC; c <= maxC; c++) {
|
|
180
|
+
const key = `${r},${c + colOff}`;
|
|
181
|
+
let el = cellIndex?.get(key);
|
|
182
|
+
// Handle virtual scroll recycling — if element is stale, rebuild index once
|
|
183
|
+
if (el && !el.isConnected) {
|
|
184
|
+
buildCellIndex();
|
|
185
|
+
el = cellIndex?.get(key);
|
|
130
186
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
el.removeAttribute(DRAG_ANCHOR_ATTR);
|
|
187
|
+
if (el) {
|
|
188
|
+
styleCellInRange(el, r, c, minR, maxR, minC, maxC, anchor);
|
|
134
189
|
}
|
|
135
|
-
// Edge borders via inset box-shadow (no layout shift)
|
|
136
|
-
const shadows = [];
|
|
137
|
-
if (r === minR)
|
|
138
|
-
shadows.push('inset 0 2px 0 0 var(--ogrid-selection, #217346)');
|
|
139
|
-
if (r === maxR)
|
|
140
|
-
shadows.push('inset 0 -2px 0 0 var(--ogrid-selection, #217346)');
|
|
141
|
-
if (c === minC)
|
|
142
|
-
shadows.push('inset 2px 0 0 0 var(--ogrid-selection, #217346)');
|
|
143
|
-
if (c === maxC)
|
|
144
|
-
shadows.push('inset -2px 0 0 0 var(--ogrid-selection, #217346)');
|
|
145
|
-
el.style.boxShadow = shadows.length > 0 ? shadows.join(', ') : '';
|
|
146
|
-
}
|
|
147
|
-
else {
|
|
148
|
-
if (el.hasAttribute(DRAG_ATTR))
|
|
149
|
-
el.removeAttribute(DRAG_ATTR);
|
|
150
|
-
if (el.hasAttribute(DRAG_ANCHOR_ATTR))
|
|
151
|
-
el.removeAttribute(DRAG_ANCHOR_ATTR);
|
|
152
|
-
if (el.style.boxShadow)
|
|
153
|
-
el.style.boxShadow = '';
|
|
154
190
|
}
|
|
155
191
|
}
|
|
156
192
|
};
|
|
157
193
|
// Expose applyDragAttrs via ref so mouseDown can access it
|
|
158
194
|
applyDragAttrsRef.current = applyDragAttrs;
|
|
195
|
+
/** Clear all drag styling using the tracked set — O(marked) not O(all cells). */
|
|
159
196
|
const clearDragAttrs = () => {
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
return;
|
|
163
|
-
const marked = wrapper.querySelectorAll(`[${DRAG_ATTR}]`);
|
|
164
|
-
for (let i = 0; i < marked.length; i++) {
|
|
165
|
-
const el = marked[i];
|
|
166
|
-
el.removeAttribute(DRAG_ATTR);
|
|
167
|
-
el.removeAttribute(DRAG_ANCHOR_ATTR);
|
|
168
|
-
el.style.boxShadow = '';
|
|
197
|
+
for (const el of markedCells) {
|
|
198
|
+
unstyleCell(el);
|
|
169
199
|
}
|
|
200
|
+
markedCells.clear();
|
|
201
|
+
cellIndex = null;
|
|
170
202
|
};
|
|
171
203
|
/** Resolve mouse coordinates to a cell range (shared by RAF callback and mouseUp flush). */
|
|
172
204
|
const resolveRange = (cx, cy) => {
|
|
@@ -266,6 +298,8 @@ export function useCellSelection(params) {
|
|
|
266
298
|
if (!dragMovedRef.current) {
|
|
267
299
|
dragMovedRef.current = true;
|
|
268
300
|
setIsDragging(true);
|
|
301
|
+
// Build cell index once at drag start for O(1) lookups during drag
|
|
302
|
+
buildCellIndex();
|
|
269
303
|
}
|
|
270
304
|
// Always store latest position so mouseUp can flush if RAF hasn't executed
|
|
271
305
|
lastMousePosRef.current = { cx: e.clientX, cy: e.clientY };
|
|
@@ -344,8 +378,7 @@ export function useCellSelection(params) {
|
|
|
344
378
|
cancelAnimationFrame(rafRef.current);
|
|
345
379
|
stopAutoScroll();
|
|
346
380
|
};
|
|
347
|
-
|
|
348
|
-
}, [setActiveCell]); // wrapperRef, colOffsetRef excluded — refs are stable across renders
|
|
381
|
+
}, [setActiveCell, colOffsetRef, setSelectionRange, wrapperRef]);
|
|
349
382
|
return {
|
|
350
383
|
selectionRange,
|
|
351
384
|
setSelectionRange,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { useCallback, useRef, useState } from 'react';
|
|
2
|
-
import {
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { formatSelectionAsTsv, parseTsvClipboard, applyPastedValues, applyCutClear } from '../utils';
|
|
3
3
|
import { normalizeSelectionRange } from '../types';
|
|
4
4
|
import { useLatestRef } from './useLatestRef';
|
|
5
5
|
/**
|
|
@@ -21,6 +21,9 @@ export function useClipboard(params) {
|
|
|
21
21
|
const [copyRange, setCopyRange] = useState(null);
|
|
22
22
|
/** In-page clipboard fallback when system clipboard is unavailable. */
|
|
23
23
|
const internalClipboardRef = useRef(null);
|
|
24
|
+
/** Guard against async clipboard reads completing after unmount. */
|
|
25
|
+
const isMountedRef = useRef(true);
|
|
26
|
+
useEffect(() => () => { isMountedRef.current = false; }, []);
|
|
24
27
|
/** Resolve current effective range from selection or active cell. */
|
|
25
28
|
const getEffectiveRange = useCallback(() => {
|
|
26
29
|
const sel = selectionRangeRef.current;
|
|
@@ -66,6 +69,9 @@ export function useClipboard(params) {
|
|
|
66
69
|
catch {
|
|
67
70
|
text = '';
|
|
68
71
|
}
|
|
72
|
+
// Bail out if component unmounted during async clipboard read
|
|
73
|
+
if (!isMountedRef.current)
|
|
74
|
+
return;
|
|
69
75
|
if (!text.trim() && internalClipboardRef.current != null) {
|
|
70
76
|
text = internalClipboardRef.current;
|
|
71
77
|
}
|
|
@@ -78,58 +84,13 @@ export function useClipboard(params) {
|
|
|
78
84
|
const visibleCols = visibleColsRef.current;
|
|
79
85
|
const parsedRows = parseTsvClipboard(text);
|
|
80
86
|
beginBatch?.();
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const targetRow = anchorRow + r;
|
|
85
|
-
const targetCol = anchorCol + c;
|
|
86
|
-
if (targetRow >= items.length || targetCol >= visibleCols.length)
|
|
87
|
-
continue;
|
|
88
|
-
const item = items[targetRow];
|
|
89
|
-
const col = visibleCols[targetCol];
|
|
90
|
-
const colEditable = col.editable === true ||
|
|
91
|
-
(typeof col.editable === 'function' && col.editable(item));
|
|
92
|
-
if (!colEditable)
|
|
93
|
-
continue;
|
|
94
|
-
const rawValue = cells[c] ?? '';
|
|
95
|
-
const oldValue = getCellValue(item, col);
|
|
96
|
-
const result = parseValue(rawValue, oldValue, item, col);
|
|
97
|
-
if (!result.valid)
|
|
98
|
-
continue;
|
|
99
|
-
onCellValueChanged({
|
|
100
|
-
item,
|
|
101
|
-
columnId: col.columnId,
|
|
102
|
-
oldValue,
|
|
103
|
-
newValue: result.value,
|
|
104
|
-
rowIndex: targetRow,
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
}
|
|
87
|
+
const pasteEvents = applyPastedValues(parsedRows, anchorRow, anchorCol, items, visibleCols);
|
|
88
|
+
for (const evt of pasteEvents)
|
|
89
|
+
onCellValueChanged(evt);
|
|
108
90
|
if (cutRangeRef.current) {
|
|
109
|
-
const
|
|
110
|
-
for (
|
|
111
|
-
|
|
112
|
-
if (r >= items.length || c >= visibleCols.length)
|
|
113
|
-
continue;
|
|
114
|
-
const item = items[r];
|
|
115
|
-
const col = visibleCols[c];
|
|
116
|
-
const colEditable = col.editable === true ||
|
|
117
|
-
(typeof col.editable === 'function' && col.editable(item));
|
|
118
|
-
if (!colEditable)
|
|
119
|
-
continue;
|
|
120
|
-
const oldValue = getCellValue(item, col);
|
|
121
|
-
const result = parseValue('', oldValue, item, col);
|
|
122
|
-
if (!result.valid)
|
|
123
|
-
continue;
|
|
124
|
-
onCellValueChanged({
|
|
125
|
-
item,
|
|
126
|
-
columnId: col.columnId,
|
|
127
|
-
oldValue,
|
|
128
|
-
newValue: result.value,
|
|
129
|
-
rowIndex: r,
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
}
|
|
91
|
+
const cutEvents = applyCutClear(cutRangeRef.current, items, visibleCols);
|
|
92
|
+
for (const evt of cutEvents)
|
|
93
|
+
onCellValueChanged(evt);
|
|
133
94
|
cutRangeRef.current = null;
|
|
134
95
|
setCutRange(null);
|
|
135
96
|
}
|
|
@@ -141,5 +102,5 @@ export function useClipboard(params) {
|
|
|
141
102
|
setCutRange(null);
|
|
142
103
|
cutRangeRef.current = null;
|
|
143
104
|
}, []);
|
|
144
|
-
return { handleCopy, handleCut, handlePaste,
|
|
105
|
+
return { handleCopy, handleCut, handlePaste, cutRange, copyRange, clearClipboardRanges };
|
|
145
106
|
}
|
|
@@ -9,7 +9,7 @@ import { useState, useCallback, useEffect } from 'react';
|
|
|
9
9
|
* visibleCount, totalCount. UI renders trigger + popover and wires handlers.
|
|
10
10
|
*/
|
|
11
11
|
export function useColumnChooserState(params) {
|
|
12
|
-
const { columns, visibleColumns, onVisibilityChange } = params;
|
|
12
|
+
const { columns, visibleColumns, onVisibilityChange, onSetVisibleColumns } = params;
|
|
13
13
|
const [open, setOpen] = useState(false);
|
|
14
14
|
useEffect(() => {
|
|
15
15
|
if (!open)
|
|
@@ -33,19 +33,31 @@ export function useColumnChooserState(params) {
|
|
|
33
33
|
onVisibilityChange(columnKey, visible);
|
|
34
34
|
}, [onVisibilityChange]);
|
|
35
35
|
const handleSelectAll = useCallback(() => {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
if (onSetVisibleColumns) {
|
|
37
|
+
onSetVisibleColumns(new Set(columns.map((col) => col.columnId)));
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
columns.forEach((col) => {
|
|
41
|
+
if (!visibleColumns.has(col.columnId)) {
|
|
42
|
+
onVisibilityChange(col.columnId, true);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}, [columns, visibleColumns, onVisibilityChange, onSetVisibleColumns]);
|
|
42
47
|
const handleClearAll = useCallback(() => {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
48
|
+
if (onSetVisibleColumns) {
|
|
49
|
+
// Keep required columns visible
|
|
50
|
+
const required = new Set(columns.filter((col) => col.required).map((col) => col.columnId));
|
|
51
|
+
onSetVisibleColumns(required);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
columns.forEach((col) => {
|
|
55
|
+
if (!col.required && visibleColumns.has(col.columnId)) {
|
|
56
|
+
onVisibilityChange(col.columnId, false);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}, [columns, visibleColumns, onVisibilityChange, onSetVisibleColumns]);
|
|
49
61
|
const visibleCount = visibleColumns.size;
|
|
50
62
|
const totalCount = columns.length;
|
|
51
63
|
return {
|
|
@@ -91,27 +91,38 @@ export function useColumnHeaderFilterState(params) {
|
|
|
91
91
|
e.stopPropagation();
|
|
92
92
|
onSort?.();
|
|
93
93
|
}, [onSort]);
|
|
94
|
+
// Destructure stable callbacks from sub-hooks before using as deps
|
|
95
|
+
const { handleApplyMultiSelect: _applyMultiSelect } = multiSelectFilterState;
|
|
96
|
+
const { handleTextApply: _textApply, handleTextClear: _textClear } = textFilterState;
|
|
97
|
+
const { handleUserSelect: _userSelect, handleClearUser: _clearUser } = peopleFilterState;
|
|
98
|
+
const { handleDateApply: _dateApply } = dateFilterState;
|
|
94
99
|
// Wrap sub-hook handlers to close popover
|
|
95
100
|
const handleApplyMultiSelect = useCallback(() => {
|
|
96
|
-
|
|
101
|
+
_applyMultiSelect();
|
|
97
102
|
setFilterOpen(false);
|
|
98
|
-
}, [
|
|
103
|
+
}, [_applyMultiSelect]);
|
|
99
104
|
const handleTextApply = useCallback(() => {
|
|
100
|
-
|
|
105
|
+
_textApply();
|
|
101
106
|
setFilterOpen(false);
|
|
102
|
-
}, [
|
|
107
|
+
}, [_textApply]);
|
|
108
|
+
// Clear immediately commits an empty value and closes the popover (no 2-step clear required)
|
|
109
|
+
const handleTextClear = useCallback(() => {
|
|
110
|
+
_textClear();
|
|
111
|
+
onTextChange?.('');
|
|
112
|
+
setFilterOpen(false);
|
|
113
|
+
}, [_textClear, onTextChange]);
|
|
103
114
|
const handleUserSelect = useCallback((user) => {
|
|
104
|
-
|
|
115
|
+
_userSelect(user);
|
|
105
116
|
setFilterOpen(false);
|
|
106
|
-
}, [
|
|
117
|
+
}, [_userSelect]);
|
|
107
118
|
const handleClearUser = useCallback(() => {
|
|
108
|
-
|
|
119
|
+
_clearUser();
|
|
109
120
|
setFilterOpen(false);
|
|
110
|
-
}, [
|
|
121
|
+
}, [_clearUser]);
|
|
111
122
|
const handleDateApply = useCallback(() => {
|
|
112
|
-
|
|
123
|
+
_dateApply();
|
|
113
124
|
setFilterOpen(false);
|
|
114
|
-
}, [
|
|
125
|
+
}, [_dateApply]);
|
|
115
126
|
// Event propagation stoppers
|
|
116
127
|
const handlePopoverClick = useCallback((e) => e.stopPropagation(), []);
|
|
117
128
|
const handleInputFocus = useCallback((e) => e.stopPropagation(), []);
|
|
@@ -161,7 +172,7 @@ export function useColumnHeaderFilterState(params) {
|
|
|
161
172
|
handleFilterIconClick,
|
|
162
173
|
handleApplyMultiSelect,
|
|
163
174
|
handleTextApply,
|
|
164
|
-
handleTextClear
|
|
175
|
+
handleTextClear,
|
|
165
176
|
handleUserSelect,
|
|
166
177
|
handleClearUser,
|
|
167
178
|
handleDateApply,
|
|
@@ -5,7 +5,7 @@ import { measureColumnContentWidth } from '../utils';
|
|
|
5
5
|
* Tracks which column's menu is open, anchor element, and action handlers.
|
|
6
6
|
*/
|
|
7
7
|
export function useColumnHeaderMenuState(params) {
|
|
8
|
-
const { pinnedColumns, onPinColumn, onUnpinColumn, sortBy, sortDirection, onColumnSort, onColumnResized, onAutosizeColumn, columns,
|
|
8
|
+
const { pinnedColumns, onPinColumn, onUnpinColumn, sortBy, sortDirection, onColumnSort, onColumnResized, onAutosizeColumn, columns, } = params;
|
|
9
9
|
const [isOpen, setIsOpen] = useState(false);
|
|
10
10
|
const [openForColumn, setOpenForColumn] = useState(null);
|
|
11
11
|
const [anchorElement, setAnchorElement] = useState(null);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared hook that pre-computes per-column styles and class names for DataGridTable.
|
|
3
|
+
* Extracted from Radix/Fluent/Material DataGridTable to avoid duplication.
|
|
4
|
+
*
|
|
5
|
+
* @param params.addStickyPosition - When true, adds `position: 'sticky'` inline for pinned columns.
|
|
6
|
+
* This is needed by Fluent UI whose `TableCell` injects atomic `position: relative` via CSS-in-JS,
|
|
7
|
+
* overriding the shared `.pinnedColLeft { position: sticky }` class. Radix/Material don't need it.
|
|
8
|
+
*/
|
|
9
|
+
import { useMemo } from 'react';
|
|
10
|
+
import { DEFAULT_MIN_COLUMN_WIDTH } from '@alaarab/ogrid-core';
|
|
11
|
+
/**
|
|
12
|
+
* Computes per-column styles and class names once per render, avoiding per-cell object creation.
|
|
13
|
+
*/
|
|
14
|
+
export function useColumnMeta(params) {
|
|
15
|
+
const { visibleCols, getColumnWidth, columnSizingOverrides, measuredColumnWidths, pinnedColumns, leftOffsets, rightOffsets, pinnedColLeftClass, pinnedColRightClass, addStickyPosition = false, } = params;
|
|
16
|
+
return useMemo(() => {
|
|
17
|
+
const cellStyles = {};
|
|
18
|
+
const cellClasses = {};
|
|
19
|
+
const hdrStyles = {};
|
|
20
|
+
const hdrClasses = {};
|
|
21
|
+
for (let i = 0; i < visibleCols.length; i++) {
|
|
22
|
+
const col = visibleCols[i];
|
|
23
|
+
const columnWidth = getColumnWidth(col);
|
|
24
|
+
const hasExplicitWidth = !!(columnSizingOverrides[col.columnId] || col.idealWidth != null || col.defaultWidth != null);
|
|
25
|
+
const isPinnedLeft = pinnedColumns[col.columnId] === 'left';
|
|
26
|
+
const isPinnedRight = pinnedColumns[col.columnId] === 'right';
|
|
27
|
+
const isPinned = isPinnedLeft || isPinnedRight;
|
|
28
|
+
const hasResizeOverride = !!columnSizingOverrides[col.columnId];
|
|
29
|
+
const measuredW = measuredColumnWidths[col.columnId];
|
|
30
|
+
const baseMinWidth = col.minWidth ?? DEFAULT_MIN_COLUMN_WIDTH;
|
|
31
|
+
const effectiveMinWidth = hasResizeOverride ? columnWidth : Math.max(baseMinWidth, measuredW ?? 0);
|
|
32
|
+
const stickyOverride = addStickyPosition && isPinned ? { position: 'sticky' } : undefined;
|
|
33
|
+
cellStyles[col.columnId] = {
|
|
34
|
+
minWidth: effectiveMinWidth,
|
|
35
|
+
width: hasExplicitWidth ? columnWidth : undefined,
|
|
36
|
+
maxWidth: hasExplicitWidth ? columnWidth : undefined,
|
|
37
|
+
textAlign: col.type === 'numeric' ? 'right' : col.type === 'boolean' ? 'center' : undefined,
|
|
38
|
+
...stickyOverride,
|
|
39
|
+
...(isPinnedLeft && leftOffsets[col.columnId] != null ? { left: leftOffsets[col.columnId] } : undefined),
|
|
40
|
+
...(isPinnedRight && rightOffsets[col.columnId] != null ? { right: rightOffsets[col.columnId] } : undefined),
|
|
41
|
+
};
|
|
42
|
+
hdrStyles[col.columnId] = {
|
|
43
|
+
minWidth: effectiveMinWidth,
|
|
44
|
+
width: hasExplicitWidth ? columnWidth : undefined,
|
|
45
|
+
maxWidth: hasExplicitWidth ? columnWidth : undefined,
|
|
46
|
+
...stickyOverride,
|
|
47
|
+
...(isPinnedLeft && leftOffsets[col.columnId] != null ? { left: leftOffsets[col.columnId] } : undefined),
|
|
48
|
+
...(isPinnedRight && rightOffsets[col.columnId] != null ? { right: rightOffsets[col.columnId] } : undefined),
|
|
49
|
+
};
|
|
50
|
+
const parts = [];
|
|
51
|
+
if (isPinnedLeft)
|
|
52
|
+
parts.push(pinnedColLeftClass);
|
|
53
|
+
if (isPinnedRight)
|
|
54
|
+
parts.push(pinnedColRightClass);
|
|
55
|
+
const cn = parts.join(' ');
|
|
56
|
+
cellClasses[col.columnId] = cn;
|
|
57
|
+
hdrClasses[col.columnId] = cn;
|
|
58
|
+
}
|
|
59
|
+
return { cellStyles, cellClasses, hdrStyles, hdrClasses };
|
|
60
|
+
}, [visibleCols, getColumnWidth, columnSizingOverrides, measuredColumnWidths, pinnedColumns, leftOffsets, rightOffsets, pinnedColLeftClass, pinnedColRightClass, addStickyPosition]);
|
|
61
|
+
}
|