Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/modern-tigers-impress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cube-dev/ui-kit": patch
---

Add a clear button to FilterPicker, Select and ComboBox components. Redesign the clear button in SearchInput component.
3 changes: 3 additions & 0 deletions src/components/content/ItemBase/ItemBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ const ItemBaseElement = tasty({
'with-icon ^ with-prefix': 'max-content 1sf max-content max-content',
'with-icon & with-prefix':
'max-content max-content 1sf max-content max-content',
'(with-icon ^ with-right-icon) & !with-description & !with-prefix & !with-suffix & !with-label':
'max-content',
},
gridRows: {
'': 'auto auto',
Expand Down Expand Up @@ -461,6 +463,7 @@ const ItemBase = <T extends HTMLElement = HTMLDivElement>(
return {
'with-icon': !!finalIcon,
'with-right-icon': !!finalRightIcon,
'with-label': !!(children || labelProps),
'with-prefix': !!finalPrefix,
'with-suffix': !!finalSuffix,
'with-description': !!description,
Expand Down
15 changes: 15 additions & 0 deletions src/components/fields/ComboBox/ComboBox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ export default {
defaultValue: { summary: false },
},
},
isClearable: {
control: { type: 'boolean' },
description:
'Whether the combo box is clearable using ESC keyboard button or clear button inside the input',
table: {
defaultValue: { summary: false },
},
},

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

export const Clearable = Template.bind({});
Clearable.args = {
defaultSelectedKey: 'purple',
isClearable: true,
placeholder: 'Choose a color...',
};

export const Wide: StoryFn<CubeComboBoxProps<any>> = (
args: CubeComboBoxProps<any>,
) => (
Expand Down
55 changes: 54 additions & 1 deletion src/components/fields/ComboBox/ComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
import { Section as BaseSection, useComboBoxState } from 'react-stately';

import { useEvent } from '../../../_internal/index';
import { DownIcon, LoadingIcon } from '../../../icons';
import { CloseIcon, DownIcon, LoadingIcon } from '../../../icons';
import { useProviderProps } from '../../../provider';
import { FieldBaseProps } from '../../../shared';
import {
Expand All @@ -37,6 +37,7 @@ import {
} from '../../../utils/react';
import { useFocus } from '../../../utils/react/interactions';
import { useEventBus } from '../../../utils/react/useEventBus';
import { Button } from '../../actions';
import { useFieldProps, useFormProps, wrapWithField } from '../../form';
import { Item } from '../../Item';
import { OverlayWrapper } from '../../overlays/OverlayWrapper';
Expand Down Expand Up @@ -70,7 +71,9 @@ const TriggerElement = tasty({
placeContent: 'center',
placeSelf: 'stretch',
radius: '(1r - 1bw) right',
padding: '0',
width: '3x',
boxSizing: 'border-box',
color: {
'': '#dark-02',
hovered: '#dark-02',
Expand All @@ -90,6 +93,16 @@ const TriggerElement = tasty({
},
});

const ClearButton = tasty(Button, {
icon: <CloseIcon />,
type: 'neutral',
styles: {
height: '($size - 1x)',
width: '($size - 1x)',
margin: '0 .5x',
},
});

export interface CubeComboBoxProps<T>
extends Omit<
CubeSelectBaseProps<T>,
Expand Down Expand Up @@ -125,6 +138,8 @@ export interface CubeComboBoxProps<T>
suffixPosition?: 'before' | 'after';
menuTrigger?: MenuTriggerAction;
allowsCustomValue?: boolean;
/** Whether the combo box is clearable using ESC keyboard button or clear button inside the input */
isClearable?: boolean;
}

const PROP_STYLES = [...BASE_STYLES, ...OUTER_STYLES, ...COLOR_STYLES];
Expand Down Expand Up @@ -201,6 +216,7 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
labelSuffix,
selectedKey,
defaultSelectedKey,
isClearable,
...otherProps
} = props;

Expand Down Expand Up @@ -306,6 +322,32 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
let validationIcon = isInvalid ? InvalidIcon : ValidIcon;
let validation = cloneElement(validationIcon);

// Clear button logic
Copy link

Copilot AI Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for determining hasValue is complex and warrants explanation. Consider adding a comment explaining why custom value mode checks input value while regular mode checks selected key.

Suggested change
// Clear button logic
// Clear button logic
// In custom value mode, the user can type arbitrary input, so we check if the input field is non-empty.
// In regular mode, only selections from the list are allowed, so we check if a key is selected.

Copilot uses AI. Check for mistakes.
let hasValue = props.allowsCustomValue
? state.inputValue !== ''
: state.selectedKey != null;
let showClearButton =
isClearable && hasValue && !isDisabled && !props.isReadOnly;

// Clear function
let clearValue = useEvent(() => {
// Always clear input value in state so UI resets to placeholder
state.setInputValue('');
// Notify external input value only when custom value mode is enabled
if (props.allowsCustomValue) {
props.onInputChange?.('');
}
props.onSelectionChange?.(null);
state.setSelectedKey(null);
Comment on lines +322 to +331
Copy link

Copilot AI Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clear function handles two different modes (custom value vs regular selection) with different notification behaviors. Consider adding a comment explaining why onInputChange is only called in custom value mode.

Copilot uses AI. Check for mistakes.

// Close the popup if it's open
if (state.isOpen) {
state.close();
}
// Focus back to the input
inputRef.current?.focus();
});

let comboBoxWidth = wrapperRef?.current?.offsetWidth;

if (icon) {
Expand Down Expand Up @@ -333,6 +375,7 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
loading: isLoading,
prefix: !!prefix,
suffix: true,
clearable: showClearButton,
}),
[
isInvalid,
Expand All @@ -342,6 +385,7 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
isFocused,
isLoading,
prefix,
showClearButton,
],
);

Expand Down Expand Up @@ -453,6 +497,15 @@ export const ComboBox = forwardRef(function ComboBox<T extends object>(
</>
) : null}
{suffixPosition === 'after' ? suffix : null}
{showClearButton && (
<ClearButton
size={size}
theme={validationState === 'invalid' ? 'danger' : undefined}
qa="ComboBoxClearButton"
data-no-trigger={hideTrigger ? '' : undefined}
onPress={clearValue}
/>
)}
{!hideTrigger ? (
<TriggerElement
data-popover-trigger
Expand Down
29 changes: 29 additions & 0 deletions src/components/fields/FilterPicker/FilterPicker.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ const meta: Meta<typeof FilterPicker> = {
defaultValue: { summary: false },
},
},
isClearable: {
control: { type: 'boolean' },
description:
'Whether the filter picker is clearable using a clear button in the rightIcon slot',
table: {
defaultValue: { summary: false },
},
},
disallowEmptySelection: {
control: { type: 'boolean' },
description: 'Whether to disallow empty selection',
Expand Down Expand Up @@ -448,6 +456,27 @@ export const SingleSelection: Story = {
),
};

export const Clearable: Story = {
args: {
label: 'Clearable Filter Picker',
placeholder: 'Choose items...',
selectionMode: 'single',
searchPlaceholder: 'Search fruits...',
width: 'max 30x',
defaultSelectedKey: 'apple',
isClearable: true,
},
render: (args) => (
<FilterPicker {...args}>
{fruits.map((fruit) => (
<FilterPicker.Item key={fruit.key} textValue={fruit.label}>
{fruit.label}
</FilterPicker.Item>
))}
</FilterPicker>
),
};

export const MultipleSelection: Story = {
args: {
label: 'Select Multiple Options',
Expand Down
53 changes: 50 additions & 3 deletions src/components/fields/FilterPicker/FilterPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ import {
import { FocusScope, Key, useKeyboard } from 'react-aria';
import { Section as BaseSection, Item, ListState } from 'react-stately';

import { useEvent } from '../../../_internal';
import { useWarn } from '../../../_internal/hooks/use-warn';
import { DirectionIcon, LoadingIcon } from '../../../icons';
import { CloseIcon, DirectionIcon, LoadingIcon } from '../../../icons';
import { useProviderProps } from '../../../provider';
import {
BASE_STYLES,
Expand All @@ -35,12 +36,11 @@ import {
import { generateRandomId } from '../../../utils/random';
import { mergeProps } from '../../../utils/react';
import { useEventBus } from '../../../utils/react/useEventBus';
import { CubeItemButtonProps, ItemButton } from '../../actions';
import { Button, CubeItemButtonProps, ItemButton } from '../../actions';
import { CubeItemBaseProps } from '../../content/ItemBase';
import { Text } from '../../content/Text';
import { useFieldProps, useFormProps, wrapWithField } from '../../form';
import { Dialog, DialogTrigger } from '../../overlays/Dialog';
import { CubeTooltipProviderProps } from '../../overlays/Tooltip/TooltipProvider';
import {
CubeFilterListBoxProps,
FilterListBox,
Expand Down Expand Up @@ -117,6 +117,8 @@ export interface CubeFilterPickerProps<T>
listStateRef?: MutableRefObject<ListState<T>>;
/** Additional modifiers for styling the FilterPicker */
mods?: Record<string, boolean>;
/** Whether the filter picker is clearable using a clear button in the rightIcon slot */
isClearable?: boolean;
}

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

const ClearButton = tasty(Button, {
children: <CloseIcon />,
type: 'neutral',
mods: { pressed: false },
styles: {
height: '($size - 1x)',
width: '($size - 1x)',
},
});

export const FilterPicker = forwardRef(function FilterPicker<T extends object>(
props: CubeFilterPickerProps<T>,
ref: ForwardedRef<HTMLElement>,
Expand Down Expand Up @@ -235,6 +247,7 @@ export const FilterPicker = forwardRef(function FilterPicker<T extends object>(
shouldUseVirtualFocus,
onEscape,
onOptionClick,
isClearable,
...otherProps
} = props;

Expand Down Expand Up @@ -944,6 +957,33 @@ export const FilterPicker = forwardRef(function FilterPicker<T extends object>(
setShouldUpdatePosition(!state.isOpen);
}, [state.isOpen]);

// Clear button logic
let showClearButton =
isClearable && hasSelection && !isDisabled && !props.isReadOnly;

// Clear function
let clearValue = useEvent(() => {
if (selectionMode === 'multiple') {
if (!isControlledMultiple) {
setInternalSelectedKeys([]);
}
onSelectionChange?.([]);
} else {
if (!isControlledSingle) {
setInternalSelectedKey(null);
}
onSelectionChange?.(null);
}

if (state.isOpen) {
state.close();
}

triggerRef?.current?.focus?.();

return false;
});

return (
<ItemButton
ref={triggerRef as any}
Expand All @@ -963,6 +1003,13 @@ export const FilterPicker = forwardRef(function FilterPicker<T extends object>(
<LoadingIcon />
) : rightIcon !== undefined ? (
rightIcon
) : showClearButton ? (
<ClearButton
size={size}
theme={validationState === 'invalid' ? 'danger' : undefined}
qa="FilterPickerClearButton"
onPress={clearValue}
/>
) : (
<DirectionIcon to={state.isOpen ? 'top' : 'bottom'} />
)
Expand Down
10 changes: 3 additions & 7 deletions src/components/fields/SearchInput/SearchInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,9 @@ export interface CubeSearchInputProps
const ClearButton = tasty(Button, {
icon: <CloseIcon />,
styles: {
radius: 'right (1r - 1bw)',
width: {
'': '3x',
'[data-size="large"]': '4x',
},
height: 'auto',
placeSelf: 'stretch',
height: '($size - 1x)',
width: '($size - 1x)',
margin: '0 .5x',
},
});

Expand Down
16 changes: 16 additions & 0 deletions src/components/fields/Select/Select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ export default {
defaultValue: { summary: 'bottom' },
},
},
isClearable: {
control: { type: 'boolean' },
description:
'Whether the select is clearable using a clear button in the rightIcon slot',
table: {
type: { summary: 'boolean' },
defaultValue: { summary: false },
},
},
shouldFlip: {
control: { type: 'boolean' },
description: 'Whether dropdown should flip to fit in viewport',
Expand Down Expand Up @@ -404,6 +413,13 @@ WithPlaceholder.args = { placeholder: 'Enter a value' };
export const WithDefaultValue = Template.bind({});
WithDefaultValue.args = { defaultSelectedKey: 'purple' };

export const Clearable = Template.bind({});
Clearable.args = {
defaultSelectedKey: 'blue',
isClearable: true,
placeholder: 'Choose a color...',
};

export const WithIcon = Template.bind({});
WithIcon.args = { icon: <IconCoin /> };

Expand Down
Loading
Loading