Skip to content

Commit 0dbb922

Browse files
pigarevaoksclaude
andauthored
feat(filter-input): add disabled chip support [AS-801] (#71)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 14b4490 commit 0dbb922

File tree

12 files changed

+208
-9
lines changed

12 files changed

+208
-9
lines changed

packages/design-system/src/components/FilterInput/FilterInputField/ChipsWithGaps.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,13 @@ export const ChipsWithGaps: FC<ChipsWithGapsProps> = ({
4949
valueParts={chip.valueParts}
5050
valueSeparator={chip.valueSeparator}
5151
errorValueIndices={chip.errorValueIndices}
52-
onRemove={() => onChipRemove(chip.id)}
53-
onSegmentClick={(segment, anchorRect) => onChipClick(chip.id, segment, anchorRect)}
52+
disabled={chip.disabled}
53+
onRemove={chip.disabled ? undefined : () => onChipRemove(chip.id)}
54+
onSegmentClick={
55+
chip.disabled
56+
? undefined
57+
: (segment, anchorRect) => onChipClick(chip.id, segment, anchorRect)
58+
}
5459
/>
5560
</div>,
5661
);

packages/design-system/src/components/FilterInput/FilterInputField/FilterInputChip/FilterInputChip.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export interface FilterInputChipProps extends Omit<HTMLAttributes<HTMLDivElement
2121
valueSeparator?: string;
2222
errorValueIndices?: number[];
2323
building?: boolean;
24+
/** When true, the chip cannot be edited or removed (dimmed appearance) */
25+
disabled?: boolean;
2426
onRemove?: () => void;
2527
onSegmentClick?: (segment: ChipSegment, anchorRect: DOMRect) => void;
2628
}
@@ -36,12 +38,13 @@ export const FilterInputChip: FC<FilterInputChipProps> = ({
3638
valueSeparator,
3739
errorValueIndices,
3840
building = false,
41+
disabled = false,
3942
onRemove,
4043
onSegmentClick,
4144
className,
4245
...props
4346
}) => {
44-
const interactive = !building;
47+
const interactive = !building && !disabled;
4548
const hasError = !!error;
4649
const internalRef = useRef<HTMLDivElement>(null);
4750

@@ -85,7 +88,11 @@ export const FilterInputChip: FC<FilterInputChipProps> = ({
8588
return (
8689
<div
8790
ref={setRefs}
88-
className={cn(chipVariants({ error: hasError, interactive }), 'max-w-[600px]', className)}
91+
className={cn(
92+
chipVariants({ error: hasError, interactive, disabled }),
93+
'max-w-[600px]',
94+
className,
95+
)}
8996
data-slot='filter-input-condition-chip'
9097
{...props}
9198
>
@@ -125,7 +132,7 @@ export const FilterInputChip: FC<FilterInputChipProps> = ({
125132

126133
{building && <ChipSearchInput />}
127134

128-
{onRemove && <FilterInputRemoveButton error={hasError} onRemove={onRemove} />}
135+
{onRemove && !disabled && <FilterInputRemoveButton error={hasError} onRemove={onRemove} />}
129136
</div>
130137
);
131138
};

packages/design-system/src/components/FilterInput/FilterInputField/FilterInputChip/classes.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,18 @@ export const chipVariants = cva(
1313
true: 'cursor-pointer',
1414
false: '',
1515
},
16+
disabled: {
17+
true: 'opacity-50 cursor-default',
18+
false: '',
19+
},
1620
},
1721
compoundVariants: [
1822
{ interactive: false, error: false, className: 'border-border-strong-primary' },
1923
],
2024
defaultVariants: {
2125
error: false,
2226
interactive: false,
27+
disabled: false,
2328
},
2429
},
2530
);

packages/design-system/src/components/FilterInput/hooks/useFilterInputAutocomplete/useFilterInputAutocomplete.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ export const useFilterInputAutocomplete = ({
6868
// Refs keep values fresh for callbacks to avoid stale closures and unnecessary recreation
6969
const effectiveInsertIndexRef = useRef(effectiveInsertIndex);
7070
effectiveInsertIndexRef.current = effectiveInsertIndex;
71+
const conditionsRef = useRef(conditions);
72+
conditionsRef.current = conditions;
7173
const conditionsLengthRef = useRef(conditions.length);
7274
conditionsLengthRef.current = conditions.length;
7375

@@ -181,6 +183,7 @@ export const useFilterInputAutocomplete = ({
181183
isFocused,
182184
fields,
183185
inputRef,
186+
conditionsRef,
184187
conditionsLengthRef,
185188
effectiveInsertIndexRef,
186189
setInputText,

packages/design-system/src/components/FilterInput/hooks/useFilterInputAutocomplete/useInputHandlers.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ChangeEvent, KeyboardEvent, MutableRefObject, RefObject } from 'react';
22
import { useCallback, useRef } from 'react';
33
import { getOperatorFromLabel, hasFieldValues, OPERATOR_SYMBOLS } from '../../lib';
4-
import type { FieldMetadata, FilterOperator, MenuState } from '../../types';
4+
import type { Condition, FieldMetadata, FilterOperator, MenuState } from '../../types';
55

66
interface UseInputHandlersDeps {
77
inputText: string;
@@ -10,6 +10,7 @@ interface UseInputHandlersDeps {
1010
isFocused: boolean;
1111
fields: FieldMetadata[];
1212
inputRef: RefObject<HTMLInputElement | null>;
13+
conditionsRef: MutableRefObject<Condition[]>;
1314
conditionsLengthRef: MutableRefObject<number>;
1415
effectiveInsertIndexRef: MutableRefObject<number>;
1516
setInputText: (text: string) => void;
@@ -29,6 +30,7 @@ export const useInputHandlers = ({
2930
isFocused,
3031
fields,
3132
inputRef,
33+
conditionsRef,
3234
conditionsLengthRef,
3335
effectiveInsertIndexRef,
3436
setInputText,
@@ -133,7 +135,7 @@ export const useInputHandlers = ({
133135
) {
134136
e.preventDefault();
135137
const removeIdx = effectiveInsertIndexRef.current - 1;
136-
if (removeIdx >= 0) {
138+
if (removeIdx >= 0 && !conditionsRef.current[removeIdx]?.disabled) {
137139
removeConditionAtIndex(removeIdx);
138140
setInsertIndex(prev => {
139141
const eff = prev ?? conditionsLengthRef.current;
@@ -155,6 +157,7 @@ export const useInputHandlers = ({
155157
setInputText,
156158
setMenuState,
157159
setInsertIndex,
160+
conditionsRef,
158161
conditionsLengthRef,
159162
effectiveInsertIndexRef,
160163
],

packages/design-system/src/components/FilterInput/hooks/useFilterInputExpression/buildChips.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const buildBaseChip = (i: number, condition: Condition, field: FieldMetadata | u
4040
operator: condition.operator
4141
? getOperatorLabel(condition.operator, field?.type || DEFAULT_FIELD_TYPE)
4242
: undefined,
43+
...(condition.disabled && { disabled: true }),
4344
});
4445

4546
/** Build chip for a date range condition (between) */

packages/design-system/src/components/FilterInput/hooks/useFilterInputExpression/useFilterInputExpression.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ export const useFilterInputExpression = ({
164164
if (idx === null) return;
165165

166166
setState(prev => {
167+
// Do not remove disabled conditions
168+
if (prev.conditions[idx]?.disabled) return prev;
169+
167170
const newConditions = prev.conditions.filter((_, i) => i !== idx);
168171
const newConnectors = removeConnectorAtConditionIndex(prev.connectors, idx);
169172

@@ -179,6 +182,8 @@ export const useFilterInputExpression = ({
179182
(idx: number) => {
180183
setState(prev => {
181184
if (idx < 0 || idx >= prev.conditions.length) return prev;
185+
// Do not remove disabled conditions
186+
if (prev.conditions[idx]?.disabled) return prev;
182187
const newConditions = prev.conditions.filter((_, i) => i !== idx);
183188
const newConnectors = removeConnectorAtConditionIndex(prev.connectors, idx);
184189

@@ -191,8 +196,17 @@ export const useFilterInputExpression = ({
191196
);
192197

193198
const clearAll = useCallback(() => {
194-
setState(EMPTY_STATE);
195-
onChange?.(null);
199+
setState(prev => {
200+
const disabledConditions = prev.conditions.filter(c => c.disabled);
201+
if (disabledConditions.length === 0) {
202+
onChange?.(null);
203+
return EMPTY_STATE;
204+
}
205+
// Keep disabled conditions and their connectors
206+
const next = { conditions: disabledConditions, connectors: [] as Array<'and' | 'or'> };
207+
onChange?.(buildExpression(next.conditions, next.connectors));
208+
return next;
209+
});
196210
}, [onChange]);
197211

198212
const setConnectorValue = useCallback(

packages/design-system/src/components/FilterInput/stories/FilterInput.stories.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,69 @@ export const ErrorWithValue: Story = {
199199
);
200200
},
201201
};
202+
203+
/**
204+
* Disabled chips cannot be edited or removed.
205+
* They appear dimmed and do not react to clicks.
206+
* Useful for locked filter conditions (e.g. drill-down context in investigation flows).
207+
*/
208+
export const WithDisabledChips: Story = {
209+
render: () => {
210+
const [expression, setExpression] = useState<ExprNode | null>({
211+
type: 'group',
212+
operator: 'and',
213+
children: [
214+
{ type: 'condition', field: 'country', operator: '=', value: 'US', disabled: true },
215+
{ type: 'condition', field: 'status', operator: '=', value: 'blocked', disabled: true },
216+
{ type: 'condition', field: 'priority', operator: '>', value: 5 },
217+
],
218+
});
219+
220+
return (
221+
<>
222+
<FilterInput
223+
fields={sampleFields}
224+
value={expression}
225+
onChange={setExpression}
226+
placeholder='Add more filters...'
227+
/>
228+
{expression && (
229+
<div className='mt-16 p-4 bg-gray-100 rounded text-xs'>
230+
<pre>{JSON.stringify(expression, null, 2)}</pre>
231+
</div>
232+
)}
233+
</>
234+
);
235+
},
236+
};
237+
238+
/**
239+
* All chips disabled — clear button removes nothing, input still allows adding new chips.
240+
*/
241+
export const AllChipsDisabled: Story = {
242+
render: () => {
243+
const [expression, setExpression] = useState<ExprNode | null>({
244+
type: 'group',
245+
operator: 'and',
246+
children: [
247+
{ type: 'condition', field: 'status', operator: '=', value: 'registered', disabled: true },
248+
{
249+
type: 'condition',
250+
field: 'country',
251+
operator: 'in',
252+
value: ['US', 'DE'],
253+
disabled: true,
254+
},
255+
],
256+
});
257+
258+
return (
259+
<FilterInput
260+
fields={sampleFields}
261+
value={expression}
262+
onChange={setExpression}
263+
placeholder='Add more filters...'
264+
/>
265+
);
266+
},
267+
};

packages/design-system/src/components/FilterInput/stories/FilterInputChip.stories.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,56 @@ export const InteractiveDeleteExample: StoryFn = () => {
190190
);
191191
};
192192

193+
// ============================================================================
194+
// Disabled Chip Variants
195+
// ============================================================================
196+
197+
/**
198+
* Disabled chip — dimmed, not clickable, no remove button.
199+
* Used for locked filter conditions (e.g. drill-down context).
200+
*/
201+
export const Disabled = Template.bind({});
202+
Disabled.args = {
203+
attribute: 'IP Address',
204+
operator: 'is',
205+
value: '34.74.73.20',
206+
disabled: true,
207+
};
208+
209+
/**
210+
* Disabled chip with onRemove — remove button is still hidden.
211+
*/
212+
export const DisabledWithOnRemove = Template.bind({});
213+
DisabledWithOnRemove.args = {
214+
attribute: 'Host',
215+
operator: 'is',
216+
value: 'api.example.com',
217+
disabled: true,
218+
onRemove: () => alert('This should never fire'),
219+
};
220+
221+
/**
222+
* Mix of disabled and interactive chips.
223+
*/
224+
export const DisabledAndInteractiveMix: StoryFn = () => (
225+
<div className='flex items-center gap-4'>
226+
<FilterInputChip
227+
attribute='IP Address'
228+
operator='is'
229+
value='34.74.73.20'
230+
disabled
231+
onRemove={() => undefined}
232+
/>
233+
<FilterInputConnectorChip variant='and' />
234+
<FilterInputChip
235+
attribute='Country'
236+
operator='is'
237+
value='US'
238+
onRemove={() => alert('Removed Country filter')}
239+
/>
240+
</div>
241+
);
242+
193243
// ============================================================================
194244
// Connector Variants
195245
// ============================================================================
@@ -250,6 +300,21 @@ export const AllStatesShowcase: StoryFn = () => (
250300
</div>
251301
</div>
252302

303+
{/* Disabled chip variants */}
304+
<div>
305+
<h3 className='text-sm font-medium text-text-primary mb-2'>Disabled Chip</h3>
306+
<div className='flex items-center gap-2 flex-wrap'>
307+
<FilterInputChip attribute='IP Address' operator='is' value='34.74.73.20' disabled />
308+
<FilterInputChip
309+
attribute='Host'
310+
operator='is'
311+
value='api.example.com'
312+
disabled
313+
onRemove={() => undefined}
314+
/>
315+
</div>
316+
</div>
317+
253318
{/* Building chip variants */}
254319
<div>
255320
<h3 className='text-sm font-medium text-text-primary mb-2'>Building Chip</h3>

packages/design-system/src/components/FilterInput/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export interface FilterInputChipData {
2424
valueSeparator?: string;
2525
/** Indices of invalid values in a multi-value chip (e.g. "in" operator) */
2626
errorValueIndices?: number[];
27+
/** When true, the chip cannot be edited or removed */
28+
disabled?: boolean;
2729
}
2830

2931
/**
@@ -95,6 +97,8 @@ export interface Condition {
9597
error?: ChipErrorSegment;
9698
/** For date fields: tracks whether the value originated as relative preset or absolute date */
9799
dateOrigin?: 'relative' | 'absolute';
100+
/** When true, the condition cannot be edited or removed */
101+
disabled?: boolean;
98102
}
99103

100104
/**

0 commit comments

Comments
 (0)