@wallarm-org/design-system 0.78.1 → 0.80.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 (96) hide show
  1. package/dist/components/Button/Button.d.ts +1 -1
  2. package/dist/components/Code/Code.d.ts +1 -1
  3. package/dist/components/FilterInput/FilterInputErrors/parseFilterInputErrors.js +2 -2
  4. package/dist/components/FilterInput/FilterInputField/ChipsWithGaps.js +1 -1
  5. package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/FilterInputValueMenu.d.ts +21 -3
  6. package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/FilterInputValueMenu.js +10 -75
  7. package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/FlatValueMenu.d.ts +8 -0
  8. package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/FlatValueMenu.js +81 -0
  9. package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/NestedValueMenu.d.ts +10 -0
  10. package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/NestedValueMenu.js +145 -0
  11. package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/NestedValueMenuItem.d.ts +26 -0
  12. package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/NestedValueMenuItem.js +54 -0
  13. package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/ValueMenuFooterHints.d.ts +12 -0
  14. package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/ValueMenuFooterHints.js +69 -0
  15. package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/useNestedValueMenuState.d.ts +65 -0
  16. package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/useNestedValueMenuState.js +293 -0
  17. package/dist/components/FilterInput/FilterInputMenu/hooks/useKeyboardNav.d.ts +3 -1
  18. package/dist/components/FilterInput/FilterInputMenu/hooks/useKeyboardNav.js +20 -1
  19. package/dist/components/FilterInput/hooks/useFilterInputAutocomplete/lib/deriveAutocompleteValues.js +3 -3
  20. package/dist/components/FilterInput/hooks/useFilterInputAutocomplete/lib/valueResolution.js +5 -5
  21. package/dist/components/FilterInput/hooks/useFilterInputExpression/buildChips.js +2 -2
  22. package/dist/components/FilterInput/lib/buildValueMenuSections.d.ts +33 -0
  23. package/dist/components/FilterInput/lib/buildValueMenuSections.js +68 -0
  24. package/dist/components/FilterInput/lib/collapseValuesToLabels.d.ts +32 -0
  25. package/dist/components/FilterInput/lib/collapseValuesToLabels.js +55 -0
  26. package/dist/components/FilterInput/lib/fields.d.ts +19 -7
  27. package/dist/components/FilterInput/lib/fields.js +18 -4
  28. package/dist/components/FilterInput/lib/index.d.ts +3 -1
  29. package/dist/components/FilterInput/lib/index.js +3 -1
  30. package/dist/components/FilterInput/lib/validation.js +2 -2
  31. package/dist/components/FilterInput/types.d.ts +31 -2
  32. package/dist/components/Heading/Heading.d.ts +1 -1
  33. package/dist/components/Link/classes.d.ts +1 -1
  34. package/dist/components/Text/Text.d.ts +1 -1
  35. package/dist/components/ToggleButton/ToggleButton.d.ts +1 -1
  36. package/dist/icons/AudioWaveform.d.ts +3 -0
  37. package/dist/icons/AudioWaveform.js +15 -0
  38. package/dist/icons/BellCog.d.ts +3 -0
  39. package/dist/icons/BellCog.js +15 -0
  40. package/dist/icons/Brain.d.ts +3 -0
  41. package/dist/icons/Brain.js +15 -0
  42. package/dist/icons/Collapse.d.ts +3 -0
  43. package/dist/icons/Collapse.js +15 -0
  44. package/dist/icons/Dot.js +3 -3
  45. package/dist/icons/Duplicate.d.ts +3 -0
  46. package/dist/icons/Duplicate.js +15 -0
  47. package/dist/icons/Expand.d.ts +3 -0
  48. package/dist/icons/Expand.js +15 -0
  49. package/dist/icons/FastForward.d.ts +3 -0
  50. package/dist/icons/FastForward.js +15 -0
  51. package/dist/icons/FilePlus.d.ts +3 -0
  52. package/dist/icons/FilePlus.js +15 -0
  53. package/dist/icons/GitBranch.d.ts +3 -0
  54. package/dist/icons/GitBranch.js +15 -0
  55. package/dist/icons/Plug.d.ts +3 -0
  56. package/dist/icons/Plug.js +15 -0
  57. package/dist/icons/ProviderIcon/ProviderIcon.d.ts +7 -0
  58. package/dist/icons/ProviderIcon/ProviderIcon.js +11 -0
  59. package/dist/icons/ProviderIcon/index.d.ts +2 -0
  60. package/dist/icons/ProviderIcon/index.js +1 -0
  61. package/dist/icons/ProviderIcon/paths/index.d.ts +3 -0
  62. package/dist/icons/ProviderIcon/paths/index.js +896 -0
  63. package/dist/icons/ProviderIcon/types.d.ts +1 -0
  64. package/dist/icons/ProviderIcon/types.js +0 -0
  65. package/dist/icons/Radar.d.ts +3 -0
  66. package/dist/icons/Radar.js +15 -0
  67. package/dist/icons/Rss.d.ts +3 -0
  68. package/dist/icons/Rss.js +15 -0
  69. package/dist/icons/ScrollText.d.ts +3 -0
  70. package/dist/icons/ScrollText.js +15 -0
  71. package/dist/icons/Signpost.d.ts +3 -0
  72. package/dist/icons/Signpost.js +15 -0
  73. package/dist/icons/SocialIcon/SocialIcon.d.ts +8 -0
  74. package/dist/icons/SocialIcon/SocialIcon.js +15 -0
  75. package/dist/icons/SocialIcon/index.d.ts +2 -0
  76. package/dist/icons/SocialIcon/index.js +1 -0
  77. package/dist/icons/SocialIcon/paths/index.d.ts +3 -0
  78. package/dist/icons/SocialIcon/paths/index.js +2542 -0
  79. package/dist/icons/SocialIcon/types.d.ts +2 -0
  80. package/dist/icons/SocialIcon/types.js +0 -0
  81. package/dist/icons/SquareUserRound.d.ts +3 -0
  82. package/dist/icons/SquareUserRound.js +15 -0
  83. package/dist/icons/ThumbsDown.d.ts +3 -0
  84. package/dist/icons/ThumbsDown.js +15 -0
  85. package/dist/icons/ThumbsUp.d.ts +3 -0
  86. package/dist/icons/ThumbsUp.js +15 -0
  87. package/dist/icons/UsersRound.d.ts +3 -0
  88. package/dist/icons/UsersRound.js +15 -0
  89. package/dist/icons/index.d.ts +20 -0
  90. package/dist/icons/index.js +20 -0
  91. package/dist/icons/module/Icons.figma.js +55 -1
  92. package/dist/icons/module/const.js +19 -2
  93. package/dist/metadata/components.json +2 -2
  94. package/package.json +1 -1
  95. package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/ValueMenuFooter.d.ts +0 -6
  96. package/dist/components/FilterInput/FilterInputMenu/FilterInputValueMenu/ValueMenuFooter.js +0 -72
@@ -0,0 +1,65 @@
1
+ import type { RefObject } from 'react';
2
+ import type { CheckboxCheckedState } from '@ark-ui/react/checkbox';
3
+ import type { ValueMenuRow, ValueMenuSection } from '../../lib/buildValueMenuSections';
4
+ import type { FieldValueOption } from '../../types';
5
+ type ConditionValue = string | number | boolean;
6
+ /** Sentinel id for the submenu's "Select all / Unselect all" toggle row. */
7
+ export declare const SELECT_ALL_ID = "__filter_value_select_all__";
8
+ interface UseNestedValueMenuStateOptions {
9
+ /** Filtered, ordered top-level sections. */
10
+ sections: ValueMenuSection[];
11
+ /** Full (unfiltered) value tree — used for leaf-label resolution / preview. */
12
+ allValues: FieldValueOption[];
13
+ open: boolean;
14
+ multiSelect: boolean;
15
+ initialValues: ConditionValue[];
16
+ onSelect: (value: ConditionValue) => void;
17
+ onCommit?: (values: ConditionValue[]) => void;
18
+ onEscape?: () => void;
19
+ onOpenChange?: (open: boolean) => void;
20
+ onBuildingValueChange?: (preview: string | undefined) => void;
21
+ onItemToggle?: () => void;
22
+ inputRef?: RefObject<HTMLInputElement | null>;
23
+ menuRef?: RefObject<HTMLDivElement | null>;
24
+ submenuRef?: RefObject<HTMLDivElement | null>;
25
+ blurCommitRef?: RefObject<(() => boolean) | null>;
26
+ }
27
+ /**
28
+ * Selection + two-level keyboard navigation for the **nested** value menu
29
+ * (sections + a right-side submenu of leaf sub-values). Selection state is a
30
+ * single flat array of committed **leaf** values — group rows are never stored;
31
+ * toggling one is a bulk shortcut for its descendant leaves.
32
+ *
33
+ * Only one level listens for keys at a time: the two `useKeyboardNav` instances
34
+ * are gated on `open` so the top-level listener detaches while the submenu is
35
+ * open and vice-versa (no double navigation).
36
+ */
37
+ export declare const useNestedValueMenuState: ({ sections, allValues, open, multiSelect, initialValues, onSelect, onCommit, onEscape, onOpenChange, onBuildingValueChange, onItemToggle, inputRef, menuRef, submenuRef, blurCommitRef, }: UseNestedValueMenuStateOptions) => {
38
+ checkedValues: ConditionValue[];
39
+ openParentId: string | null;
40
+ openParent: (id: string) => void;
41
+ closeParent: () => void;
42
+ cancelClose: () => void;
43
+ scheduleClose: () => void;
44
+ openParentRow: ValueMenuRow | undefined;
45
+ submenuRows: ValueMenuRow[];
46
+ selectRow: (row: ValueMenuRow, fromSubmenu: boolean) => void;
47
+ toggleGroup: (option: FieldValueOption) => void;
48
+ isLeafChecked: (value: ConditionValue) => boolean;
49
+ getRowCheckState: (row: ValueMenuRow) => CheckboxCheckedState;
50
+ allLeavesChecked: (option: FieldValueOption) => boolean;
51
+ commitChecked: () => boolean;
52
+ topHighlightedValue: string;
53
+ onTopHighlightChange: (details: {
54
+ highlightedValue: string | null;
55
+ }) => void;
56
+ topPendingIds: Set<string>;
57
+ registerTopItem: (id: string) => (el: HTMLElement | null) => void;
58
+ submenuHighlightedValue: string;
59
+ onSubmenuHighlightChange: (details: {
60
+ highlightedValue: string | null;
61
+ }) => void;
62
+ submenuPendingIds: Set<string>;
63
+ registerSubmenuItem: (id: string) => (el: HTMLElement | null) => void;
64
+ };
65
+ export {};
@@ -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();
@@ -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 ? match.value : text;
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 ? match.value : trimmed;
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 };