@wallarm-org/design-system 0.66.3 → 0.67.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 (37) hide show
  1. package/dist/components/FilterInput/FilterInputContext/types.d.ts +6 -0
  2. package/dist/components/FilterInput/FilterInputContext/useFilterInputContextValue.d.ts +2 -0
  3. package/dist/components/FilterInput/FilterInputContext/useFilterInputContextValue.js +4 -0
  4. package/dist/components/FilterInput/FilterInputErrors/parseFilterInputErrors.js +11 -2
  5. package/dist/components/FilterInput/FilterInputField/ChipsWithGaps.js +4 -2
  6. package/dist/components/FilterInput/FilterInputField/FilterInputChip/FilterInputChip.d.ts +7 -1
  7. package/dist/components/FilterInput/FilterInputField/FilterInputChip/FilterInputChip.js +44 -6
  8. package/dist/components/FilterInput/FilterInputField/FilterInputChip/PairSeparator.d.ts +3 -0
  9. package/dist/components/FilterInput/FilterInputField/FilterInputChip/PairSeparator.js +16 -0
  10. package/dist/components/FilterInput/FilterInputField/FilterInputChip/context/EditingContext.d.ts +2 -0
  11. package/dist/components/FilterInput/FilterInputField/FilterInputChip/index.d.ts +3 -2
  12. package/dist/components/FilterInput/FilterInputField/FilterInputChip/index.js +3 -2
  13. package/dist/components/FilterInput/FilterInputField/FilterInputChip/segmentVariant.d.ts +2 -0
  14. package/dist/components/FilterInput/FilterInputField/FilterInputChip/segmentVariant.js +2 -1
  15. package/dist/components/FilterInput/FilterInputField/FilterInputField.js +3 -1
  16. package/dist/components/FilterInput/hooks/useFilterInputAutocomplete/lib/deriveAutocompleteValues.d.ts +8 -1
  17. package/dist/components/FilterInput/hooks/useFilterInputAutocomplete/lib/deriveAutocompleteValues.js +22 -3
  18. package/dist/components/FilterInput/hooks/useFilterInputAutocomplete/useAutocompleteState.d.ts +10 -0
  19. package/dist/components/FilterInput/hooks/useFilterInputAutocomplete/useAutocompleteState.js +6 -0
  20. package/dist/components/FilterInput/hooks/useFilterInputAutocomplete/useChipEditing.d.ts +2 -0
  21. package/dist/components/FilterInput/hooks/useFilterInputAutocomplete/useChipEditing.js +31 -0
  22. package/dist/components/FilterInput/hooks/useFilterInputAutocomplete/useFilterInputAutocomplete.d.ts +2 -0
  23. package/dist/components/FilterInput/hooks/useFilterInputAutocomplete/useFilterInputAutocomplete.js +14 -3
  24. package/dist/components/FilterInput/hooks/useFilterInputAutocomplete/useMenuFlow/types.d.ts +9 -0
  25. package/dist/components/FilterInput/hooks/useFilterInputAutocomplete/useMenuFlow/useOperatorFlow.js +8 -5
  26. package/dist/components/FilterInput/hooks/useFilterInputAutocomplete/useMenuFlow/useValueFlow.d.ts +1 -1
  27. package/dist/components/FilterInput/hooks/useFilterInputAutocomplete/useMenuFlow/useValueFlow.js +77 -13
  28. package/dist/components/FilterInput/hooks/useFilterInputAutocomplete/useResetState.d.ts +3 -1
  29. package/dist/components/FilterInput/hooks/useFilterInputAutocomplete/useResetState.js +5 -1
  30. package/dist/components/FilterInput/hooks/useFilterInputExpression/buildChips.js +25 -1
  31. package/dist/components/FilterInput/hooks/useFilterInputExpression/expression.d.ts +9 -3
  32. package/dist/components/FilterInput/hooks/useFilterInputExpression/expression.js +75 -9
  33. package/dist/components/FilterInput/hooks/useFilterInputExpression/useFilterInputExpression.d.ts +1 -1
  34. package/dist/components/FilterInput/hooks/useFilterInputExpression/useFilterInputExpression.js +49 -14
  35. package/dist/components/FilterInput/types.d.ts +25 -1
  36. package/dist/metadata/components.json +8 -2
  37. package/package.json +1 -1
@@ -1,10 +1,13 @@
1
1
  import type { RefObject } from 'react';
2
2
  import type { ChipSegment } from '../../../FilterInputField/FilterInputChip';
3
3
  import type { Condition, FieldMetadata, FilterOperator, MenuState, UpsertCondition } from '../../../types';
4
+ import type { BuildingBase } from '../useAutocompleteState';
4
5
  export interface MenuFlowDeps {
5
6
  editing: {
6
7
  editingChipId: string | null;
7
8
  editingSegment: ChipSegment | null;
9
+ /** Which triplet is being edited: 0 = base, 1 = paired second. */
10
+ editingSide: 0 | 1;
8
11
  /** Pre-derived chipId === null && segment !== null marker — single source
9
12
  * of truth, avoids re-deriving in every consumer. */
10
13
  isBuildingEdit: boolean;
@@ -36,6 +39,12 @@ export interface MenuFlowDeps {
36
39
  setInputText: (text: string) => void;
37
40
  setMenuState: (state: MenuState) => void;
38
41
  setBuildingMultiValue: (val: string | undefined) => void;
42
+ /** Which triplet is being built: 0 = base, 1 = paired second. */
43
+ buildingSide: 0 | 1;
44
+ setBuildingSide: (side: 0 | 1) => void;
45
+ /** Base triplet stashed while building a paired chip's second value. */
46
+ buildingBase: BuildingBase | null;
47
+ setBuildingBase: (base: BuildingBase | null) => void;
39
48
  }
40
49
  /** Shared shape: each sub-hook receives the same deps plus a ref to `conditions`
41
50
  * that stays fresh without forcing callbacks to recreate on every keystroke. */
@@ -2,7 +2,7 @@ import { useCallback } from "react";
2
2
  import { SEGMENT_VARIANT } from "../../../FilterInputField/FilterInputChip/index.js";
3
3
  import { OPERATOR_SYMBOLS, chipIdToConditionIndex, getFieldOperators, getOperatorFromLabel, isNoValueOperator, isOperatorAllowedForField, isValueShapeCompatible } from "../../../lib/index.js";
4
4
  const useOperatorFlow = ({ editing, selectedField, selectedOperator, insertIndex, upsertCondition, conditionsRef, resetState, setSelectedOperator, setMenuState, setBuildingMultiValue, setInputText })=>{
5
- const { editingChipId, editingSegment, isBuildingEdit, setEditingSegment, setSegmentFilterText, clearEditing } = editing;
5
+ const { editingChipId, editingSegment, editingSide, isBuildingEdit, setEditingSegment, setSegmentFilterText, clearEditing } = editing;
6
6
  const handleOperatorSelect = useCallback((operator)=>{
7
7
  if (!selectedField) return;
8
8
  if (isBuildingEdit && editingSegment === SEGMENT_VARIANT.operator) {
@@ -20,7 +20,7 @@ const useOperatorFlow = ({ editing, selectedField, selectedOperator, insertIndex
20
20
  }
21
21
  if (isNoValueOperator(operator)) {
22
22
  const isEditing = !!editingChipId;
23
- upsertCondition(selectedField, operator, null, editingChipId, isEditing ? void 0 : insertIndex);
23
+ upsertCondition(selectedField, operator, null, editingChipId, isEditing ? void 0 : insertIndex, void 0, void 0, editingSide);
24
24
  resetState(!isEditing);
25
25
  return;
26
26
  }
@@ -28,13 +28,15 @@ const useOperatorFlow = ({ editing, selectedField, selectedOperator, insertIndex
28
28
  const idx = chipIdToConditionIndex(editingChipId);
29
29
  const condition = null !== idx ? conditionsRef.current[idx] : null;
30
30
  if (condition) {
31
- const hasValue = null !== condition.value && '' !== condition.value;
31
+ const currentValue = 1 === editingSide ? condition.pair?.value : condition.value;
32
+ const currentDateOrigin = 1 === editingSide ? condition.pair?.dateOrigin : condition.dateOrigin;
33
+ const hasValue = null !== currentValue && '' !== currentValue && void 0 !== currentValue;
32
34
  if (hasValue) {
33
- upsertCondition(selectedField, operator, condition.value, editingChipId, void 0, void 0, condition.dateOrigin);
35
+ upsertCondition(selectedField, operator, currentValue, editingChipId, void 0, void 0, currentDateOrigin, editingSide);
34
36
  resetState();
35
37
  return;
36
38
  }
37
- upsertCondition(selectedField, operator, null, editingChipId);
39
+ upsertCondition(selectedField, operator, 1 === editingSide ? '' : null, editingChipId, void 0, void 0, void 0, editingSide);
38
40
  setEditingSegment(SEGMENT_VARIANT.value);
39
41
  setSegmentFilterText('');
40
42
  }
@@ -45,6 +47,7 @@ const useOperatorFlow = ({ editing, selectedField, selectedOperator, insertIndex
45
47
  }, [
46
48
  editingChipId,
47
49
  editingSegment,
50
+ editingSide,
48
51
  isBuildingEdit,
49
52
  setEditingSegment,
50
53
  setSegmentFilterText,
@@ -4,7 +4,7 @@ import type { MenuFlowInternalDeps } from './types';
4
4
  * keyboard-typed custom-value commit (which routes through the field-type
5
5
  * specific resolver). Also handles multi-select preview/toggle plumbing.
6
6
  */
7
- export declare const useValueFlow: ({ editing, selectedField, selectedOperator, insertIndex, upsertCondition, conditionsRef, resetState, dateRange, setBuildingMultiValue, setInputText, }: MenuFlowInternalDeps) => {
7
+ export declare const useValueFlow: ({ editing, selectedField, selectedOperator, insertIndex, upsertCondition, conditionsRef, resetState, dateRange, buildingSide, setBuildingSide, buildingBase, setBuildingBase, setSelectedField, setSelectedOperator, setMenuState, setBuildingMultiValue, setInputText, }: MenuFlowInternalDeps) => {
8
8
  handleValueSelect: (val: string | number | boolean) => void;
9
9
  handleMultiCommit: (values: Array<string | number | boolean>) => void;
10
10
  handleBuildingValueChange: (preview: string | undefined) => void;
@@ -2,28 +2,77 @@ import { useCallback } from "react";
2
2
  import { SEGMENT_VARIANT } from "../../../FilterInputField/FilterInputChip/index.js";
3
3
  import { isBetweenOperator, isMultiSelectOperator } from "../../../lib/index.js";
4
4
  import { resolveDateRangeValue, resolveDateValue, resolveMultiValues, resolveSingleValue } from "../lib/index.js";
5
- const useValueFlow = ({ editing, selectedField, selectedOperator, insertIndex, upsertCondition, conditionsRef, resetState, dateRange, setBuildingMultiValue, setInputText })=>{
6
- const { editingChipId, editingSegment, resetSegmentTyping } = editing;
5
+ const useValueFlow = ({ editing, selectedField, selectedOperator, insertIndex, upsertCondition, conditionsRef, resetState, dateRange, buildingSide, setBuildingSide, buildingBase, setBuildingBase, setSelectedField, setSelectedOperator, setMenuState, setBuildingMultiValue, setInputText })=>{
6
+ const { editingChipId, editingSegment, editingSide, resetSegmentTyping } = editing;
7
+ const stashAndAdvance = useCallback((value)=>{
8
+ const pairedField = selectedField?.pairedField;
9
+ if (0 !== buildingSide || !pairedField || editingChipId || !selectedField || !selectedOperator) return false;
10
+ setBuildingBase({
11
+ field: selectedField,
12
+ operator: selectedOperator,
13
+ value
14
+ });
15
+ setSelectedField(pairedField);
16
+ setSelectedOperator(null);
17
+ setBuildingSide(1);
18
+ setInputText('');
19
+ setMenuState('operator');
20
+ return true;
21
+ }, [
22
+ selectedField,
23
+ selectedOperator,
24
+ buildingSide,
25
+ editingChipId,
26
+ setBuildingBase,
27
+ setSelectedField,
28
+ setSelectedOperator,
29
+ setBuildingSide,
30
+ setInputText,
31
+ setMenuState
32
+ ]);
33
+ const commitPairedSecond = useCallback((value, error, dateOrigin)=>{
34
+ if (1 !== buildingSide || !buildingBase || !selectedField || !selectedOperator) return false;
35
+ upsertCondition(buildingBase.field, buildingBase.operator, buildingBase.value, null, insertIndex);
36
+ upsertCondition(selectedField, selectedOperator, value, null, void 0, error, dateOrigin, 1);
37
+ setBuildingBase(null);
38
+ setBuildingSide(0);
39
+ resetState(true);
40
+ return true;
41
+ }, [
42
+ buildingSide,
43
+ buildingBase,
44
+ selectedField,
45
+ selectedOperator,
46
+ insertIndex,
47
+ upsertCondition,
48
+ setBuildingBase,
49
+ setBuildingSide,
50
+ resetState
51
+ ]);
7
52
  const handleValueSelect = useCallback((val)=>{
8
53
  if (!selectedField || !selectedOperator) return;
54
+ let committedValue = val;
9
55
  if (isBetweenOperator(selectedOperator) && 'date' === selectedField.type) {
10
56
  const result = dateRange.selectValue(String(val));
11
57
  if (!result) return;
12
- upsertCondition(selectedField, selectedOperator, result, editingChipId);
13
- resetState(!editingChipId);
14
- return;
58
+ committedValue = result;
15
59
  }
60
+ if (stashAndAdvance(committedValue)) return;
61
+ if (commitPairedSecond(committedValue)) return;
16
62
  const isEditing = !!editingChipId;
17
- upsertCondition(selectedField, selectedOperator, val, editingChipId, isEditing ? void 0 : insertIndex);
63
+ upsertCondition(selectedField, selectedOperator, committedValue, editingChipId, isEditing ? void 0 : insertIndex, void 0, void 0, editingSide);
18
64
  resetState(!isEditing);
19
65
  }, [
20
66
  selectedField,
21
67
  selectedOperator,
22
68
  editingChipId,
69
+ editingSide,
23
70
  dateRange,
24
71
  insertIndex,
25
72
  upsertCondition,
26
- resetState
73
+ resetState,
74
+ stashAndAdvance,
75
+ commitPairedSecond
27
76
  ]);
28
77
  const handleMultiCommit = useCallback((values)=>{
29
78
  if (!selectedField || !selectedOperator || 0 === values.length) return;
@@ -71,29 +120,44 @@ const useValueFlow = ({ editing, selectedField, selectedOperator, insertIndex, u
71
120
  if (!selectedField || !selectedOperator || !customText.trim()) return;
72
121
  const trimmed = customText.trim();
73
122
  const isEditing = !!editingChipId;
123
+ let resolvedValue = trimmed;
124
+ let valueError;
125
+ let dateOrigin;
74
126
  if (isMultiSelectOperator(selectedOperator)) {
75
127
  const { resolved, error } = resolveMultiValues(selectedField, trimmed);
76
- upsertCondition(selectedField, selectedOperator, resolved, editingChipId, isEditing ? void 0 : insertIndex, error ? SEGMENT_VARIANT.value : void 0);
128
+ resolvedValue = resolved;
129
+ valueError = error ? SEGMENT_VARIANT.value : void 0;
77
130
  } else if ('date' === selectedField.type) if (isBetweenOperator(selectedOperator)) {
78
131
  const rangeValue = resolveDateRangeValue(trimmed);
79
- upsertCondition(selectedField, selectedOperator, rangeValue ?? trimmed, editingChipId, isEditing ? void 0 : insertIndex, rangeValue ? void 0 : SEGMENT_VARIANT.value, 'absolute');
132
+ resolvedValue = rangeValue ?? trimmed;
133
+ valueError = rangeValue ? void 0 : SEGMENT_VARIANT.value;
134
+ dateOrigin = 'absolute';
80
135
  } else {
81
- const { error, dateOrigin } = resolveDateValue(trimmed, editingChipId, conditionsRef.current);
82
- upsertCondition(selectedField, selectedOperator, trimmed, editingChipId, isEditing ? void 0 : insertIndex, error ? SEGMENT_VARIANT.value : void 0, dateOrigin);
136
+ const resolved = resolveDateValue(trimmed, editingChipId, conditionsRef.current);
137
+ resolvedValue = trimmed;
138
+ valueError = resolved.error ? SEGMENT_VARIANT.value : void 0;
139
+ dateOrigin = resolved.dateOrigin;
83
140
  }
84
141
  else {
85
142
  const { resolved, error } = resolveSingleValue(selectedField, trimmed);
86
- upsertCondition(selectedField, selectedOperator, resolved, editingChipId, isEditing ? void 0 : insertIndex, error ? SEGMENT_VARIANT.value : void 0);
143
+ resolvedValue = resolved;
144
+ valueError = error ? SEGMENT_VARIANT.value : void 0;
87
145
  }
146
+ if (stashAndAdvance(resolvedValue)) return;
147
+ if (commitPairedSecond(resolvedValue, valueError, dateOrigin)) return;
148
+ upsertCondition(selectedField, selectedOperator, resolvedValue, editingChipId, isEditing ? void 0 : insertIndex, valueError, dateOrigin, editingSide);
88
149
  resetState(!isEditing);
89
150
  }, [
90
151
  selectedField,
91
152
  selectedOperator,
92
153
  editingChipId,
154
+ editingSide,
93
155
  insertIndex,
94
156
  conditionsRef,
95
157
  upsertCondition,
96
- resetState
158
+ resetState,
159
+ stashAndAdvance,
160
+ commitPairedSecond
97
161
  ]);
98
162
  return {
99
163
  handleValueSelect,
@@ -14,6 +14,8 @@ interface UseResetStateDeps {
14
14
  setSelectedField: Dispatch<SetStateAction<FieldMetadata | null>>;
15
15
  setSelectedOperator: Dispatch<SetStateAction<FilterOperator | null>>;
16
16
  setBuildingMultiValue: Dispatch<SetStateAction<string | undefined>>;
17
+ setBuildingSide: (side: 0 | 1) => void;
18
+ setBuildingBase: (base: null) => void;
17
19
  setInsertIndex: Dispatch<SetStateAction<number | null>>;
18
20
  setInsertAfterConnector: Dispatch<SetStateAction<boolean>>;
19
21
  setMenuState: Dispatch<SetStateAction<MenuState>>;
@@ -29,5 +31,5 @@ interface UseResetStateDeps {
29
31
  * vs. genuine outside click). If unifying these, preserve the re-render
30
32
  * refocus case or commit chains break. AS-882.
31
33
  */
32
- export declare const useResetState: ({ editing, dateRange, containerRef, inputRef, resetMenuAnchor, setInputText, setSelectedField, setSelectedOperator, setBuildingMultiValue, setInsertIndex, setInsertAfterConnector, setMenuState, }: UseResetStateDeps) => (continueBuilding?: boolean) => void;
34
+ export declare const useResetState: ({ editing, dateRange, containerRef, inputRef, resetMenuAnchor, setInputText, setSelectedField, setSelectedOperator, setBuildingMultiValue, setBuildingSide, setBuildingBase, setInsertIndex, setInsertAfterConnector, setMenuState, }: UseResetStateDeps) => (continueBuilding?: boolean) => void;
33
35
  export {};
@@ -1,7 +1,7 @@
1
1
  import { useCallback } from "react";
2
2
  import { flushSync } from "react-dom";
3
3
  import { isMenuRelated } from "../../lib/index.js";
4
- const useResetState = ({ editing, dateRange, containerRef, inputRef, resetMenuAnchor, setInputText, setSelectedField, setSelectedOperator, setBuildingMultiValue, setInsertIndex, setInsertAfterConnector, setMenuState })=>{
4
+ const useResetState = ({ editing, dateRange, containerRef, inputRef, resetMenuAnchor, setInputText, setSelectedField, setSelectedOperator, setBuildingMultiValue, setBuildingSide, setBuildingBase, setInsertIndex, setInsertAfterConnector, setMenuState })=>{
5
5
  const resetState = useCallback((continueBuilding = false)=>{
6
6
  const doReset = ()=>{
7
7
  setInputText('');
@@ -10,6 +10,8 @@ const useResetState = ({ editing, dateRange, containerRef, inputRef, resetMenuAn
10
10
  editing.clearEditing();
11
11
  dateRange.reset();
12
12
  setBuildingMultiValue(void 0);
13
+ setBuildingSide(0);
14
+ setBuildingBase(null);
13
15
  setInsertIndex(null);
14
16
  setInsertAfterConnector(false);
15
17
  resetMenuAnchor();
@@ -32,6 +34,8 @@ const useResetState = ({ editing, dateRange, containerRef, inputRef, resetMenuAn
32
34
  setSelectedField,
33
35
  setSelectedOperator,
34
36
  setBuildingMultiValue,
37
+ setBuildingSide,
38
+ setBuildingBase,
35
39
  setInsertIndex,
36
40
  setInsertAfterConnector,
37
41
  setMenuState
@@ -82,7 +82,20 @@ const buildMultiValueChip = (baseChip, condition, field, fields, chipError)=>{
82
82
  }
83
83
  };
84
84
  };
85
- const makeConditionChip = (i, conditions, fields, error)=>{
85
+ const buildPairChip = (condition, field, fields)=>{
86
+ if (!condition.pair || !field?.pairedField) return;
87
+ const pf = field.pairedField;
88
+ const { operator, value, error } = condition.pair;
89
+ return {
90
+ attribute: pf.label || pf.name,
91
+ operator: operator ? getOperatorLabel(operator, pf.type || DEFAULT_FIELD_TYPE) : void 0,
92
+ value: resolveValueLabel(value, pf, fields) ?? String(value ?? ''),
93
+ ...error && {
94
+ error
95
+ }
96
+ };
97
+ };
98
+ const makeConditionChipBase = (i, conditions, fields, error)=>{
86
99
  const condition = conditions[i];
87
100
  if (!condition) return makeEmptyChip(i, error);
88
101
  const chipError = condition.error || (error ? true : void 0);
@@ -101,6 +114,17 @@ const makeConditionChip = (i, conditions, fields, error)=>{
101
114
  error: chipError
102
115
  };
103
116
  };
117
+ const makeConditionChip = (i, conditions, fields, error)=>{
118
+ const base = makeConditionChipBase(i, conditions, fields, error);
119
+ const condition = conditions[i];
120
+ if (!condition) return base;
121
+ const field = fields.find((f)=>f.name === condition.field);
122
+ const pair = buildPairChip(condition, field, fields);
123
+ return pair ? {
124
+ ...base,
125
+ pair
126
+ } : base;
127
+ };
104
128
  const buildChips = (conditions, connectors, fields, error)=>{
105
129
  if (0 === conditions.length) return [];
106
130
  const hasMixed = connectors.includes('and') && connectors.includes('or');
@@ -1,15 +1,21 @@
1
- import type { Condition, ExprNode } from '../../types';
1
+ import type { Condition, ExprNode, FieldMetadata } from '../../types';
2
2
  /**
3
3
  * Build an ExprNode from flat conditions + per-gap connectors.
4
4
  * AND has higher precedence than OR: conditions connected by AND
5
5
  * are grouped together, then those groups are joined by OR.
6
+ *
7
+ * Pass `fields` to expand paired conditions into two AND-joined conditions
8
+ * (the serialization contract). Omit it to keep paired conditions intact.
6
9
  */
7
- export declare const buildExpression: (conditions: Condition[], connectors: Array<"and" | "or">) => ExprNode | null;
10
+ export declare const buildExpression: (rawConditions: Condition[], rawConnectors: Array<"and" | "or">, fields?: FieldMetadata[]) => ExprNode | null;
8
11
  /**
9
12
  * Flatten a (possibly nested) ExprNode back to flat conditions + connectors.
10
13
  * Walks the tree depth-first; between siblings inserts the parent's operator.
14
+ *
15
+ * Pass `fields` to re-pair AND-adjacent conditions into paired conditions (the
16
+ * inverse of `buildExpression`'s expansion). Omit it to keep them separate.
11
17
  */
12
- export declare const expressionToConditions: (expr: ExprNode | null) => {
18
+ export declare const expressionToConditions: (expr: ExprNode | null, fields?: FieldMetadata[]) => {
13
19
  conditions: Condition[];
14
20
  connectors: Array<"and" | "or">;
15
21
  };
@@ -1,4 +1,39 @@
1
- const buildExpression = (conditions, connectors)=>{
1
+ const expandPairs = (conditions, connectors, fields)=>{
2
+ const outConditions = [];
3
+ const outConnectors = [];
4
+ conditions.forEach((condition, i)=>{
5
+ if (i > 0) outConnectors.push(connectors[i - 1] ?? 'and');
6
+ const field = fields.find((f)=>f.name === condition.field);
7
+ if (condition.pair && field?.pairedField) {
8
+ const { pair, ...base } = condition;
9
+ outConditions.push(base);
10
+ outConnectors.push('and');
11
+ outConditions.push({
12
+ type: 'condition',
13
+ field: field.pairedField.name,
14
+ ...pair.operator && {
15
+ operator: pair.operator
16
+ },
17
+ value: pair.value,
18
+ ...pair.error && {
19
+ error: pair.error
20
+ },
21
+ ...pair.dateOrigin && {
22
+ dateOrigin: pair.dateOrigin
23
+ }
24
+ });
25
+ } else outConditions.push(condition);
26
+ });
27
+ return {
28
+ conditions: outConditions,
29
+ connectors: outConnectors
30
+ };
31
+ };
32
+ const buildExpression = (rawConditions, rawConnectors, fields)=>{
33
+ const { conditions, connectors } = fields ? expandPairs(rawConditions, rawConnectors, fields) : {
34
+ conditions: rawConditions,
35
+ connectors: rawConnectors
36
+ };
2
37
  if (0 === conditions.length) return null;
3
38
  if (1 === conditions.length) return conditions[0] ?? null;
4
39
  const first = conditions[0];
@@ -27,17 +62,48 @@ const buildExpression = (conditions, connectors)=>{
27
62
  children: andNodes
28
63
  };
29
64
  };
30
- const expressionToConditions = (expr)=>{
65
+ const repairPairs = (conditions, connectors, fields)=>{
66
+ const outConditions = [];
67
+ const outConnectors = [];
68
+ let i = 0;
69
+ while(i < conditions.length){
70
+ const condition = conditions[i];
71
+ if (i > 0) outConnectors.push(connectors[i - 1] ?? 'and');
72
+ const field = fields.find((f)=>f.name === condition.field);
73
+ const next = conditions[i + 1];
74
+ const canMerge = field?.pairedField && !condition.pair && 'and' === connectors[i] && next && next.field === field.pairedField.name;
75
+ if (canMerge && next) {
76
+ outConditions.push({
77
+ ...condition,
78
+ pair: {
79
+ ...next.operator && {
80
+ operator: next.operator
81
+ },
82
+ value: next.value,
83
+ ...next.error && {
84
+ error: next.error
85
+ },
86
+ ...next.dateOrigin && {
87
+ dateOrigin: next.dateOrigin
88
+ }
89
+ }
90
+ });
91
+ i += 2;
92
+ } else {
93
+ outConditions.push(condition);
94
+ i += 1;
95
+ }
96
+ }
97
+ return {
98
+ conditions: outConditions,
99
+ connectors: outConnectors
100
+ };
101
+ };
102
+ const expressionToConditions = (expr, fields)=>{
31
103
  if (!expr) return {
32
104
  conditions: [],
33
105
  connectors: []
34
106
  };
35
- if ('condition' === expr.type) return {
36
- conditions: [
37
- expr
38
- ],
39
- connectors: []
40
- };
41
107
  const conditions = [];
42
108
  const connectors = [];
43
109
  const walk = (node)=>{
@@ -48,7 +114,7 @@ const expressionToConditions = (expr)=>{
48
114
  });
49
115
  };
50
116
  walk(expr);
51
- return {
117
+ return fields ? repairPairs(conditions, connectors, fields) : {
52
118
  conditions,
53
119
  connectors
54
120
  };
@@ -10,7 +10,7 @@ export declare const useFilterInputExpression: ({ fields, value, onChange, error
10
10
  conditions: Condition[];
11
11
  connectors: ("and" | "or")[];
12
12
  chips: import("../..").FilterInputChipData[];
13
- upsertCondition: (field: FieldMetadata, operator: FilterOperator | undefined, val: string | number | boolean | null | Array<string | number | boolean>, editingChipId?: string | null, atIndex?: number, error?: ChipErrorSegment, dateOrigin?: "relative" | "absolute") => void;
13
+ upsertCondition: (field: FieldMetadata, operator: FilterOperator | undefined, val: string | number | boolean | null | Array<string | number | boolean>, editingChipId?: string | null, atIndex?: number, error?: ChipErrorSegment, dateOrigin?: "relative" | "absolute", side?: 0 | 1) => void;
14
14
  removeCondition: (chipId: string) => void;
15
15
  removeConditionAtIndex: (idx: number) => void;
16
16
  clearAll: () => void;
@@ -96,7 +96,7 @@ const useFilterInputExpression = ({ fields, value, onChange, error, externalErro
96
96
  const [state, setState] = useState(EMPTY_STATE);
97
97
  useEffect(()=>{
98
98
  if (void 0 !== value) {
99
- const result = expressionToConditions(value);
99
+ const result = expressionToConditions(value, fields);
100
100
  setState({
101
101
  conditions: revalidateConditions(result.conditions, fields),
102
102
  connectors: result.connectors
@@ -113,7 +113,36 @@ const useFilterInputExpression = ({ fields, value, onChange, error, externalErro
113
113
  error,
114
114
  externalErrors
115
115
  ]);
116
- const upsertCondition = useCallback((field, operator, val, editingChipId, atIndex, error, dateOrigin)=>{
116
+ const upsertCondition = useCallback((field, operator, val, editingChipId, atIndex, error, dateOrigin, side)=>{
117
+ if (1 === side) return void setState((prev)=>{
118
+ const idx = null != editingChipId ? chipIdToConditionIndex(editingChipId) : prev.conditions.length - 1;
119
+ if (null == idx || idx < 0 || idx >= prev.conditions.length) return prev;
120
+ const base = prev.conditions[idx];
121
+ const updated = [
122
+ ...prev.conditions
123
+ ];
124
+ updated[idx] = {
125
+ ...base,
126
+ pair: {
127
+ ...operator && {
128
+ operator
129
+ },
130
+ value: val,
131
+ ...error && {
132
+ error
133
+ },
134
+ ...dateOrigin && {
135
+ dateOrigin
136
+ }
137
+ }
138
+ };
139
+ const next = {
140
+ conditions: updated,
141
+ connectors: prev.connectors
142
+ };
143
+ onChange?.(buildExpression(next.conditions, next.connectors, fields));
144
+ return next;
145
+ });
117
146
  const condition = buildCondition(field, operator, val, error, dateOrigin);
118
147
  setState((prev)=>{
119
148
  const newConditions = applyCondition(prev.conditions, condition, editingChipId, atIndex);
@@ -122,11 +151,12 @@ const useFilterInputExpression = ({ fields, value, onChange, error, externalErro
122
151
  conditions: newConditions,
123
152
  connectors: newConnectors
124
153
  };
125
- onChange?.(buildExpression(next.conditions, next.connectors));
154
+ onChange?.(buildExpression(next.conditions, next.connectors, fields));
126
155
  return next;
127
156
  });
128
157
  }, [
129
- onChange
158
+ onChange,
159
+ fields
130
160
  ]);
131
161
  const removeCondition = useCallback((chipId)=>{
132
162
  const idx = chipIdToConditionIndex(chipId);
@@ -139,11 +169,12 @@ const useFilterInputExpression = ({ fields, value, onChange, error, externalErro
139
169
  conditions: newConditions,
140
170
  connectors: newConnectors
141
171
  };
142
- onChange?.(buildExpression(next.conditions, next.connectors));
172
+ onChange?.(buildExpression(next.conditions, next.connectors, fields));
143
173
  return next;
144
174
  });
145
175
  }, [
146
- onChange
176
+ onChange,
177
+ fields
147
178
  ]);
148
179
  const removeConditionAtIndex = useCallback((idx)=>{
149
180
  setState((prev)=>{
@@ -155,11 +186,12 @@ const useFilterInputExpression = ({ fields, value, onChange, error, externalErro
155
186
  conditions: newConditions,
156
187
  connectors: newConnectors
157
188
  };
158
- onChange?.(buildExpression(next.conditions, next.connectors));
189
+ onChange?.(buildExpression(next.conditions, next.connectors, fields));
159
190
  return next;
160
191
  });
161
192
  }, [
162
- onChange
193
+ onChange,
194
+ fields
163
195
  ]);
164
196
  const clearAll = useCallback(()=>{
165
197
  setState((prev)=>{
@@ -172,21 +204,23 @@ const useFilterInputExpression = ({ fields, value, onChange, error, externalErro
172
204
  conditions: disabledConditions,
173
205
  connectors: []
174
206
  };
175
- onChange?.(buildExpression(next.conditions, next.connectors));
207
+ onChange?.(buildExpression(next.conditions, next.connectors, fields));
176
208
  return next;
177
209
  });
178
210
  }, [
179
- onChange
211
+ onChange,
212
+ fields
180
213
  ]);
181
214
  const replaceExpression = useCallback((expr)=>{
182
- const result = expressionToConditions(expr);
215
+ const result = expressionToConditions(expr, fields);
183
216
  setState({
184
217
  conditions: result.conditions,
185
218
  connectors: result.connectors
186
219
  });
187
220
  onChange?.(expr);
188
221
  }, [
189
- onChange
222
+ onChange,
223
+ fields
190
224
  ]);
191
225
  const setConnectorValue = useCallback((connectorId, value)=>{
192
226
  const match = connectorId.match(CONNECTOR_ID_PATTERN);
@@ -202,11 +236,12 @@ const useFilterInputExpression = ({ fields, value, onChange, error, externalErro
202
236
  conditions: prev.conditions,
203
237
  connectors: updated
204
238
  };
205
- onChange?.(buildExpression(next.conditions, next.connectors));
239
+ onChange?.(buildExpression(next.conditions, next.connectors, fields));
206
240
  return next;
207
241
  });
208
242
  }, [
209
- onChange
243
+ onChange,
244
+ fields
210
245
  ]);
211
246
  return {
212
247
  conditions: state.conditions,
@@ -14,7 +14,10 @@ export type ChipErrorSegment = boolean | 'attribute' | 'value';
14
14
  * Re-declared in several option interfaces — exported here to keep the source of
15
15
  * truth single-rooted (changes to the signature reach all consumers).
16
16
  */
17
- export type UpsertCondition = (field: FieldMetadata, operator: FilterOperator | undefined, val: string | number | boolean | null | Array<string | number | boolean>, editingChipId?: string | null, atIndex?: number, error?: ChipErrorSegment, dateOrigin?: 'relative' | 'absolute') => void;
17
+ export type UpsertCondition = (field: FieldMetadata, operator: FilterOperator | undefined, val: string | number | boolean | null | Array<string | number | boolean>, editingChipId?: string | null, atIndex?: number, error?: ChipErrorSegment, dateOrigin?: 'relative' | 'absolute',
18
+ /** When 1, write the paired (second) triplet onto the target condition instead
19
+ * of replacing the base triplet. Defaults to 0 (base triplet). */
20
+ side?: 0 | 1) => void;
18
21
  export interface FilterInputChipData {
19
22
  id: string;
20
23
  variant: FilterInputChipVariant;
@@ -30,6 +33,13 @@ export interface FilterInputChipData {
30
33
  errorValueIndices?: number[];
31
34
  /** When true, the chip cannot be edited or removed */
32
35
  disabled?: boolean;
36
+ /** Second paired triplet (display) for two-step fields. */
37
+ pair?: {
38
+ attribute: string;
39
+ operator?: string;
40
+ value?: string;
41
+ error?: ChipErrorSegment;
42
+ };
33
43
  }
34
44
  /**
35
45
  * Field Type for filter attributes
@@ -120,6 +130,13 @@ export interface FieldMetadata {
120
130
  * emitting the query. Display in the chip is unaffected.
121
131
  */
122
132
  serializeValue?: (value: string | number | boolean) => string | number | boolean;
133
+ /**
134
+ * When set, this field is a two-step paired field: the chip holds a second
135
+ * attribute/operator/value triplet. The paired segment is a full field
136
+ * definition (label, type, operators, values, validate, …). Nesting is one
137
+ * level only — a pairedField's own `pairedField` is ignored.
138
+ */
139
+ pairedField?: FieldMetadata;
123
140
  }
124
141
  /**
125
142
  * Expression Tree Types
@@ -139,6 +156,13 @@ export interface Condition {
139
156
  dateOrigin?: 'relative' | 'absolute';
140
157
  /** When true, the condition cannot be edited or removed */
141
158
  disabled?: boolean;
159
+ /** Second paired triplet. Present only for fields with `pairedField`. */
160
+ pair?: {
161
+ operator?: FilterOperator;
162
+ value: string | number | boolean | null | Array<string | number | boolean>;
163
+ error?: ChipErrorSegment;
164
+ dateOrigin?: 'relative' | 'absolute';
165
+ };
142
166
  }
143
167
  /**
144
168
  * Group Node - Represents a grouped expression with AND/OR logic
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.66.2",
3
- "generatedAt": "2026-06-23T09:25:43.087Z",
2
+ "version": "0.66.3",
3
+ "generatedAt": "2026-06-24T13:26:24.868Z",
4
4
  "components": [
5
5
  {
6
6
  "name": "Accordion",
@@ -29593,6 +29593,12 @@
29593
29593
  "required": false,
29594
29594
  "description": "When true, the chip cannot be edited or removed (dimmed appearance)"
29595
29595
  },
29596
+ {
29597
+ "name": "pair",
29598
+ "type": "complex",
29599
+ "required": false,
29600
+ "description": "Second paired triplet (two-step fields). The paired attribute is fixed."
29601
+ },
29596
29602
  {
29597
29603
  "name": "defaultChecked",
29598
29604
  "type": "boolean | undefined",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wallarm-org/design-system",
3
- "version": "0.66.3",
3
+ "version": "0.67.0",
4
4
  "description": "Core design system library with React components and Storybook documentation",
5
5
  "publishConfig": {
6
6
  "access": "public",