diff --git a/.changeset/afraid-mugs-sort.md b/.changeset/afraid-mugs-sort.md new file mode 100644 index 000000000..5de537789 --- /dev/null +++ b/.changeset/afraid-mugs-sort.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Add ItemAction component with a temporary implementation. diff --git a/.changeset/modern-tigers-impress.md b/.changeset/modern-tigers-impress.md new file mode 100644 index 000000000..b8d6cf45e --- /dev/null +++ b/.changeset/modern-tigers-impress.md @@ -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. diff --git a/src/components/actions/ItemAction/ItemAction.tsx b/src/components/actions/ItemAction/ItemAction.tsx new file mode 100644 index 000000000..8e7cc5599 --- /dev/null +++ b/src/components/actions/ItemAction/ItemAction.tsx @@ -0,0 +1,18 @@ +import { FocusableRef } from '@react-types/shared'; +import { forwardRef } from 'react'; + +import { tasty } from '../../../tasty'; +import { Button, CubeButtonProps } from '../Button'; + +export interface CubeItemActionProps extends CubeButtonProps { + // All props from Button are inherited +} + +export const ItemAction = tasty(Button, { + type: 'neutral', + styles: { + height: '($size - 1x)', + width: '($size - 1x)', + margin: '0 (.5x - 1bw)', + }, +}); diff --git a/src/components/actions/ItemAction/index.ts b/src/components/actions/ItemAction/index.ts new file mode 100644 index 000000000..c342ce2b8 --- /dev/null +++ b/src/components/actions/ItemAction/index.ts @@ -0,0 +1,2 @@ +export { ItemAction } from './ItemAction'; +export type { CubeItemActionProps } from './ItemAction'; diff --git a/src/components/actions/index.ts b/src/components/actions/index.ts index 11cf6653c..946815956 100644 --- a/src/components/actions/index.ts +++ b/src/components/actions/index.ts @@ -10,6 +10,7 @@ const Button = Object.assign( export * from './Button'; export * from './Action/Action'; +export * from './ItemAction'; export * from './ItemButton'; export * from './Menu'; export * from './CommandMenu'; diff --git a/src/components/content/ItemBase/ItemBase.tsx b/src/components/content/ItemBase/ItemBase.tsx index 9df119342..9a46d4c1f 100644 --- a/src/components/content/ItemBase/ItemBase.tsx +++ b/src/components/content/ItemBase/ItemBase.tsx @@ -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', @@ -461,6 +463,7 @@ const ItemBase = ( return { 'with-icon': !!finalIcon, 'with-right-icon': !!finalRightIcon, + 'with-label': !!(children || labelProps), 'with-prefix': !!finalPrefix, 'with-suffix': !!finalSuffix, 'with-description': !!description, diff --git a/src/components/fields/ComboBox/ComboBox.stories.tsx b/src/components/fields/ComboBox/ComboBox.stories.tsx index 42ba117dc..afceadc23 100644 --- a/src/components/fields/ComboBox/ComboBox.stories.tsx +++ b/src/components/fields/ComboBox/ComboBox.stories.tsx @@ -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: { @@ -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> = ( args: CubeComboBoxProps, ) => ( diff --git a/src/components/fields/ComboBox/ComboBox.tsx b/src/components/fields/ComboBox/ComboBox.tsx index d5fc970f1..37122c28a 100644 --- a/src/components/fields/ComboBox/ComboBox.tsx +++ b/src/components/fields/ComboBox/ComboBox.tsx @@ -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 { @@ -37,6 +37,7 @@ import { } from '../../../utils/react'; import { useFocus } from '../../../utils/react/interactions'; import { useEventBus } from '../../../utils/react/useEventBus'; +import { ItemAction } from '../../actions'; import { useFieldProps, useFormProps, wrapWithField } from '../../form'; import { Item } from '../../Item'; import { OverlayWrapper } from '../../overlays/OverlayWrapper'; @@ -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', @@ -125,6 +128,8 @@ export interface CubeComboBoxProps 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]; @@ -201,6 +206,7 @@ export const ComboBox = forwardRef(function ComboBox( labelSuffix, selectedKey, defaultSelectedKey, + isClearable, ...otherProps } = props; @@ -306,6 +312,32 @@ export const ComboBox = forwardRef(function ComboBox( let validationIcon = isInvalid ? InvalidIcon : ValidIcon; let validation = cloneElement(validationIcon); + // Clear button logic + 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); + + // 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) { @@ -333,6 +365,7 @@ export const ComboBox = forwardRef(function ComboBox( loading: isLoading, prefix: !!prefix, suffix: true, + clearable: showClearButton, }), [ isInvalid, @@ -342,6 +375,7 @@ export const ComboBox = forwardRef(function ComboBox( isFocused, isLoading, prefix, + showClearButton, ], ); @@ -453,6 +487,16 @@ export const ComboBox = forwardRef(function ComboBox( ) : null} {suffixPosition === 'after' ? suffix : null} + {showClearButton && ( + } + size={size} + theme={validationState === 'invalid' ? 'danger' : undefined} + qa="ComboBoxClearButton" + data-no-trigger={hideTrigger ? '' : undefined} + onPress={clearValue} + /> + )} {!hideTrigger ? ( = { 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', @@ -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) => ( + + {fruits.map((fruit) => ( + + {fruit.label} + + ))} + + ), +}; + export const MultipleSelection: Story = { args: { label: 'Select Multiple Options', diff --git a/src/components/fields/FilterPicker/FilterPicker.tsx b/src/components/fields/FilterPicker/FilterPicker.tsx index 460ecc205..e47f3a006 100644 --- a/src/components/fields/FilterPicker/FilterPicker.tsx +++ b/src/components/fields/FilterPicker/FilterPicker.tsx @@ -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, @@ -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 { CubeItemButtonProps, ItemAction, 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, @@ -117,6 +117,8 @@ export interface CubeFilterPickerProps listStateRef?: MutableRefObject>; /** Additional modifiers for styling the FilterPicker */ mods?: Record; + /** 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]; @@ -235,6 +237,7 @@ export const FilterPicker = forwardRef(function FilterPicker( shouldUseVirtualFocus, onEscape, onOptionClick, + isClearable, ...otherProps } = props; @@ -944,6 +947,33 @@ export const FilterPicker = forwardRef(function FilterPicker( 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 ( ( ) : rightIcon !== undefined ? ( rightIcon + ) : showClearButton ? ( + } + size={size} + theme={validationState === 'invalid' ? 'danger' : undefined} + qa="FilterPickerClearButton" + mods={{ pressed: false }} + onPress={clearValue} + /> ) : ( ) diff --git a/src/components/fields/SearchInput/SearchInput.tsx b/src/components/fields/SearchInput/SearchInput.tsx index 7f51953e9..3e24e3f0f 100644 --- a/src/components/fields/SearchInput/SearchInput.tsx +++ b/src/components/fields/SearchInput/SearchInput.tsx @@ -4,13 +4,12 @@ import { SearchFieldProps, useSearchFieldState } from 'react-stately'; import { CloseIcon, SearchIcon } from '../../../icons'; import { useProviderProps } from '../../../provider'; -import { tasty } from '../../../tasty'; import { ariaToCubeButtonProps } from '../../../utils/react/mapProps'; import { castNullableStringValue, WithNullableValue, } from '../../../utils/react/nullableValue'; -import { Button } from '../../actions'; +import { ItemAction } from '../../actions'; import { CubeTextInputBaseProps, TextInputBase } from '../TextInput'; export { useSearchFieldState, useSearchField }; @@ -23,19 +22,6 @@ export interface CubeSearchInputProps isClearable?: boolean; } -const ClearButton = tasty(Button, { - icon: , - styles: { - radius: 'right (1r - 1bw)', - width: { - '': '3x', - '[data-size="large"]': '4x', - }, - height: 'auto', - placeSelf: 'stretch', - }, -}); - export const SearchInput = forwardRef(function SearchInput( props: WithNullableValue, ref, @@ -65,7 +51,8 @@ export const SearchInput = forwardRef(function SearchInput( <> {props.suffix} {showClearButton && ( - } size={props.size} type={validationState === 'invalid' ? 'clear' : 'neutral'} theme={validationState === 'invalid' ? 'danger' : undefined} diff --git a/src/components/fields/Select/Select.stories.tsx b/src/components/fields/Select/Select.stories.tsx index 93ed7c89b..0daef31f7 100644 --- a/src/components/fields/Select/Select.stories.tsx +++ b/src/components/fields/Select/Select.stories.tsx @@ -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', @@ -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: }; diff --git a/src/components/fields/Select/Select.tsx b/src/components/fields/Select/Select.tsx index 3bc340d58..d855db4de 100644 --- a/src/components/fields/Select/Select.tsx +++ b/src/components/fields/Select/Select.tsx @@ -26,7 +26,8 @@ import { Section as BaseSection, useSelectState } from 'react-stately'; import { CubeTooltipProviderProps } from 'src/components/overlays/Tooltip/TooltipProvider'; import styled from 'styled-components'; -import { DirectionIcon, LoadingIcon } from '../../../icons/index'; +import { useEvent } from '../../../_internal'; +import { CloseIcon, DirectionIcon, LoadingIcon } from '../../../icons/index'; import { useProviderProps } from '../../../provider'; import { FieldBaseProps } from '../../../shared/index'; import { @@ -51,6 +52,7 @@ import { import { useFocus } from '../../../utils/react/interactions'; import { useEventBus } from '../../../utils/react/useEventBus'; import { getOverlayTransitionCSS } from '../../../utils/transitions'; +import { ItemAction } from '../../actions'; import { StyledDivider as ListDivider, StyledSectionHeading as ListSectionHeading, @@ -200,6 +202,8 @@ export interface CubeSelectBaseProps type?: 'outline' | 'clear' | 'primary' | (string & {}); suffixPosition?: 'before' | 'after'; theme?: 'default' | 'special'; + /** Whether the select is clearable using a clear button in the rightIcon slot */ + isClearable?: boolean; } export interface CubeSelectProps extends CubeSelectBaseProps { @@ -267,6 +271,7 @@ function Select( theme = 'default', labelSuffix, suffixPosition = 'before', + isClearable, ...otherProps } = props; let state = useSelectState(props); @@ -331,6 +336,23 @@ function Select( let validationIcon = isInvalid ? InvalidIcon : ValidIcon; let validation = cloneElement(validationIcon); + // Clear button logic + let hasSelection = state.selectedItem != null; + let showClearButton = + isClearable && hasSelection && !isDisabled && !props.isReadOnly; + + // Clear function + let clearValue = useEvent(() => { + props.onSelectionChange?.(null); + state.setSelectedKey(null); + // Close the popup if it's open + if (state.isOpen) { + state.close(); + } + // Return focus to the trigger for better UX + triggerRef.current?.focus?.(); + }); + let triggerWidth = triggerRef?.current?.offsetWidth; const showPlaceholder = !!placeholder?.trim() && !state.selectedItem; @@ -404,6 +426,15 @@ function Select( rightIcon={ rightIcon !== undefined ? ( rightIcon + ) : showClearButton ? ( + } + size={size} + theme={validationState === 'invalid' ? 'danger' : undefined} + qa="SelectClearButton" + mods={{ pressed: false }} + onPress={clearValue} + /> ) : isLoading ? ( ) : ( diff --git a/src/components/fields/TextInput/TextInputBase.tsx b/src/components/fields/TextInput/TextInputBase.tsx index e9d5d2148..f632049c1 100644 --- a/src/components/fields/TextInput/TextInputBase.tsx +++ b/src/components/fields/TextInput/TextInputBase.tsx @@ -90,14 +90,15 @@ export const INPUT_WRAPPER_STYLES: Styles = { transition: 'theme', backgroundClip: 'content-box', height: { - '': '$size-md $size-md', - '[data-size="small"]': '$size-sm $size-sm', - '[data-size="medium"]': '$size-md $size-md', - '[data-size="large"]': '$size-lg $size-lg', - multiline: 'min $size-md', - '[data-size="small"] & multiline': 'min $size-sm', - '[data-size="medium"] & multiline': 'min $size-md', - '[data-size="large"] & multiline': 'min $size-lg', + '': '$size $size', + multiline: 'min $size', + }, + + $size: { + '': '$size-md', + '[data-size="small"]': '$size-sm', + '[data-size="medium"]': '$size-md', + '[data-size="large"]': '$size-lg', }, Prefix: ADD_STYLES,