@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.
- package/README.md +3 -1
- package/coverage.svg +1 -1
- package/lib/commonjs/DataGrid.js +490 -81
- package/lib/commonjs/DataGrid.js.map +1 -1
- package/lib/commonjs/DataGridFilterPanel.js +547 -0
- package/lib/commonjs/DataGridFilterPanel.js.map +1 -0
- package/lib/commonjs/adapter/columnTypes.js +256 -0
- package/lib/commonjs/adapter/columnTypes.js.map +1 -0
- package/lib/commonjs/adapter/columns.js +140 -4
- package/lib/commonjs/adapter/columns.js.map +1 -1
- package/lib/commonjs/adapter/filterOperators.js +422 -0
- package/lib/commonjs/adapter/filterOperators.js.map +1 -0
- package/lib/commonjs/adapter/pinning.js +105 -0
- package/lib/commonjs/adapter/pinning.js.map +1 -0
- package/lib/commonjs/adapter/selectColumn.js +11 -0
- package/lib/commonjs/adapter/selectColumn.js.map +1 -1
- package/lib/commonjs/adapter/treeData.js +195 -0
- package/lib/commonjs/adapter/treeData.js.map +1 -0
- package/lib/commonjs/index.js +26 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/numberCells.js +280 -0
- package/lib/commonjs/numberCells.js.map +1 -0
- package/lib/commonjs/styles.js +107 -1
- package/lib/commonjs/styles.js.map +1 -1
- package/lib/commonjs/types.js +39 -1
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/useDataGridTable.js +234 -18
- package/lib/commonjs/useDataGridTable.js.map +1 -1
- package/lib/module/DataGrid.js +497 -88
- package/lib/module/DataGrid.js.map +1 -1
- package/lib/module/DataGridFilterPanel.js +540 -0
- package/lib/module/DataGridFilterPanel.js.map +1 -0
- package/lib/module/adapter/columnTypes.js +240 -0
- package/lib/module/adapter/columnTypes.js.map +1 -0
- package/lib/module/adapter/columns.js +137 -4
- package/lib/module/adapter/columns.js.map +1 -1
- package/lib/module/adapter/filterOperators.js +406 -0
- package/lib/module/adapter/filterOperators.js.map +1 -0
- package/lib/module/adapter/pinning.js +94 -0
- package/lib/module/adapter/pinning.js.map +1 -0
- package/lib/module/adapter/selectColumn.js +11 -1
- package/lib/module/adapter/selectColumn.js.map +1 -1
- package/lib/module/adapter/treeData.js +187 -0
- package/lib/module/adapter/treeData.js.map +1 -0
- package/lib/module/index.js +2 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/numberCells.js +275 -0
- package/lib/module/numberCells.js.map +1 -0
- package/lib/module/styles.js +106 -0
- package/lib/module/styles.js.map +1 -1
- package/lib/module/types.js +38 -0
- package/lib/module/types.js.map +1 -1
- package/lib/module/useDataGridTable.js +238 -22
- package/lib/module/useDataGridTable.js.map +1 -1
- package/lib/typescript/DataGrid.d.ts.map +1 -1
- package/lib/typescript/DataGridFilterPanel.d.ts +52 -0
- package/lib/typescript/DataGridFilterPanel.d.ts.map +1 -0
- package/lib/typescript/adapter/columnTypes.d.ts +32 -0
- package/lib/typescript/adapter/columnTypes.d.ts.map +1 -0
- package/lib/typescript/adapter/columns.d.ts +14 -5
- package/lib/typescript/adapter/columns.d.ts.map +1 -1
- package/lib/typescript/adapter/filterOperators.d.ts +43 -0
- package/lib/typescript/adapter/filterOperators.d.ts.map +1 -0
- package/lib/typescript/adapter/pinning.d.ts +26 -0
- package/lib/typescript/adapter/pinning.d.ts.map +1 -0
- package/lib/typescript/adapter/selectColumn.d.ts +8 -0
- package/lib/typescript/adapter/selectColumn.d.ts.map +1 -1
- package/lib/typescript/adapter/treeData.d.ts +36 -0
- package/lib/typescript/adapter/treeData.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +4 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/numberCells.d.ts +46 -0
- package/lib/typescript/numberCells.d.ts.map +1 -0
- package/lib/typescript/styles.d.ts +32 -0
- package/lib/typescript/styles.d.ts.map +1 -1
- package/lib/typescript/types.d.ts +154 -7
- package/lib/typescript/types.d.ts.map +1 -1
- package/lib/typescript/useDataGridTable.d.ts +40 -4
- package/lib/typescript/useDataGridTable.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/DataGrid.tsx +745 -95
- package/src/DataGridFilterPanel.tsx +648 -0
- package/src/adapter/columnTypes.ts +327 -0
- package/src/adapter/columns.test.ts +75 -1
- package/src/adapter/columns.ts +192 -3
- package/src/adapter/filterOperators.test.ts +292 -0
- package/src/adapter/filterOperators.ts +524 -0
- package/src/adapter/pinning.ts +134 -0
- package/src/adapter/selectColumn.ts +12 -1
- package/src/adapter/treeData.ts +228 -0
- package/src/index.ts +19 -0
- package/src/numberCells.tsx +379 -0
- package/src/styles.ts +131 -0
- package/src/types.ts +190 -7
- package/src/useDataGridTable.test.ts +69 -0
- 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
|
+
}
|