@wallarm-org/design-system 0.78.1 → 0.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/FilterInput/FilterInputErrors/parseFilterInputErrors.js +2 -2
- package/dist/components/FilterInput/FilterInputField/ChipsWithGaps.js +1 -1
- package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/FilterInputValueMenu.d.ts +21 -3
- package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/FilterInputValueMenu.js +10 -75
- package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/FlatValueMenu.d.ts +8 -0
- package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/FlatValueMenu.js +81 -0
- package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/NestedValueMenu.d.ts +10 -0
- package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/NestedValueMenu.js +145 -0
- package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/NestedValueMenuItem.d.ts +26 -0
- package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/NestedValueMenuItem.js +54 -0
- package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/ValueMenuFooterHints.d.ts +12 -0
- package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/ValueMenuFooterHints.js +69 -0
- package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/useNestedValueMenuState.d.ts +65 -0
- package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/useNestedValueMenuState.js +293 -0
- package/dist/components/FilterInput/FilterInputMenu/hooks/useKeyboardNav.d.ts +3 -1
- package/dist/components/FilterInput/FilterInputMenu/hooks/useKeyboardNav.js +20 -1
- package/dist/components/FilterInput/hooks/useFilterInputAutocomplete/lib/deriveAutocompleteValues.js +3 -3
- package/dist/components/FilterInput/hooks/useFilterInputAutocomplete/lib/valueResolution.js +5 -5
- package/dist/components/FilterInput/hooks/useFilterInputExpression/buildChips.js +2 -2
- package/dist/components/FilterInput/lib/buildValueMenuSections.d.ts +33 -0
- package/dist/components/FilterInput/lib/buildValueMenuSections.js +68 -0
- package/dist/components/FilterInput/lib/collapseValuesToLabels.d.ts +32 -0
- package/dist/components/FilterInput/lib/collapseValuesToLabels.js +55 -0
- package/dist/components/FilterInput/lib/fields.d.ts +19 -7
- package/dist/components/FilterInput/lib/fields.js +18 -4
- package/dist/components/FilterInput/lib/index.d.ts +3 -1
- package/dist/components/FilterInput/lib/index.js +3 -1
- package/dist/components/FilterInput/lib/validation.js +2 -2
- package/dist/components/FilterInput/types.d.ts +31 -2
- package/dist/metadata/components.json +2 -2
- package/package.json +1 -1
- package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/ValueMenuFooter.d.ts +0 -6
- package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/ValueMenuFooter.js +0 -72
package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/useNestedValueMenuState.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { collectLeaves } from "../../lib/index.js";
|
|
3
|
+
import { useKeyboardNav } from "../hooks/useKeyboardNav.js";
|
|
4
|
+
const SELECT_ALL_ID = '__filter_value_select_all__';
|
|
5
|
+
const leafKeys = (option)=>collectLeaves(option.children).map((leaf)=>String(leaf.value));
|
|
6
|
+
const useNestedValueMenuState = ({ sections, allValues, open, multiSelect, initialValues, onSelect, onCommit, onEscape, onOpenChange, onBuildingValueChange, onItemToggle, inputRef, menuRef, submenuRef, blurCommitRef })=>{
|
|
7
|
+
const [checkedValues, setCheckedValues] = useState(initialValues);
|
|
8
|
+
const checkedValuesRef = useRef(checkedValues);
|
|
9
|
+
checkedValuesRef.current = checkedValues;
|
|
10
|
+
const [openParentId, setOpenParentId] = useState(null);
|
|
11
|
+
const checkedSet = useMemo(()=>new Set(checkedValues.map(String)), [
|
|
12
|
+
checkedValues
|
|
13
|
+
]);
|
|
14
|
+
const prevSerializedRef = useRef(null);
|
|
15
|
+
useEffect(()=>{
|
|
16
|
+
const serialized = initialValues.map(String).sort().join('\0');
|
|
17
|
+
if (serialized === prevSerializedRef.current) return;
|
|
18
|
+
prevSerializedRef.current = serialized;
|
|
19
|
+
setCheckedValues(initialValues);
|
|
20
|
+
}, [
|
|
21
|
+
initialValues
|
|
22
|
+
]);
|
|
23
|
+
const prevOpenRef = useRef(open);
|
|
24
|
+
useEffect(()=>{
|
|
25
|
+
if (!open && prevOpenRef.current) setOpenParentId(null);
|
|
26
|
+
prevOpenRef.current = open;
|
|
27
|
+
}, [
|
|
28
|
+
open
|
|
29
|
+
]);
|
|
30
|
+
const topRows = useMemo(()=>sections.flatMap((section)=>section.rows), [
|
|
31
|
+
sections
|
|
32
|
+
]);
|
|
33
|
+
const topRowsRef = useRef(topRows);
|
|
34
|
+
topRowsRef.current = topRows;
|
|
35
|
+
const openParentRow = useMemo(()=>topRows.find((row)=>row.id === openParentId && row.isGroup), [
|
|
36
|
+
topRows,
|
|
37
|
+
openParentId
|
|
38
|
+
]);
|
|
39
|
+
const submenuRows = useMemo(()=>{
|
|
40
|
+
if (!openParentRow) return [];
|
|
41
|
+
return (openParentRow.option.children ?? []).map((child)=>({
|
|
42
|
+
id: 'value' in child && null != child.value ? `leaf:${String(child.value)}` : `group:${child.label}`,
|
|
43
|
+
option: child,
|
|
44
|
+
isGroup: Array.isArray(child.children)
|
|
45
|
+
}));
|
|
46
|
+
}, [
|
|
47
|
+
openParentRow
|
|
48
|
+
]);
|
|
49
|
+
const toggleValue = useCallback((value)=>{
|
|
50
|
+
setCheckedValues((prev)=>{
|
|
51
|
+
const key = String(value);
|
|
52
|
+
return prev.some((v)=>String(v) === key) ? prev.filter((v)=>String(v) !== key) : [
|
|
53
|
+
...prev,
|
|
54
|
+
value
|
|
55
|
+
];
|
|
56
|
+
});
|
|
57
|
+
}, []);
|
|
58
|
+
const toggleMany = useCallback((values, on)=>{
|
|
59
|
+
if (0 === values.length) return;
|
|
60
|
+
setCheckedValues((prev)=>{
|
|
61
|
+
const keys = new Set(values.map(String));
|
|
62
|
+
const without = prev.filter((v)=>!keys.has(String(v)));
|
|
63
|
+
return on ? [
|
|
64
|
+
...without,
|
|
65
|
+
...values
|
|
66
|
+
] : without;
|
|
67
|
+
});
|
|
68
|
+
}, []);
|
|
69
|
+
const onCommitRef = useRef(onCommit);
|
|
70
|
+
onCommitRef.current = onCommit;
|
|
71
|
+
const committingRef = useRef(false);
|
|
72
|
+
const commitChecked = useCallback(()=>{
|
|
73
|
+
if (committingRef.current) return false;
|
|
74
|
+
if (0 === checkedValuesRef.current.length || !onCommitRef.current) return false;
|
|
75
|
+
committingRef.current = true;
|
|
76
|
+
try {
|
|
77
|
+
onCommitRef.current(checkedValuesRef.current);
|
|
78
|
+
return true;
|
|
79
|
+
} finally{
|
|
80
|
+
committingRef.current = false;
|
|
81
|
+
}
|
|
82
|
+
}, []);
|
|
83
|
+
useEffect(()=>{
|
|
84
|
+
if (!blurCommitRef) return;
|
|
85
|
+
blurCommitRef.current = multiSelect ? commitChecked : null;
|
|
86
|
+
return ()=>{
|
|
87
|
+
blurCommitRef.current = null;
|
|
88
|
+
};
|
|
89
|
+
}, [
|
|
90
|
+
multiSelect,
|
|
91
|
+
blurCommitRef,
|
|
92
|
+
commitChecked
|
|
93
|
+
]);
|
|
94
|
+
const isLeafChecked = useCallback((value)=>checkedSet.has(String(value)), [
|
|
95
|
+
checkedSet
|
|
96
|
+
]);
|
|
97
|
+
const getRowCheckState = useCallback((row)=>{
|
|
98
|
+
if (!row.isGroup) return isLeafChecked(row.option.value);
|
|
99
|
+
const keys = leafKeys(row.option);
|
|
100
|
+
const present = keys.filter((key)=>checkedSet.has(key)).length;
|
|
101
|
+
if (0 === present) return false;
|
|
102
|
+
if (present === keys.length) return true;
|
|
103
|
+
return 'indeterminate';
|
|
104
|
+
}, [
|
|
105
|
+
checkedSet,
|
|
106
|
+
isLeafChecked
|
|
107
|
+
]);
|
|
108
|
+
const allLeavesChecked = useCallback((option)=>{
|
|
109
|
+
const keys = leafKeys(option);
|
|
110
|
+
return keys.length > 0 && keys.every((key)=>checkedSet.has(key));
|
|
111
|
+
}, [
|
|
112
|
+
checkedSet
|
|
113
|
+
]);
|
|
114
|
+
const closeTimerRef = useRef(null);
|
|
115
|
+
const cancelClose = useCallback(()=>{
|
|
116
|
+
if (closeTimerRef.current) {
|
|
117
|
+
clearTimeout(closeTimerRef.current);
|
|
118
|
+
closeTimerRef.current = null;
|
|
119
|
+
}
|
|
120
|
+
}, []);
|
|
121
|
+
const scheduleClose = useCallback(()=>{
|
|
122
|
+
cancelClose();
|
|
123
|
+
closeTimerRef.current = setTimeout(()=>{
|
|
124
|
+
closeTimerRef.current = null;
|
|
125
|
+
setOpenParentId(null);
|
|
126
|
+
}, 220);
|
|
127
|
+
}, [
|
|
128
|
+
cancelClose
|
|
129
|
+
]);
|
|
130
|
+
const openParent = useCallback((id)=>{
|
|
131
|
+
cancelClose();
|
|
132
|
+
setOpenParentId(id);
|
|
133
|
+
}, [
|
|
134
|
+
cancelClose
|
|
135
|
+
]);
|
|
136
|
+
const closeParent = useCallback(()=>{
|
|
137
|
+
cancelClose();
|
|
138
|
+
setOpenParentId(null);
|
|
139
|
+
}, [
|
|
140
|
+
cancelClose
|
|
141
|
+
]);
|
|
142
|
+
useEffect(()=>()=>cancelClose(), [
|
|
143
|
+
cancelClose
|
|
144
|
+
]);
|
|
145
|
+
const toggleGroup = useCallback((option)=>{
|
|
146
|
+
const leaves = collectLeaves(option.children).map((l)=>l.value);
|
|
147
|
+
toggleMany(leaves, !allLeavesChecked(option));
|
|
148
|
+
onItemToggle?.();
|
|
149
|
+
}, [
|
|
150
|
+
toggleMany,
|
|
151
|
+
allLeavesChecked,
|
|
152
|
+
onItemToggle
|
|
153
|
+
]);
|
|
154
|
+
const selectRow = useCallback((row, fromSubmenu)=>{
|
|
155
|
+
if (row.isGroup) {
|
|
156
|
+
if (multiSelect) toggleGroup(row.option);
|
|
157
|
+
else if (!fromSubmenu) openParent(row.id);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const value = row.option.value;
|
|
161
|
+
if (multiSelect) {
|
|
162
|
+
toggleValue(value);
|
|
163
|
+
onItemToggle?.();
|
|
164
|
+
} else onSelect(value);
|
|
165
|
+
}, [
|
|
166
|
+
multiSelect,
|
|
167
|
+
toggleGroup,
|
|
168
|
+
openParent,
|
|
169
|
+
toggleValue,
|
|
170
|
+
onItemToggle,
|
|
171
|
+
onSelect
|
|
172
|
+
]);
|
|
173
|
+
const handleClose = useCallback(()=>{
|
|
174
|
+
if (multiSelect) commitChecked();
|
|
175
|
+
onOpenChange?.(false);
|
|
176
|
+
}, [
|
|
177
|
+
multiSelect,
|
|
178
|
+
commitChecked,
|
|
179
|
+
onOpenChange
|
|
180
|
+
]);
|
|
181
|
+
const topNavItems = useMemo(()=>topRows.map((row)=>({
|
|
182
|
+
id: row.id,
|
|
183
|
+
label: row.option.label,
|
|
184
|
+
value: row.option.value
|
|
185
|
+
})), [
|
|
186
|
+
topRows
|
|
187
|
+
]);
|
|
188
|
+
const topHighlightRef = useRef('');
|
|
189
|
+
const handleTopSelect = useCallback((item)=>{
|
|
190
|
+
const row = topRowsRef.current.find((r)=>r.id === item.id);
|
|
191
|
+
if (row) selectRow(row, false);
|
|
192
|
+
}, [
|
|
193
|
+
selectRow
|
|
194
|
+
]);
|
|
195
|
+
const handleTopArrowRight = useCallback(()=>{
|
|
196
|
+
const row = topRowsRef.current.find((r)=>r.id === topHighlightRef.current);
|
|
197
|
+
if (row?.isGroup) openParent(row.id);
|
|
198
|
+
else if (multiSelect) commitChecked();
|
|
199
|
+
}, [
|
|
200
|
+
openParent,
|
|
201
|
+
multiSelect,
|
|
202
|
+
commitChecked
|
|
203
|
+
]);
|
|
204
|
+
const top = useKeyboardNav({
|
|
205
|
+
items: topNavItems,
|
|
206
|
+
open: open && null == openParentId,
|
|
207
|
+
onSelect: handleTopSelect,
|
|
208
|
+
onClose: onEscape ?? handleClose,
|
|
209
|
+
onArrowRight: handleTopArrowRight,
|
|
210
|
+
onPendingCommit: multiSelect ? commitChecked : void 0,
|
|
211
|
+
inputRef,
|
|
212
|
+
menuRef
|
|
213
|
+
});
|
|
214
|
+
topHighlightRef.current = top.highlightedValue;
|
|
215
|
+
const submenuNavItems = useMemo(()=>{
|
|
216
|
+
if (!openParentRow) return [];
|
|
217
|
+
return [
|
|
218
|
+
{
|
|
219
|
+
id: SELECT_ALL_ID,
|
|
220
|
+
label: 'Select all',
|
|
221
|
+
value: SELECT_ALL_ID
|
|
222
|
+
},
|
|
223
|
+
...submenuRows.map((row)=>({
|
|
224
|
+
id: row.id,
|
|
225
|
+
label: row.option.label,
|
|
226
|
+
value: row.option.value
|
|
227
|
+
}))
|
|
228
|
+
];
|
|
229
|
+
}, [
|
|
230
|
+
openParentRow,
|
|
231
|
+
submenuRows
|
|
232
|
+
]);
|
|
233
|
+
const handleSubmenuSelect = useCallback((item)=>{
|
|
234
|
+
if (!openParentRow) return;
|
|
235
|
+
if (item.id === SELECT_ALL_ID) return void toggleGroup(openParentRow.option);
|
|
236
|
+
const row = submenuRows.find((r)=>r.id === item.id);
|
|
237
|
+
if (row) selectRow(row, true);
|
|
238
|
+
}, [
|
|
239
|
+
openParentRow,
|
|
240
|
+
submenuRows,
|
|
241
|
+
toggleGroup,
|
|
242
|
+
selectRow
|
|
243
|
+
]);
|
|
244
|
+
const submenu = useKeyboardNav({
|
|
245
|
+
items: submenuNavItems,
|
|
246
|
+
open: open && null != openParentId,
|
|
247
|
+
onSelect: handleSubmenuSelect,
|
|
248
|
+
onClose: closeParent,
|
|
249
|
+
onArrowLeft: closeParent,
|
|
250
|
+
onPendingCommit: multiSelect ? commitChecked : void 0,
|
|
251
|
+
inputRef,
|
|
252
|
+
menuRef: submenuRef
|
|
253
|
+
});
|
|
254
|
+
const labelByValue = useMemo(()=>{
|
|
255
|
+
const map = new Map();
|
|
256
|
+
for (const leaf of collectLeaves(allValues))map.set(String(leaf.value), leaf.label);
|
|
257
|
+
return map;
|
|
258
|
+
}, [
|
|
259
|
+
allValues
|
|
260
|
+
]);
|
|
261
|
+
const buildingMultiValue = multiSelect && checkedValues.length > 0 ? checkedValues.map((v)=>labelByValue.get(String(v)) ?? String(v)).join(', ') : void 0;
|
|
262
|
+
useEffect(()=>{
|
|
263
|
+
onBuildingValueChange?.(buildingMultiValue);
|
|
264
|
+
}, [
|
|
265
|
+
buildingMultiValue,
|
|
266
|
+
onBuildingValueChange
|
|
267
|
+
]);
|
|
268
|
+
return {
|
|
269
|
+
checkedValues,
|
|
270
|
+
openParentId,
|
|
271
|
+
openParent,
|
|
272
|
+
closeParent,
|
|
273
|
+
cancelClose,
|
|
274
|
+
scheduleClose,
|
|
275
|
+
openParentRow,
|
|
276
|
+
submenuRows,
|
|
277
|
+
selectRow,
|
|
278
|
+
toggleGroup,
|
|
279
|
+
isLeafChecked,
|
|
280
|
+
getRowCheckState,
|
|
281
|
+
allLeavesChecked,
|
|
282
|
+
commitChecked,
|
|
283
|
+
topHighlightedValue: top.highlightedValue,
|
|
284
|
+
onTopHighlightChange: top.onHighlightChange,
|
|
285
|
+
topPendingIds: top.pendingIds,
|
|
286
|
+
registerTopItem: top.registerItem,
|
|
287
|
+
submenuHighlightedValue: submenu.highlightedValue,
|
|
288
|
+
onSubmenuHighlightChange: submenu.onHighlightChange,
|
|
289
|
+
submenuPendingIds: submenu.pendingIds,
|
|
290
|
+
registerSubmenuItem: submenu.registerItem
|
|
291
|
+
};
|
|
292
|
+
};
|
|
293
|
+
export { SELECT_ALL_ID, useNestedValueMenuState };
|
|
@@ -6,6 +6,8 @@ interface UseKeyboardNavOptions {
|
|
|
6
6
|
onSelect: (item: FilterInputDropdownItem) => void;
|
|
7
7
|
onClose?: () => void;
|
|
8
8
|
onArrowRight?: () => void;
|
|
9
|
+
/** ArrowLeft handler — used to exit a submenu level back to its parent. */
|
|
10
|
+
onArrowLeft?: () => void;
|
|
9
11
|
/** Called after all pending items are selected via Cmd+Enter */
|
|
10
12
|
onPendingCommit?: () => void;
|
|
11
13
|
/** When true, ArrowRight selects the active item (like Enter) instead of calling onArrowRight */
|
|
@@ -24,7 +26,7 @@ interface UseKeyboardNavOptions {
|
|
|
24
26
|
* Multi-select shortcut: Cmd/Ctrl+Arrow marks items as pending (highlighted),
|
|
25
27
|
* then Enter selects all pending items at once.
|
|
26
28
|
*/
|
|
27
|
-
export declare const useKeyboardNav: ({ items, open, onSelect, onClose, onArrowRight, onPendingCommit, arrowRightSelectsActive, inputRef, menuRef, }: UseKeyboardNavOptions) => {
|
|
29
|
+
export declare const useKeyboardNav: ({ items, open, onSelect, onClose, onArrowRight, onArrowLeft, onPendingCommit, arrowRightSelectsActive, inputRef, menuRef, }: UseKeyboardNavOptions) => {
|
|
28
30
|
activeIndex: number;
|
|
29
31
|
setActiveIndex: import("react").Dispatch<import("react").SetStateAction<number>>;
|
|
30
32
|
highlightedValue: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
-
const useKeyboardNav = ({ items, open, onSelect, onClose, onArrowRight, onPendingCommit, arrowRightSelectsActive = false, inputRef, menuRef })=>{
|
|
2
|
+
const useKeyboardNav = ({ items, open, onSelect, onClose, onArrowRight, onArrowLeft, onPendingCommit, arrowRightSelectsActive = false, inputRef, menuRef })=>{
|
|
3
3
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
4
4
|
const [pendingIds, setPendingIds] = useState(new Set());
|
|
5
5
|
const itemRegistryRef = useRef(new Map());
|
|
@@ -15,6 +15,7 @@ const useKeyboardNav = ({ items, open, onSelect, onClose, onArrowRight, onPendin
|
|
|
15
15
|
onSelect,
|
|
16
16
|
onClose,
|
|
17
17
|
onArrowRight,
|
|
18
|
+
onArrowLeft,
|
|
18
19
|
onPendingCommit,
|
|
19
20
|
arrowRightSelectsActive,
|
|
20
21
|
inputRef,
|
|
@@ -25,6 +26,7 @@ const useKeyboardNav = ({ items, open, onSelect, onClose, onArrowRight, onPendin
|
|
|
25
26
|
onSelect,
|
|
26
27
|
onClose,
|
|
27
28
|
onArrowRight,
|
|
29
|
+
onArrowLeft,
|
|
28
30
|
onPendingCommit,
|
|
29
31
|
arrowRightSelectsActive,
|
|
30
32
|
inputRef,
|
|
@@ -132,6 +134,14 @@ const useKeyboardNav = ({ items, open, onSelect, onClose, onArrowRight, onPendin
|
|
|
132
134
|
} else arrowRight();
|
|
133
135
|
return;
|
|
134
136
|
}
|
|
137
|
+
if ('ArrowLeft' === e.key) {
|
|
138
|
+
const { onArrowLeft: arrowLeft } = stateRef.current;
|
|
139
|
+
if (!arrowLeft) return;
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
e.stopPropagation();
|
|
142
|
+
arrowLeft();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
135
145
|
const { inputRef: iRef } = stateRef.current;
|
|
136
146
|
if ('ArrowUp' === e.key && iRef?.current && 0 === activeIndexRef.current) {
|
|
137
147
|
e.preventDefault();
|
|
@@ -198,6 +208,15 @@ const useKeyboardNav = ({ items, open, onSelect, onClose, onArrowRight, onPendin
|
|
|
198
208
|
} else arrowRight();
|
|
199
209
|
break;
|
|
200
210
|
}
|
|
211
|
+
case 'ArrowLeft':
|
|
212
|
+
{
|
|
213
|
+
const { onArrowLeft: arrowLeft } = stateRef.current;
|
|
214
|
+
if (!arrowLeft) break;
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
e.stopPropagation();
|
|
217
|
+
arrowLeft();
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
201
220
|
case 'Escape':
|
|
202
221
|
e.preventDefault();
|
|
203
222
|
e.stopPropagation();
|
package/dist/components/FilterInput/hooks/useFilterInputAutocomplete/lib/deriveAutocompleteValues.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { NO_VALUE_PLACEHOLDER, chipIdToConditionIndex, getDateDisplayLabel, getFieldValues, getOperatorLabel, hasStaticAllowlist, isMultiSelectOperator, isNoValueOperator } from "../../../lib/index.js";
|
|
1
|
+
import { NO_VALUE_PLACEHOLDER, chipIdToConditionIndex, collectLeaves, getDateDisplayLabel, getFieldValues, getOperatorLabel, hasStaticAllowlist, isMultiSelectOperator, isNoValueOperator } from "../../../lib/index.js";
|
|
2
2
|
const displayBaseValue = (value, field)=>{
|
|
3
|
-
const options = getFieldValues(field);
|
|
3
|
+
const options = collectLeaves(getFieldValues(field));
|
|
4
4
|
const resolve = (v)=>options.find((opt)=>String(opt.value) === String(v))?.label ?? String(v ?? '');
|
|
5
5
|
return Array.isArray(value) ? value.map(resolve).join(', ') : resolve(value);
|
|
6
6
|
};
|
|
@@ -23,7 +23,7 @@ const deriveAutocompleteValues = ({ editingChipId, selectedField, selectedOperat
|
|
|
23
23
|
editingCondition.value
|
|
24
24
|
] : [];
|
|
25
25
|
if (editingCondition.error && selectedField && hasStaticAllowlist(selectedField)) {
|
|
26
|
-
const fieldValues = getFieldValues(selectedField);
|
|
26
|
+
const fieldValues = collectLeaves(getFieldValues(selectedField));
|
|
27
27
|
if (fieldValues.length > 0) return values.filter((v)=>fieldValues.some((opt)=>opt.value === v));
|
|
28
28
|
}
|
|
29
29
|
return values;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { MIN_DATE_STRING_LENGTH } from "../../../FilterInputMenu/FilterInputDateValueMenu/constants.js";
|
|
2
|
-
import { chipIdToConditionIndex, findMatchingFieldValue, getFieldValues, getInvalidValueIndices, hasStaticAllowlist, isDatePreset } from "../../../lib/index.js";
|
|
2
|
+
import { chipIdToConditionIndex, collectLeaves, findMatchingFieldValue, getFieldValues, getInvalidValueIndices, hasStaticAllowlist, isDatePreset } from "../../../lib/index.js";
|
|
3
3
|
const resolveFieldValue = (field, text)=>{
|
|
4
|
-
const match = findMatchingFieldValue(getFieldValues(field), text);
|
|
5
|
-
return match
|
|
4
|
+
const match = findMatchingFieldValue(collectLeaves(getFieldValues(field)), text);
|
|
5
|
+
return match?.value ?? text;
|
|
6
6
|
};
|
|
7
7
|
const resolveSingleValue = (field, trimmed)=>{
|
|
8
|
-
const fv = getFieldValues(field);
|
|
8
|
+
const fv = collectLeaves(getFieldValues(field));
|
|
9
9
|
const match = findMatchingFieldValue(fv, trimmed);
|
|
10
|
-
const raw = match
|
|
10
|
+
const raw = match?.value ?? trimmed;
|
|
11
11
|
const resolved = field.normalize ? field.normalize(raw) : raw;
|
|
12
12
|
if (field.validate) return {
|
|
13
13
|
resolved,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SEGMENT_VARIANT } from "../../FilterInputField/FilterInputChip/index.js";
|
|
2
|
-
import { NO_VALUE_PLACEHOLDER, canBorrowCrossFieldLabel, findOptionByValue, findValueLabelInFields, getDateDisplayLabel, getInvalidValueIndices, getOperatorLabel, incompleteTripletError, isEmptyFilterValue, isNoValueOperator } from "../../lib/index.js";
|
|
2
|
+
import { NO_VALUE_PLACEHOLDER, canBorrowCrossFieldLabel, collapseValues, findOptionByValue, findValueLabelInFields, getDateDisplayLabel, getInvalidValueIndices, getOperatorLabel, incompleteTripletError, isEmptyFilterValue, isNoValueOperator } from "../../lib/index.js";
|
|
3
3
|
const INVALID_DATE = 'Invalid Date';
|
|
4
4
|
const DATE_RANGE_SEPARATOR = ' – ';
|
|
5
5
|
const MULTI_VALUE_SEPARATOR = ', ';
|
|
@@ -69,8 +69,8 @@ const buildDateChip = (baseChip, condition, chipError)=>{
|
|
|
69
69
|
};
|
|
70
70
|
const buildMultiValueChip = (baseChip, condition, field, fields, chipError)=>{
|
|
71
71
|
const values = condition.value;
|
|
72
|
-
const valueParts = values.map((v)=>resolveValueLabel(v, field, fields) ?? String(v));
|
|
73
72
|
const invalidIndices = field ? getInvalidValueIndices(field, values) : [];
|
|
73
|
+
const valueParts = field && 0 === invalidIndices.length ? collapseValues(field, values).map((token)=>'group' === token.kind ? null != token.count ? `${token.label} (${token.count})` : token.label : resolveValueLabel(token.value, field, fields) ?? String(token.value)) : values.map((v)=>resolveValueLabel(v, field, fields) ?? String(v));
|
|
74
74
|
return {
|
|
75
75
|
...baseChip,
|
|
76
76
|
value: valueParts.join(MULTI_VALUE_SEPARATOR),
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { FieldValueOption } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* A single navigable row in the value menu. `isGroup` rows are presentational
|
|
4
|
+
* parent categories that open a submenu of their `option.children`; leaf rows
|
|
5
|
+
* carry a committable `option.value`.
|
|
6
|
+
*/
|
|
7
|
+
export interface ValueMenuRow {
|
|
8
|
+
/** Stable id for keyboard-nav + DOM registry (unique within its level). */
|
|
9
|
+
id: string;
|
|
10
|
+
option: FieldValueOption;
|
|
11
|
+
isGroup: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** A render-ready value-menu section. `label` undefined = headerless. */
|
|
14
|
+
export interface ValueMenuSection {
|
|
15
|
+
label?: string;
|
|
16
|
+
rows: ValueMenuRow[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Bucket value options into ordered, filtered menu sections.
|
|
20
|
+
*
|
|
21
|
+
* Three shapes, in priority order:
|
|
22
|
+
* 1. **Top-level groups** — each top-level group node becomes a section (its
|
|
23
|
+
* `label` is the header, its `children` are the rows); any top-level leaves
|
|
24
|
+
* fall into a leading headerless section.
|
|
25
|
+
* 2. **`section` field sugar** — leaves tagged with `section` are bucketed under
|
|
26
|
+
* that header in first-appearance order; untagged leaves lead headerless.
|
|
27
|
+
* 3. **Flat** — no grouping: a single headerless section (today's behavior, so
|
|
28
|
+
* existing fields are visually unchanged).
|
|
29
|
+
*
|
|
30
|
+
* Each section is filtered by `filterText`; sections with no surviving rows are
|
|
31
|
+
* dropped. A group row survives if its label OR any descendant leaf matches.
|
|
32
|
+
*/
|
|
33
|
+
export declare function buildValueMenuSections(values: FieldValueOption[], filterText: string): ValueMenuSection[];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { collectLeaves, isValueGroup } from "./fields.js";
|
|
2
|
+
import { filterAndSort } from "./filterSort.js";
|
|
3
|
+
const rowSearchText = (option)=>{
|
|
4
|
+
const text = [
|
|
5
|
+
option.label,
|
|
6
|
+
String(option.value ?? '')
|
|
7
|
+
];
|
|
8
|
+
if (option.description) text.push(option.description);
|
|
9
|
+
for (const leaf of collectLeaves(option.children))text.push(leaf.label, String(leaf.value ?? ''));
|
|
10
|
+
return text;
|
|
11
|
+
};
|
|
12
|
+
const rowId = (option)=>isValueGroup(option) ? `group:${option.label}` : `leaf:${String(option.value)}`;
|
|
13
|
+
const toRows = (options, filterText)=>filterAndSort(options, filterText, rowSearchText).map((option)=>({
|
|
14
|
+
id: rowId(option),
|
|
15
|
+
option,
|
|
16
|
+
isGroup: isValueGroup(option)
|
|
17
|
+
}));
|
|
18
|
+
function buildValueMenuSections(values, filterText) {
|
|
19
|
+
const topGroups = values.filter(isValueGroup);
|
|
20
|
+
if (topGroups.length > 0) {
|
|
21
|
+
const sections = [];
|
|
22
|
+
const topLeaves = values.filter((v)=>!isValueGroup(v));
|
|
23
|
+
const leafRows = toRows(topLeaves, filterText);
|
|
24
|
+
if (leafRows.length > 0) sections.push({
|
|
25
|
+
rows: leafRows
|
|
26
|
+
});
|
|
27
|
+
for (const group of topGroups){
|
|
28
|
+
const rows = toRows(group.children ?? [], filterText);
|
|
29
|
+
if (rows.length > 0) sections.push({
|
|
30
|
+
label: group.label,
|
|
31
|
+
rows
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return sections;
|
|
35
|
+
}
|
|
36
|
+
if (values.some((v)=>v.section)) {
|
|
37
|
+
const order = [];
|
|
38
|
+
const buckets = new Map();
|
|
39
|
+
const unsectioned = [];
|
|
40
|
+
for (const option of values)if (option.section) {
|
|
41
|
+
if (!buckets.has(option.section)) {
|
|
42
|
+
buckets.set(option.section, []);
|
|
43
|
+
order.push(option.section);
|
|
44
|
+
}
|
|
45
|
+
buckets.get(option.section).push(option);
|
|
46
|
+
} else unsectioned.push(option);
|
|
47
|
+
const sections = [];
|
|
48
|
+
const unsectionedRows = toRows(unsectioned, filterText);
|
|
49
|
+
if (unsectionedRows.length > 0) sections.push({
|
|
50
|
+
rows: unsectionedRows
|
|
51
|
+
});
|
|
52
|
+
for (const label of order){
|
|
53
|
+
const rows = toRows(buckets.get(label), filterText);
|
|
54
|
+
if (rows.length > 0) sections.push({
|
|
55
|
+
label,
|
|
56
|
+
rows
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return sections;
|
|
60
|
+
}
|
|
61
|
+
const rows = toRows(values, filterText);
|
|
62
|
+
return rows.length > 0 ? [
|
|
63
|
+
{
|
|
64
|
+
rows
|
|
65
|
+
}
|
|
66
|
+
] : [];
|
|
67
|
+
}
|
|
68
|
+
export { buildValueMenuSections };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { FieldMetadata } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* A display token for a multi-value chip. A `group` token stands in for a
|
|
4
|
+
* **submenu** parent category (e.g. "SQL injection"): `count` present means a
|
|
5
|
+
* partial selection ("SQL injection (4)"), `count` absent means every sub-value
|
|
6
|
+
* is selected ("SQL injection"). A `leaf` token is a single committed value.
|
|
7
|
+
*/
|
|
8
|
+
export type CollapseToken = {
|
|
9
|
+
kind: 'group';
|
|
10
|
+
label: string;
|
|
11
|
+
count?: number;
|
|
12
|
+
} | {
|
|
13
|
+
kind: 'leaf';
|
|
14
|
+
value: string | number | boolean;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Collapse a committed leaf-value array into display tokens, folding a submenu
|
|
18
|
+
* parent category into a single group token. Display-only: the stored
|
|
19
|
+
* `Condition.value` array is never mutated and always holds leaf values.
|
|
20
|
+
*
|
|
21
|
+
* For a field with no nested groups this returns one leaf token per value in
|
|
22
|
+
* the original committed order (identical to the previous flat behavior). For a
|
|
23
|
+
* nested field it walks the config in declaration order:
|
|
24
|
+
* - **Top-level groups are section headers** (no checkbox) — never collapsed;
|
|
25
|
+
* we recurse so their direct leaves render individually.
|
|
26
|
+
* - **Nested groups are submenu categories** — any selection collapses to a
|
|
27
|
+
* single group token: fully selected → just the label; partially selected →
|
|
28
|
+
* label + selected-leaf `count`.
|
|
29
|
+
*
|
|
30
|
+
* Committed values absent from the config (freeform) trail in committed order.
|
|
31
|
+
*/
|
|
32
|
+
export declare const collapseValues: (field: FieldMetadata, values: Array<string | number | boolean>) => CollapseToken[];
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { collectLeaves, isValueGroup } from "./fields.js";
|
|
2
|
+
const collapseValues = (field, values)=>{
|
|
3
|
+
const options = field.values;
|
|
4
|
+
const hasGroups = options?.some(isValueGroup) ?? false;
|
|
5
|
+
if (!options || !hasGroups) return values.map((value)=>({
|
|
6
|
+
kind: 'leaf',
|
|
7
|
+
value
|
|
8
|
+
}));
|
|
9
|
+
const committed = new Set(values.map(String));
|
|
10
|
+
const consumed = new Set();
|
|
11
|
+
const tokens = [];
|
|
12
|
+
const walk = (opts, depth)=>{
|
|
13
|
+
for (const option of opts)if (isValueGroup(option)) {
|
|
14
|
+
if (0 === depth) {
|
|
15
|
+
walk(option.children, depth + 1);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const leafKeys = collectLeaves(option.children).map((leaf)=>String(leaf.value));
|
|
19
|
+
const selected = leafKeys.filter((key)=>committed.has(key));
|
|
20
|
+
if (0 === selected.length) continue;
|
|
21
|
+
const isFull = selected.length === leafKeys.length;
|
|
22
|
+
tokens.push(isFull ? {
|
|
23
|
+
kind: 'group',
|
|
24
|
+
label: option.label
|
|
25
|
+
} : {
|
|
26
|
+
kind: 'group',
|
|
27
|
+
label: option.label,
|
|
28
|
+
count: selected.length
|
|
29
|
+
});
|
|
30
|
+
for (const key of selected)consumed.add(key);
|
|
31
|
+
} else if (null != option.value) {
|
|
32
|
+
const key = String(option.value);
|
|
33
|
+
if (committed.has(key) && !consumed.has(key)) {
|
|
34
|
+
tokens.push({
|
|
35
|
+
kind: 'leaf',
|
|
36
|
+
value: option.value
|
|
37
|
+
});
|
|
38
|
+
consumed.add(key);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
walk(options, 0);
|
|
43
|
+
for (const value of values){
|
|
44
|
+
const key = String(value);
|
|
45
|
+
if (!consumed.has(key)) {
|
|
46
|
+
tokens.push({
|
|
47
|
+
kind: 'leaf',
|
|
48
|
+
value
|
|
49
|
+
});
|
|
50
|
+
consumed.add(key);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return tokens;
|
|
54
|
+
};
|
|
55
|
+
export { collapseValues };
|
|
@@ -1,15 +1,27 @@
|
|
|
1
1
|
import type { FieldMetadata, FieldValueOption } from '../types';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
|
|
3
|
+
* A **group** value option: a presentational node with nested `children` and no
|
|
4
|
+
* committable `value` of its own. Discriminated by the presence of `children`.
|
|
5
|
+
*/
|
|
6
|
+
export declare const isValueGroup: (option: FieldValueOption | undefined) => option is FieldValueOption & {
|
|
7
|
+
children: FieldValueOption[];
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Recursively flatten value options to their committable **leaves** (options
|
|
11
|
+
* without `children`). Section headers and parent categories are dropped —
|
|
12
|
+
* only leaves carry a `value` that can reach the expression.
|
|
13
|
+
*/
|
|
14
|
+
export declare const collectLeaves: (options: FieldValueOption[] | undefined) => FieldValueOption[];
|
|
15
|
+
/**
|
|
16
|
+
* Find an option in `field.values` matching the value (stringified compare),
|
|
17
|
+
* recursing into nested groups so submenu leaves resolve too.
|
|
6
18
|
*/
|
|
7
19
|
export declare const findOptionByValue: (field: FieldMetadata | undefined, value: string | number | boolean | null | undefined) => FieldValueOption | undefined;
|
|
8
20
|
/**
|
|
9
|
-
* Find a value's human label across *all* fields' option lists
|
|
10
|
-
* value isn't defined on its current field (e.g.
|
|
11
|
-
* label still lives on the field it came from, so
|
|
12
|
-
* can both show the label instead of the raw value.
|
|
21
|
+
* Find a value's human label across *all* fields' option lists (recursing into
|
|
22
|
+
* nested groups). Used when a value isn't defined on its current field (e.g.
|
|
23
|
+
* after a field change) but its label still lives on the field it came from, so
|
|
24
|
+
* the chip and the value menu can both show the label instead of the raw value.
|
|
13
25
|
*/
|
|
14
26
|
export declare const findValueLabelInFields: (value: string | number | boolean | null | undefined, fields: FieldMetadata[]) => string | undefined;
|
|
15
27
|
/**
|