@opencorestack/opengridx 3.4.0 → 3.5.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/CHANGELOG.md +45 -0
- package/README.md +10 -3
- package/dist/ai.cjs +2 -1
- package/dist/ai.cjs.map +1 -1
- package/dist/ai.d.ts +203 -3
- package/dist/ai.es.js +2 -1
- package/dist/ai.es.js.map +1 -1
- package/dist/index.d.ts +248 -4
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +7334 -5906
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +16 -16
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +88 -7
- package/docs/README.md +3 -1
- package/docs/architecture/datagrid-orchestration.md +11 -5
- package/docs/components/filter-panel.md +1 -0
- package/docs/components/header.md +4 -1
- package/docs/components/toolbar.md +7 -0
- package/docs/customization/theming.md +3 -1
- package/docs/features/ai-toolkit.md +99 -5
- package/docs/features/cell-selection.md +8 -2
- package/docs/features/fill-handle.md +125 -0
- package/docs/features/filtering.md +3 -0
- package/docs/features/header-filters.md +138 -0
- package/docs/features/keyboard-navigation.md +20 -2
- package/docs/features/virtualization.md +5 -5
- package/docs/performance.md +212 -1
- package/docs/roadmap.md +3 -4
- package/lib/ai/agentTools.ts +160 -0
- package/lib/ai/index.ts +19 -2
- package/lib/ai/promptHandler.ts +109 -0
- package/lib/ai/types.ts +89 -25
- package/lib/ai/validate.ts +10 -0
- package/lib/components/AiAssistant/GridAiAssistantArea.tsx +26 -0
- package/lib/components/AiAssistant/GridAiAssistantPanel.css +171 -0
- package/lib/components/AiAssistant/GridAiAssistantPanel.tsx +145 -0
- package/lib/components/AiAssistant/useSpeechInput.ts +74 -0
- package/lib/components/Cell/Cell.css +28 -0
- package/lib/components/Cell/Cell.tsx +6 -2
- package/lib/components/DataGrid/DataGrid.tsx +84 -4
- package/lib/components/DataGrid/GridLiveRegion.tsx +5 -2
- package/lib/components/DataGrid/GridPinnedRows.tsx +5 -1
- package/lib/components/DataGrid/GridVirtualRows.tsx +5 -1
- package/lib/components/FilterPanel/FilterPanel.tsx +2 -9
- package/lib/components/Header/Header.tsx +44 -23
- package/lib/components/Header/headerColumnStyle.ts +28 -0
- package/lib/components/HeaderFilter/HeaderFilter.css +160 -0
- package/lib/components/HeaderFilter/HeaderFilterCell.tsx +370 -0
- package/lib/components/HeaderFilter/HeaderFilterOperatorMenu.tsx +124 -0
- package/lib/components/HeaderFilter/HeaderFilterRow.tsx +157 -0
- package/lib/components/Toolbar/GridToolbar.tsx +51 -0
- package/lib/hooks/core/gridApiPlaceholder.ts +8 -0
- package/lib/hooks/core/gridToolbarHostContext.ts +5 -0
- package/lib/hooks/core/useGridApiMethods.ts +39 -0
- package/lib/hooks/core/useGridAriaRows.ts +5 -3
- package/lib/hooks/core/useGridCellSelection.ts +77 -11
- package/lib/hooks/core/useGridColumnsPanel.ts +24 -1
- package/lib/hooks/core/useGridControlledState.ts +27 -0
- package/lib/hooks/core/useGridDevWarnings.ts +21 -6
- package/lib/hooks/core/useGridFillHandle.ts +323 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +29 -10
- package/lib/hooks/core/useGridMaxContentHeight.ts +16 -0
- package/lib/hooks/core/useGridToolbarProps.ts +19 -0
- package/lib/hooks/core/useGridVirtualization.ts +19 -0
- package/lib/hooks/features/useGridAiAssistant.ts +284 -0
- package/lib/hooks/features/useGridBatchEdit.ts +2 -2
- package/lib/index.ts +3 -0
- package/lib/styles/opengridx.css +2 -0
- package/lib/theme/DataGridThemeProvider.tsx +2 -0
- package/lib/theme/types.ts +2 -0
- package/lib/types/index.ts +219 -1
- package/lib/utils/aiAssistant.ts +205 -0
- package/lib/utils/aiBrand.ts +17 -0
- package/lib/utils/cellSelection.ts +25 -5
- package/lib/utils/editing/fill.ts +238 -0
- package/lib/utils/filtering/index.ts +14 -0
- package/lib/utils/focus/index.ts +9 -3
- package/lib/utils/headerFilters.ts +189 -0
- package/lib/utils/maxElementHeight.ts +53 -0
- package/llms.txt +44 -4
- package/package.json +3 -2
|
@@ -2,12 +2,14 @@ import React, { useId } from 'react';
|
|
|
2
2
|
import { Checkbox } from '../ui/Checkbox';
|
|
3
3
|
import { ColumnResizeHandle } from '../ColumnResizeHandle/ColumnResizeHandle';
|
|
4
4
|
import { clampResizeWidth } from '../ColumnResizeHandle/clampResizeWidth';
|
|
5
|
-
import type { GridColDef, GridRowModel, GridSortDirection, GridColumnPinning, GridAggregationModel, GridColumnGroupingModel } from '../../types';
|
|
5
|
+
import type { GridColDef, GridRowModel, GridSortDirection, GridColumnPinning, GridAggregationModel, GridColumnGroupingModel, GridFilterModel } from '../../types';
|
|
6
6
|
import { isColumnPinned, calculatePinnedPositions, getPinnedEdgeFields } from '../../utils/pinning';
|
|
7
7
|
import { getRenderedColumnWidth } from '../../utils/columnWidth';
|
|
8
8
|
import { buildColumnGroupRow, getColumnGroupDepth, getColumnGroupPaths } from '../../utils/columnGroups';
|
|
9
9
|
import { ColumnMenu } from './ColumnMenu';
|
|
10
10
|
import { getHeaderLineClamp, shouldWrapHeaderText } from '../../utils/headerWrap';
|
|
11
|
+
import { getHeaderColumnStyle } from './headerColumnStyle';
|
|
12
|
+
import { HeaderFilterRow } from '../HeaderFilter/HeaderFilterRow';
|
|
11
13
|
|
|
12
14
|
|
|
13
15
|
function MenuIcon() {
|
|
@@ -60,8 +62,11 @@ export interface HeaderProps<R extends GridRowModel = GridRowModel> {
|
|
|
60
62
|
onDragOver?: (field: string) => (event: React.DragEvent) => void;
|
|
61
63
|
onDragEnd?: () => void;
|
|
62
64
|
onDrop?: (targetField: string) => (event: React.DragEvent) => void;
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
+
/**
|
|
66
|
+
* The grid's focus position; `id: null` means a header cell is focused, and `headerFilter: true`
|
|
67
|
+
* a cell of the header filter row.
|
|
68
|
+
*/
|
|
69
|
+
focusedCell?: { id: string | number | null; field: string; headerFilter?: boolean } | null;
|
|
65
70
|
onHeaderClick?: (field: string) => void;
|
|
66
71
|
onSortAdd?: (field: string, direction: GridSortDirection) => void;
|
|
67
72
|
multiSort?: boolean;
|
|
@@ -74,6 +79,14 @@ export interface HeaderProps<R extends GridRowModel = GridRowModel> {
|
|
|
74
79
|
wrapHeaderText?: boolean;
|
|
75
80
|
/** Header row height in pixels; sets how many lines a wrapped title may take. Defaults to 56. */
|
|
76
81
|
headerHeight?: number;
|
|
82
|
+
/** Renders the header filter row under the column headers (`DataGridProps.headerFilters`). */
|
|
83
|
+
headerFilters?: boolean;
|
|
84
|
+
/** The filter model the header filter row reads. */
|
|
85
|
+
filterModel?: GridFilterModel;
|
|
86
|
+
/** Receives the filter model after a header filter changes. */
|
|
87
|
+
onFilterModelChange?: (model: GridFilterModel) => void;
|
|
88
|
+
/** Opens the filter panel; a header filter cell showing "Custom filter" calls it. */
|
|
89
|
+
onOpenFilterPanel?: () => void;
|
|
77
90
|
}
|
|
78
91
|
|
|
79
92
|
export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps<R>) {
|
|
@@ -112,6 +125,10 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
|
|
|
112
125
|
columnIndexMap,
|
|
113
126
|
wrapHeaderText = false,
|
|
114
127
|
headerHeight = 56,
|
|
128
|
+
headerFilters = false,
|
|
129
|
+
filterModel,
|
|
130
|
+
onFilterModelChange,
|
|
131
|
+
onOpenFilterPanel,
|
|
115
132
|
} = props;
|
|
116
133
|
|
|
117
134
|
const selectAllId = useId();
|
|
@@ -148,7 +165,7 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
|
|
|
148
165
|
setMenuOpenParams({ colDef, anchorEl: anchor });
|
|
149
166
|
};
|
|
150
167
|
|
|
151
|
-
const isHeaderFocused = (field: string) => focusedCell?.id === null && focusedCell.field === field;
|
|
168
|
+
const isHeaderFocused = (field: string) => focusedCell?.id === null && !focusedCell.headerFilter && focusedCell.field === field;
|
|
152
169
|
|
|
153
170
|
// aria-colindex: system columns first (reorder, expand, checkbox), then the data columns.
|
|
154
171
|
const reorderColIndex = rowReordering ? 1 : 0;
|
|
@@ -437,30 +454,12 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
|
|
|
437
454
|
colDef.headerClassName
|
|
438
455
|
].filter(Boolean).join(' ');
|
|
439
456
|
|
|
440
|
-
const effectiveWidth = columnWidths[colDef.field] ?? colDef.width ?? 100;
|
|
441
|
-
|
|
442
457
|
const style: React.CSSProperties = {
|
|
443
|
-
|
|
444
|
-
minWidth: colDef.minWidth,
|
|
445
|
-
maxWidth: colDef.maxWidth,
|
|
446
|
-
flexGrow: colDef.flex ?? 0,
|
|
447
|
-
flexShrink: 0,
|
|
448
|
-
flexBasis: colDef.flex ? 'auto' : 'auto',
|
|
449
|
-
boxSizing: 'border-box',
|
|
450
|
-
position: isPinned ? 'sticky' : 'relative',
|
|
458
|
+
...getHeaderColumnStyle(colDef, columnWidths, pinnedPosition, pinnedPosition ? pinnedPositions[colDef.field] : undefined),
|
|
451
459
|
zIndex: colDef.zIndex,
|
|
452
460
|
opacity: isDragging ? 0.5 : 1
|
|
453
461
|
};
|
|
454
462
|
|
|
455
|
-
if (isPinned && pinnedPosition) {
|
|
456
|
-
const offset = pinnedPositions[colDef.field];
|
|
457
|
-
if (pinnedPosition === 'left') {
|
|
458
|
-
style.left = offset;
|
|
459
|
-
} else {
|
|
460
|
-
style.right = offset;
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
463
|
const headerContent = colDef.renderHeader
|
|
465
464
|
? colDef.renderHeader({ field: colDef.field, colDef: colDef as unknown as GridColDef, colIndex })
|
|
466
465
|
: colDef.headerName || colDef.field;
|
|
@@ -565,6 +564,28 @@ export function Header<R extends GridRowModel = GridRowModel>(props: HeaderProps
|
|
|
565
564
|
/>
|
|
566
565
|
)}
|
|
567
566
|
</div>{/* end .ogx__header */}
|
|
567
|
+
|
|
568
|
+
{/* ── Header filter row ── */}
|
|
569
|
+
{headerFilters && (
|
|
570
|
+
<HeaderFilterRow
|
|
571
|
+
columns={columns as unknown as GridColDef[]}
|
|
572
|
+
columnWidths={columnWidths}
|
|
573
|
+
pinnedColumns={pinnedColumns}
|
|
574
|
+
pinnedPositions={pinnedPositions}
|
|
575
|
+
pinnedEdges={pinnedEdges}
|
|
576
|
+
rowReordering={rowReordering}
|
|
577
|
+
hasDetailPanel={hasDetailPanel}
|
|
578
|
+
checkboxSelection={checkboxSelection}
|
|
579
|
+
pinExpandColumn={pinExpandColumn}
|
|
580
|
+
pinCheckboxColumn={pinCheckboxColumn}
|
|
581
|
+
columnIndexMap={columnIndexMap}
|
|
582
|
+
ariaRowIndex={groupDepth + 2}
|
|
583
|
+
filterModel={filterModel}
|
|
584
|
+
onFilterModelChange={onFilterModelChange}
|
|
585
|
+
onOpenFilterPanel={onOpenFilterPanel}
|
|
586
|
+
focusedField={focusedCell?.id === null && focusedCell.headerFilter ? focusedCell.field : null}
|
|
587
|
+
/>
|
|
588
|
+
)}
|
|
568
589
|
</div>
|
|
569
590
|
);
|
|
570
591
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import type { GridColDef, GridRowModel } from '../../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Width, flex and sticky offset of a data column's cell in a header row. The column header row
|
|
6
|
+
* and the header filter row both use it, so their cells line up whatever the pinning and widths.
|
|
7
|
+
*/
|
|
8
|
+
export function getHeaderColumnStyle<R extends GridRowModel>(
|
|
9
|
+
colDef: GridColDef<R>,
|
|
10
|
+
columnWidths: Record<string, number>,
|
|
11
|
+
pinnedPosition: 'left' | 'right' | null,
|
|
12
|
+
pinnedOffset: number | undefined,
|
|
13
|
+
): React.CSSProperties {
|
|
14
|
+
const style: React.CSSProperties = {
|
|
15
|
+
width: columnWidths[colDef.field] ?? colDef.width ?? 100,
|
|
16
|
+
minWidth: colDef.minWidth,
|
|
17
|
+
maxWidth: colDef.maxWidth,
|
|
18
|
+
flexGrow: colDef.flex ?? 0,
|
|
19
|
+
flexShrink: 0,
|
|
20
|
+
flexBasis: 'auto',
|
|
21
|
+
boxSizing: 'border-box',
|
|
22
|
+
position: pinnedPosition ? 'sticky' : 'relative',
|
|
23
|
+
};
|
|
24
|
+
if (pinnedPosition === 'left') style.left = pinnedOffset;
|
|
25
|
+
else if (pinnedPosition === 'right') style.right = pinnedOffset;
|
|
26
|
+
return style;
|
|
27
|
+
}
|
|
28
|
+
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/* Header filter row (headerFilters): inside .ogx__header-wrap, under the column header row, so it
|
|
2
|
+
sticks with the headers. Its height (--ogx-header-filter-height) is set by the grid from
|
|
3
|
+
headerFilterHeight. */
|
|
4
|
+
.ogx__header-filter-row {
|
|
5
|
+
display: flex;
|
|
6
|
+
align-items: stretch;
|
|
7
|
+
height: var(--ogx-header-filter-height, 40px);
|
|
8
|
+
min-height: var(--ogx-header-filter-height, 40px);
|
|
9
|
+
background: var(--ogx-grid-header-background);
|
|
10
|
+
border-bottom: 1px solid var(--ogx-grid-border-color);
|
|
11
|
+
}
|
|
12
|
+
.ogx__header-filter-cell {
|
|
13
|
+
display: flex;
|
|
14
|
+
align-items: center;
|
|
15
|
+
gap: 2px;
|
|
16
|
+
padding: 4px 6px;
|
|
17
|
+
box-sizing: border-box;
|
|
18
|
+
flex-shrink: 0;
|
|
19
|
+
overflow: hidden;
|
|
20
|
+
position: relative;
|
|
21
|
+
background: var(--ogx-grid-header-background);
|
|
22
|
+
font-family: var(--ogx-font-family);
|
|
23
|
+
font-size: var(--ogx-font-size-sm, 0.8125rem);
|
|
24
|
+
color: var(--ogx-grid-header-text);
|
|
25
|
+
}
|
|
26
|
+
.ogx__header-filter-cell:focus,
|
|
27
|
+
.ogx__header-filter-cell:focus-visible {
|
|
28
|
+
outline: none;
|
|
29
|
+
}
|
|
30
|
+
.ogx--kb .ogx__header-filter-cell--focused {
|
|
31
|
+
outline: 2px solid var(--ogx-grid-cell-focus-border, var(--ogx-color-primary, #2563eb));
|
|
32
|
+
outline-offset: -2px;
|
|
33
|
+
z-index: 6;
|
|
34
|
+
}
|
|
35
|
+
.ogx__header-filter-cell--pinned {
|
|
36
|
+
z-index: 12;
|
|
37
|
+
}
|
|
38
|
+
.ogx__header-filter-cell--pinned-left-last {
|
|
39
|
+
box-shadow: var(--ogx-grid-pinned-left-shadow, 4px 0 12px -4px rgba(0, 0, 0, 0.05));
|
|
40
|
+
}
|
|
41
|
+
.ogx__header-filter-cell--pinned-right-first {
|
|
42
|
+
margin-left: auto;
|
|
43
|
+
box-shadow: var(--ogx-grid-pinned-right-shadow, -4px 0 12px -4px rgba(0, 0, 0, 0.05));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* Operator button: a compact symbol for the current operator; opens the operator menu. */
|
|
47
|
+
.ogx__header-filter-operator {
|
|
48
|
+
display: inline-flex;
|
|
49
|
+
align-items: center;
|
|
50
|
+
justify-content: center;
|
|
51
|
+
flex-shrink: 0;
|
|
52
|
+
min-width: 22px;
|
|
53
|
+
height: 24px;
|
|
54
|
+
padding: 0 3px;
|
|
55
|
+
border: none;
|
|
56
|
+
border-radius: var(--ogx-border-radius-sm, 4px);
|
|
57
|
+
background: transparent;
|
|
58
|
+
color: var(--ogx-color-gray-500);
|
|
59
|
+
font: inherit;
|
|
60
|
+
font-size: 12px;
|
|
61
|
+
line-height: 1;
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
}
|
|
64
|
+
.ogx__header-filter-operator:hover,
|
|
65
|
+
.ogx__header-filter-operator--open {
|
|
66
|
+
background: var(--ogx-grid-header-hover-background);
|
|
67
|
+
color: var(--ogx-grid-header-text);
|
|
68
|
+
}
|
|
69
|
+
.ogx__header-filter-cell--active .ogx__header-filter-operator {
|
|
70
|
+
color: var(--ogx-color-primary);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* Value controls: the shared Input, and native selects styled to match it. */
|
|
74
|
+
.ogx__header-filter-input-wrapper {
|
|
75
|
+
flex: 1 1 auto;
|
|
76
|
+
min-width: 0;
|
|
77
|
+
height: 28px;
|
|
78
|
+
}
|
|
79
|
+
.ogx-input.ogx__header-filter-input {
|
|
80
|
+
width: 100%;
|
|
81
|
+
min-width: 0;
|
|
82
|
+
height: 100%;
|
|
83
|
+
box-sizing: border-box;
|
|
84
|
+
padding: 0 6px;
|
|
85
|
+
font-size: inherit;
|
|
86
|
+
}
|
|
87
|
+
.ogx__header-filter-select {
|
|
88
|
+
flex: 1 1 auto;
|
|
89
|
+
min-width: 0;
|
|
90
|
+
height: 28px;
|
|
91
|
+
padding: 0 4px;
|
|
92
|
+
box-sizing: border-box;
|
|
93
|
+
border: var(--ogx-border-width-thin, 1px) solid var(--ogx-border-color);
|
|
94
|
+
border-radius: var(--ogx-border-radius-md);
|
|
95
|
+
background: var(--ogx-color-white);
|
|
96
|
+
color: var(--ogx-color-gray-900);
|
|
97
|
+
font-family: var(--ogx-font-family);
|
|
98
|
+
font-size: inherit;
|
|
99
|
+
outline: none;
|
|
100
|
+
cursor: pointer;
|
|
101
|
+
}
|
|
102
|
+
.ogx__header-filter-select:hover {
|
|
103
|
+
border-color: var(--ogx-border-color-hover);
|
|
104
|
+
}
|
|
105
|
+
.ogx__header-filter-select:focus {
|
|
106
|
+
border-color: var(--ogx-color-primary);
|
|
107
|
+
box-shadow: 0 0 0 1px var(--ogx-color-primary);
|
|
108
|
+
}
|
|
109
|
+
.ogx__header-filter-cell--active .ogx-input-wrapper,
|
|
110
|
+
.ogx__header-filter-cell--active .ogx__header-filter-select {
|
|
111
|
+
border-color: var(--ogx-color-primary);
|
|
112
|
+
}
|
|
113
|
+
.ogx__header-filter-operator-label {
|
|
114
|
+
flex: 1 1 auto;
|
|
115
|
+
min-width: 0;
|
|
116
|
+
overflow: hidden;
|
|
117
|
+
text-overflow: ellipsis;
|
|
118
|
+
white-space: nowrap;
|
|
119
|
+
color: var(--ogx-color-gray-600);
|
|
120
|
+
font-style: italic;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.ogx__header-filter-clear {
|
|
124
|
+
display: inline-flex;
|
|
125
|
+
align-items: center;
|
|
126
|
+
justify-content: center;
|
|
127
|
+
flex-shrink: 0;
|
|
128
|
+
width: 22px;
|
|
129
|
+
height: 22px;
|
|
130
|
+
padding: 0;
|
|
131
|
+
border: none;
|
|
132
|
+
border-radius: var(--ogx-border-radius-sm, 4px);
|
|
133
|
+
background: transparent;
|
|
134
|
+
color: var(--ogx-color-gray-500);
|
|
135
|
+
cursor: pointer;
|
|
136
|
+
}
|
|
137
|
+
.ogx__header-filter-clear:hover {
|
|
138
|
+
background: var(--ogx-grid-header-hover-background);
|
|
139
|
+
color: var(--ogx-grid-header-text);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* Read-only label for a filter the row cannot show (OR logic, nested groups, other conditions). */
|
|
143
|
+
.ogx__header-filter-custom {
|
|
144
|
+
flex: 1 1 auto;
|
|
145
|
+
min-width: 0;
|
|
146
|
+
overflow: hidden;
|
|
147
|
+
text-overflow: ellipsis;
|
|
148
|
+
white-space: nowrap;
|
|
149
|
+
padding: 0 4px;
|
|
150
|
+
border: none;
|
|
151
|
+
background: transparent;
|
|
152
|
+
color: var(--ogx-color-primary);
|
|
153
|
+
font: inherit;
|
|
154
|
+
font-style: italic;
|
|
155
|
+
text-align: left;
|
|
156
|
+
}
|
|
157
|
+
button.ogx__header-filter-custom {
|
|
158
|
+
cursor: pointer;
|
|
159
|
+
text-decoration: underline dotted;
|
|
160
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { GridColDef, GridFilterItem, GridFilterOperator } from '../../types';
|
|
3
|
+
import { filterValueToInputText, NO_VALUE_OPERATORS } from '../../utils/filtering';
|
|
4
|
+
import {
|
|
5
|
+
HEADER_FILTER_DEBOUNCE_MS,
|
|
6
|
+
HEADER_FILTER_OPERATOR_LABELS,
|
|
7
|
+
HEADER_FILTER_OPERATOR_SYMBOLS,
|
|
8
|
+
buildHeaderFilterItem,
|
|
9
|
+
convertHeaderFilterValue,
|
|
10
|
+
getHeaderFilterDefaultOperator,
|
|
11
|
+
getHeaderFilterOperators,
|
|
12
|
+
type GridHeaderFilterState,
|
|
13
|
+
} from '../../utils/headerFilters';
|
|
14
|
+
import { Input } from '../ui/Input';
|
|
15
|
+
import { HeaderFilterOperatorMenu } from './HeaderFilterOperatorMenu';
|
|
16
|
+
|
|
17
|
+
export interface HeaderFilterCellProps {
|
|
18
|
+
colDef: GridColDef;
|
|
19
|
+
/** What the cell shows: the item it owns, or "Custom filter". */
|
|
20
|
+
state: GridHeaderFilterState;
|
|
21
|
+
style: React.CSSProperties;
|
|
22
|
+
/** Classes for pinning and focus, added to `ogx__header-filter-cell`. */
|
|
23
|
+
className: string;
|
|
24
|
+
ariaColIndex: number;
|
|
25
|
+
/** Stores (or removes, with null) the cell's own filter item. Must keep its identity. */
|
|
26
|
+
onItemChange: (field: string, item: GridFilterItem | null) => void;
|
|
27
|
+
onOpenFilterPanel?: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ValueOption {
|
|
31
|
+
key: string;
|
|
32
|
+
value: unknown;
|
|
33
|
+
label: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getValueOptions(col: GridColDef): ValueOption[] {
|
|
37
|
+
return (col.valueOptions ?? []).map((option, index) => (
|
|
38
|
+
option !== null && typeof option === 'object'
|
|
39
|
+
? { key: String(index), value: option.value, label: option.label }
|
|
40
|
+
: { key: String(index), value: option, label: String(option) }
|
|
41
|
+
));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function matchesOption(option: ValueOption, value: unknown): boolean {
|
|
45
|
+
const single = Array.isArray(value) ? value[0] : value;
|
|
46
|
+
if (Object.is(option.value, single)) return true;
|
|
47
|
+
return single != null && String(option.value).toLowerCase() === String(single).toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The item value for typed text. isAnyOf takes a comma-separated list. */
|
|
51
|
+
function fromInputText(text: string, operator: GridFilterOperator): unknown {
|
|
52
|
+
if (operator === 'isAnyOf') return text.split(',').map(part => part.trim()).filter(part => part !== '');
|
|
53
|
+
return text;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sameValue(a: unknown, b: unknown): boolean {
|
|
57
|
+
if (Object.is(a, b)) return true;
|
|
58
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => sameValue(v, b[i]));
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sameItem(a: GridFilterItem | undefined, b: GridFilterItem | undefined): boolean {
|
|
63
|
+
if (a === b) return true;
|
|
64
|
+
if (!a || !b) return false;
|
|
65
|
+
return a.operator === b.operator && sameValue(a.value, b.value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ClearIcon() {
|
|
69
|
+
return (
|
|
70
|
+
<svg focusable="false" aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
|
71
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
72
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
73
|
+
</svg>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One cell of the header filter row: operator button, value control and clear button. */
|
|
78
|
+
export function HeaderFilterCell({ colDef, state, style, className, ariaColIndex, onItemChange, onOpenFilterPanel }: HeaderFilterCellProps) {
|
|
79
|
+
const label = colDef.headerName || colDef.field;
|
|
80
|
+
const item = state.kind === 'editable' ? state.item : undefined;
|
|
81
|
+
const operator: GridFilterOperator = item?.operator ?? getHeaderFilterDefaultOperator(colDef);
|
|
82
|
+
const valueOptions = getValueOptions(colDef);
|
|
83
|
+
const usesSelect = colDef.type === 'boolean' || (colDef.type === 'singleSelect' && valueOptions.length > 0);
|
|
84
|
+
const showsValue = !NO_VALUE_OPERATORS.has(operator);
|
|
85
|
+
|
|
86
|
+
// Text typed but not committed yet (debounced); null while the input shows the committed value.
|
|
87
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
88
|
+
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>(null);
|
|
89
|
+
|
|
90
|
+
// The last item this cell reported (null: removed). Its own change arriving late (a parent that
|
|
91
|
+
// stores the model asynchronously) is not mistaken for a change made elsewhere.
|
|
92
|
+
const [lastEmitted, setLastEmitted] = useState<GridFilterItem | null | undefined>(undefined);
|
|
93
|
+
// A change made elsewhere (filter panel, Clear all, apiRef) discards typing not committed yet.
|
|
94
|
+
const [seenItem, setSeenItem] = useState(item);
|
|
95
|
+
if (seenItem !== item) {
|
|
96
|
+
setSeenItem(item);
|
|
97
|
+
const own = lastEmitted !== undefined && sameItem(lastEmitted ?? undefined, item);
|
|
98
|
+
if (!sameItem(seenItem, item) && !own) setDraft(null);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const cellRef = useRef<HTMLDivElement>(null);
|
|
102
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
103
|
+
const selectRef = useRef<HTMLSelectElement>(null);
|
|
104
|
+
const customRef = useRef<HTMLButtonElement>(null);
|
|
105
|
+
const operatorRef = useRef<HTMLButtonElement>(null);
|
|
106
|
+
|
|
107
|
+
const latestRef = useRef({ item, colDef, onItemChange });
|
|
108
|
+
useLayoutEffect(() => {
|
|
109
|
+
latestRef.current = { item, colDef, onItemChange };
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Commits typed text unless it matches what the item already holds.
|
|
113
|
+
const commitText = useCallback((text: string) => {
|
|
114
|
+
const { item: current, colDef: col, onItemChange: emit } = latestRef.current;
|
|
115
|
+
const unchanged = current ? text === filterValueToInputText(current.value, col.type) : text.trim() === '';
|
|
116
|
+
if (unchanged) return;
|
|
117
|
+
const op = current?.operator ?? getHeaderFilterDefaultOperator(col);
|
|
118
|
+
const next = buildHeaderFilterItem(col, op, fromInputText(text, op));
|
|
119
|
+
setLastEmitted(next);
|
|
120
|
+
emit(col.field, next);
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
// Typing still waiting for the debounce is committed if the cell unmounts meanwhile (its column
|
|
124
|
+
// scrolled out of the render window, the row turned off).
|
|
125
|
+
const pendingRef = useRef<string | null>(null);
|
|
126
|
+
useLayoutEffect(() => {
|
|
127
|
+
pendingRef.current = draft;
|
|
128
|
+
});
|
|
129
|
+
useEffect(() => () => {
|
|
130
|
+
const pending = pendingRef.current;
|
|
131
|
+
pendingRef.current = null;
|
|
132
|
+
if (pending !== null) commitText(pending);
|
|
133
|
+
}, [commitText]);
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (draft === null) return undefined;
|
|
137
|
+
const handle = setTimeout(() => {
|
|
138
|
+
pendingRef.current = null;
|
|
139
|
+
commitText(draft);
|
|
140
|
+
setDraft(null);
|
|
141
|
+
}, HEADER_FILTER_DEBOUNCE_MS);
|
|
142
|
+
return () => clearTimeout(handle);
|
|
143
|
+
}, [draft, commitText]);
|
|
144
|
+
|
|
145
|
+
const focusCell = () => cellRef.current?.focus({ preventScroll: true });
|
|
146
|
+
const focusControl = () => (inputRef.current ?? selectRef.current)?.focus({ preventScroll: true });
|
|
147
|
+
|
|
148
|
+
const report = (next: GridFilterItem | null) => {
|
|
149
|
+
setLastEmitted(next);
|
|
150
|
+
onItemChange(colDef.field, next);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const setValue = (value: unknown) => {
|
|
154
|
+
setDraft(null);
|
|
155
|
+
report(buildHeaderFilterItem(colDef, operator, value));
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const clear = () => {
|
|
159
|
+
setDraft(null);
|
|
160
|
+
report(null);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const openMenu = () => { if (operatorRef.current) setMenuAnchor(operatorRef.current); };
|
|
164
|
+
|
|
165
|
+
const closeMenu = (restoreFocus: boolean) => {
|
|
166
|
+
setMenuAnchor(null);
|
|
167
|
+
if (!restoreFocus) return;
|
|
168
|
+
if (inputRef.current || selectRef.current) focusControl();
|
|
169
|
+
else focusCell();
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const selectOperator = (op: GridFilterOperator) => {
|
|
173
|
+
// Keep what was typed even if the debounce has not committed it yet.
|
|
174
|
+
const value = draft !== null ? fromInputText(draft, op) : item?.value;
|
|
175
|
+
setDraft(null);
|
|
176
|
+
setMenuAnchor(null);
|
|
177
|
+
report(buildHeaderFilterItem(colDef, op, convertHeaderFilterValue(value, op)));
|
|
178
|
+
// The value control may only mount with the new operator (from isEmpty), so focus after commit.
|
|
179
|
+
requestAnimationFrame(() => {
|
|
180
|
+
if (inputRef.current || selectRef.current) focusControl();
|
|
181
|
+
else focusCell();
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const isCustom = state.kind === 'custom';
|
|
186
|
+
const isTextInput = !isCustom && showsValue && !usesSelect;
|
|
187
|
+
|
|
188
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
189
|
+
const cell = event.currentTarget;
|
|
190
|
+
const target = event.target as Node;
|
|
191
|
+
// Keys from the portalled operator menu reach this handler through the React tree only.
|
|
192
|
+
if (!cell.contains(target)) return;
|
|
193
|
+
if (event.nativeEvent.isComposing) return;
|
|
194
|
+
const { key } = event;
|
|
195
|
+
const onCell = target === cell;
|
|
196
|
+
|
|
197
|
+
const opensMenu = (event.altKey && key === 'ArrowDown') || ((event.ctrlKey || event.metaKey) && key === 'Enter');
|
|
198
|
+
// Alt+ArrowDown in a select opens its own list.
|
|
199
|
+
if (opensMenu && !isCustom && target !== selectRef.current) {
|
|
200
|
+
event.preventDefault();
|
|
201
|
+
event.stopPropagation();
|
|
202
|
+
openMenu();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (!onCell) {
|
|
206
|
+
if (key === 'Escape') {
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
event.stopPropagation();
|
|
209
|
+
focusCell();
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (key === 'Enter' || key === ' ' || key === 'Spacebar') {
|
|
214
|
+
event.preventDefault();
|
|
215
|
+
event.stopPropagation();
|
|
216
|
+
if (isCustom) customRef.current?.click();
|
|
217
|
+
else if (inputRef.current || selectRef.current) focusControl();
|
|
218
|
+
else openMenu();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if ((key === 'Delete' || key === 'Backspace') && item) {
|
|
222
|
+
event.preventDefault();
|
|
223
|
+
event.stopPropagation();
|
|
224
|
+
clear();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// Typing on the cell starts a new value in its text box.
|
|
228
|
+
if (isTextInput && colDef.type !== 'date' && key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
229
|
+
event.preventDefault();
|
|
230
|
+
event.stopPropagation();
|
|
231
|
+
inputRef.current?.focus({ preventScroll: true });
|
|
232
|
+
setDraft(key);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
let content: React.ReactNode = null;
|
|
237
|
+
if (isCustom) {
|
|
238
|
+
content = onOpenFilterPanel ? (
|
|
239
|
+
<button
|
|
240
|
+
ref={customRef}
|
|
241
|
+
type="button"
|
|
242
|
+
className="ogx__header-filter-custom"
|
|
243
|
+
tabIndex={-1}
|
|
244
|
+
onClick={onOpenFilterPanel}
|
|
245
|
+
title="Set in the filter panel"
|
|
246
|
+
>
|
|
247
|
+
Custom filter
|
|
248
|
+
</button>
|
|
249
|
+
) : (
|
|
250
|
+
<span className="ogx__header-filter-custom" title="Set in the filter panel">Custom filter</span>
|
|
251
|
+
);
|
|
252
|
+
} else {
|
|
253
|
+
let valueControl: React.ReactNode;
|
|
254
|
+
if (!showsValue) {
|
|
255
|
+
valueControl = <span className="ogx__header-filter-operator-label">{HEADER_FILTER_OPERATOR_LABELS[operator] ?? operator}</span>;
|
|
256
|
+
} else if (colDef.type === 'boolean') {
|
|
257
|
+
valueControl = (
|
|
258
|
+
<select
|
|
259
|
+
ref={selectRef}
|
|
260
|
+
className="ogx__header-filter-select"
|
|
261
|
+
tabIndex={-1}
|
|
262
|
+
value={item?.value == null || item.value === '' ? '' : String(item.value)}
|
|
263
|
+
onChange={(e) => setValue(e.target.value === '' ? '' : e.target.value === 'true')}
|
|
264
|
+
aria-label={`Filter value for ${label}`}
|
|
265
|
+
>
|
|
266
|
+
<option value="">Any</option>
|
|
267
|
+
<option value="true">Yes</option>
|
|
268
|
+
<option value="false">No</option>
|
|
269
|
+
</select>
|
|
270
|
+
);
|
|
271
|
+
} else if (usesSelect) {
|
|
272
|
+
const selected = valueOptions.find(o => matchesOption(o, item?.value));
|
|
273
|
+
valueControl = (
|
|
274
|
+
<select
|
|
275
|
+
ref={selectRef}
|
|
276
|
+
className="ogx__header-filter-select"
|
|
277
|
+
tabIndex={-1}
|
|
278
|
+
value={selected?.key ?? ''}
|
|
279
|
+
onChange={(e) => {
|
|
280
|
+
const option = valueOptions.find(o => o.key === e.target.value);
|
|
281
|
+
setValue(convertHeaderFilterValue(option ? option.value : '', operator));
|
|
282
|
+
}}
|
|
283
|
+
aria-label={`Filter value for ${label}`}
|
|
284
|
+
>
|
|
285
|
+
<option value="">Any</option>
|
|
286
|
+
{valueOptions.map(o => <option key={o.key} value={o.key}>{o.label}</option>)}
|
|
287
|
+
</select>
|
|
288
|
+
);
|
|
289
|
+
} else {
|
|
290
|
+
valueControl = (
|
|
291
|
+
<Input
|
|
292
|
+
ref={inputRef}
|
|
293
|
+
className="ogx__header-filter-input-wrapper"
|
|
294
|
+
inputClassName="ogx__header-filter-input"
|
|
295
|
+
type={colDef.type === 'date' ? 'date' : 'text'}
|
|
296
|
+
inputMode={colDef.type === 'number' ? 'decimal' : undefined}
|
|
297
|
+
tabIndex={-1}
|
|
298
|
+
autoComplete="off"
|
|
299
|
+
value={draft ?? filterValueToInputText(item?.value, colDef.type)}
|
|
300
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
301
|
+
aria-label={`Filter value for ${label}`}
|
|
302
|
+
/>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
const operatorLabel = HEADER_FILTER_OPERATOR_LABELS[operator] ?? operator;
|
|
306
|
+
content = (
|
|
307
|
+
<>
|
|
308
|
+
<button
|
|
309
|
+
ref={operatorRef}
|
|
310
|
+
type="button"
|
|
311
|
+
className={`ogx__header-filter-operator${menuAnchor ? ' ogx__header-filter-operator--open' : ''}`}
|
|
312
|
+
tabIndex={-1}
|
|
313
|
+
aria-haspopup="menu"
|
|
314
|
+
aria-expanded={menuAnchor !== null}
|
|
315
|
+
aria-label={`Filter operator for ${label}: ${operatorLabel}`}
|
|
316
|
+
title={operatorLabel}
|
|
317
|
+
onClick={() => (menuAnchor ? setMenuAnchor(null) : openMenu())}
|
|
318
|
+
>
|
|
319
|
+
{HEADER_FILTER_OPERATOR_SYMBOLS[operator] ?? '?'}
|
|
320
|
+
</button>
|
|
321
|
+
{valueControl}
|
|
322
|
+
{item && (
|
|
323
|
+
<button
|
|
324
|
+
type="button"
|
|
325
|
+
className="ogx__header-filter-clear"
|
|
326
|
+
tabIndex={-1}
|
|
327
|
+
aria-label={`Clear filter for ${label}`}
|
|
328
|
+
title="Clear"
|
|
329
|
+
onClick={() => { clear(); focusCell(); }}
|
|
330
|
+
>
|
|
331
|
+
<ClearIcon />
|
|
332
|
+
</button>
|
|
333
|
+
)}
|
|
334
|
+
{menuAnchor && (
|
|
335
|
+
<HeaderFilterOperatorMenu
|
|
336
|
+
anchorEl={menuAnchor}
|
|
337
|
+
columnLabel={label}
|
|
338
|
+
operators={getHeaderFilterOperators(colDef)}
|
|
339
|
+
current={operator}
|
|
340
|
+
onSelect={selectOperator}
|
|
341
|
+
onClose={closeMenu}
|
|
342
|
+
/>
|
|
343
|
+
)}
|
|
344
|
+
</>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const classNames = [
|
|
349
|
+
'ogx__header-filter-cell',
|
|
350
|
+
item && 'ogx__header-filter-cell--active',
|
|
351
|
+
isCustom && 'ogx__header-filter-cell--custom',
|
|
352
|
+
className,
|
|
353
|
+
].filter(Boolean).join(' ');
|
|
354
|
+
|
|
355
|
+
return (
|
|
356
|
+
<div
|
|
357
|
+
ref={cellRef}
|
|
358
|
+
className={classNames}
|
|
359
|
+
style={style}
|
|
360
|
+
role="columnheader"
|
|
361
|
+
aria-label={`Filter ${label}`}
|
|
362
|
+
aria-colindex={ariaColIndex}
|
|
363
|
+
data-field={colDef.field}
|
|
364
|
+
tabIndex={-1}
|
|
365
|
+
onKeyDown={handleKeyDown}
|
|
366
|
+
>
|
|
367
|
+
{content}
|
|
368
|
+
</div>
|
|
369
|
+
);
|
|
370
|
+
}
|