Skip to content

Commit 78dc7da

Browse files
authored
feat: add a clear button to FilterPicker, Select, ComboBox (#787)
1 parent b0599c8 commit 78dc7da

File tree

14 files changed

+225
-29
lines changed

14 files changed

+225
-29
lines changed

.changeset/afraid-mugs-sort.md

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 ItemAction component with a temporary implementation.
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.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { FocusableRef } from '@react-types/shared';
2+
import { forwardRef } from 'react';
3+
4+
import { tasty } from '../../../tasty';
5+
import { Button, CubeButtonProps } from '../Button';
6+
7+
export interface CubeItemActionProps extends CubeButtonProps {
8+
// All props from Button are inherited
9+
}
10+
11+
export const ItemAction = tasty(Button, {
12+
type: 'neutral',
13+
styles: {
14+
height: '($size - 1x)',
15+
width: '($size - 1x)',
16+
margin: '0 (.5x - 1bw)',
17+
},
18+
});
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { ItemAction } from './ItemAction';
2+
export type { CubeItemActionProps } from './ItemAction';

src/components/actions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const Button = Object.assign(
1010

1111
export * from './Button';
1212
export * from './Action/Action';
13+
export * from './ItemAction';
1314
export * from './ItemButton';
1415
export * from './Menu';
1516
export * from './CommandMenu';

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: 45 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 { ItemAction } 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',
@@ -125,6 +128,8 @@ export interface CubeComboBoxProps<T>
125128
suffixPosition?: 'before' | 'after';
126129
menuTrigger?: MenuTriggerAction;
127130
allowsCustomValue?: boolean;
131+
/** Whether the combo box is clearable using ESC keyboard button or clear button inside the input */
132+
isClearable?: boolean;
128133
}
129134

130135
const PROP_STYLES = [...BASE_STYLES, ...OUTER_STYLES, ...COLOR_STYLES];
@@ -201,6 +206,7 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
201206
labelSuffix,
202207
selectedKey,
203208
defaultSelectedKey,
209+
isClearable,
204210
...otherProps
205211
} = props;
206212

@@ -306,6 +312,32 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
306312
let validationIcon = isInvalid ? InvalidIcon : ValidIcon;
307313
let validation = cloneElement(validationIcon);
308314

315+
// Clear button logic
316+
let hasValue = props.allowsCustomValue
317+
? state.inputValue !== ''
318+
: state.selectedKey != null;
319+
let showClearButton =
320+
isClearable && hasValue && !isDisabled && !props.isReadOnly;
321+
322+
// Clear function
323+
let clearValue = useEvent(() => {
324+
// Always clear input value in state so UI resets to placeholder
325+
state.setInputValue('');
326+
// Notify external input value only when custom value mode is enabled
327+
if (props.allowsCustomValue) {
328+
props.onInputChange?.('');
329+
}
330+
props.onSelectionChange?.(null);
331+
state.setSelectedKey(null);
332+
333+
// Close the popup if it's open
334+
if (state.isOpen) {
335+
state.close();
336+
}
337+
// Focus back to the input
338+
inputRef.current?.focus();
339+
});
340+
309341
let comboBoxWidth = wrapperRef?.current?.offsetWidth;
310342

311343
if (icon) {
@@ -333,6 +365,7 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
333365
loading: isLoading,
334366
prefix: !!prefix,
335367
suffix: true,
368+
clearable: showClearButton,
336369
}),
337370
[
338371
isInvalid,
@@ -342,6 +375,7 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
342375
isFocused,
343376
isLoading,
344377
prefix,
378+
showClearButton,
345379
],
346380
);
347381

@@ -453,6 +487,16 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
453487
</>
454488
) : null}
455489
{suffixPosition === 'after' ? suffix : null}
490+
{showClearButton && (
491+
<ItemAction
492+
icon={<CloseIcon />}
493+
size={size}
494+
theme={validationState === 'invalid' ? 'danger' : undefined}
495+
qa="ComboBoxClearButton"
496+
data-no-trigger={hideTrigger ? '' : undefined}
497+
onPress={clearValue}
498+
/>
499+
)}
456500
{!hideTrigger ? (
457501
<TriggerElement
458502
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 { CubeItemButtonProps, ItemAction, 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];
@@ -235,6 +237,7 @@ export const FilterPicker = forwardRef(function FilterPicker<T extends object>(
235237
shouldUseVirtualFocus,
236238
onEscape,
237239
onOptionClick,
240+
isClearable,
238241
...otherProps
239242
} = props;
240243

@@ -944,6 +947,33 @@ export const FilterPicker = forwardRef(function FilterPicker<T extends object>(
944947
setShouldUpdatePosition(!state.isOpen);
945948
}, [state.isOpen]);
946949

950+
// Clear button logic
951+
let showClearButton =
952+
isClearable && hasSelection && !isDisabled && !props.isReadOnly;
953+
954+
// Clear function
955+
let clearValue = useEvent(() => {
956+
if (selectionMode === 'multiple') {
957+
if (!isControlledMultiple) {
958+
setInternalSelectedKeys([]);
959+
}
960+
onSelectionChange?.([]);
961+
} else {
962+
if (!isControlledSingle) {
963+
setInternalSelectedKey(null);
964+
}
965+
onSelectionChange?.(null);
966+
}
967+
968+
if (state.isOpen) {
969+
state.close();
970+
}
971+
972+
triggerRef?.current?.focus?.();
973+
974+
return false;
975+
});
976+
947977
return (
948978
<ItemButton
949979
ref={triggerRef as any}
@@ -963,6 +993,15 @@ export const FilterPicker = forwardRef(function FilterPicker<T extends object>(
963993
<LoadingIcon />
964994
) : rightIcon !== undefined ? (
965995
rightIcon
996+
) : showClearButton ? (
997+
<ItemAction
998+
icon={<CloseIcon />}
999+
size={size}
1000+
theme={validationState === 'invalid' ? 'danger' : undefined}
1001+
qa="FilterPickerClearButton"
1002+
mods={{ pressed: false }}
1003+
onPress={clearValue}
1004+
/>
9661005
) : (
9671006
<DirectionIcon to={state.isOpen ? 'top' : 'bottom'} />
9681007
)

0 commit comments

Comments
 (0)