diff --git a/.changeset/olive-buckets-own.md b/.changeset/olive-buckets-own.md new file mode 100644 index 0000000000..764f38fdc5 --- /dev/null +++ b/.changeset/olive-buckets-own.md @@ -0,0 +1,6 @@ +--- +"@nextui-org/select": patch +"@nextui-org/theme": patch +--- + +add `isClearable` and `onClear` prop to Select component (#2239) diff --git a/apps/docs/content/components/select/index.ts b/apps/docs/content/components/select/index.ts index 1b28504d4d..18d87e7e0a 100644 --- a/apps/docs/content/components/select/index.ts +++ b/apps/docs/content/components/select/index.ts @@ -27,6 +27,7 @@ import multipleControlledOnChange from "./multiple-controlled-onchange"; import multipleWithChips from "./multiple-chips"; import customSelectorIcon from "./custom-selector-icon"; import customStyles from "./custom-styles"; +import isClearable from "./is-clearable"; export const selectContent = { usage, @@ -58,4 +59,5 @@ export const selectContent = { multipleWithChips, customSelectorIcon, customStyles, + isClearable, }; diff --git a/apps/docs/content/components/select/is-clearable.raw.jsx b/apps/docs/content/components/select/is-clearable.raw.jsx new file mode 100644 index 0000000000..af93522aaa --- /dev/null +++ b/apps/docs/content/components/select/is-clearable.raw.jsx @@ -0,0 +1,65 @@ +import {Select, SelectItem} from "@nextui-org/react"; + +export const animals = [ + {key: "cat", label: "Cat"}, + {key: "dog", label: "Dog"}, + {key: "elephant", label: "Elephant"}, + {key: "lion", label: "Lion"}, + {key: "tiger", label: "Tiger"}, + {key: "giraffe", label: "Giraffe"}, + {key: "dolphin", label: "Dolphin"}, + {key: "penguin", label: "Penguin"}, + {key: "zebra", label: "Zebra"}, + {key: "shark", label: "Shark"}, + {key: "whale", label: "Whale"}, + {key: "otter", label: "Otter"}, + {key: "crocodile", label: "Crocodile"}, +]; + +export const PetBoldIcon = (props) => { + return ( + + ); +}; + +export default function App() { + return ( +
+ +
+ ); +} diff --git a/apps/docs/content/components/select/is-clearable.ts b/apps/docs/content/components/select/is-clearable.ts new file mode 100644 index 0000000000..803dd33068 --- /dev/null +++ b/apps/docs/content/components/select/is-clearable.ts @@ -0,0 +1,9 @@ +import App from "./is-clearable.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/docs/components/select.mdx b/apps/docs/content/docs/components/select.mdx index 55b476c101..790ee9a919 100644 --- a/apps/docs/content/docs/components/select.mdx +++ b/apps/docs/content/docs/components/select.mdx @@ -148,6 +148,12 @@ You can combine the `isInvalid` and `errorMessage` properties to show an invalid +### Clear Button + +If you pass the `isClearable` property to the select, it will have a clear button which will be visible only when a value is selected. + + + ### Controlled You can use the `selectedKeys` and `onSelectionChange` / `onChange` properties to control the select value. @@ -383,6 +389,7 @@ the popover and listbox components. | isDisabled | `boolean` | Whether the select is disabled. | `false` | | isMultiline | `boolean` | Whether the select should allow multiple lines of text. | `false` | | isInvalid | `boolean` | Whether the select is invalid. | `false` | +| isClearable | `boolean` | Whether the select should have a clear button. | `false` | | validationState | `valid` \| `invalid` | Whether the select should display its "valid" or "invalid" visual styling. (**Deprecated**) use **isInvalid** instead. | - | | showScrollIndicators | `boolean` | Whether the select should show scroll indicators when the listbox is scrollable. | `true` | | autoFocus | `boolean` | Whether the select should be focused on the first mount. | `false` | @@ -403,7 +410,7 @@ the popover and listbox components. | onSelectionChange | `(keys: "all" \| Set & {anchorKey?: string; currentKey?: string}) => void` | Callback fired when the selected keys change. | | onChange | `React.ChangeEvent` | Native select change event, fired when the selected value changes. | | renderValue | [RenderValueFunction](#render-value-function) | Function to render the value of the select. It renders the selected item by default. | - +| onClear | `() => void` | Handler that is called when the clear button is clicked. --- ### SelectItem Props diff --git a/packages/components/select/src/select.tsx b/packages/components/select/src/select.tsx index da29639daf..4551db5c67 100644 --- a/packages/components/select/src/select.tsx +++ b/packages/components/select/src/select.tsx @@ -1,6 +1,6 @@ import {Listbox} from "@nextui-org/listbox"; import {FreeSoloPopover} from "@nextui-org/popover"; -import {ChevronDownIcon} from "@nextui-org/shared-icons"; +import {ChevronDownIcon, CloseFilledIcon} from "@nextui-org/shared-icons"; import {Spinner} from "@nextui-org/spinner"; import {forwardRef} from "@nextui-org/system"; import {ScrollShadow} from "@nextui-org/scroll-shadow"; @@ -29,8 +29,10 @@ function Select(props: Props, ref: ForwardedRef(props: Props, ref: ForwardedRef { + if (isClearable && state.selectedItems?.length) { + return ; + } + + return null; + }, [isClearable, getClearButtonProps, state.selectedItems?.length]); + + const end = useMemo(() => { + if (clearButton) { + return ( +
+ {clearButton} + {endContent && {endContent}} +
+ ); + } + + return endContent && {endContent}; + }, [clearButton, endContent]); + const helperWrapper = useMemo(() => { const shouldShowError = isInvalid && errorMessage; const hasContent = shouldShowError || description; @@ -130,7 +153,7 @@ function Select(props: Props, ref: ForwardedRef, )} - {endContent} + {end} {renderIndicator} diff --git a/packages/components/select/src/use-select.ts b/packages/components/select/src/use-select.ts index 6fb08a5bce..13f4e73080 100644 --- a/packages/components/select/src/use-select.ts +++ b/packages/components/select/src/use-select.ts @@ -17,7 +17,7 @@ import {useAriaButton} from "@nextui-org/use-aria-button"; import {useFocusRing} from "@react-aria/focus"; import {clsx, dataAttr, objectToDeps} from "@nextui-org/shared-utils"; import {mergeProps} from "@react-aria/utils"; -import {useHover} from "@react-aria/interactions"; +import {useHover, usePress} from "@react-aria/interactions"; import {PopoverProps} from "@nextui-org/popover"; import {ScrollShadowProps} from "@nextui-org/scroll-shadow"; import { @@ -133,6 +133,11 @@ interface Props extends Omit, keyof SelectVariantPr * Handler that is called when the selection changes. */ onSelectionChange?: (keys: SharedSelection) => void; + /** + * Callback fired when the value is cleared. + * if you pass this prop, the clear button will be shown. + */ + onClear?: () => void; } interface SelectData { @@ -187,6 +192,7 @@ export function useSelect(originalProps: UseSelectProps) { validationState, onChange, onClose, + onClear, className, classNames, ...otherProps @@ -297,11 +303,24 @@ export function useSelect(originalProps: UseSelectProps) { triggerRef, ); + const handleClear = useCallback(() => { + state.setSelectedKeys(new Set([])); + onClear?.(); + domRef.current?.focus(); + }, [onClear, state]); + + const {pressProps: clearPressProps} = usePress({ + isDisabled: !!originalProps?.isDisabled, + onPress: handleClear, + }); + const isInvalid = originalProps.isInvalid || validationState === "invalid" || isAriaInvalid; const {isPressed, buttonProps} = useAriaButton(triggerProps, triggerRef); const {focusProps, isFocused, isFocusVisible} = useFocusRing(); + const {focusProps: clearFocusProps, isFocusVisible: isClearButtonFocusVisible} = useFocusRing(); + const {isHovered, hoverProps} = useHover({isDisabled: originalProps.isDisabled}); const labelPlacement = useMemo(() => { @@ -319,6 +338,7 @@ export function useSelect(originalProps: UseSelectProps) { (!(hasPlaceholder || !!description) || !!originalProps.isMultiline)); const shouldLabelBeInside = labelPlacement === "inside"; const isOutsideLeft = labelPlacement === "outside-left"; + const isClearable = originalProps.isClearable; const isFilled = state.isOpen || @@ -337,11 +357,19 @@ export function useSelect(originalProps: UseSelectProps) { select({ ...variantProps, isInvalid, + isClearable, labelPlacement, disableAnimation, className, }), - [objectToDeps(variantProps), isInvalid, labelPlacement, disableAnimation, className], + [ + objectToDeps(variantProps), + isClearable, + isInvalid, + labelPlacement, + disableAnimation, + className, + ], ); // scroll the listbox to the selected item @@ -636,6 +664,22 @@ export function useSelect(originalProps: UseSelectProps) { [slots, spinnerRef, spinnerProps, classNames?.spinner], ); + const getClearButtonProps: PropGetter = useCallback( + (props = {}) => { + return { + ...props, + role: "button", + tabIndex: -1, + "aria-label": "clear selection", + "data-slot": "clear-button", + "data-focus-visible": dataAttr(isClearButtonFocusVisible), + className: slots.clearButton({class: clsx(classNames?.clearButton, props?.className)}), + ...mergeProps(clearPressProps, clearFocusProps), + }; + }, + [slots, isClearButtonFocusVisible, clearPressProps, clearFocusProps, classNames?.clearButton], + ); + // store the data to be used in useHiddenSelect selectData.set(state, { isDisabled: originalProps?.isDisabled, @@ -653,6 +697,7 @@ export function useSelect(originalProps: UseSelectProps) { name, triggerRef, isLoading, + isClearable, placeholder, startContent, endContent, @@ -671,6 +716,7 @@ export function useSelect(originalProps: UseSelectProps) { errorMessage, getBaseProps, getTriggerProps, + getClearButtonProps, getLabelProps, getValueProps, getListboxProps, diff --git a/packages/components/select/stories/select.stories.tsx b/packages/components/select/stories/select.stories.tsx index a5dcaf3022..da2a70ab6e 100644 --- a/packages/components/select/stories/select.stories.tsx +++ b/packages/components/select/stories/select.stories.tsx @@ -1043,3 +1043,11 @@ export const CustomStyles = { }, }, }; + +export const Clearable = { + render: Template, + args: { + ...defaultProps, + isClearable: true, + }, +}; diff --git a/packages/core/theme/src/components/select.ts b/packages/core/theme/src/components/select.ts index bb70b7691f..171a926e17 100644 --- a/packages/core/theme/src/components/select.ts +++ b/packages/core/theme/src/components/select.ts @@ -29,6 +29,24 @@ const select = tv({ listboxWrapper: "scroll-py-6 max-h-64 w-full", listbox: "", popoverContent: "w-full p-1 overflow-hidden", + clearButton: [ + "w-4", + "h-4", + "z-10", + "mb-4", + "relative", + "start-auto", + "appearance-none", + "outline-none", + "select-none", + "opacity-70", + "hover:!opacity-100", + "cursor-pointer", + "active:!opacity-70", + "rounded-full", + // focus ring + ...dataFocusVisibleClasses, + ], helperWrapper: "p-1 flex relative flex-col gap-1.5", description: "text-tiny text-foreground-400", errorMessage: "text-tiny text-danger", @@ -101,14 +119,17 @@ const select = tv({ label: "text-tiny", trigger: "h-8 min-h-8 px-2 rounded-small", value: "text-small", + clearButton: "text-medium", }, md: { trigger: "h-10 min-h-10 rounded-medium", value: "text-small", + clearButton: "text-large", }, lg: { trigger: "h-12 min-h-12 rounded-large", value: "text-medium", + clearButton: "text-large", }, }, radius: { @@ -149,6 +170,11 @@ const select = tv({ base: "min-w-40", }, }, + isClearable: { + true: { + clearButton: "peer-data-[filled=true]:opacity-70 peer-data-[filled=true]:block", + }, + }, isDisabled: { true: { base: "opacity-disabled pointer-events-none", @@ -196,6 +222,7 @@ const select = tv({ "motion-reduce:transition-none", ], selectorIcon: "transition-transform duration-150 ease motion-reduce:transition-none", + clearButton: ["transition-opacity", "motion-reduce:transition-none"], }, }, disableSelectorIconRotation: {