diff --git a/.changeset/olive-buckets-own.md b/.changeset/olive-buckets-own.md new file mode 100644 index 0000000000..fe10eb7e7a --- /dev/null +++ b/.changeset/olive-buckets-own.md @@ -0,0 +1,6 @@ +--- +"@nextui-org/select": minor +"@nextui-org/theme": minor +--- + +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.ts b/apps/docs/content/components/select/is-clearable.ts new file mode 100644 index 0000000000..4fc20ad591 --- /dev/null +++ b/apps/docs/content/components/select/is-clearable.ts @@ -0,0 +1,83 @@ +const data = `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"} + ];`; + +const PetBoldIcon = `export const PetBoldIcon = (props) => ( + + );`; + +const App = `import {Select, SelectItem} from "@nextui-org/react"; +import {animals} from "./data"; +import {PetBoldIcon} from "./PetBoldIcon"; + +export default function App() { + return ( +
+ +
+ ); +}`; + +const react = { + "/App.jsx": App, + "/data.js": data, + "/PetBoldIcon.jsx": PetBoldIcon, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/docs/components/select.mdx b/apps/docs/content/docs/components/select.mdx index 0469b09cab..eee9c15922 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/package.json b/packages/components/select/package.json index fe40e2aa98..6520671444 100644 --- a/packages/components/select/package.json +++ b/packages/components/select/package.json @@ -35,7 +35,7 @@ }, "peerDependencies": { "@nextui-org/system": ">=2.0.0", - "@nextui-org/theme": ">=2.1.0", + "@nextui-org/theme": ">=2.3.0", "framer-motion": ">=11.5.6", "react": ">=18", "react-dom": ">=18" diff --git a/packages/components/select/src/select.tsx b/packages/components/select/src/select.tsx index 87ee33d17a..65a615a687 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(() => { if (!hasHelper) return null; @@ -127,7 +150,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 0c8d31733b..c9673015c6 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 @@ -296,11 +302,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(() => { @@ -317,6 +336,7 @@ export function useSelect(originalProps: UseSelectProps) { (labelPlacement === "outside" && (hasPlaceholder || !!originalProps.isMultiline)); const shouldLabelBeInside = labelPlacement === "inside"; const isOutsideLeft = labelPlacement === "outside-left"; + const isClearable = originalProps.isClearable; const isFilled = state.isOpen || @@ -335,11 +355,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 @@ -634,6 +662,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, @@ -651,6 +695,7 @@ export function useSelect(originalProps: UseSelectProps) { name, triggerRef, isLoading, + isClearable, placeholder, startContent, endContent, @@ -669,6 +714,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 0a58ad10a3..2cfde90d52 100644 --- a/packages/components/select/stories/select.stories.tsx +++ b/packages/components/select/stories/select.stories.tsx @@ -1006,3 +1006,14 @@ export const CustomStyles = { }, }, }; + +export const Clearable = { + render: Template, + args: { + ...defaultProps, + isClearable: true, + endContent: , + // eslint-disable-next-line no-console + onClear: () => console.log("Select cleared"), + }, +}; diff --git a/packages/core/theme/src/components/select.ts b/packages/core/theme/src/components/select.ts index b3fb5fb34b..2d73f7fd4e 100644 --- a/packages/core/theme/src/components/select.ts +++ b/packages/core/theme/src/components/select.ts @@ -29,6 +29,23 @@ 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", + "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", @@ -103,14 +120,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: { @@ -151,6 +171,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", @@ -198,6 +223,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: {