@cdx-ui/data-grid 0.3.11 → 0.3.13

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 (96) hide show
  1. package/README.md +3 -1
  2. package/coverage.svg +1 -1
  3. package/lib/commonjs/DataGrid.js +490 -81
  4. package/lib/commonjs/DataGrid.js.map +1 -1
  5. package/lib/commonjs/DataGridFilterPanel.js +547 -0
  6. package/lib/commonjs/DataGridFilterPanel.js.map +1 -0
  7. package/lib/commonjs/adapter/columnTypes.js +256 -0
  8. package/lib/commonjs/adapter/columnTypes.js.map +1 -0
  9. package/lib/commonjs/adapter/columns.js +140 -4
  10. package/lib/commonjs/adapter/columns.js.map +1 -1
  11. package/lib/commonjs/adapter/filterOperators.js +422 -0
  12. package/lib/commonjs/adapter/filterOperators.js.map +1 -0
  13. package/lib/commonjs/adapter/pinning.js +105 -0
  14. package/lib/commonjs/adapter/pinning.js.map +1 -0
  15. package/lib/commonjs/adapter/selectColumn.js +11 -0
  16. package/lib/commonjs/adapter/selectColumn.js.map +1 -1
  17. package/lib/commonjs/adapter/treeData.js +195 -0
  18. package/lib/commonjs/adapter/treeData.js.map +1 -0
  19. package/lib/commonjs/index.js +26 -0
  20. package/lib/commonjs/index.js.map +1 -1
  21. package/lib/commonjs/numberCells.js +280 -0
  22. package/lib/commonjs/numberCells.js.map +1 -0
  23. package/lib/commonjs/styles.js +107 -1
  24. package/lib/commonjs/styles.js.map +1 -1
  25. package/lib/commonjs/types.js +39 -1
  26. package/lib/commonjs/types.js.map +1 -1
  27. package/lib/commonjs/useDataGridTable.js +234 -18
  28. package/lib/commonjs/useDataGridTable.js.map +1 -1
  29. package/lib/module/DataGrid.js +497 -88
  30. package/lib/module/DataGrid.js.map +1 -1
  31. package/lib/module/DataGridFilterPanel.js +540 -0
  32. package/lib/module/DataGridFilterPanel.js.map +1 -0
  33. package/lib/module/adapter/columnTypes.js +240 -0
  34. package/lib/module/adapter/columnTypes.js.map +1 -0
  35. package/lib/module/adapter/columns.js +137 -4
  36. package/lib/module/adapter/columns.js.map +1 -1
  37. package/lib/module/adapter/filterOperators.js +406 -0
  38. package/lib/module/adapter/filterOperators.js.map +1 -0
  39. package/lib/module/adapter/pinning.js +94 -0
  40. package/lib/module/adapter/pinning.js.map +1 -0
  41. package/lib/module/adapter/selectColumn.js +11 -1
  42. package/lib/module/adapter/selectColumn.js.map +1 -1
  43. package/lib/module/adapter/treeData.js +187 -0
  44. package/lib/module/adapter/treeData.js.map +1 -0
  45. package/lib/module/index.js +2 -0
  46. package/lib/module/index.js.map +1 -1
  47. package/lib/module/numberCells.js +275 -0
  48. package/lib/module/numberCells.js.map +1 -0
  49. package/lib/module/styles.js +106 -0
  50. package/lib/module/styles.js.map +1 -1
  51. package/lib/module/types.js +38 -0
  52. package/lib/module/types.js.map +1 -1
  53. package/lib/module/useDataGridTable.js +238 -22
  54. package/lib/module/useDataGridTable.js.map +1 -1
  55. package/lib/typescript/DataGrid.d.ts.map +1 -1
  56. package/lib/typescript/DataGridFilterPanel.d.ts +52 -0
  57. package/lib/typescript/DataGridFilterPanel.d.ts.map +1 -0
  58. package/lib/typescript/adapter/columnTypes.d.ts +32 -0
  59. package/lib/typescript/adapter/columnTypes.d.ts.map +1 -0
  60. package/lib/typescript/adapter/columns.d.ts +14 -5
  61. package/lib/typescript/adapter/columns.d.ts.map +1 -1
  62. package/lib/typescript/adapter/filterOperators.d.ts +43 -0
  63. package/lib/typescript/adapter/filterOperators.d.ts.map +1 -0
  64. package/lib/typescript/adapter/pinning.d.ts +26 -0
  65. package/lib/typescript/adapter/pinning.d.ts.map +1 -0
  66. package/lib/typescript/adapter/selectColumn.d.ts +8 -0
  67. package/lib/typescript/adapter/selectColumn.d.ts.map +1 -1
  68. package/lib/typescript/adapter/treeData.d.ts +36 -0
  69. package/lib/typescript/adapter/treeData.d.ts.map +1 -0
  70. package/lib/typescript/index.d.ts +4 -1
  71. package/lib/typescript/index.d.ts.map +1 -1
  72. package/lib/typescript/numberCells.d.ts +46 -0
  73. package/lib/typescript/numberCells.d.ts.map +1 -0
  74. package/lib/typescript/styles.d.ts +32 -0
  75. package/lib/typescript/styles.d.ts.map +1 -1
  76. package/lib/typescript/types.d.ts +154 -7
  77. package/lib/typescript/types.d.ts.map +1 -1
  78. package/lib/typescript/useDataGridTable.d.ts +40 -4
  79. package/lib/typescript/useDataGridTable.d.ts.map +1 -1
  80. package/package.json +5 -5
  81. package/src/DataGrid.tsx +745 -95
  82. package/src/DataGridFilterPanel.tsx +648 -0
  83. package/src/adapter/columnTypes.ts +327 -0
  84. package/src/adapter/columns.test.ts +75 -1
  85. package/src/adapter/columns.ts +192 -3
  86. package/src/adapter/filterOperators.test.ts +292 -0
  87. package/src/adapter/filterOperators.ts +524 -0
  88. package/src/adapter/pinning.ts +134 -0
  89. package/src/adapter/selectColumn.ts +12 -1
  90. package/src/adapter/treeData.ts +228 -0
  91. package/src/index.ts +19 -0
  92. package/src/numberCells.tsx +379 -0
  93. package/src/styles.ts +131 -0
  94. package/src/types.ts +190 -7
  95. package/src/useDataGridTable.test.ts +69 -0
  96. package/src/useDataGridTable.ts +338 -17
@@ -0,0 +1,648 @@
1
+ import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
2
+ import { Platform, View } from 'react-native';
3
+ import {
4
+ OverlayContainer,
5
+ useAnchoredOverlay,
6
+ useDismissOverlay,
7
+ useEscapeKey,
8
+ } from '@cdx-ui/primitives';
9
+ import { mergeRefs } from '@cdx-ui/utils';
10
+ import {
11
+ Button,
12
+ DateField,
13
+ DatePicker,
14
+ IconButton,
15
+ Input,
16
+ Select,
17
+ Text,
18
+ type DatePickerProps,
19
+ } from '@cdx-ui/components';
20
+ import { Add, Close, Delete } from '@cdx-ui/icons';
21
+ import {
22
+ filterOperatorNeedsValue,
23
+ filterValueToInputText,
24
+ getDefaultFilterOperator,
25
+ getFilterOperatorLabel,
26
+ getGridFilterOperators,
27
+ headerFilterValueFromText,
28
+ isActiveFilterItem,
29
+ } from './adapter/filterOperators';
30
+ import {
31
+ dataGridFilterPanelColumnVariants,
32
+ dataGridFilterPanelFieldVariants,
33
+ dataGridFilterPanelFooterVariants,
34
+ dataGridFilterPanelLogicVariants,
35
+ dataGridFilterPanelOperatorVariants,
36
+ dataGridFilterPanelRowVariants,
37
+ dataGridFilterPanelValueVariants,
38
+ dataGridFilterPanelVariants,
39
+ } from './styles';
40
+ import type {
41
+ GridColDef,
42
+ GridFilterItem,
43
+ GridFilterModel,
44
+ GridFilterOperator,
45
+ GridLogicOperator,
46
+ GridValidRowModel,
47
+ } from './types';
48
+
49
+ const LOGIC_OPERATORS: readonly { value: GridLogicOperator; label: string }[] = [
50
+ { value: 'and', label: 'And' },
51
+ { value: 'or', label: 'Or' },
52
+ ];
53
+
54
+ const BOOLEAN_FILTER_VALUES: readonly { value: 'any' | 'true' | 'false'; label: string }[] = [
55
+ { value: 'any', label: 'any' },
56
+ { value: 'true', label: 'true' },
57
+ { value: 'false', label: 'false' },
58
+ ];
59
+
60
+ function booleanSelectValue(value: unknown): 'any' | 'true' | 'false' {
61
+ if (value === true || value === 'true') {
62
+ return 'true';
63
+ }
64
+ if (value === false || value === 'false') {
65
+ return 'false';
66
+ }
67
+ return 'any';
68
+ }
69
+
70
+ function booleanFilterItem(
71
+ field: string,
72
+ operator: GridFilterOperator,
73
+ next: string,
74
+ ): GridFilterItem {
75
+ if (next === 'true') {
76
+ return { field, operator, value: true };
77
+ }
78
+ if (next === 'false') {
79
+ return { field, operator, value: false };
80
+ }
81
+ return { field, operator };
82
+ }
83
+
84
+ function logicOperatorLabel(operator: GridLogicOperator): string {
85
+ return operator === 'or' ? 'Or' : 'And';
86
+ }
87
+
88
+ export function filterPlaceholder<R extends GridValidRowModel>(
89
+ col: GridColDef<R> | undefined,
90
+ operator: GridFilterOperator,
91
+ ): string {
92
+ if (operator === 'isAnyOf') {
93
+ return 'a, b';
94
+ }
95
+ switch (col?.type) {
96
+ case 'number':
97
+ return '0';
98
+ case 'date':
99
+ case 'dateTime':
100
+ return 'YYYY-MM-DD';
101
+ case 'boolean':
102
+ return 'true';
103
+ default:
104
+ return 'Filter value';
105
+ }
106
+ }
107
+
108
+ function isDateColumn<R extends GridValidRowModel>(col: GridColDef<R> | undefined): boolean {
109
+ return col?.type === 'date' || col?.type === 'dateTime';
110
+ }
111
+
112
+ function padDatePart(value: number): string {
113
+ return String(value).padStart(2, '0');
114
+ }
115
+
116
+ /** `DatePicker` speaks `YYYY-MM-DD`. The filter model keeps a local `Date`. */
117
+ function dateValueToPickerValue(value: unknown): DatePickerProps['value'] {
118
+ let wire: string | undefined;
119
+ if (value instanceof Date && Number.isFinite(value.getTime())) {
120
+ wire = `${String(value.getFullYear())}-${padDatePart(value.getMonth() + 1)}-${padDatePart(value.getDate())}`;
121
+ } else if (typeof value === 'string' && pickerValueToDate(value.slice(0, 10))) {
122
+ wire = value.slice(0, 10);
123
+ }
124
+ return wire as DatePickerProps['value'];
125
+ }
126
+
127
+ function pickerValueToDate(value: string): Date | undefined {
128
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
129
+ if (!match) {
130
+ return undefined;
131
+ }
132
+ const year = Number(match[1]);
133
+ const month = Number(match[2]);
134
+ const day = Number(match[3]);
135
+ const date = new Date(year, month - 1, day);
136
+ return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day
137
+ ? date
138
+ : undefined;
139
+ }
140
+
141
+ function FilterDateValue({
142
+ value,
143
+ accessibilityLabel,
144
+ onChange,
145
+ }: {
146
+ value: unknown;
147
+ accessibilityLabel: string;
148
+ onChange: (date: Date | undefined) => void;
149
+ }) {
150
+ return (
151
+ <DatePicker
152
+ value={dateValueToPickerValue(value)}
153
+ onValueChange={(next) => onChange(next === undefined ? undefined : pickerValueToDate(next))}
154
+ className="w-full gap-0"
155
+ >
156
+ <DateField.Group size="small" fullWidth>
157
+ <DateField.Input />
158
+ <DateField.Suffix>
159
+ <DatePicker.Trigger>
160
+ <DatePicker.TriggerIndicator />
161
+ </DatePicker.Trigger>
162
+ </DateField.Suffix>
163
+ </DateField.Group>
164
+ <DatePicker.Popover>
165
+ <DatePicker.Calendar aria-label={accessibilityLabel} />
166
+ </DatePicker.Popover>
167
+ </DatePicker>
168
+ );
169
+ }
170
+
171
+ export function createDefaultFilterItem<R extends GridValidRowModel>(
172
+ columns: readonly GridColDef<R>[],
173
+ ): GridFilterItem | undefined {
174
+ if (columns.length === 0) {
175
+ return undefined;
176
+ }
177
+ const col = columns[0];
178
+ return { field: col.field, operator: getDefaultFilterOperator(col.type) };
179
+ }
180
+
181
+ function createFilterItemForField<R extends GridValidRowModel>(
182
+ columns: readonly GridColDef<R>[],
183
+ field: string | undefined,
184
+ ): GridFilterItem | undefined {
185
+ const col =
186
+ field === undefined ? undefined : columns.find((candidate) => candidate.field === field);
187
+ return col && col.filterable !== false
188
+ ? { field: col.field, operator: getDefaultFilterOperator(col.type) }
189
+ : undefined;
190
+ }
191
+
192
+ const FOCUSABLE_SELECTOR = 'input, textarea, button, [tabindex]:not([tabindex="-1"])';
193
+
194
+ function focusFirstControl(container: View | null): boolean {
195
+ const element = container as unknown as HTMLElement | null;
196
+ const control = element?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
197
+ if (!control) {
198
+ return false;
199
+ }
200
+ control.focus();
201
+ return true;
202
+ }
203
+
204
+ function focusOpener(ref: RefObject<View | null>) {
205
+ const element = ref.current as unknown as HTMLElement | null;
206
+ if (Platform.OS === 'web' && typeof element?.focus === 'function') {
207
+ element.focus();
208
+ }
209
+ }
210
+
211
+ /** The control that opened the panel and, for a column opener, the field it targets. */
212
+ export interface DataGridFilterPanelOpener {
213
+ ref: RefObject<View | null>;
214
+ field?: string;
215
+ /** Changes on every open-for-field call, so a repeat call moves focus again. */
216
+ request?: number;
217
+ }
218
+
219
+ export interface UseDataGridFilterPanelParams<R extends GridValidRowModel> {
220
+ /** The panel's columns. A field that is missing or has `filterable: false` does not open it. */
221
+ columns: readonly GridColDef<R>[];
222
+ filterModel: GridFilterModel;
223
+ /** Receives the appended item. It filters nothing, so callers should not reset the page. */
224
+ onFilterModelChange: (model: GridFilterModel) => void;
225
+ }
226
+
227
+ export interface UseDataGridFilterPanelResult {
228
+ opener: DataGridFilterPanelOpener | null;
229
+ /** Opens the panel on `field`, or retargets it when already open. Header filter cells and the
230
+ * column menu call this; the column menu passes its trigger, since the menu item unmounts. */
231
+ openForField: (field: string, ref: RefObject<View | null>) => void;
232
+ /** The toolbar button: opens the panel on the model as it is, or closes it. */
233
+ toggle: (ref: RefObject<View | null>) => void;
234
+ close: (options: { restoreFocus: boolean }) => void;
235
+ }
236
+
237
+ /**
238
+ * Open state for the filter panel. Opening for a field shows the field's first item, even a
239
+ * value-less one, and leaves the model alone. When the field has none, it appends
240
+ * `{ field, operator }` and drops value-less items on other fields, as the append branch of MUI X
241
+ * `showFilterPanel(field)` does. MUI also rewrites the model when the field has an item.
242
+ */
243
+ export function useDataGridFilterPanel<R extends GridValidRowModel>({
244
+ columns,
245
+ filterModel,
246
+ onFilterModelChange,
247
+ }: UseDataGridFilterPanelParams<R>): UseDataGridFilterPanelResult {
248
+ const [opener, setOpener] = useState<DataGridFilterPanelOpener | null>(null);
249
+ const requestRef = useRef(0);
250
+
251
+ const openForField = useCallback(
252
+ (field: string, ref: RefObject<View | null>) => {
253
+ const item = createFilterItemForField(columns, field);
254
+ if (!item) {
255
+ return;
256
+ }
257
+ if (!filterModel.some((candidate) => candidate.field === field)) {
258
+ onFilterModelChange([...filterModel.filter(isActiveFilterItem), item]);
259
+ }
260
+ requestRef.current += 1;
261
+ setOpener({ ref, field, request: requestRef.current });
262
+ },
263
+ [columns, filterModel, onFilterModelChange],
264
+ );
265
+
266
+ const toggle = useCallback((ref: RefObject<View | null>) => {
267
+ setOpener((current) => (current?.ref === ref ? null : { ref }));
268
+ }, []);
269
+
270
+ const close = useCallback(
271
+ ({ restoreFocus }: { restoreFocus: boolean }) => {
272
+ if (opener && restoreFocus) {
273
+ focusOpener(opener.ref);
274
+ }
275
+ setOpener(null);
276
+ },
277
+ [opener],
278
+ );
279
+
280
+ return { opener, openForField, toggle, close };
281
+ }
282
+
283
+ function resolveItemOperator<R extends GridValidRowModel>(
284
+ col: GridColDef<R> | undefined,
285
+ item: GridFilterItem,
286
+ ): GridFilterOperator {
287
+ const operators = getGridFilterOperators(col?.type);
288
+ return item.operator !== undefined && operators.includes(item.operator)
289
+ ? item.operator
290
+ : getDefaultFilterOperator(col?.type);
291
+ }
292
+
293
+ function itemWithValueText<R extends GridValidRowModel>(
294
+ col: GridColDef<R> | undefined,
295
+ field: string,
296
+ operator: GridFilterOperator,
297
+ text: string,
298
+ ): GridFilterItem {
299
+ if (!filterOperatorNeedsValue(operator)) {
300
+ return { field, operator };
301
+ }
302
+ return {
303
+ field,
304
+ operator,
305
+ value: text.trim().length > 0 ? headerFilterValueFromText(col, operator, text) : undefined,
306
+ };
307
+ }
308
+
309
+ function FilterPanelRow<R extends GridValidRowModel>({
310
+ index,
311
+ operatorFieldRef,
312
+ valueFieldRef,
313
+ item,
314
+ columns,
315
+ logicOperator,
316
+ onLogicOperatorChange,
317
+ onChange,
318
+ onRemove,
319
+ }: {
320
+ index: number;
321
+ operatorFieldRef?: RefObject<View | null>;
322
+ valueFieldRef?: RefObject<View | null>;
323
+ item: GridFilterItem;
324
+ columns: readonly GridColDef<R>[];
325
+ logicOperator: GridLogicOperator;
326
+ onLogicOperatorChange: (operator: GridLogicOperator) => void;
327
+ onChange: (item: GridFilterItem) => void;
328
+ onRemove: () => void;
329
+ }) {
330
+ const col = columns.find((candidate) => candidate.field === item.field);
331
+ const operator = resolveItemOperator(col, item);
332
+ const operators = getGridFilterOperators(col?.type);
333
+ const valueText = filterValueToInputText(item.value);
334
+ const rowLabel = `filter ${(index + 1).toString()}`;
335
+
336
+ return (
337
+ <View className={dataGridFilterPanelRowVariants()} accessibilityRole="none">
338
+ <IconButton
339
+ as={Close}
340
+ variant="ghost"
341
+ color="neutral"
342
+ size="small"
343
+ iconClassName="size-4"
344
+ accessibilityLabel={`Remove ${rowLabel}`}
345
+ onPress={onRemove}
346
+ />
347
+ <View className={dataGridFilterPanelLogicVariants()}>
348
+ {index === 1 ? (
349
+ <Select
350
+ items={[...LOGIC_OPERATORS]}
351
+ value={logicOperator}
352
+ onValueChange={(next) => {
353
+ const nextOperator = LOGIC_OPERATORS.find((candidate) => candidate.value === next);
354
+ if (nextOperator) {
355
+ onLogicOperatorChange(nextOperator.value);
356
+ }
357
+ }}
358
+ size="small"
359
+ className="w-full"
360
+ accessibilityLabel="Logic operator"
361
+ >
362
+ <Select.Trigger>
363
+ <Select.Value />
364
+ </Select.Trigger>
365
+ <Select.Content>
366
+ {LOGIC_OPERATORS.map((candidate) => (
367
+ <Select.Item key={candidate.value} value={candidate.value}>
368
+ <Select.ItemLabel>{candidate.label}</Select.ItemLabel>
369
+ </Select.Item>
370
+ ))}
371
+ </Select.Content>
372
+ </Select>
373
+ ) : index > 1 ? (
374
+ <Text className="px-3 text-sm text-content-secondary">
375
+ {logicOperatorLabel(logicOperator)}
376
+ </Text>
377
+ ) : null}
378
+ </View>
379
+ <View className={dataGridFilterPanelFieldVariants({ size: 'column' })}>
380
+ <Text className="text-xs text-content-secondary">Columns</Text>
381
+ <Select
382
+ items={columns.map((candidate) => ({
383
+ value: candidate.field,
384
+ label: candidate.headerName ?? candidate.field,
385
+ }))}
386
+ value={item.field}
387
+ onValueChange={(next) => {
388
+ const nextCol = columns.find((candidate) => candidate.field === next);
389
+ if (nextCol && nextCol.field !== item.field) {
390
+ onChange({ field: nextCol.field, operator: getDefaultFilterOperator(nextCol.type) });
391
+ }
392
+ }}
393
+ size="small"
394
+ className={dataGridFilterPanelColumnVariants()}
395
+ accessibilityLabel={`Column for ${rowLabel}`}
396
+ >
397
+ <Select.Trigger>
398
+ <Select.Value />
399
+ </Select.Trigger>
400
+ <Select.Content>
401
+ {columns.map((candidate) => (
402
+ <Select.Item key={candidate.field} value={candidate.field}>
403
+ <Select.ItemLabel>{candidate.headerName ?? candidate.field}</Select.ItemLabel>
404
+ </Select.Item>
405
+ ))}
406
+ </Select.Content>
407
+ </Select>
408
+ </View>
409
+ <View
410
+ ref={operatorFieldRef}
411
+ className={dataGridFilterPanelFieldVariants({ size: 'operator' })}
412
+ >
413
+ <Text className="text-xs text-content-secondary">Operator</Text>
414
+ <Select
415
+ items={operators.map((candidate) => ({
416
+ value: candidate,
417
+ label: getFilterOperatorLabel(candidate),
418
+ }))}
419
+ value={operator}
420
+ onValueChange={(next) => {
421
+ const nextOperator = operators.find((candidate) => candidate === next);
422
+ if (nextOperator === undefined) {
423
+ return;
424
+ }
425
+ if (!filterOperatorNeedsValue(nextOperator)) {
426
+ onChange({ field: item.field, operator: nextOperator });
427
+ } else if (isDateColumn(col)) {
428
+ onChange({ field: item.field, operator: nextOperator, value: item.value });
429
+ } else if (col?.type === 'boolean') {
430
+ onChange(booleanFilterItem(item.field, nextOperator, booleanSelectValue(item.value)));
431
+ } else {
432
+ onChange(itemWithValueText(col, item.field, nextOperator, valueText));
433
+ }
434
+ }}
435
+ size="small"
436
+ className={dataGridFilterPanelOperatorVariants()}
437
+ accessibilityLabel={`Operator for ${rowLabel}`}
438
+ >
439
+ <Select.Trigger>
440
+ <Select.Value />
441
+ </Select.Trigger>
442
+ <Select.Content>
443
+ {operators.map((candidate) => (
444
+ <Select.Item key={candidate} value={candidate}>
445
+ <Select.ItemLabel>{getFilterOperatorLabel(candidate)}</Select.ItemLabel>
446
+ </Select.Item>
447
+ ))}
448
+ </Select.Content>
449
+ </Select>
450
+ </View>
451
+ <View ref={valueFieldRef} className={dataGridFilterPanelFieldVariants({ size: 'value' })}>
452
+ {filterOperatorNeedsValue(operator) ? (
453
+ <>
454
+ <Text className="text-xs text-content-secondary">Value</Text>
455
+ {isDateColumn(col) ? (
456
+ <FilterDateValue
457
+ value={item.value}
458
+ accessibilityLabel={`Value for ${rowLabel}`}
459
+ onChange={(date) =>
460
+ onChange(
461
+ date === undefined
462
+ ? { field: item.field, operator }
463
+ : { field: item.field, operator, value: date },
464
+ )
465
+ }
466
+ />
467
+ ) : col?.type === 'boolean' ? (
468
+ <Select
469
+ items={[...BOOLEAN_FILTER_VALUES]}
470
+ value={booleanSelectValue(item.value)}
471
+ onValueChange={(next) => onChange(booleanFilterItem(item.field, operator, next))}
472
+ size="small"
473
+ className={dataGridFilterPanelValueVariants()}
474
+ accessibilityLabel={`Value for ${rowLabel}`}
475
+ >
476
+ <Select.Trigger>
477
+ <Select.Value />
478
+ </Select.Trigger>
479
+ <Select.Content>
480
+ {BOOLEAN_FILTER_VALUES.map((candidate) => (
481
+ <Select.Item key={candidate.value} value={candidate.value}>
482
+ <Select.ItemLabel>{candidate.label}</Select.ItemLabel>
483
+ </Select.Item>
484
+ ))}
485
+ </Select.Content>
486
+ </Select>
487
+ ) : (
488
+ <Input size="small" className={dataGridFilterPanelValueVariants()}>
489
+ <Input.Field
490
+ value={valueText}
491
+ onChangeText={(text) =>
492
+ onChange(itemWithValueText(col, item.field, operator, text))
493
+ }
494
+ placeholder={filterPlaceholder(col, operator)}
495
+ accessibilityLabel={`Value for ${rowLabel}`}
496
+ />
497
+ </Input>
498
+ )}
499
+ </>
500
+ ) : null}
501
+ </View>
502
+ </View>
503
+ );
504
+ }
505
+
506
+ export interface DataGridFilterPanelProps<R extends GridValidRowModel> {
507
+ columns: readonly GridColDef<R>[];
508
+ filterModel: GridFilterModel;
509
+ onFilterModelChange: (model: GridFilterModel) => void;
510
+ logicOperator: GridLogicOperator;
511
+ onLogicOperatorChange: (operator: GridLogicOperator) => void;
512
+ /** `restoreFocus` is false for an outside press, which leaves focus where the user pressed. */
513
+ onClose: (options: { restoreFocus: boolean }) => void;
514
+ /** Presses on the opener are not outside presses. A column opener also anchors the panel. */
515
+ opener: DataGridFilterPanelOpener;
516
+ }
517
+
518
+ export function DataGridFilterPanel<R extends GridValidRowModel>({
519
+ columns,
520
+ filterModel,
521
+ onFilterModelChange,
522
+ logicOperator,
523
+ onLogicOperatorChange,
524
+ onClose,
525
+ opener,
526
+ }: DataGridFilterPanelProps<R>) {
527
+ const anchored = opener.field !== undefined;
528
+ const panelRef = useRef<View>(null);
529
+ const operatorFieldRef = useRef<View>(null);
530
+ const valueFieldRef = useRef<View>(null);
531
+ const focusedRequestRef = useRef<number | undefined>(undefined);
532
+ const {
533
+ floatingRef,
534
+ onFloatingLayout,
535
+ style: anchoredStyle,
536
+ position,
537
+ } = useAnchoredOverlay(opener.ref, anchored, {
538
+ placement: 'bottom-start',
539
+ collisionBehavior: 'flip-shift',
540
+ width: 'content',
541
+ });
542
+ const dismiss = () => onClose({ restoreFocus: false });
543
+ const closeAndRestoreFocus = () => onClose({ restoreFocus: true });
544
+ useDismissOverlay(true, panelRef, [opener.ref], dismiss);
545
+ useEscapeKey(true, closeAndRestoreFocus);
546
+
547
+ const defaultItem = createDefaultFilterItem(columns);
548
+ const placeholderItem = createFilterItemForField(columns, opener.field) ?? defaultItem;
549
+ const items = filterModel.length > 0 ? filterModel : placeholderItem ? [placeholderItem] : [];
550
+ const targetIndex =
551
+ opener.field === undefined ? -1 : items.findIndex((item) => item.field === opener.field);
552
+ const targetItem = targetIndex >= 0 ? items[targetIndex] : undefined;
553
+ const targetNeedsValue =
554
+ targetItem !== undefined &&
555
+ filterOperatorNeedsValue(
556
+ resolveItemOperator(
557
+ columns.find((candidate) => candidate.field === targetItem.field),
558
+ targetItem,
559
+ ),
560
+ );
561
+
562
+ // Rows are keyed by index, so an appended target row can mount a render after the open request.
563
+ // Retry on each commit until the target control exists, then stop for that request.
564
+ useEffect(() => {
565
+ if (
566
+ Platform.OS !== 'web' ||
567
+ opener.request === undefined ||
568
+ focusedRequestRef.current === opener.request ||
569
+ targetItem === undefined
570
+ ) {
571
+ return;
572
+ }
573
+ if (focusFirstControl((targetNeedsValue ? valueFieldRef : operatorFieldRef).current)) {
574
+ focusedRequestRef.current = opener.request;
575
+ }
576
+ });
577
+
578
+ const replaceItem = (index: number, next: GridFilterItem) => {
579
+ onFilterModelChange(items.map((item, itemIndex) => (itemIndex === index ? next : item)));
580
+ };
581
+ const removeItem = (index: number) => {
582
+ const next = items.filter((_, itemIndex) => itemIndex !== index);
583
+ onFilterModelChange(next);
584
+ if (next.length === 0) {
585
+ closeAndRestoreFocus();
586
+ }
587
+ };
588
+
589
+ const panel = (
590
+ <View
591
+ ref={anchored ? mergeRefs(panelRef, floatingRef) : panelRef}
592
+ className={dataGridFilterPanelVariants({ anchored })}
593
+ style={
594
+ anchored
595
+ ? [anchoredStyle, position ? { maxWidth: position.availableWidth } : undefined]
596
+ : undefined
597
+ }
598
+ onLayout={anchored ? onFloatingLayout : undefined}
599
+ accessibilityLabel="Filters"
600
+ testID="data-grid-filter-panel"
601
+ >
602
+ {items.map((item, index) => (
603
+ <FilterPanelRow
604
+ key={index}
605
+ index={index}
606
+ operatorFieldRef={index === targetIndex ? operatorFieldRef : undefined}
607
+ valueFieldRef={index === targetIndex ? valueFieldRef : undefined}
608
+ item={item}
609
+ columns={columns}
610
+ logicOperator={logicOperator}
611
+ onLogicOperatorChange={onLogicOperatorChange}
612
+ onChange={(next) => replaceItem(index, next)}
613
+ onRemove={() => removeItem(index)}
614
+ />
615
+ ))}
616
+ <View className={dataGridFilterPanelFooterVariants()}>
617
+ <Button
618
+ variant="ghost"
619
+ color="action"
620
+ size="small"
621
+ isDisabled={defaultItem === undefined}
622
+ onPress={() => {
623
+ if (defaultItem) {
624
+ onFilterModelChange([...items, defaultItem]);
625
+ }
626
+ }}
627
+ >
628
+ <Button.Icon as={Add} />
629
+ <Button.Label>Add filter</Button.Label>
630
+ </Button>
631
+ <Button
632
+ variant="ghost"
633
+ color="action"
634
+ size="small"
635
+ onPress={() => {
636
+ onFilterModelChange([]);
637
+ closeAndRestoreFocus();
638
+ }}
639
+ >
640
+ <Button.Icon as={Delete} />
641
+ <Button.Label>Remove all</Button.Label>
642
+ </Button>
643
+ </View>
644
+ </View>
645
+ );
646
+
647
+ return anchored ? <OverlayContainer onDismiss={dismiss}>{panel}</OverlayContainer> : panel;
648
+ }