@alaarab/ogrid 1.7.2 → 1.8.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.
|
@@ -8,13 +8,71 @@ import { ColumnHeaderFilter } from '../ColumnHeaderFilter';
|
|
|
8
8
|
import { InlineCellEditor } from './InlineCellEditor';
|
|
9
9
|
import { StatusBar } from './StatusBar';
|
|
10
10
|
import { GridContextMenu } from './GridContextMenu';
|
|
11
|
-
import { useDataGridState, useColumnResize, getHeaderFilterConfig, getCellRenderDescriptor, buildHeaderRows, MarchingAntsOverlay, resolveCellDisplayContent, resolveCellStyle, buildInlineEditorProps, buildPopoverEditorProps, getCellInteractionProps, } from '@alaarab/ogrid-core';
|
|
11
|
+
import { useDataGridState, useColumnResize, getHeaderFilterConfig, getCellRenderDescriptor, isRowInRange, buildHeaderRows, MarchingAntsOverlay, resolveCellDisplayContent, resolveCellStyle, buildInlineEditorProps, buildPopoverEditorProps, getCellInteractionProps, } from '@alaarab/ogrid-core';
|
|
12
12
|
import styles from './DataGridTable.module.css';
|
|
13
13
|
// Module-scope stable constants (avoid per-render allocations)
|
|
14
14
|
const GRID_ROOT_STYLE = { position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' };
|
|
15
15
|
const CURSOR_CELL_STYLE = { cursor: 'cell' };
|
|
16
16
|
const STOP_PROPAGATION = (e) => e.stopPropagation();
|
|
17
17
|
const PREVENT_DEFAULT = (e) => { e.preventDefault(); };
|
|
18
|
+
function GridRowInner(props) {
|
|
19
|
+
const { item, rowIndex, rowId, isSelected, visibleCols, columnMeta, renderCellContent, handleSingleRowClick, handleRowCheckboxChange, lastMouseShiftRef, hasCheckboxCol, } = props;
|
|
20
|
+
return (_jsxs("tr", { className: isSelected ? styles.selectedRow : '', "data-row-id": rowId, onClick: handleSingleRowClick, children: [hasCheckboxCol && (_jsx("td", { className: styles.selectionCell, children: _jsx("div", { className: styles.selectionCellInner, "data-row-index": rowIndex, "data-col-index": 0, onClick: STOP_PROPAGATION, children: _jsx(Checkbox.Root, { className: styles.rowCheckbox, checked: isSelected, onCheckedChange: (c) => handleRowCheckboxChange(rowId, !!c, rowIndex, lastMouseShiftRef.current), "aria-label": `Select row ${rowIndex + 1}`, children: _jsx(Checkbox.Indicator, { className: styles.rowCheckboxIndicator, children: "\u2713" }) }) }) })), visibleCols.map((col, colIdx) => (_jsx("td", { className: columnMeta.cellClasses[col.columnId] || undefined, style: columnMeta.cellStyles[col.columnId], children: renderCellContent(item, col, rowIndex, colIdx) }, col.columnId)))] }));
|
|
21
|
+
}
|
|
22
|
+
function areGridRowPropsEqual(prev, next) {
|
|
23
|
+
// Data / structure changes — always re-render
|
|
24
|
+
if (prev.item !== next.item)
|
|
25
|
+
return false;
|
|
26
|
+
if (prev.isSelected !== next.isSelected)
|
|
27
|
+
return false;
|
|
28
|
+
if (prev.visibleCols !== next.visibleCols)
|
|
29
|
+
return false;
|
|
30
|
+
if (prev.columnMeta !== next.columnMeta)
|
|
31
|
+
return false;
|
|
32
|
+
if (prev.hasCheckboxCol !== next.hasCheckboxCol)
|
|
33
|
+
return false;
|
|
34
|
+
const ri = prev.rowIndex;
|
|
35
|
+
// Editing cell in this row?
|
|
36
|
+
if (prev.editingRowId !== next.editingRowId) {
|
|
37
|
+
if (prev.editingRowId === prev.rowId || next.editingRowId === next.rowId)
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
// Active cell in this row?
|
|
41
|
+
const prevActive = prev.activeCell?.rowIndex === ri;
|
|
42
|
+
const nextActive = next.activeCell?.rowIndex === ri;
|
|
43
|
+
if (prevActive !== nextActive)
|
|
44
|
+
return false;
|
|
45
|
+
if (prevActive && nextActive && prev.activeCell.columnIndex !== next.activeCell.columnIndex)
|
|
46
|
+
return false;
|
|
47
|
+
// Selection range touches this row?
|
|
48
|
+
const prevInSel = isRowInRange(prev.selectionRange, ri);
|
|
49
|
+
const nextInSel = isRowInRange(next.selectionRange, ri);
|
|
50
|
+
if (prevInSel !== nextInSel)
|
|
51
|
+
return false;
|
|
52
|
+
if (prevInSel && nextInSel) {
|
|
53
|
+
if (prev.selectionRange.startCol !== next.selectionRange.startCol ||
|
|
54
|
+
prev.selectionRange.endCol !== next.selectionRange.endCol)
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
// Fill handle (selection end row) + isDragging
|
|
58
|
+
const prevIsEnd = prev.selectionRange?.endRow === ri;
|
|
59
|
+
const nextIsEnd = next.selectionRange?.endRow === ri;
|
|
60
|
+
if (prevIsEnd !== nextIsEnd)
|
|
61
|
+
return false;
|
|
62
|
+
if ((prevIsEnd || nextIsEnd) && prev.isDragging !== next.isDragging)
|
|
63
|
+
return false;
|
|
64
|
+
// Cut/copy ranges touch this row?
|
|
65
|
+
if (prev.cutRange !== next.cutRange) {
|
|
66
|
+
if (isRowInRange(prev.cutRange, ri) || isRowInRange(next.cutRange, ri))
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
if (prev.copyRange !== next.copyRange) {
|
|
70
|
+
if (isRowInRange(prev.copyRange, ri) || isRowInRange(next.copyRange, ri))
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
const GridRow = React.memo(GridRowInner, areGridRowPropsEqual);
|
|
18
76
|
function DataGridTableInner(props) {
|
|
19
77
|
const wrapperRef = useRef(null);
|
|
20
78
|
const tableContainerRef = useRef(null);
|
|
@@ -23,8 +81,8 @@ function DataGridTableInner(props) {
|
|
|
23
81
|
const { layout, rowSelection: rowSel, editing, interaction, contextMenu: ctxMenu, viewModels } = state;
|
|
24
82
|
const { visibleCols, totalColCount, hasCheckboxCol, colOffset, containerWidth, minTableWidth, desiredTableWidth, columnSizingOverrides, setColumnSizingOverrides } = layout;
|
|
25
83
|
const { selectedRowIds, updateSelection, handleRowCheckboxChange, handleSelectAll, allSelected, someSelected } = rowSel;
|
|
26
|
-
const { setEditingCell, pendingEditorValue, setPendingEditorValue, commitCellEdit, cancelPopoverEdit, popoverAnchorEl, setPopoverAnchorEl } = editing;
|
|
27
|
-
const { setActiveCell, handleCellMouseDown, handleSelectAllCells, selectionRange, hasCellSelection, handleGridKeyDown, handleFillHandleMouseDown, handleCopy, handleCut, handlePaste, cutRange, copyRange, canUndo, canRedo, onUndo, onRedo } = interaction;
|
|
84
|
+
const { editingCell, setEditingCell, pendingEditorValue, setPendingEditorValue, commitCellEdit, cancelPopoverEdit, popoverAnchorEl, setPopoverAnchorEl } = editing;
|
|
85
|
+
const { setActiveCell, handleCellMouseDown, handleSelectAllCells, selectionRange, hasCellSelection, handleGridKeyDown, handleFillHandleMouseDown, handleCopy, handleCut, handlePaste, cutRange, copyRange, canUndo, canRedo, onUndo, onRedo, isDragging } = interaction;
|
|
28
86
|
const { menuPosition, handleCellContextMenu, closeContextMenu } = ctxMenu;
|
|
29
87
|
const { headerFilterInput, cellDescriptorInput, statusBarConfig, showEmptyInGrid } = viewModels;
|
|
30
88
|
const { items, columns, getRowId, emptyState, layoutMode = 'fill', rowSelection = 'none', freezeRows, freezeCols, suppressHorizontalScroll, isLoading = false, loadingMessage = 'Loading\u2026', 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, visibleColumns, } = props;
|
|
@@ -38,6 +96,14 @@ function DataGridTableInner(props) {
|
|
|
38
96
|
});
|
|
39
97
|
const editCallbacks = useMemo(() => ({ commitCellEdit, setEditingCell, setPendingEditorValue, cancelPopoverEdit }), [commitCellEdit, setEditingCell, setPendingEditorValue, cancelPopoverEdit]);
|
|
40
98
|
const interactionHandlers = useMemo(() => ({ handleCellMouseDown, setActiveCell, setEditingCell, handleCellContextMenu }), [handleCellMouseDown, setActiveCell, setEditingCell, handleCellContextMenu]);
|
|
99
|
+
// Refs for volatile state — lets renderCellContent be stable (same function ref across
|
|
100
|
+
// selection changes) so that GridRow's React.memo comparator can skip unaffected rows.
|
|
101
|
+
const cellDescriptorInputRef = useRef(cellDescriptorInput);
|
|
102
|
+
cellDescriptorInputRef.current = cellDescriptorInput;
|
|
103
|
+
const pendingEditorValueRef = useRef(pendingEditorValue);
|
|
104
|
+
pendingEditorValueRef.current = pendingEditorValue;
|
|
105
|
+
const popoverAnchorElRef = useRef(popoverAnchorEl);
|
|
106
|
+
popoverAnchorElRef.current = popoverAnchorEl;
|
|
41
107
|
// Pre-compute column styles and classNames (avoids per-cell object creation in the row loop)
|
|
42
108
|
const columnMeta = useMemo(() => {
|
|
43
109
|
const cellStyles = {};
|
|
@@ -91,15 +157,17 @@ function DataGridTableInner(props) {
|
|
|
91
157
|
}, [rowSelection, updateSelection]);
|
|
92
158
|
// Stable header select-all handler
|
|
93
159
|
const handleSelectAllChecked = useCallback((c) => handleSelectAll(!!c), [handleSelectAll]);
|
|
160
|
+
// renderCellContent reads volatile state from refs — keeps function identity stable so
|
|
161
|
+
// GridRow's React.memo comparator can skip rows whose selection state hasn't changed.
|
|
94
162
|
const renderCellContent = useCallback((item, col, rowIndex, colIdx) => {
|
|
95
|
-
const descriptor = getCellRenderDescriptor(item, col, rowIndex, colIdx,
|
|
163
|
+
const descriptor = getCellRenderDescriptor(item, col, rowIndex, colIdx, cellDescriptorInputRef.current);
|
|
96
164
|
if (descriptor.mode === 'editing-inline') {
|
|
97
165
|
return _jsx(InlineCellEditor, { ...buildInlineEditorProps(item, col, descriptor, editCallbacks) });
|
|
98
166
|
}
|
|
99
167
|
if (descriptor.mode === 'editing-popover' && typeof col.cellEditor === 'function') {
|
|
100
|
-
const editorProps = buildPopoverEditorProps(item, col, descriptor,
|
|
168
|
+
const editorProps = buildPopoverEditorProps(item, col, descriptor, pendingEditorValueRef.current, editCallbacks);
|
|
101
169
|
const CustomEditor = col.cellEditor;
|
|
102
|
-
return (_jsxs(Popover.Root, { open: !!
|
|
170
|
+
return (_jsxs(Popover.Root, { open: !!popoverAnchorElRef.current, onOpenChange: (open) => { if (!open)
|
|
103
171
|
cancelPopoverEdit(); }, children: [_jsx(Popover.Anchor, { asChild: true, children: _jsx("div", { ref: (el) => el && setPopoverAnchorEl(el), style: { minHeight: '100%', minWidth: 40 }, "aria-hidden": true }) }), _jsx(Popover.Portal, { children: _jsx(Popover.Content, { sideOffset: 4, onOpenAutoFocus: (e) => e.preventDefault(), children: _jsx(CustomEditor, { ...editorProps }) }) })] }));
|
|
104
172
|
}
|
|
105
173
|
const content = resolveCellDisplayContent(col, item, descriptor.displayValue);
|
|
@@ -114,27 +182,26 @@ function DataGridTableInner(props) {
|
|
|
114
182
|
].filter(Boolean).join(' ');
|
|
115
183
|
const interactionProps = getCellInteractionProps(descriptor, col.columnId, interactionHandlers);
|
|
116
184
|
return (_jsxs("div", { className: cellClassNames, ...interactionProps, style: descriptor.canEditAny ? CURSOR_CELL_STYLE : undefined, children: [styledContent, descriptor.canEditAny && descriptor.isSelectionEndCell && (_jsx("div", { className: styles.fillHandle, onMouseDown: handleFillHandleMouseDown, "aria-label": "Fill handle" }))] }));
|
|
117
|
-
}, [
|
|
185
|
+
}, [editCallbacks, interactionHandlers, handleFillHandleMouseDown, setPopoverAnchorEl, cancelPopoverEdit]);
|
|
118
186
|
return (_jsxs("div", { style: GRID_ROOT_STYLE, children: [_jsxs("div", { ref: wrapperRef, tabIndex: 0, onMouseDown: (e) => { lastMouseShiftRef.current = e.shiftKey; }, className: `${styles.tableWrapper} ${rowSelection !== 'none' ? styles.selectableGrid : ''}`, role: "region", "aria-label": ariaLabel ?? (ariaLabelledBy ? undefined : 'Data grid'), "aria-labelledby": ariaLabelledBy, "data-empty": showEmptyInGrid ? 'true' : undefined, "data-column-count": totalColCount, "data-freeze-rows": freezeRows != null && freezeRows >= 1 ? freezeRows : undefined, "data-freeze-cols": freezeCols != null && freezeCols >= 1 ? freezeCols : undefined, "data-overflow-x": allowOverflowX ? 'true' : 'false', "data-container-width": containerWidth, "data-min-table-width": Math.round(minTableWidth), "data-has-selection": rowSelection !== 'none' ? 'true' : undefined, onContextMenu: PREVENT_DEFAULT, onKeyDown: handleGridKeyDown, style: {
|
|
119
187
|
['--data-table-column-count']: totalColCount,
|
|
120
188
|
['--data-table-width']: showEmptyInGrid ? '100%' : allowOverflowX ? 'fit-content' : fitToContent ? 'fit-content' : '100%',
|
|
121
189
|
['--data-table-min-width']: showEmptyInGrid ? '100%' : allowOverflowX ? 'max-content' : fitToContent ? 'max-content' : '100%',
|
|
122
190
|
['--data-table-total-min-width']: `${minTableWidth}px`,
|
|
123
|
-
}, children: [
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
createPortal(_jsx(GridContextMenu, { x: menuPosition.x, y: menuPosition.y, hasSelection: hasCellSelection, canUndo: canUndo, canRedo: canRedo, onUndo: onUndo ?? (() => { }), onRedo: onRedo ?? (() => { }), onCopy: handleCopy, onCut: handleCut, onPaste: () => void handlePaste(), onSelectAll: handleSelectAllCells, onClose: closeContextMenu }), document.body)] }), isLoading && items.length > 0 && (_jsx("div", { className: styles.loadingOverlay, "aria-live": "polite", children: _jsxs("div", { className: styles.loadingOverlayContent, children: [_jsx("div", { className: styles.spinner }), _jsx("span", { className: styles.loadingOverlayText, children: loadingMessage })] }) }))] }));
|
|
191
|
+
}, children: [_jsx("div", { className: styles.tableScrollContent, children: _jsx("div", { className: isLoading && items.length > 0 ? styles.loadingDimmed : undefined, children: _jsxs("div", { className: styles.tableWidthAnchor, ref: tableContainerRef, children: [_jsxs("table", { className: styles.dataTable, children: [_jsx("thead", { className: styles.stickyHeader, children: headerRows.map((row, rowIdx) => (_jsxs("tr", { children: [rowIdx === headerRows.length - 1 && hasCheckboxCol && (_jsx("th", { className: styles.selectionHeaderCell, scope: "col", rowSpan: 1, children: _jsx("div", { className: styles.selectionHeaderCellInner, children: _jsx(Checkbox.Root, { className: styles.rowCheckbox, checked: allSelected ? true : someSelected ? 'indeterminate' : false, onCheckedChange: handleSelectAllChecked, "aria-label": "Select all rows", children: _jsx(Checkbox.Indicator, { className: styles.rowCheckboxIndicator, children: someSelected && !allSelected ? '–' : '✓' }) }) }) })), rowIdx === 0 && rowIdx < headerRows.length - 1 && hasCheckboxCol && (_jsx("th", { rowSpan: headerRows.length - 1 })), row.map((cell, cellIdx) => {
|
|
192
|
+
if (cell.isGroup) {
|
|
193
|
+
return (_jsx("th", { colSpan: cell.colSpan, className: styles.groupHeaderCell, scope: "colgroup", children: cell.label }, cellIdx));
|
|
194
|
+
}
|
|
195
|
+
// Leaf cell
|
|
196
|
+
const col = cell.columnDef;
|
|
197
|
+
const leafRowSpan = headerRows.length > 1 && rowIdx < headerRows.length - 1
|
|
198
|
+
? headerRows.length - rowIdx
|
|
199
|
+
: undefined;
|
|
200
|
+
return (_jsxs("th", { scope: "col", "data-column-id": col.columnId, rowSpan: leafRowSpan, className: columnMeta.hdrClasses[col.columnId] || undefined, style: columnMeta.hdrStyles[col.columnId], children: [_jsx(ColumnHeaderFilter, { ...getHeaderFilterConfig(col, headerFilterInput) }), _jsx("div", { className: styles.resizeHandle, onMouseDown: (e) => handleResizeStart(e, col), "aria-label": `Resize ${col.name}` })] }, col.columnId));
|
|
201
|
+
})] }, rowIdx))) }), !showEmptyInGrid && (_jsx("tbody", { children: items.map((item, rowIndex) => {
|
|
202
|
+
const rowIdStr = getRowId(item);
|
|
203
|
+
return (_jsx(GridRow, { item: item, rowIndex: rowIndex, rowId: rowIdStr, isSelected: selectedRowIds.has(rowIdStr), visibleCols: visibleCols, columnMeta: columnMeta, renderCellContent: renderCellContent, handleSingleRowClick: handleSingleRowClick, handleRowCheckboxChange: handleRowCheckboxChange, lastMouseShiftRef: lastMouseShiftRef, hasCheckboxCol: hasCheckboxCol, selectionRange: selectionRange, activeCell: interaction.activeCell, cutRange: cutRange, copyRange: copyRange, isDragging: isDragging, editingRowId: editingCell?.rowId ?? null }, rowIdStr));
|
|
204
|
+
}) }))] }), _jsx(MarchingAntsOverlay, { containerRef: tableContainerRef, selectionRange: selectionRange, copyRange: copyRange, cutRange: cutRange, colOffset: colOffset }), showEmptyInGrid && emptyState && (_jsx("div", { className: styles.emptyStateInGrid, children: _jsx("div", { children: emptyState.render ? (emptyState.render()) : (_jsxs(_Fragment, { children: [_jsx("div", { className: styles.emptyStateInGridTitle, children: "No results found" }), _jsx("div", { className: styles.emptyStateInGridMessage, children: emptyState.message != null ? (emptyState.message) : emptyState.hasActiveFilters ? (_jsxs(_Fragment, { children: ["No items match your current filters. Try adjusting your search or", ' ', _jsx("button", { type: "button", className: styles.emptyStateInGridLink, onClick: emptyState.onClearAll, children: "clear all filters" }), ' ', "to see all items."] })) : ('There are no items available at this time.') })] })) }) }))] }) }) }), menuPosition &&
|
|
205
|
+
createPortal(_jsx(GridContextMenu, { x: menuPosition.x, y: menuPosition.y, hasSelection: hasCellSelection, canUndo: canUndo, canRedo: canRedo, onUndo: onUndo ?? (() => { }), onRedo: onRedo ?? (() => { }), onCopy: handleCopy, onCut: handleCut, onPaste: () => void handlePaste(), onSelectAll: handleSelectAllCells, onClose: closeContextMenu }), document.body)] }), statusBarConfig && (_jsx(StatusBar, { totalCount: statusBarConfig.totalCount, filteredCount: statusBarConfig.filteredCount, selectedCount: statusBarConfig.selectedCount ?? selectedRowIds.size, selectedCellCount: selectionRange ? (Math.abs(selectionRange.endRow - selectionRange.startRow) + 1) * (Math.abs(selectionRange.endCol - selectionRange.startCol) + 1) : undefined, aggregation: statusBarConfig.aggregation, suppressRowCount: statusBarConfig.suppressRowCount })), isLoading && items.length > 0 && (_jsx("div", { className: styles.loadingOverlay, "aria-live": "polite", children: _jsxs("div", { className: styles.loadingOverlayContent, children: [_jsx("div", { className: styles.spinner }), _jsx("span", { className: styles.loadingOverlayText, children: loadingMessage })] }) }))] }));
|
|
139
206
|
}
|
|
140
207
|
export const DataGridTable = React.memo(DataGridTableInner);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alaarab/ogrid",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "OGrid default (Radix) – Data grid with sorting, filtering, pagination, column chooser, and CSV export. Packed with Radix UI; no Fluent or Material required.",
|
|
5
5
|
"main": "dist/esm/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"node": ">=18"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@alaarab/ogrid-core": "^1.
|
|
44
|
+
"@alaarab/ogrid-core": "^1.8.0",
|
|
45
45
|
"@radix-ui/react-checkbox": "^1.1.2",
|
|
46
46
|
"@radix-ui/react-popover": "^1.1.2"
|
|
47
47
|
},
|