@opencorestack/opengridx 2.0.4 → 3.0.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/CHANGELOG.md +408 -3
- package/README.md +93 -72
- package/dist/index.d.ts +611 -121
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +7992 -5650
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +17 -23
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +348 -161
- package/docs/README.md +6 -1
- package/docs/architecture/datagrid-orchestration.md +81 -0
- package/docs/architecture/grid-row-meta.md +102 -0
- package/docs/architecture/use-grid-columns.md +128 -0
- package/docs/architecture/use-grid-controlled-state.md +157 -0
- package/docs/architecture/use-grid-row-pipeline.md +120 -0
- package/docs/architecture/use-grid-scroll-sync.md +111 -0
- package/docs/architecture/use-grid-state-snapshot.md +90 -0
- package/docs/architecture/use-grid-virtualization.md +115 -0
- package/docs/architecture/use-grid-visible-rows.md +85 -0
- package/docs/assets/banner-alt.png +0 -0
- package/docs/assets/banner.png +0 -0
- package/docs/assets/hero.png +0 -0
- package/docs/assets/logo.png +0 -0
- package/docs/components/aggregation-footer.md +15 -10
- package/docs/components/cell.md +16 -6
- package/docs/components/column-group-header.md +55 -28
- package/docs/components/column-resize.md +17 -13
- package/docs/components/column-visibility.md +45 -22
- package/docs/components/datagrid.md +14 -12
- package/docs/components/empty-state.md +2 -1
- package/docs/components/error-overlay.md +12 -7
- package/docs/components/filter-panel.md +28 -8
- package/docs/components/header.md +28 -9
- package/docs/components/pagination.md +14 -8
- package/docs/components/row.md +21 -5
- package/docs/components/toolbar.md +27 -9
- package/docs/components/tooltip.md +16 -7
- package/docs/customization/slots-api.md +100 -36
- package/docs/customization/theming.md +66 -18
- package/docs/features/aggregation-pivot.md +75 -9
- package/docs/features/cell-spanning.md +80 -125
- package/docs/features/clipboard.md +41 -16
- package/docs/features/custom-pagination.md +38 -18
- package/docs/features/data-source.md +36 -13
- package/docs/features/editing-reordering.md +125 -16
- package/docs/features/export-guide.md +147 -41
- package/docs/features/filtering.md +44 -6
- package/docs/features/infinite-scroll.md +54 -46
- package/docs/features/keyboard-navigation.md +83 -0
- package/docs/features/list-view.md +58 -5
- package/docs/features/loading-states.md +18 -20
- package/docs/features/master-detail.md +18 -5
- package/docs/features/pdf-export.md +56 -19
- package/docs/features/pinning.md +23 -5
- package/docs/features/selection.md +50 -10
- package/docs/features/sorting-pagination.md +66 -14
- package/docs/features/state-persistence.md +78 -10
- package/docs/features/toolbar-customization.md +26 -17
- package/docs/features/tree-data-grouping.md +44 -9
- package/docs/features/virtualization.md +64 -15
- package/docs/migration/v1-to-v2.md +115 -0
- package/docs/migration/v2-to-v3.md +1222 -0
- package/docs/performance.md +124 -0
- package/docs/roadmap.md +16 -10
- package/lib/components/Cell/Cell.css +13 -1
- package/lib/components/Cell/Cell.tsx +130 -57
- package/lib/components/Cell/CellErrorBoundary.tsx +12 -6
- package/lib/components/Cell/GridEditInputCell.tsx +114 -41
- package/lib/components/ColumnGroupHeader/ColumnGroupHeader.css +7 -1
- package/lib/components/ColumnResizeHandle/ColumnResizeHandle.css +11 -0
- package/lib/components/ColumnResizeHandle/ColumnResizeHandle.tsx +104 -38
- package/lib/components/ColumnResizeHandle/clampResizeWidth.ts +13 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +6 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +21 -13
- package/lib/components/DataGrid/DataGrid.css +84 -12
- package/lib/components/DataGrid/DataGrid.tsx +606 -872
- package/lib/components/DataGrid/GridAggregationFooter.tsx +53 -17
- package/lib/components/DataGrid/GridEmptyState.tsx +7 -1
- package/lib/components/DataGrid/GridErrorOverlay.tsx +15 -12
- package/lib/components/DataGrid/GridListView.tsx +166 -23
- package/lib/components/DataGrid/GridLiveRegion.tsx +31 -0
- package/lib/components/DataGrid/GridLoadingOverlay.tsx +27 -0
- package/lib/components/DataGrid/GridPaginationArea.tsx +44 -0
- package/lib/components/DataGrid/GridPinnedRows.tsx +65 -11
- package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +40 -38
- package/lib/components/DataGrid/GridToolbarSlot.tsx +51 -0
- package/lib/components/DataGrid/GridVirtualRows.tsx +48 -25
- package/lib/components/DetailPanel/DetailPanel.css +0 -6
- package/lib/components/DetailPanel/DetailPanel.tsx +34 -5
- package/lib/components/DetailPanel/DetailPanelContent.tsx +17 -0
- package/lib/components/FilterPanel/FilterPanel.css +13 -2
- package/lib/components/FilterPanel/FilterPanel.tsx +272 -99
- package/lib/components/Header/ColumnMenu.tsx +91 -39
- package/lib/components/Header/Header.css +3 -3
- package/lib/components/Header/Header.tsx +179 -160
- package/lib/components/ListView/ListView.css +25 -0
- package/lib/components/ListView/ListViewRow.tsx +79 -10
- package/lib/components/Pagination/Pagination.tsx +15 -7
- package/lib/components/Row/Row.tsx +185 -75
- package/lib/components/Toolbar/GlobalSearch.css +6 -3
- package/lib/components/Toolbar/GlobalSearch.tsx +26 -27
- package/lib/components/Toolbar/GridToolbar.css +12 -11
- package/lib/components/Toolbar/GridToolbar.tsx +133 -89
- package/lib/components/Toolbar/PivotPanel.tsx +89 -49
- package/lib/components/Tooltip/Tooltip.css +20 -0
- package/lib/components/Tooltip/Tooltip.tsx +68 -35
- package/lib/components/ui/Button.tsx +3 -0
- package/lib/components/ui/Checkbox.tsx +2 -1
- package/lib/components/ui/ExpandIcon.css +0 -9
- package/lib/components/ui/ExpandIcon.tsx +4 -1
- package/lib/hooks/core/gridApiPlaceholder.ts +34 -0
- package/lib/hooks/core/gridToolbarHostContext.ts +14 -0
- package/lib/hooks/core/useDataGrid.ts +115 -173
- package/lib/hooks/core/useGridAggregationApi.ts +72 -0
- package/lib/hooks/core/useGridApiMethods.ts +115 -0
- package/lib/hooks/core/useGridApiRef.ts +15 -3
- package/lib/hooks/core/useGridApiRefBinding.ts +18 -0
- package/lib/hooks/core/useGridAriaRows.ts +38 -0
- package/lib/hooks/core/useGridClipboardApi.ts +38 -0
- package/lib/hooks/core/useGridColumnLookup.ts +30 -0
- package/lib/hooks/core/useGridColumns.tsx +284 -76
- package/lib/hooks/core/useGridColumnsPanel.ts +77 -0
- package/lib/hooks/core/useGridControlledState.ts +99 -6
- package/lib/hooks/core/useGridDetailPanel.ts +51 -0
- package/lib/hooks/core/useGridDevWarnings.ts +123 -0
- package/lib/hooks/core/useGridFocusHandlers.ts +50 -0
- package/lib/hooks/core/useGridGroupingColumn.ts +62 -0
- package/lib/hooks/core/useGridHeaderHandlers.ts +92 -0
- package/lib/hooks/core/useGridHierarchy.ts +81 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +452 -250
- package/lib/hooks/core/useGridLiveRowSelection.ts +67 -0
- package/lib/hooks/core/useGridPageCorrection.ts +55 -0
- package/lib/hooks/core/useGridRowIdOf.ts +32 -0
- package/lib/hooks/core/useGridRowInteractions.ts +81 -0
- package/lib/hooks/core/useGridRowPipeline.ts +69 -39
- package/lib/hooks/core/useGridRowSelection.ts +91 -0
- package/lib/hooks/core/useGridScrollSync.ts +80 -32
- package/lib/hooks/core/useGridScrollToIndexesApi.ts +45 -0
- package/lib/hooks/core/useGridStateSnapshot.ts +40 -5
- package/lib/hooks/core/useGridThemeDimensions.ts +49 -0
- package/lib/hooks/core/useGridToolbarProps.ts +97 -0
- package/lib/hooks/core/useGridViewport.ts +38 -0
- package/lib/hooks/core/useGridViewportSize.ts +26 -0
- package/lib/hooks/core/useGridVisibleRows.ts +20 -7
- package/lib/hooks/core/useLayout.ts +215 -195
- package/lib/hooks/core/useRowsScrollEndLatch.ts +41 -0
- package/lib/hooks/core/useStableColumns.ts +34 -0
- package/lib/hooks/features/useAggregation.ts +86 -90
- package/lib/hooks/features/useColumnGroupReorderGuard.ts +34 -0
- package/lib/hooks/features/useDetailPanelHeights.ts +29 -0
- package/lib/hooks/features/useGridClipboard.ts +133 -73
- package/lib/hooks/features/useGridDataSource.ts +354 -128
- package/lib/hooks/features/useGridEditing.ts +211 -51
- package/lib/hooks/features/useGridPivot.ts +92 -0
- package/lib/hooks/features/useGridSpanRenderWindow.ts +104 -0
- package/lib/hooks/features/useGridSpanning.ts +410 -306
- package/lib/hooks/features/usePivot.ts +0 -0
- package/lib/hooks/features/useServerTreeChildren.ts +50 -0
- package/lib/hooks/useColumnReorder.ts +38 -44
- package/lib/hooks/useRowGrouping.ts +335 -214
- package/lib/hooks/useRowReorder.ts +62 -31
- package/lib/hooks/useTreeData.ts +330 -262
- package/lib/index.ts +30 -2
- package/lib/state/index.ts +1 -0
- package/lib/state/types.ts +7 -1
- package/lib/state/useGridStateStorage.ts +88 -29
- package/lib/theme/DataGridThemeProvider.tsx +143 -38
- package/lib/theme/gridThemeContext.ts +5 -0
- package/lib/theme/index.ts +5 -0
- package/lib/theme/presets.ts +5 -4
- package/lib/theme/types.ts +40 -2
- package/lib/types/index.ts +311 -108
- package/lib/utils/aggregation/index.ts +157 -0
- package/lib/utils/aria/index.ts +40 -0
- package/lib/utils/columnGroups.ts +110 -0
- package/lib/utils/columnLookup.ts +33 -0
- package/lib/utils/columnWidth.ts +22 -0
- package/lib/utils/dataSource/index.ts +46 -0
- package/lib/utils/detailPanel.ts +66 -0
- package/lib/utils/dragData.ts +44 -0
- package/lib/utils/editing/index.ts +30 -0
- package/lib/utils/export/exportShared.ts +197 -0
- package/lib/utils/export/exportToExcelAdvanced.ts +344 -121
- package/lib/utils/export/exportToPdf.ts +143 -92
- package/lib/utils/export/groupLabel.ts +14 -0
- package/lib/utils/export/index.ts +278 -272
- package/lib/utils/export/pdfText.ts +50 -0
- package/lib/utils/filtering/index.ts +277 -148
- package/lib/utils/focus/index.ts +115 -0
- package/lib/utils/gridApiRows.ts +35 -0
- package/lib/utils/gridModes.ts +77 -0
- package/lib/utils/grouping/groupedExportRows.ts +91 -0
- package/lib/utils/pagination/index.ts +56 -0
- package/lib/utils/pinning/index.ts +77 -33
- package/lib/utils/pivot/index.ts +368 -0
- package/lib/utils/scroll/index.ts +78 -0
- package/lib/utils/scroll/scrollEnd.ts +18 -0
- package/lib/utils/sorting/index.ts +154 -30
- package/lib/utils/syntheticRows.ts +12 -0
- package/lib/utils/values.ts +206 -0
- package/lib/utils/viewport.ts +14 -0
- package/llms.txt +272 -166
- package/package.json +18 -6
- package/lib/components/ColumnGroupHeader/ColumnGroupHeader.tsx +0 -220
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.test.tsx +0 -85
- package/lib/components/DataGrid/DataGrid.regression.test.tsx +0 -132
- package/lib/components/DataGrid/DataGrid.smoke.test.tsx +0 -58
- package/lib/components/DataGrid/DataGrid.v2.test.tsx +0 -127
- package/lib/components/Header/Header.test.tsx +0 -135
- package/lib/components/Pagination/Pagination.test.tsx +0 -64
- package/lib/hooks/core/useDataGrid.regression.test.ts +0 -66
- package/lib/hooks/core/useGridControlledState.test.ts +0 -86
- package/lib/hooks/core/useGridKeyboardNavigation.test.ts +0 -176
- package/lib/hooks/core/useGridRowPipeline.test.ts +0 -117
- package/lib/hooks/features/useAggregation.test.ts +0 -175
- package/lib/hooks/features/usePivot.test.ts +0 -202
- package/lib/hooks/useRowGrouping.test.ts +0 -137
- package/lib/utils/export/exportToPdf.test.ts +0 -120
- package/lib/utils/export/groupedExport.test.ts +0 -144
- package/lib/utils/filtering/filtering.test.ts +0 -178
- package/lib/utils/sorting/sorting.test.ts +0 -88
|
@@ -7,16 +7,51 @@ import { DragHandleIcon } from '../ui/DragHandleIcon';
|
|
|
7
7
|
import { DetailPanel } from '../DetailPanel/DetailPanel';
|
|
8
8
|
import type { GridRowModel, GridColDef, GridRowId, GridColumnPinning, GridRowPinning, GridRowParams, GridCellParams, GridDetailPanelHeight, GridRowMeta } from '../../types';
|
|
9
9
|
import type { CellColSpanInfo, RowSpanningCaches } from '../../hooks/features/useGridSpanning';
|
|
10
|
-
import { isColumnPinned, calculatePinnedPositions, isRowPinned } from '../../utils/pinning';
|
|
11
|
-
|
|
10
|
+
import { isColumnPinned, calculatePinnedPositions, getPinnedEdgeFields, isRowPinned } from '../../utils/pinning';
|
|
11
|
+
import { resolveCellEditable } from '../../utils/editing';
|
|
12
|
+
import { tryGetCellValue } from '../../utils/values';
|
|
13
|
+
import { isSyntheticRowId } from '../../utils/syntheticRows';
|
|
14
|
+
import { getRenderedColumnWidth } from '../../utils/columnWidth';
|
|
15
|
+
import { DEFAULT_DETAIL_PANEL_HEIGHT } from '../../utils/detailPanel';
|
|
16
|
+
|
|
17
|
+
/** Sticky system cells (drag handle, detail toggle, checkbox) sit above scrolled, spanned and focused cells. */
|
|
18
|
+
const SYSTEM_CELL_Z_INDEX = 12;
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Builds the Cell span info for a colSpan origin: the merged width and flex-grow are the sums over
|
|
23
|
+
* the columns it covers, taken from the rendered (layout-resolved) columns. The span never crosses a
|
|
24
|
+
* spacer, so a span cut by the column window only covers what is rendered.
|
|
25
|
+
*/
|
|
26
|
+
function mergeColSpan<R extends GridRowModel>(
|
|
27
|
+
columns: GridColDef<R>[],
|
|
28
|
+
originIndex: number,
|
|
29
|
+
colSpan: number,
|
|
30
|
+
columnWidths: Record<string, number>,
|
|
31
|
+
): CellColSpanInfo {
|
|
32
|
+
let width = 0;
|
|
33
|
+
let flexGrow = 0;
|
|
34
|
+
let covered = 0;
|
|
35
|
+
for (let i = originIndex; i < columns.length && covered < colSpan; i++) {
|
|
36
|
+
const col = columns[i];
|
|
37
|
+
if (col.isSpacer) break;
|
|
38
|
+
width += getRenderedColumnWidth(col, columnWidths);
|
|
39
|
+
flexGrow += col.flex ?? 0;
|
|
40
|
+
covered++;
|
|
41
|
+
}
|
|
42
|
+
return { spannedByColSpan: false, cellProps: { colSpan: covered, width, flexGrow } };
|
|
43
|
+
}
|
|
12
44
|
|
|
13
45
|
export interface RowProps<R extends GridRowModel = GridRowModel> {
|
|
14
46
|
row: R;
|
|
47
|
+
/** The row's id (from getRowId). Defaults to `row.id`; the grid always passes it. */
|
|
48
|
+
rowId?: GridRowId;
|
|
15
49
|
columns: GridColDef<R>[];
|
|
16
50
|
rowIndex: number;
|
|
17
51
|
isSelected?: boolean;
|
|
18
52
|
checkboxSelection?: boolean;
|
|
19
53
|
onRowClick?: (params: GridRowParams<R>) => void;
|
|
54
|
+
onRowDoubleClick?: (params: GridRowParams<R>) => void;
|
|
20
55
|
onCellClick?: (params: GridCellParams<R>) => void;
|
|
21
56
|
onSelectionChange?: (rowId: GridRowId, isSelected: boolean) => void;
|
|
22
57
|
columnWidths?: Record<string, number>;
|
|
@@ -28,6 +63,8 @@ export interface RowProps<R extends GridRowModel = GridRowModel> {
|
|
|
28
63
|
onDetailPanelToggle?: (rowId: GridRowId) => void;
|
|
29
64
|
detailPanelContent?: React.ReactNode;
|
|
30
65
|
detailPanelHeight?: GridDetailPanelHeight;
|
|
66
|
+
/** Called with the rendered height of an `'auto'` detail panel whenever it changes. */
|
|
67
|
+
onDetailPanelHeightChange?: (rowId: GridRowId, height: number) => void;
|
|
31
68
|
|
|
32
69
|
pinCheckboxColumn?: boolean;
|
|
33
70
|
pinExpandColumn?: boolean;
|
|
@@ -42,8 +79,11 @@ export interface RowProps<R extends GridRowModel = GridRowModel> {
|
|
|
42
79
|
|
|
43
80
|
editingCell?: { id: GridRowId; field: string; value: unknown; } | null;
|
|
44
81
|
onEditStart?: (params: { id: GridRowId, field: string, value: unknown }) => void;
|
|
45
|
-
|
|
82
|
+
/** `id` / `field` name the cell whose editor asked to stop. */
|
|
83
|
+
onEditStop?: (params?: { cancel?: boolean; id?: GridRowId; field?: string }) => void;
|
|
46
84
|
onEditCellValueChange?: (params: { id: GridRowId, field: string, value: unknown }) => void;
|
|
85
|
+
/** Per-cell editability predicate (`DataGrid.isCellEditable`). */
|
|
86
|
+
isCellEditable?: (params: GridCellParams<R>) => boolean;
|
|
47
87
|
|
|
48
88
|
focusedCellField?: string | null;
|
|
49
89
|
isFocusVisible?: boolean;
|
|
@@ -52,16 +92,22 @@ export interface RowProps<R extends GridRowModel = GridRowModel> {
|
|
|
52
92
|
rowSpanningCaches?: RowSpanningCaches;
|
|
53
93
|
rowHeight?: number;
|
|
54
94
|
rowMeta?: GridRowMeta;
|
|
95
|
+
/** Position of each visible data column in render order, for `colIndex` and `aria-colindex`. */
|
|
96
|
+
columnIndexMap?: Map<string, number>;
|
|
97
|
+
/** 1-based `aria-rowindex` of this row in the whole grid (header rows included). Defaults to `rowIndex + 2`. */
|
|
98
|
+
ariaRowIndex?: number;
|
|
55
99
|
}
|
|
56
100
|
|
|
57
101
|
export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
58
102
|
const {
|
|
59
103
|
row,
|
|
104
|
+
rowId: rowIdProp,
|
|
60
105
|
columns,
|
|
61
106
|
rowIndex,
|
|
62
107
|
isSelected = false,
|
|
63
108
|
checkboxSelection = false,
|
|
64
109
|
onRowClick,
|
|
110
|
+
onRowDoubleClick,
|
|
65
111
|
onCellClick,
|
|
66
112
|
onSelectionChange,
|
|
67
113
|
columnWidths = {},
|
|
@@ -71,7 +117,8 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
71
117
|
isDetailPanelExpanded = false,
|
|
72
118
|
onDetailPanelToggle,
|
|
73
119
|
detailPanelContent,
|
|
74
|
-
detailPanelHeight =
|
|
120
|
+
detailPanelHeight = DEFAULT_DETAIL_PANEL_HEIGHT,
|
|
121
|
+
onDetailPanelHeightChange,
|
|
75
122
|
pinCheckboxColumn = true,
|
|
76
123
|
pinExpandColumn = true,
|
|
77
124
|
rowReordering = false,
|
|
@@ -86,6 +133,7 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
86
133
|
onEditStart,
|
|
87
134
|
onEditStop,
|
|
88
135
|
onEditCellValueChange,
|
|
136
|
+
isCellEditable,
|
|
89
137
|
|
|
90
138
|
focusedCellField,
|
|
91
139
|
isFocusVisible,
|
|
@@ -93,22 +141,16 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
93
141
|
colspanMap,
|
|
94
142
|
rowSpanningCaches,
|
|
95
143
|
rowHeight = 52,
|
|
96
|
-
rowMeta
|
|
144
|
+
rowMeta,
|
|
145
|
+
columnIndexMap,
|
|
146
|
+
ariaRowIndex,
|
|
97
147
|
} = props;
|
|
98
148
|
|
|
149
|
+
const id = rowIdProp ?? row.id;
|
|
150
|
+
// All hooks must come before any early returns (Rules of Hooks).
|
|
151
|
+
// DOM focus is moved onto the focused cell by useGridKeyboardNavigation, not by the row.
|
|
99
152
|
const checkboxId = useId();
|
|
100
|
-
const
|
|
101
|
-
const checkboxCellRef = React.useRef<HTMLDivElement>(null);
|
|
102
|
-
|
|
103
|
-
// All hooks must come before any early returns (Rules of Hooks)
|
|
104
|
-
React.useLayoutEffect(() => {
|
|
105
|
-
if (focusedCellField === '__expand_col__' && expandCellRef.current) {
|
|
106
|
-
expandCellRef.current.focus({ preventScroll: true });
|
|
107
|
-
}
|
|
108
|
-
if (focusedCellField === '__checkbox_col__' && checkboxCellRef.current) {
|
|
109
|
-
checkboxCellRef.current.focus({ preventScroll: true });
|
|
110
|
-
}
|
|
111
|
-
}, [focusedCellField]);
|
|
153
|
+
const detailPanelId = useId();
|
|
112
154
|
|
|
113
155
|
const pinnedPositions = React.useMemo(() => {
|
|
114
156
|
return calculatePinnedPositions(
|
|
@@ -123,21 +165,28 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
123
165
|
);
|
|
124
166
|
}, [columns, columnWidths, pinnedColumns, checkboxSelection, pinCheckboxColumn, hasDetailPanel, pinExpandColumn, rowReordering]);
|
|
125
167
|
|
|
126
|
-
const
|
|
168
|
+
const pinnedEdges = React.useMemo(() => getPinnedEdgeFields(columns, pinnedColumns), [columns, pinnedColumns]);
|
|
127
169
|
|
|
170
|
+
const isGroupRow = rowMeta?.hasChildren === true;
|
|
171
|
+
// Group rows, subtotal rows and auto-created tree parents: not data, so no selection or detail panel.
|
|
172
|
+
const isSyntheticRow = rowMeta?.isGroupRow === true;
|
|
173
|
+
const showDetailPanel = hasDetailPanel && !isSyntheticRow;
|
|
174
|
+
// The pivot Grand Total is synthetic too, but its cells show values like any row.
|
|
175
|
+
const isSelectableRow = !isSyntheticRow && !isSyntheticRowId(id);
|
|
176
|
+
|
|
177
|
+
// Which cells may be edited is decided per cell below (resolveCellEditable), not per row:
|
|
178
|
+
// tree-data parents are real rows and stay editable; synthetic group rows never are.
|
|
128
179
|
const handleCellEditStart = React.useCallback((field: string, value: unknown) => {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
}, [isGroupRow, row.id, onEditStart]);
|
|
180
|
+
onEditStart?.({ id, field, value });
|
|
181
|
+
}, [id, onEditStart]);
|
|
133
182
|
|
|
134
183
|
const handleCellValueChange = React.useCallback((field: string, newValue: unknown) => {
|
|
135
|
-
onEditCellValueChange?.({ id
|
|
136
|
-
}, [onEditCellValueChange,
|
|
184
|
+
onEditCellValueChange?.({ id, field, value: newValue });
|
|
185
|
+
}, [onEditCellValueChange, id]);
|
|
137
186
|
|
|
138
|
-
const handleEditStopWrapper = React.useCallback((cancel?: boolean) => {
|
|
139
|
-
onEditStop?.({ cancel });
|
|
140
|
-
}, [onEditStop]);
|
|
187
|
+
const handleEditStopWrapper = React.useCallback((cancel?: boolean, field?: string) => {
|
|
188
|
+
onEditStop?.({ cancel, id, field });
|
|
189
|
+
}, [onEditStop, id]);
|
|
141
190
|
|
|
142
191
|
// ── Skeleton row (shown during infinite-scroll fetch) ─────────────────────
|
|
143
192
|
if (row._isSkeleton) {
|
|
@@ -159,25 +208,42 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
159
208
|
);
|
|
160
209
|
}
|
|
161
210
|
|
|
211
|
+
// `.ogx__cell--editing` covers custom renderEditCell editors, which have no .ogx__edit-cell wrapper.
|
|
162
212
|
const handleRowClick = (event: React.MouseEvent) => {
|
|
163
213
|
|
|
164
|
-
if ((event.target as HTMLElement).closest('.ogx-checkbox-wrapper, .ogx-expand-icon, .ogx-drag-handle, .ogx__edit-cell')) {
|
|
214
|
+
if ((event.target as HTMLElement).closest('.ogx-checkbox-wrapper, .ogx-expand-icon, .ogx-drag-handle, .ogx__edit-cell, .ogx__cell--editing')) {
|
|
165
215
|
return;
|
|
166
216
|
}
|
|
167
|
-
onRowClick?.({ row, id
|
|
217
|
+
onRowClick?.({ row, id, rowIndex });
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const handleRowDoubleClick = (event: React.MouseEvent) => {
|
|
221
|
+
if ((event.target as HTMLElement).closest('.ogx-checkbox-wrapper, .ogx-expand-icon, .ogx-drag-handle, .ogx__edit-cell, .ogx__cell--editing')) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
onRowDoubleClick?.({ row, id, rowIndex });
|
|
168
225
|
};
|
|
169
226
|
|
|
170
227
|
const handleCheckboxChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
171
228
|
event.stopPropagation();
|
|
172
|
-
onSelectionChange?.(
|
|
229
|
+
onSelectionChange?.(id, event.target.checked);
|
|
173
230
|
};
|
|
174
231
|
|
|
175
232
|
const handleDetailPanelToggle = (event: React.MouseEvent) => {
|
|
176
233
|
event.stopPropagation();
|
|
177
|
-
onDetailPanelToggle?.(
|
|
234
|
+
onDetailPanelToggle?.(id);
|
|
178
235
|
};
|
|
179
236
|
|
|
180
|
-
|
|
237
|
+
// aria-colindex: system columns first (reorder, expand, checkbox), then the data columns.
|
|
238
|
+
const reorderColIndex = rowReordering ? 1 : 0;
|
|
239
|
+
const expandColIndex = hasDetailPanel ? reorderColIndex + 1 : reorderColIndex;
|
|
240
|
+
const checkboxColIndex = checkboxSelection ? expandColIndex + 1 : expandColIndex;
|
|
241
|
+
const systemColumnCount = checkboxColIndex;
|
|
242
|
+
const dataColumnCount = columnIndexMap?.size ?? columns.filter(c => !c.isSpacer).length;
|
|
243
|
+
const isHierarchyRow = rowMeta !== undefined;
|
|
244
|
+
const hasChildren = rowMeta?.hasChildren === true;
|
|
245
|
+
|
|
246
|
+
const rowPinnedPosition = isRowPinned(id, pinnedRows);
|
|
181
247
|
const isRowPinnedTop = rowPinnedPosition === 'top';
|
|
182
248
|
const isRowPinnedBottom = rowPinnedPosition === 'bottom';
|
|
183
249
|
|
|
@@ -190,7 +256,8 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
190
256
|
isRowPinnedBottom && 'ogx__row--pinned-bottom',
|
|
191
257
|
isDragging && 'ogx__row--dragging',
|
|
192
258
|
isDragOver && 'ogx__row--drag-over',
|
|
193
|
-
isGroupRow && 'ogx__row--group'
|
|
259
|
+
isGroupRow && 'ogx__row--group',
|
|
260
|
+
rowMeta?.isGroupFooter && 'ogx__row--group-footer'
|
|
194
261
|
].filter(Boolean).join(' ');
|
|
195
262
|
|
|
196
263
|
return (
|
|
@@ -198,12 +265,15 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
198
265
|
<div
|
|
199
266
|
className={classNames}
|
|
200
267
|
onClick={handleRowClick}
|
|
268
|
+
onDoubleClick={onRowDoubleClick ? handleRowDoubleClick : undefined}
|
|
201
269
|
role="row"
|
|
202
|
-
aria-rowindex={rowIndex +
|
|
270
|
+
aria-rowindex={ariaRowIndex ?? rowIndex + 2}
|
|
203
271
|
aria-selected={isSelected}
|
|
272
|
+
aria-level={isHierarchyRow ? (rowMeta?.treeDepth ?? 0) + 1 : undefined}
|
|
273
|
+
aria-expanded={hasChildren ? rowMeta?.isExpanded === true : undefined}
|
|
204
274
|
data-rowindex={rowIndex}
|
|
205
|
-
onDragOver={onDragOver ? onDragOver(
|
|
206
|
-
onDrop={onDrop ? onDrop(
|
|
275
|
+
onDragOver={onDragOver ? onDragOver(id) : undefined}
|
|
276
|
+
onDrop={onDrop ? onDrop(id) : undefined}
|
|
207
277
|
style={{
|
|
208
278
|
maxHeight: `${rowHeight}px`,
|
|
209
279
|
minHeight: `${rowHeight}px`,
|
|
@@ -214,20 +284,23 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
214
284
|
{ }
|
|
215
285
|
{rowReordering && (
|
|
216
286
|
<div
|
|
217
|
-
className=
|
|
287
|
+
className={`ogx__cell ogx__cell--drag-handle ${focusedCellField === '__reorder_col__' ? 'ogx__cell--focused' : ''} ${(focusedCellField === '__reorder_col__' && isFocusVisible) ? 'ogx__cell--focus-visible' : ''}`}
|
|
218
288
|
role="gridcell"
|
|
219
|
-
aria-label=
|
|
289
|
+
aria-label={onDragStart ? 'Drag to reorder row' : undefined}
|
|
290
|
+
aria-colindex={reorderColIndex}
|
|
291
|
+
data-field="__reorder_col__"
|
|
292
|
+
tabIndex={-1}
|
|
220
293
|
style={{
|
|
221
294
|
position: 'sticky',
|
|
222
295
|
left: 0,
|
|
223
|
-
zIndex:
|
|
296
|
+
zIndex: SYSTEM_CELL_Z_INDEX
|
|
224
297
|
}}
|
|
225
|
-
draggable={
|
|
226
|
-
onDragStart={onDragStart ? onDragStart(
|
|
298
|
+
draggable={!!onDragStart}
|
|
299
|
+
onDragStart={onDragStart ? onDragStart(id) : undefined}
|
|
227
300
|
onDragEnd={onDragEnd}
|
|
228
301
|
onClick={(e) => e.stopPropagation()}
|
|
229
302
|
>
|
|
230
|
-
<DragHandleIcon />
|
|
303
|
+
{onDragStart && <DragHandleIcon />}
|
|
231
304
|
</div>
|
|
232
305
|
)}
|
|
233
306
|
|
|
@@ -238,14 +311,16 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
238
311
|
} ${focusedCellField === '__expand_col__' ? 'ogx__cell--focused' : ''} ${(focusedCellField === '__expand_col__' && isFocusVisible) ? 'ogx__cell--focus-visible' : ''
|
|
239
312
|
}`}
|
|
240
313
|
role="gridcell"
|
|
241
|
-
aria-label={isDetailPanelExpanded ? 'Collapse row details' : 'Expand row details'}
|
|
242
|
-
aria-expanded={isDetailPanelExpanded}
|
|
314
|
+
aria-label={showDetailPanel ? (isDetailPanelExpanded ? 'Collapse row details' : 'Expand row details') : undefined}
|
|
315
|
+
aria-expanded={showDetailPanel ? isDetailPanelExpanded : undefined}
|
|
316
|
+
aria-controls={showDetailPanel && isDetailPanelExpanded ? detailPanelId : undefined}
|
|
317
|
+
aria-colindex={expandColIndex}
|
|
318
|
+
data-field="__expand_col__"
|
|
243
319
|
style={{
|
|
244
320
|
position: pinExpandColumn ? 'sticky' : undefined,
|
|
245
321
|
left: pinExpandColumn ? (rowReordering ? 48 : 0) : undefined,
|
|
246
|
-
zIndex: pinExpandColumn ?
|
|
322
|
+
zIndex: pinExpandColumn ? SYSTEM_CELL_Z_INDEX : undefined
|
|
247
323
|
}}
|
|
248
|
-
ref={expandCellRef}
|
|
249
324
|
tabIndex={-1}
|
|
250
325
|
onClick={(e) => {
|
|
251
326
|
e.stopPropagation();
|
|
@@ -259,11 +334,14 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
259
334
|
});
|
|
260
335
|
}}
|
|
261
336
|
>
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
337
|
+
{showDetailPanel && (
|
|
338
|
+
<ExpandIcon
|
|
339
|
+
isExpanded={isDetailPanelExpanded}
|
|
340
|
+
onClick={handleDetailPanelToggle}
|
|
341
|
+
variant="plus-minus"
|
|
342
|
+
tabIndex={-1}
|
|
343
|
+
/>
|
|
344
|
+
)}
|
|
267
345
|
</div>
|
|
268
346
|
)}
|
|
269
347
|
|
|
@@ -274,12 +352,13 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
274
352
|
} ${focusedCellField === '__checkbox_col__' ? 'ogx__cell--focused' : ''} ${(focusedCellField === '__checkbox_col__' && isFocusVisible) ? 'ogx__cell--focus-visible' : ''
|
|
275
353
|
}`}
|
|
276
354
|
role="gridcell"
|
|
355
|
+
aria-colindex={checkboxColIndex}
|
|
356
|
+
data-field="__checkbox_col__"
|
|
277
357
|
style={{
|
|
278
358
|
position: pinCheckboxColumn ? 'sticky' : undefined,
|
|
279
359
|
left: pinCheckboxColumn ? ((rowReordering ? 48 : 0) + (hasDetailPanel && pinExpandColumn ? 48 : 0)) : undefined,
|
|
280
|
-
zIndex: pinCheckboxColumn ?
|
|
360
|
+
zIndex: pinCheckboxColumn ? SYSTEM_CELL_Z_INDEX : undefined
|
|
281
361
|
}}
|
|
282
|
-
ref={checkboxCellRef}
|
|
283
362
|
tabIndex={-1}
|
|
284
363
|
onClick={(e) => {
|
|
285
364
|
e.stopPropagation();
|
|
@@ -293,21 +372,23 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
293
372
|
});
|
|
294
373
|
}}
|
|
295
374
|
>
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
375
|
+
{isSelectableRow && (
|
|
376
|
+
<Checkbox
|
|
377
|
+
id={checkboxId}
|
|
378
|
+
name={`ogx-select-row-${id}`}
|
|
379
|
+
checked={isSelected}
|
|
380
|
+
onChange={handleCheckboxChange}
|
|
381
|
+
onClick={(e) => e.stopPropagation()}
|
|
382
|
+
aria-label={isSelected ? `Deselect row ${id}` : `Select row ${id}`}
|
|
383
|
+
tabIndex={-1}
|
|
384
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
385
|
+
/>
|
|
386
|
+
)}
|
|
306
387
|
</div>
|
|
307
388
|
)}
|
|
308
389
|
|
|
309
390
|
{ }
|
|
310
|
-
{columns.map((colDef,
|
|
391
|
+
{columns.map((colDef, localIndex) => {
|
|
311
392
|
if (colDef.isSpacer) {
|
|
312
393
|
return (
|
|
313
394
|
<div
|
|
@@ -317,22 +398,45 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
317
398
|
/>
|
|
318
399
|
);
|
|
319
400
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
401
|
+
// A synthetic row holds its grouping value and aggregates under the column fields; a
|
|
402
|
+
// valueGetter written for data rows would recompute (or crash) from fields it lacks.
|
|
403
|
+
// A valueGetter that throws is contained to its cell.
|
|
404
|
+
let value: unknown;
|
|
405
|
+
let valueError: unknown;
|
|
406
|
+
if (isSyntheticRow) {
|
|
407
|
+
value = row[colDef.field];
|
|
408
|
+
} else {
|
|
409
|
+
({ value, error: valueError } = tryGetCellValue(row, colDef.field, colDef));
|
|
410
|
+
}
|
|
411
|
+
const colIndex = columnIndexMap?.get(colDef.field) ?? localIndex;
|
|
323
412
|
|
|
324
413
|
const effectiveWidth = columnWidths[colDef.field] ?? colDef.width;
|
|
325
414
|
|
|
326
415
|
const pinnedPosition = isColumnPinned(colDef.field, pinnedColumns);
|
|
327
|
-
const pinnedOffset = pinnedPosition ? pinnedPositions[colDef.field] : undefined;
|
|
328
416
|
|
|
329
|
-
const
|
|
417
|
+
const isEditable = Boolean(onEditStart) && resolveCellEditable(
|
|
418
|
+
{ row, field: colDef.field, value, colDef, rowIndex, colIndex, rowMeta },
|
|
419
|
+
isCellEditable
|
|
420
|
+
);
|
|
421
|
+
const isEditing = editingCell?.id === id && editingCell?.field === colDef.field;
|
|
330
422
|
|
|
331
423
|
const cellValue = isEditing ? editingCell?.value : value;
|
|
332
424
|
|
|
333
|
-
const
|
|
334
|
-
const
|
|
335
|
-
|
|
425
|
+
const storedColSpanInfo = colspanMap?.get(id)?.[colDef.field];
|
|
426
|
+
const colSpanInfo = storedColSpanInfo && !storedColSpanInfo.spannedByColSpan && storedColSpanInfo.cellProps.colSpan > 1
|
|
427
|
+
? mergeColSpan(columns, colIndex, storedColSpanInfo.cellProps.colSpan, columnWidths)
|
|
428
|
+
: storedColSpanInfo;
|
|
429
|
+
// A merged cell ends at the last column it covers: a right-pinned one is anchored by
|
|
430
|
+
// that column's right edge, and it is the left-pinned edge cell if that column is.
|
|
431
|
+
const lastCoveredField = colSpanInfo && !colSpanInfo.spannedByColSpan
|
|
432
|
+
? columns[colIndex + colSpanInfo.cellProps.colSpan - 1]?.field ?? colDef.field
|
|
433
|
+
: colDef.field;
|
|
434
|
+
const offsetField = pinnedPosition === 'right' ? lastCoveredField : colDef.field;
|
|
435
|
+
const pinnedOffset = pinnedPosition ? pinnedPositions[offsetField] : undefined;
|
|
436
|
+
const isPinnedEdge = (pinnedPosition === 'left' && lastCoveredField === pinnedEdges.lastLeft)
|
|
437
|
+
|| (pinnedPosition === 'right' && colDef.field === pinnedEdges.firstRight);
|
|
438
|
+
const rowSpan = rowSpanningCaches?.spannedCells[id]?.[colDef.field];
|
|
439
|
+
const isHiddenByRowSpan = rowSpanningCaches?.hiddenCells[id]?.[colDef.field] || false;
|
|
336
440
|
|
|
337
441
|
return (
|
|
338
442
|
<Cell
|
|
@@ -342,16 +446,18 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
342
446
|
colDef={colDef}
|
|
343
447
|
rowIndex={rowIndex}
|
|
344
448
|
colIndex={colIndex}
|
|
449
|
+
ariaColIndex={systemColumnCount + colIndex + 1}
|
|
345
450
|
isSelected={isSelected}
|
|
346
451
|
onCellClick={onCellClick}
|
|
347
452
|
width={effectiveWidth}
|
|
348
453
|
pinnedPosition={pinnedPosition}
|
|
349
454
|
pinnedOffset={pinnedOffset}
|
|
455
|
+
isPinnedEdge={isPinnedEdge}
|
|
350
456
|
|
|
351
457
|
isFocused={focusedCellField === colDef.field}
|
|
352
458
|
isFocusVisible={isFocusVisible}
|
|
353
459
|
|
|
354
|
-
isEditable={
|
|
460
|
+
isEditable={isEditable}
|
|
355
461
|
isEditing={isEditing}
|
|
356
462
|
onCellEditStart={handleCellEditStart}
|
|
357
463
|
onEditStop={handleEditStopWrapper}
|
|
@@ -361,20 +467,24 @@ export function Row<R extends GridRowModel = GridRowModel>(props: RowProps<R>) {
|
|
|
361
467
|
rowSpan={rowSpan}
|
|
362
468
|
isHiddenByRowSpan={isHiddenByRowSpan}
|
|
363
469
|
rowMeta={rowMeta}
|
|
470
|
+
valueError={valueError}
|
|
364
471
|
/>
|
|
365
472
|
);
|
|
366
473
|
})}
|
|
367
474
|
</div>
|
|
368
475
|
|
|
369
476
|
{ }
|
|
370
|
-
{
|
|
477
|
+
{showDetailPanel && (
|
|
371
478
|
<DetailPanel
|
|
479
|
+
id={detailPanelId}
|
|
480
|
+
colSpan={systemColumnCount + dataColumnCount}
|
|
372
481
|
row={row}
|
|
373
|
-
rowId={
|
|
482
|
+
rowId={id}
|
|
374
483
|
rowIndex={rowIndex}
|
|
375
484
|
content={detailPanelContent}
|
|
376
485
|
height={detailPanelHeight}
|
|
377
486
|
isExpanded={isDetailPanelExpanded}
|
|
487
|
+
onHeightChange={onDetailPanelHeightChange}
|
|
378
488
|
/>
|
|
379
489
|
)}
|
|
380
490
|
</>
|
|
@@ -31,12 +31,15 @@
|
|
|
31
31
|
─────────────────────────────────────────────────────────────────────────── */
|
|
32
32
|
.ogx-global-search--expanded {
|
|
33
33
|
width: 220px;
|
|
34
|
-
border-color: var(--ogx-toolbar-input-
|
|
34
|
+
border-color: var(--ogx-toolbar-input-border, var(--ogx-color-gray-300, #cbd5e1));
|
|
35
35
|
background: var(--ogx-toolbar-input-bg, var(--ogx-grid-background, #fff));
|
|
36
|
-
box-shadow: var(--ogx-toolbar-input-focus-shadow, 0 0 0 3px rgba(59, 130, 246, 0.12));
|
|
37
36
|
cursor: default;
|
|
38
37
|
overflow: hidden;
|
|
39
38
|
}
|
|
39
|
+
.ogx-global-search--expanded:focus-within {
|
|
40
|
+
border-color: var(--ogx-toolbar-input-focus-border, var(--ogx-color-primary, #3b82f6));
|
|
41
|
+
box-shadow: var(--ogx-toolbar-input-focus-shadow, 0 0 0 3px rgba(59, 130, 246, 0.12));
|
|
42
|
+
}
|
|
40
43
|
|
|
41
44
|
/* ─── Icon wrapper ────────────────────────────────────────────────────────── */
|
|
42
45
|
.ogx-global-search__icon-wrapper {
|
|
@@ -70,7 +73,7 @@
|
|
|
70
73
|
background: transparent;
|
|
71
74
|
font-family: var(--ogx-font-family, system-ui, sans-serif);
|
|
72
75
|
font-size: var(--ogx-font-size-sm, 13px);
|
|
73
|
-
color: var(--ogx-grid-row-text, #1e293b);
|
|
76
|
+
color: var(--ogx-toolbar-input-text, var(--ogx-grid-row-text, #1e293b));
|
|
74
77
|
outline: none;
|
|
75
78
|
opacity: 0;
|
|
76
79
|
pointer-events: none;
|
|
@@ -26,6 +26,11 @@ function CloseIcon() {
|
|
|
26
26
|
);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** Search text reduced to its terms, so 'john london ' and 'john london' are the same search. */
|
|
30
|
+
function normalizeSearch(text: string): string {
|
|
31
|
+
return text.trim().split(/\s+/).filter(Boolean).join(' ');
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
export function GlobalSearch({ value = '', onChange, placeholder = 'Search...', debounceMs = 250 }: GlobalSearchProps) {
|
|
30
35
|
const [isExpanded, setIsExpanded] = useState(() => value.length > 0);
|
|
31
36
|
// Fully uncontrolled: localValue is the single source of truth.
|
|
@@ -39,37 +44,22 @@ export function GlobalSearch({ value = '', onChange, placeholder = 'Search...',
|
|
|
39
44
|
const lastEmittedRef = useRef(value);
|
|
40
45
|
// Track the last incoming prop value to detect external resets
|
|
41
46
|
const lastIncomingValueRef = useRef(value);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// ── Focus preservation across re-renders ─────────────────────────────────
|
|
46
|
-
// When the parent updates filterModel (triggered by our debounce), React
|
|
47
|
-
// re-renders the entire tree. Even though the input element stays mounted,
|
|
48
|
-
// the browser can lose focus during heavy reconciliation. We detect this
|
|
49
|
-
// via useLayoutEffect (runs synchronously after DOM mutations, before paint)
|
|
50
|
-
// and immediately restore focus without any visual flicker.
|
|
51
|
-
useLayoutEffect(() => {
|
|
52
|
-
if (wasFocusedRef.current && document.activeElement !== inputRef.current) {
|
|
53
|
-
inputRef.current?.focus();
|
|
54
|
-
}
|
|
55
|
-
});
|
|
47
|
+
|
|
48
|
+
// The input stays mounted across re-renders, so React keeps its focus; nothing here moves
|
|
49
|
+
// focus except the explicit expand and clear actions.
|
|
56
50
|
|
|
57
51
|
// ── External reset detection ──────────────────────────────────────────────
|
|
58
52
|
// Only responds when the PARENT programmatically changes the value
|
|
59
|
-
// (e.g. "Clear all filters" button) — not from our own typing.
|
|
53
|
+
// (e.g. "Clear all filters" button) — not from our own typing. The parent may hand back our
|
|
54
|
+
// own search with whitespace normalised (the toolbar splits it into terms), which is not a reset.
|
|
60
55
|
useEffect(() => {
|
|
61
56
|
if (value === lastIncomingValueRef.current) return;
|
|
62
57
|
lastIncomingValueRef.current = value;
|
|
63
58
|
|
|
64
|
-
if (value !== lastEmittedRef.current) {
|
|
59
|
+
if (normalizeSearch(value) !== normalizeSearch(lastEmittedRef.current)) {
|
|
65
60
|
setLocalValue(value);
|
|
66
61
|
lastEmittedRef.current = value;
|
|
67
|
-
|
|
68
|
-
wasFocusedRef.current = false;
|
|
69
|
-
setIsExpanded(false);
|
|
70
|
-
} else {
|
|
71
|
-
setIsExpanded(true);
|
|
72
|
-
}
|
|
62
|
+
setIsExpanded(value.length > 0);
|
|
73
63
|
}
|
|
74
64
|
}, [value]);
|
|
75
65
|
|
|
@@ -84,6 +74,18 @@ export function GlobalSearch({ value = '', onChange, placeholder = 'Search...',
|
|
|
84
74
|
return () => clearTimeout(handler);
|
|
85
75
|
}, [localValue, debounceMs]);
|
|
86
76
|
|
|
77
|
+
// Typing still waiting for the debounce is sent when the search unmounts (the toolbar is
|
|
78
|
+
// hidden, the grid switches view), instead of being dropped.
|
|
79
|
+
const localValueRef = useRef(localValue);
|
|
80
|
+
useLayoutEffect(() => { localValueRef.current = localValue; });
|
|
81
|
+
useEffect(() => () => {
|
|
82
|
+
const pending = localValueRef.current;
|
|
83
|
+
if (pending !== lastEmittedRef.current) {
|
|
84
|
+
lastEmittedRef.current = pending;
|
|
85
|
+
onChangeRef.current(pending);
|
|
86
|
+
}
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
87
89
|
const handleExpand = useCallback(() => {
|
|
88
90
|
setIsExpanded(true);
|
|
89
91
|
requestAnimationFrame(() => inputRef.current?.focus());
|
|
@@ -98,22 +100,19 @@ export function GlobalSearch({ value = '', onChange, placeholder = 'Search...',
|
|
|
98
100
|
}, []);
|
|
99
101
|
|
|
100
102
|
const handleFocus = useCallback(() => {
|
|
101
|
-
wasFocusedRef.current = true;
|
|
102
103
|
setIsExpanded(true);
|
|
103
104
|
}, []);
|
|
104
105
|
|
|
105
106
|
// Blur: defer 150ms to allow focus to move within the container first
|
|
106
107
|
// (e.g. clicking the clear button) before deciding to collapse.
|
|
107
108
|
const handleBlur = useCallback(() => {
|
|
108
|
-
wasFocusedRef.current = false;
|
|
109
109
|
setTimeout(() => {
|
|
110
110
|
if (
|
|
111
111
|
containerRef.current &&
|
|
112
112
|
document.activeElement &&
|
|
113
113
|
containerRef.current.contains(document.activeElement)
|
|
114
114
|
) {
|
|
115
|
-
// Focus is still inside
|
|
116
|
-
wasFocusedRef.current = true;
|
|
115
|
+
// Focus is still inside the search box — keep it expanded
|
|
117
116
|
return;
|
|
118
117
|
}
|
|
119
118
|
if (!localValue) {
|
|
@@ -156,7 +155,7 @@ export function GlobalSearch({ value = '', onChange, placeholder = 'Search...',
|
|
|
156
155
|
autoComplete="off"
|
|
157
156
|
/>
|
|
158
157
|
{localValue && (
|
|
159
|
-
<button className="ogx-global-search__clear" onClick={handleClear} aria-label="Clear search">
|
|
158
|
+
<button type="button" className="ogx-global-search__clear" onClick={handleClear} aria-label="Clear search">
|
|
160
159
|
<CloseIcon />
|
|
161
160
|
</button>
|
|
162
161
|
)}
|