@opencorestack/opengridx 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +6 -0
  3. package/dist/ai.cjs +2 -1
  4. package/dist/ai.cjs.map +1 -1
  5. package/dist/ai.d.ts +203 -3
  6. package/dist/ai.es.js +2 -1
  7. package/dist/ai.es.js.map +1 -1
  8. package/dist/index.d.ts +248 -4
  9. package/dist/opengridx.css +1 -1
  10. package/dist/opengridx.es.js +7104 -5726
  11. package/dist/opengridx.es.js.map +1 -1
  12. package/dist/opengridx.umd.js +16 -16
  13. package/dist/opengridx.umd.js.map +1 -1
  14. package/docs/API_REFERENCE.md +88 -7
  15. package/docs/README.md +3 -1
  16. package/docs/architecture/datagrid-orchestration.md +9 -4
  17. package/docs/components/filter-panel.md +1 -0
  18. package/docs/components/header.md +4 -1
  19. package/docs/components/toolbar.md +7 -0
  20. package/docs/customization/theming.md +3 -1
  21. package/docs/features/ai-toolkit.md +99 -5
  22. package/docs/features/cell-selection.md +8 -2
  23. package/docs/features/fill-handle.md +125 -0
  24. package/docs/features/filtering.md +3 -0
  25. package/docs/features/header-filters.md +138 -0
  26. package/docs/features/keyboard-navigation.md +20 -2
  27. package/docs/roadmap.md +2 -3
  28. package/lib/ai/agentTools.ts +160 -0
  29. package/lib/ai/index.ts +19 -2
  30. package/lib/ai/promptHandler.ts +109 -0
  31. package/lib/ai/types.ts +89 -25
  32. package/lib/ai/validate.ts +10 -0
  33. package/lib/components/AiAssistant/GridAiAssistantArea.tsx +26 -0
  34. package/lib/components/AiAssistant/GridAiAssistantPanel.css +171 -0
  35. package/lib/components/AiAssistant/GridAiAssistantPanel.tsx +145 -0
  36. package/lib/components/AiAssistant/useSpeechInput.ts +74 -0
  37. package/lib/components/Cell/Cell.css +28 -0
  38. package/lib/components/Cell/Cell.tsx +6 -2
  39. package/lib/components/DataGrid/DataGrid.tsx +76 -3
  40. package/lib/components/DataGrid/GridLiveRegion.tsx +5 -2
  41. package/lib/components/DataGrid/GridPinnedRows.tsx +5 -1
  42. package/lib/components/DataGrid/GridVirtualRows.tsx +5 -1
  43. package/lib/components/FilterPanel/FilterPanel.tsx +2 -9
  44. package/lib/components/Header/Header.tsx +44 -23
  45. package/lib/components/Header/headerColumnStyle.ts +28 -0
  46. package/lib/components/HeaderFilter/HeaderFilter.css +160 -0
  47. package/lib/components/HeaderFilter/HeaderFilterCell.tsx +370 -0
  48. package/lib/components/HeaderFilter/HeaderFilterOperatorMenu.tsx +124 -0
  49. package/lib/components/HeaderFilter/HeaderFilterRow.tsx +157 -0
  50. package/lib/components/Toolbar/GridToolbar.tsx +51 -0
  51. package/lib/hooks/core/gridApiPlaceholder.ts +8 -0
  52. package/lib/hooks/core/gridToolbarHostContext.ts +5 -0
  53. package/lib/hooks/core/useGridApiMethods.ts +39 -0
  54. package/lib/hooks/core/useGridAriaRows.ts +5 -3
  55. package/lib/hooks/core/useGridCellSelection.ts +77 -11
  56. package/lib/hooks/core/useGridColumnsPanel.ts +24 -1
  57. package/lib/hooks/core/useGridControlledState.ts +27 -0
  58. package/lib/hooks/core/useGridFillHandle.ts +323 -0
  59. package/lib/hooks/core/useGridKeyboardNavigation.ts +29 -10
  60. package/lib/hooks/core/useGridToolbarProps.ts +19 -0
  61. package/lib/hooks/features/useGridAiAssistant.ts +284 -0
  62. package/lib/hooks/features/useGridBatchEdit.ts +2 -2
  63. package/lib/index.ts +3 -0
  64. package/lib/styles/opengridx.css +2 -0
  65. package/lib/theme/DataGridThemeProvider.tsx +2 -0
  66. package/lib/theme/types.ts +2 -0
  67. package/lib/types/index.ts +219 -1
  68. package/lib/utils/aiAssistant.ts +205 -0
  69. package/lib/utils/aiBrand.ts +17 -0
  70. package/lib/utils/cellSelection.ts +25 -5
  71. package/lib/utils/editing/fill.ts +238 -0
  72. package/lib/utils/filtering/index.ts +14 -0
  73. package/lib/utils/focus/index.ts +9 -3
  74. package/lib/utils/headerFilters.ts +189 -0
  75. package/llms.txt +44 -4
  76. package/package.json +1 -1
@@ -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
+ }
@@ -0,0 +1,124 @@
1
+ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import ReactDOM from 'react-dom';
3
+ import type { GridFilterOperator } from '../../types';
4
+ import { HEADER_FILTER_OPERATOR_LABELS } from '../../utils/headerFilters';
5
+ import { getViewportHeight, getViewportWidth } from '../../utils/viewport';
6
+
7
+ export interface HeaderFilterOperatorMenuProps {
8
+ anchorEl: HTMLElement;
9
+ /** Column header name, for the menu's label. */
10
+ columnLabel: string;
11
+ operators: GridFilterOperator[];
12
+ current: GridFilterOperator;
13
+ onSelect: (operator: GridFilterOperator) => void;
14
+ /** `restoreFocus`: the menu was left from the keyboard or by choosing an item. */
15
+ onClose: (restoreFocus: boolean) => void;
16
+ }
17
+
18
+ const HIDDEN_STYLE: React.CSSProperties = { position: 'fixed', zIndex: 1300, top: 0, left: 0, visibility: 'hidden' };
19
+
20
+ const itemsOf = (menu: HTMLElement | null): HTMLElement[] =>
21
+ menu ? Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitemradio"]')) : [];
22
+
23
+ /** The operator menu of a header filter cell: a menu of radio items, portalled out of the header. */
24
+ export function HeaderFilterOperatorMenu({ anchorEl, columnLabel, operators, current, onSelect, onClose }: HeaderFilterOperatorMenuProps) {
25
+ const menuRef = useRef<HTMLDivElement>(null);
26
+ const [style, setStyle] = useState<React.CSSProperties>(HIDDEN_STYLE);
27
+
28
+ useLayoutEffect(() => {
29
+ const menu = menuRef.current;
30
+ if (!menu) return;
31
+ const anchor = anchorEl.getBoundingClientRect();
32
+ const box = menu.getBoundingClientRect();
33
+ const margin = 8;
34
+ let left = Math.min(anchor.left, getViewportWidth() - box.width - margin);
35
+ if (left < margin) left = margin;
36
+ let top = anchor.bottom + 4;
37
+ if (top + box.height + margin > getViewportHeight()) top = anchor.top - box.height - 4;
38
+ setStyle({ position: 'fixed', zIndex: 1300, top, left, visibility: 'visible' });
39
+ }, [anchorEl]);
40
+
41
+ // Focus the checked item once the menu is positioned (a hidden element cannot take focus).
42
+ const isPositioned = style.visibility === 'visible';
43
+ useLayoutEffect(() => {
44
+ if (!isPositioned) return;
45
+ const items = itemsOf(menuRef.current);
46
+ (items.find(item => item.getAttribute('aria-checked') === 'true') ?? items[0])?.focus({ preventScroll: true });
47
+ }, [isPositioned]);
48
+
49
+ const onCloseRef = useRef(onClose);
50
+ useLayoutEffect(() => { onCloseRef.current = onClose; });
51
+
52
+ useEffect(() => {
53
+ const doc = anchorEl.ownerDocument;
54
+ const view = doc.defaultView;
55
+ function handleMouseDown(event: MouseEvent) {
56
+ const target = event.target as Node;
57
+ if (menuRef.current?.contains(target) || anchorEl.contains(target)) return;
58
+ onCloseRef.current(false);
59
+ }
60
+ // The menu floats at a fixed position: it closes rather than drift away from its cell.
61
+ function handleScroll(event: Event) {
62
+ if (menuRef.current?.contains(event.target as Node)) return;
63
+ onCloseRef.current(false);
64
+ }
65
+ function handleResize() { onCloseRef.current(false); }
66
+ doc.addEventListener('mousedown', handleMouseDown);
67
+ view?.addEventListener('scroll', handleScroll, true);
68
+ view?.addEventListener('resize', handleResize);
69
+ return () => {
70
+ doc.removeEventListener('mousedown', handleMouseDown);
71
+ view?.removeEventListener('scroll', handleScroll, true);
72
+ view?.removeEventListener('resize', handleResize);
73
+ };
74
+ }, [anchorEl]);
75
+
76
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
77
+ // The menu is portalled: keys must not reach the cell or the grid through the React tree.
78
+ event.stopPropagation();
79
+ const items = itemsOf(menuRef.current);
80
+ const index = items.indexOf(event.target as HTMLElement);
81
+ let next = -1;
82
+ switch (event.key) {
83
+ case 'ArrowDown': next = index + 1 >= items.length ? 0 : index + 1; break;
84
+ case 'ArrowUp': next = index <= 0 ? items.length - 1 : index - 1; break;
85
+ case 'Home': next = 0; break;
86
+ case 'End': next = items.length - 1; break;
87
+ case 'Escape':
88
+ case 'Tab':
89
+ event.preventDefault();
90
+ onClose(true);
91
+ return;
92
+ default:
93
+ return;
94
+ }
95
+ event.preventDefault();
96
+ items[next]?.focus();
97
+ };
98
+
99
+ return ReactDOM.createPortal(
100
+ <div
101
+ ref={menuRef}
102
+ className="ogx-column-menu ogx__header-filter-menu"
103
+ style={style}
104
+ role="menu"
105
+ aria-label={`Filter operator for ${columnLabel}`}
106
+ onKeyDown={handleKeyDown}
107
+ >
108
+ {operators.map(op => (
109
+ <button
110
+ key={op}
111
+ type="button"
112
+ role="menuitemradio"
113
+ aria-checked={op === current}
114
+ tabIndex={-1}
115
+ className={`ogx-menu-item${op === current ? ' ogx-menu-item--active' : ''}`}
116
+ onClick={() => onSelect(op)}
117
+ >
118
+ {HEADER_FILTER_OPERATOR_LABELS[op] ?? op}
119
+ </button>
120
+ ))}
121
+ </div>,
122
+ anchorEl.closest('.ogx-theme-provider') ?? anchorEl.ownerDocument.body
123
+ );
124
+ }