Skip to content

Commit a5b3208

Browse files
committed
feat: add a clear button to FilterPicker, Select, ComboBox
1 parent b0599c8 commit a5b3208

File tree

10 files changed

+220
-20
lines changed

10 files changed

+220
-20
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cube-dev/ui-kit": patch
3+
---
4+
5+
Add a clear button to FilterPicker, Select and ComboBox components. Redesign the clear button in SearchInput component.

src/components/content/ItemBase/ItemBase.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ const ItemBaseElement = tasty({
172172
'with-icon ^ with-prefix': 'max-content 1sf max-content max-content',
173173
'with-icon & with-prefix':
174174
'max-content max-content 1sf max-content max-content',
175+
'(with-icon ^ with-right-icon) & !with-description & !with-prefix & !with-suffix & !with-label':
176+
'max-content',
175177
},
176178
gridRows: {
177179
'': 'auto auto',
@@ -461,6 +463,7 @@ const ItemBase = <T extends HTMLElement = HTMLDivElement>(
461463
return {
462464
'with-icon': !!finalIcon,
463465
'with-right-icon': !!finalRightIcon,
466+
'with-label': !!(children || labelProps),
464467
'with-prefix': !!finalPrefix,
465468
'with-suffix': !!finalSuffix,
466469
'with-description': !!description,

src/components/fields/ComboBox/ComboBox.stories.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ export default {
5555
defaultValue: { summary: false },
5656
},
5757
},
58+
isClearable: {
59+
control: { type: 'boolean' },
60+
description:
61+
'Whether the combo box is clearable using ESC keyboard button or clear button inside the input',
62+
table: {
63+
defaultValue: { summary: false },
64+
},
65+
},
5866

5967
/* Behavior */
6068
menuTrigger: {
@@ -347,6 +355,13 @@ Valid.args = { selectedKey: 'yellow', validationState: 'valid' };
347355
export const Disabled = Template.bind({});
348356
Disabled.args = { selectedKey: 'yellow', isDisabled: true };
349357

358+
export const Clearable = Template.bind({});
359+
Clearable.args = {
360+
defaultSelectedKey: 'purple',
361+
isClearable: true,
362+
placeholder: 'Choose a color...',
363+
};
364+
350365
export const Wide: StoryFn<CubeComboBoxProps<any>> = (
351366
args: CubeComboBoxProps<any>,
352367
) => (

src/components/fields/ComboBox/ComboBox.tsx

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
import { Section as BaseSection, useComboBoxState } from 'react-stately';
2020

2121
import { useEvent } from '../../../_internal/index';
22-
import { DownIcon, LoadingIcon } from '../../../icons';
22+
import { CloseIcon, DownIcon, LoadingIcon } from '../../../icons';
2323
import { useProviderProps } from '../../../provider';
2424
import { FieldBaseProps } from '../../../shared';
2525
import {
@@ -37,6 +37,7 @@ import {
3737
} from '../../../utils/react';
3838
import { useFocus } from '../../../utils/react/interactions';
3939
import { useEventBus } from '../../../utils/react/useEventBus';
40+
import { Button } from '../../actions';
4041
import { useFieldProps, useFormProps, wrapWithField } from '../../form';
4142
import { Item } from '../../Item';
4243
import { OverlayWrapper } from '../../overlays/OverlayWrapper';
@@ -70,7 +71,9 @@ const TriggerElement = tasty({
7071
placeContent: 'center',
7172
placeSelf: 'stretch',
7273
radius: '(1r - 1bw) right',
74+
padding: '0',
7375
width: '3x',
76+
boxSizing: 'border-box',
7477
color: {
7578
'': '#dark-02',
7679
hovered: '#dark-02',
@@ -90,6 +93,15 @@ const TriggerElement = tasty({
9093
},
9194
});
9295

96+
const ClearButton = tasty(Button, {
97+
icon: <CloseIcon />,
98+
styles: {
99+
height: '($size - 1x)',
100+
width: '($size - 1x)',
101+
margin: '0 .5x',
102+
},
103+
});
104+
93105
export interface CubeComboBoxProps<T>
94106
extends Omit<
95107
CubeSelectBaseProps<T>,
@@ -125,6 +137,8 @@ export interface CubeComboBoxProps<T>
125137
suffixPosition?: 'before' | 'after';
126138
menuTrigger?: MenuTriggerAction;
127139
allowsCustomValue?: boolean;
140+
/** Whether the combo box is clearable using ESC keyboard button or clear button inside the input */
141+
isClearable?: boolean;
128142
}
129143

130144
const PROP_STYLES = [...BASE_STYLES, ...OUTER_STYLES, ...COLOR_STYLES];
@@ -201,6 +215,7 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
201215
labelSuffix,
202216
selectedKey,
203217
defaultSelectedKey,
218+
isClearable,
204219
...otherProps
205220
} = props;
206221

@@ -306,6 +321,42 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
306321
let validationIcon = isInvalid ? InvalidIcon : ValidIcon;
307322
let validation = cloneElement(validationIcon);
308323

324+
// Clear button logic
325+
let hasValue = props.allowsCustomValue
326+
? state.inputValue !== ''
327+
: state.selectedKey != null;
328+
let showClearButton =
329+
isClearable && hasValue && !isDisabled && !props.isReadOnly;
330+
331+
// Clear function
332+
let clearValue = useEvent(() => {
333+
if (props.allowsCustomValue) {
334+
props.onInputChange?.('');
335+
// If state has a setInputValue method, use it as well
336+
if (
337+
'setInputValue' in state &&
338+
typeof state.setInputValue === 'function'
339+
) {
340+
state.setInputValue('');
341+
}
342+
} else {
343+
props.onSelectionChange?.(null);
344+
// If state has a setSelectedKey method, use it as well
345+
if (
346+
'setSelectedKey' in state &&
347+
typeof state.setSelectedKey === 'function'
348+
) {
349+
state.setSelectedKey(null);
350+
}
351+
}
352+
// Close the popup if it's open
353+
if (state.isOpen) {
354+
state.close();
355+
}
356+
// Focus back to the input
357+
inputRef.current?.focus();
358+
});
359+
309360
let comboBoxWidth = wrapperRef?.current?.offsetWidth;
310361

311362
if (icon) {
@@ -333,6 +384,7 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
333384
loading: isLoading,
334385
prefix: !!prefix,
335386
suffix: true,
387+
clearable: showClearButton,
336388
}),
337389
[
338390
isInvalid,
@@ -342,6 +394,7 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
342394
isFocused,
343395
isLoading,
344396
prefix,
397+
showClearButton,
345398
],
346399
);
347400

@@ -453,6 +506,16 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
453506
</>
454507
) : null}
455508
{suffixPosition === 'after' ? suffix : null}
509+
{showClearButton && (
510+
<ClearButton
511+
size={size}
512+
type={validationState === 'invalid' ? 'clear' : 'neutral'}
513+
theme={validationState === 'invalid' ? 'danger' : undefined}
514+
qa="ComboBoxClearButton"
515+
data-no-trigger={hideTrigger ? '' : undefined}
516+
onPress={clearValue}
517+
/>
518+
)}
456519
{!hideTrigger ? (
457520
<TriggerElement
458521
data-popover-trigger

src/components/fields/FilterPicker/FilterPicker.stories.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,14 @@ const meta: Meta<typeof FilterPicker> = {
7474
defaultValue: { summary: false },
7575
},
7676
},
77+
isClearable: {
78+
control: { type: 'boolean' },
79+
description:
80+
'Whether the filter picker is clearable using a clear button in the rightIcon slot',
81+
table: {
82+
defaultValue: { summary: false },
83+
},
84+
},
7785
disallowEmptySelection: {
7886
control: { type: 'boolean' },
7987
description: 'Whether to disallow empty selection',
@@ -448,6 +456,27 @@ export const SingleSelection: Story = {
448456
),
449457
};
450458

459+
export const Clearable: Story = {
460+
args: {
461+
label: 'Clearable Filter Picker',
462+
placeholder: 'Choose items...',
463+
selectionMode: 'single',
464+
searchPlaceholder: 'Search fruits...',
465+
width: 'max 30x',
466+
defaultSelectedKey: 'apple',
467+
isClearable: true,
468+
},
469+
render: (args) => (
470+
<FilterPicker {...args}>
471+
{fruits.map((fruit) => (
472+
<FilterPicker.Item key={fruit.key} textValue={fruit.label}>
473+
{fruit.label}
474+
</FilterPicker.Item>
475+
))}
476+
</FilterPicker>
477+
),
478+
};
479+
451480
export const MultipleSelection: Story = {
452481
args: {
453482
label: 'Select Multiple Options',

src/components/fields/FilterPicker/FilterPicker.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ import {
1616
import { FocusScope, Key, useKeyboard } from 'react-aria';
1717
import { Section as BaseSection, Item, ListState } from 'react-stately';
1818

19+
import { useEvent } from '../../../_internal';
1920
import { useWarn } from '../../../_internal/hooks/use-warn';
20-
import { DirectionIcon, LoadingIcon } from '../../../icons';
21+
import { CloseIcon, DirectionIcon, LoadingIcon } from '../../../icons';
2122
import { useProviderProps } from '../../../provider';
2223
import {
2324
BASE_STYLES,
@@ -35,12 +36,11 @@ import {
3536
import { generateRandomId } from '../../../utils/random';
3637
import { mergeProps } from '../../../utils/react';
3738
import { useEventBus } from '../../../utils/react/useEventBus';
38-
import { CubeItemButtonProps, ItemButton } from '../../actions';
39+
import { Button, CubeItemButtonProps, ItemButton } from '../../actions';
3940
import { CubeItemBaseProps } from '../../content/ItemBase';
4041
import { Text } from '../../content/Text';
4142
import { useFieldProps, useFormProps, wrapWithField } from '../../form';
4243
import { Dialog, DialogTrigger } from '../../overlays/Dialog';
43-
import { CubeTooltipProviderProps } from '../../overlays/Tooltip/TooltipProvider';
4444
import {
4545
CubeFilterListBoxProps,
4646
FilterListBox,
@@ -117,6 +117,8 @@ export interface CubeFilterPickerProps<T>
117117
listStateRef?: MutableRefObject<ListState<T>>;
118118
/** Additional modifiers for styling the FilterPicker */
119119
mods?: Record<string, boolean>;
120+
/** Whether the filter picker is clearable using a clear button in the rightIcon slot */
121+
isClearable?: boolean;
120122
}
121123

122124
const PROP_STYLES = [...BASE_STYLES, ...OUTER_STYLES, ...COLOR_STYLES];
@@ -132,6 +134,16 @@ const FilterPickerWrapper = tasty({
132134
},
133135
});
134136

137+
const ClearButton = tasty(Button, {
138+
children: <CloseIcon />,
139+
type: 'neutral',
140+
mods: { pressed: false },
141+
styles: {
142+
height: '($size - 1x)',
143+
width: '($size - 1x)',
144+
},
145+
});
146+
135147
export const FilterPicker = forwardRef(function FilterPicker<T extends object>(
136148
props: CubeFilterPickerProps<T>,
137149
ref: ForwardedRef<HTMLElement>,
@@ -235,6 +247,7 @@ export const FilterPicker = forwardRef(function FilterPicker<T extends object>(
235247
shouldUseVirtualFocus,
236248
onEscape,
237249
onOptionClick,
250+
isClearable,
238251
...otherProps
239252
} = props;
240253

@@ -568,6 +581,25 @@ export const FilterPicker = forwardRef(function FilterPicker<T extends object>(
568581
const selectedLabels = getSelectedLabels();
569582
const hasSelection = selectedLabels.length > 0;
570583

584+
// Clear button logic
585+
let showClearButton =
586+
isClearable && hasSelection && !isDisabled && !props.isReadOnly;
587+
588+
// Clear function
589+
let clearValue = useEvent(() => {
590+
if (selectionMode === 'multiple') {
591+
if (!isControlledMultiple) {
592+
setInternalSelectedKeys([]);
593+
}
594+
onSelectionChange?.([]);
595+
} else {
596+
if (!isControlledSingle) {
597+
setInternalSelectedKey(null);
598+
}
599+
onSelectionChange?.(null);
600+
}
601+
});
602+
571603
// Always keep the latest selection in a ref (with normalized keys) so that we can read it synchronously in the popover close effect.
572604
const latestSelectionRef = useRef<{
573605
single: string | null;
@@ -963,6 +995,13 @@ export const FilterPicker = forwardRef(function FilterPicker<T extends object>(
963995
<LoadingIcon />
964996
) : rightIcon !== undefined ? (
965997
rightIcon
998+
) : showClearButton ? (
999+
<ClearButton
1000+
size={size}
1001+
theme={validationState === 'invalid' ? 'danger' : undefined}
1002+
qa="FilterPickerClearButton"
1003+
onPress={clearValue}
1004+
/>
9661005
) : (
9671006
<DirectionIcon to={state.isOpen ? 'top' : 'bottom'} />
9681007
)

src/components/fields/SearchInput/SearchInput.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,9 @@ export interface CubeSearchInputProps
2626
const ClearButton = tasty(Button, {
2727
icon: <CloseIcon />,
2828
styles: {
29-
radius: 'right (1r - 1bw)',
30-
width: {
31-
'': '3x',
32-
'[data-size="large"]': '4x',
33-
},
34-
height: 'auto',
35-
placeSelf: 'stretch',
29+
height: '($size - 1x)',
30+
width: '($size - 1x)',
31+
margin: '0 .5x',
3632
},
3733
});
3834

src/components/fields/Select/Select.stories.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,15 @@ export default {
105105
defaultValue: { summary: 'bottom' },
106106
},
107107
},
108+
isClearable: {
109+
control: { type: 'boolean' },
110+
description:
111+
'Whether the select is clearable using a clear button in the rightIcon slot',
112+
table: {
113+
type: { summary: 'boolean' },
114+
defaultValue: { summary: false },
115+
},
116+
},
108117
shouldFlip: {
109118
control: { type: 'boolean' },
110119
description: 'Whether dropdown should flip to fit in viewport',
@@ -404,6 +413,13 @@ WithPlaceholder.args = { placeholder: 'Enter a value' };
404413
export const WithDefaultValue = Template.bind({});
405414
WithDefaultValue.args = { defaultSelectedKey: 'purple' };
406415

416+
export const Clearable = Template.bind({});
417+
Clearable.args = {
418+
defaultSelectedKey: 'blue',
419+
isClearable: true,
420+
placeholder: 'Choose a color...',
421+
};
422+
407423
export const WithIcon = Template.bind({});
408424
WithIcon.args = { icon: <IconCoin /> };
409425

0 commit comments

Comments
 (0)