From 40579f1b63ba7728a70dd6ceeb61d48a4444bda5 Mon Sep 17 00:00:00 2001 From: Abhinav Agarwal Date: Thu, 12 Sep 2024 10:16:37 +0530 Subject: [PATCH 01/15] feat(select): add core logic for isClearable --- packages/components/select/src/select.tsx | 19 +++++-- packages/components/select/src/use-select.ts | 52 ++++++++++++++++++- .../select/stories/select.stories.tsx | 39 ++++++++++++++ packages/core/theme/src/components/select.ts | 27 ++++++++++ 4 files changed, 130 insertions(+), 7 deletions(-) diff --git a/packages/components/select/src/select.tsx b/packages/components/select/src/select.tsx index 83aa324c9c..9b3f0011cd 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"; @@ -30,7 +30,9 @@ function Select(props: Props, ref: ForwardedRef(props: Props, ref: ForwardedRef{label} : null; const clonedIcon = cloneElement(selectorIcon as ReactElement, getSelectorIconProps()); + const end = useMemo(() => { + if (isClearable) { + return state.selectedItems?.length ? ( + {endContent || } + ) : null; + } + + return endContent; + }, [isClearable, getClearButtonProps]); const helperWrapper = useMemo(() => { if (!hasHelper) return null; @@ -124,10 +135,8 @@ function Select(props: Props, ref: ForwardedRef {startContent} {renderSelectedItem} - {endContent && state.selectedItems && ( - , - )} - {endContent} + {end && state.selectedItems && ,} + {end} {renderIndicator} diff --git a/packages/components/select/src/use-select.ts b/packages/components/select/src/use-select.ts index 5f82069ed1..906225a5fe 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 { @@ -77,6 +77,9 @@ interface Props extends Omit, keyof SelectVariantPr startContent?: React.ReactNode; /** * Element to be rendered in the right side of the select. + * if you pass this prop and the `onClear` prop, the passed element + * will have the clear button props and it will be rendered instead of the + * default clear button. */ endContent?: ReactNode; /** @@ -132,6 +135,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 { @@ -186,6 +194,7 @@ export function useSelect(originalProps: UseSelectProps) { validationState, onChange, onClose, + onClear, className, classNames, ...otherProps @@ -295,11 +304,23 @@ export function useSelect(originalProps: UseSelectProps) { triggerRef, ); + const handleClear = useCallback(() => { + state.setSelectedKeys(new Set([])); + if (onClear) onClear(); + }, [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(() => { @@ -316,6 +337,7 @@ export function useSelect(originalProps: UseSelectProps) { (labelPlacement === "outside" && (hasPlaceholder || !!originalProps.isMultiline)); const shouldLabelBeInside = labelPlacement === "inside"; const isOutsideLeft = labelPlacement === "outside-left"; + const isClearable = !!onClear || originalProps.isClearable; const isFilled = state.isOpen || @@ -334,11 +356,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 @@ -630,6 +660,22 @@ export function useSelect(originalProps: UseSelectProps) { [slots, spinnerRef, spinnerProps, classNames?.spinner], ); + const getClearButtonProps: PropGetter = useCallback( + (props = {}) => { + return { + ...props, + role: "button", + tabIndex: 0, + "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, @@ -647,6 +693,7 @@ export function useSelect(originalProps: UseSelectProps) { name, triggerRef, isLoading, + isClearable, placeholder, startContent, endContent, @@ -665,6 +712,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..cf2751e520 100644 --- a/packages/components/select/stories/select.stories.tsx +++ b/packages/components/select/stories/select.stories.tsx @@ -87,6 +87,37 @@ const Template = ({color, variant, ...args}: SelectProps) => ( ); +const isClearableTemplate = ({color, variant, ...args}: SelectProps) => ( +
+
+

IsClearable = true

+ +
+
+

IsClearable = false

+ +
+
+); + const DynamicTemplate = ({color, variant, ...args}: SelectProps) => ( ); -const isClearableTemplate = ({color, variant, ...args}: SelectProps) => ( -
-
-

IsClearable = true

- -
-
-

IsClearable = false

- -
+const ClearableTemplate = ({color, variant, ...args}: SelectProps) => ( +
+

IsClearable = true

+ +

IsClearable = false

+
); @@ -1038,8 +1034,8 @@ export const CustomStyles = { }, }; -export const isClearable = { - render: isClearableTemplate, +export const Clearable = { + render: ClearableTemplate, args: { ...defaultProps, diff --git a/packages/core/theme/src/components/select.ts b/packages/core/theme/src/components/select.ts index 07e50d2d39..d2d52e5ab8 100644 --- a/packages/core/theme/src/components/select.ts +++ b/packages/core/theme/src/components/select.ts @@ -33,7 +33,7 @@ const select = tv({ "h-4", "z-10", "absolute", - "end-12", + "end-10", "start-auto", "appearance-none", "outline-none", From 798ee73eb7db15bcaf9357b900624f62c8f01f97 Mon Sep 17 00:00:00 2001 From: Abhinav Agarwal Date: Fri, 13 Sep 2024 18:07:33 +0530 Subject: [PATCH 03/15] docs: add docs for clear button, isClearable, onClear --- apps/docs/content/components/select/index.ts | 2 + .../content/components/select/isClearable.ts | 58 +++++++++++++++++++ apps/docs/content/docs/components/select.mdx | 10 +++- 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 apps/docs/content/components/select/isClearable.ts diff --git a/apps/docs/content/components/select/index.ts b/apps/docs/content/components/select/index.ts index 1b28504d4d..efbb02e79d 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 "./isClearable"; export const selectContent = { usage, @@ -58,4 +59,5 @@ export const selectContent = { multipleWithChips, customSelectorIcon, customStyles, + isClearable, }; diff --git a/apps/docs/content/components/select/isClearable.ts b/apps/docs/content/components/select/isClearable.ts new file mode 100644 index 0000000000..d29941f4bc --- /dev/null +++ b/apps/docs/content/components/select/isClearable.ts @@ -0,0 +1,58 @@ +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 App = `import {Select, SelectItem} from "@nextui-org/react"; +import {animals} from "./data"; + +export default function App() { + return ( +
+

IsClearable = true

+ +

IsClearable = true

+ +
+ ); +}`; + +const react = { + "/App.jsx": App, + "/data.js": data, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/docs/components/select.mdx b/apps/docs/content/docs/components/select.mdx index 40cc3e44ea..bd3805f45e 100644 --- a/apps/docs/content/docs/components/select.mdx +++ b/apps/docs/content/docs/components/select.mdx @@ -148,6 +148,13 @@ You can combine the `isInvalid` and `errorMessage` properties to show an invalid +### Clear Button + +If you pass the `isClearable` property to the select, or provide a `onClear` function, it will have a clear button which will be visible only when the some value is selected. +A default clear button is displayed if `endContent` property is not passed. + + + ### Controlled You can use the `selectedKeys` and `onSelectionChange` / `onChange` properties to control the select value. @@ -383,6 +390,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 +411,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 From 6c0d2a31ade1bc0aa3313c1956c93ec6faf25f97 Mon Sep 17 00:00:00 2001 From: Abhinav Agarwal Date: Fri, 13 Sep 2024 18:17:55 +0530 Subject: [PATCH 04/15] chore: lint the code --- .../content/components/select/isClearable.ts | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/docs/content/components/select/isClearable.ts b/apps/docs/content/components/select/isClearable.ts index d29941f4bc..27dee4c5d7 100644 --- a/apps/docs/content/components/select/isClearable.ts +++ b/apps/docs/content/components/select/isClearable.ts @@ -20,30 +20,30 @@ import {animals} from "./data"; export default function App() { return (
-

IsClearable = true

- -

IsClearable = true

- +

IsClearable = true

+ +

IsClearable = true

+
); }`; From c6836948465799463ff216b89f6e0c4b5e063ae6 Mon Sep 17 00:00:00 2001 From: Abhinav Agarwal Date: Fri, 13 Sep 2024 18:25:47 +0530 Subject: [PATCH 05/15] chore: add changeset --- .changeset/olive-buckets-own.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/olive-buckets-own.md diff --git a/.changeset/olive-buckets-own.md b/.changeset/olive-buckets-own.md new file mode 100644 index 0000000000..4951e30de5 --- /dev/null +++ b/.changeset/olive-buckets-own.md @@ -0,0 +1,5 @@ +--- +"@nextui-org/select": minor +--- + +Added isClearable prop, and onClear event handler to select component From 179b53089225544c34f57cad2c7d6941552b5658 Mon Sep 17 00:00:00 2001 From: Abhinav Agarwal Date: Sat, 14 Sep 2024 11:08:19 +0530 Subject: [PATCH 06/15] chore: remove case for isClearable=false from docs and story --- .../content/components/select/isClearable.ts | 37 ++++++------------- .../select/stories/select.stories.tsx | 34 +++++------------ 2 files changed, 22 insertions(+), 49 deletions(-) diff --git a/apps/docs/content/components/select/isClearable.ts b/apps/docs/content/components/select/isClearable.ts index 27dee4c5d7..30e28deb12 100644 --- a/apps/docs/content/components/select/isClearable.ts +++ b/apps/docs/content/components/select/isClearable.ts @@ -19,31 +19,18 @@ import {animals} from "./data"; export default function App() { return ( -
-

IsClearable = true

- -

IsClearable = true

- +
+
); }`; diff --git a/packages/components/select/stories/select.stories.tsx b/packages/components/select/stories/select.stories.tsx index 752b6d36e9..e31ada6b69 100644 --- a/packages/components/select/stories/select.stories.tsx +++ b/packages/components/select/stories/select.stories.tsx @@ -88,30 +88,16 @@ const Template = ({color, variant, ...args}: SelectProps) => ( ); const ClearableTemplate = ({color, variant, ...args}: SelectProps) => ( -
-

IsClearable = true

- -

IsClearable = false

- -
+ ); const DynamicTemplate = ({color, variant, ...args}: SelectProps) => ( From 5635e4f3c69e0b2514e68ae449eae5fdfc7cd352 Mon Sep 17 00:00:00 2001 From: Abhinav Agarwal Date: Sun, 29 Sep 2024 22:42:28 +0530 Subject: [PATCH 07/15] chore(select): code refactor --- packages/components/select/src/select.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/components/select/src/select.tsx b/packages/components/select/src/select.tsx index 40b188baec..90bee99b9e 100644 --- a/packages/components/select/src/select.tsx +++ b/packages/components/select/src/select.tsx @@ -55,11 +55,13 @@ function Select(props: Props, ref: ForwardedRef { - if (isClearable) { - return state.selectedItems?.length ? ( - {endContent || } - ) : null; + if (isClearable && state.selectedItems?.length) { + return ( + {endContent ? endContent : } + ); } + + return null; }, [isClearable, getClearButtonProps, state]); const helperWrapper = useMemo(() => { @@ -137,7 +139,10 @@ function Select(props: Props, ref: ForwardedRef, )} - {/* we display endContent only when we are not displaying clear button */} + {/** + * we display endContent seperately only when we are not displaying clear button. + * otherwise, we would use it as clear button. + */} {!isClearable && endContent}
{clearButton} From 2d74e6d963f9f7ca1af8529900c4d49f28ba575e Mon Sep 17 00:00:00 2001 From: Abhinav Agarwal Date: Sat, 12 Oct 2024 12:54:08 +0530 Subject: [PATCH 08/15] chore(select): update logic for clear button and add docs --- .../content/components/select/isClearable.ts | 37 +++++++++++++++++++ apps/docs/content/docs/components/select.mdx | 3 +- packages/components/select/src/select.tsx | 24 +++++++----- packages/components/select/src/use-select.ts | 6 +-- .../select/stories/select.stories.tsx | 18 ++------- packages/core/theme/src/components/select.ts | 5 +-- 6 files changed, 61 insertions(+), 32 deletions(-) diff --git a/apps/docs/content/components/select/isClearable.ts b/apps/docs/content/components/select/isClearable.ts index 30e28deb12..f01fe820fc 100644 --- a/apps/docs/content/components/select/isClearable.ts +++ b/apps/docs/content/components/select/isClearable.ts @@ -14,8 +14,43 @@ const data = `export const animals = [ {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 ( @@ -24,6 +59,7 @@ export default function App() { className="max-w-xs my-5" isClearable={true} label="Favorite Animal" + endContent={} > {animals.map((animal) => ( @@ -38,6 +74,7 @@ export default function App() { const react = { "/App.jsx": App, "/data.js": data, + "/PetBoldIcon.jsx": PetBoldIcon, }; export default { diff --git a/apps/docs/content/docs/components/select.mdx b/apps/docs/content/docs/components/select.mdx index d870168339..1edb25ae6a 100644 --- a/apps/docs/content/docs/components/select.mdx +++ b/apps/docs/content/docs/components/select.mdx @@ -150,8 +150,7 @@ You can combine the `isInvalid` and `errorMessage` properties to show an invalid ### Clear Button -If you pass the `isClearable` property to the select, or provide a `onClear` function, it will have a clear button which will be visible only when the some value is selected. -A default clear button is displayed if `endContent` property is not passed. +If you pass the `isClearable` property to the select, it will have a clear button which will be visible only when some value is selected. diff --git a/packages/components/select/src/select.tsx b/packages/components/select/src/select.tsx index 90bee99b9e..e116d78a8f 100644 --- a/packages/components/select/src/select.tsx +++ b/packages/components/select/src/select.tsx @@ -56,13 +56,24 @@ function Select(props: Props, ref: ForwardedRef { if (isClearable && state.selectedItems?.length) { + return {}; + } + + return null; + }, [isClearable, getClearButtonProps, state.selectedItems?.length]); + + const end = useMemo(() => { + if (clearButton) { return ( - {endContent ? endContent : } +
+ {clearButton} + {endContent && {endContent}} +
); } - return null; - }, [isClearable, getClearButtonProps, state]); + return endContent && {endContent}; + }, [clearButton, endContent]); const helperWrapper = useMemo(() => { if (!hasHelper) return null; @@ -139,13 +150,8 @@ function Select(props: Props, ref: ForwardedRef, )} - {/** - * we display endContent seperately only when we are not displaying clear button. - * otherwise, we would use it as clear button. - */} - {!isClearable && endContent} + {end}
- {clearButton} {renderIndicator} {helperWrapper} diff --git a/packages/components/select/src/use-select.ts b/packages/components/select/src/use-select.ts index 628b2b30e0..a1e4453889 100644 --- a/packages/components/select/src/use-select.ts +++ b/packages/components/select/src/use-select.ts @@ -307,7 +307,7 @@ export function useSelect(originalProps: UseSelectProps) { const handleClear = useCallback(() => { state.setSelectedKeys(new Set([])); - if (onClear) onClear(); + onClear?.(); }, [onClear, state]); const {pressProps: clearPressProps} = usePress({ @@ -338,7 +338,7 @@ export function useSelect(originalProps: UseSelectProps) { (labelPlacement === "outside" && (hasPlaceholder || !!originalProps.isMultiline)); const shouldLabelBeInside = labelPlacement === "inside"; const isOutsideLeft = labelPlacement === "outside-left"; - const isClearable = !!onClear || originalProps.isClearable; + const isClearable = originalProps.isClearable; const isFilled = state.isOpen || @@ -669,7 +669,7 @@ export function useSelect(originalProps: UseSelectProps) { return { ...props, role: "button", - tabIndex: 0, + tabIndex: -1, "aria-label": "clear selection", "data-slot": "clear-button", "data-focus-visible": dataAttr(isClearButtonFocusVisible), diff --git a/packages/components/select/stories/select.stories.tsx b/packages/components/select/stories/select.stories.tsx index e31ada6b69..20e0ee9751 100644 --- a/packages/components/select/stories/select.stories.tsx +++ b/packages/components/select/stories/select.stories.tsx @@ -87,19 +87,6 @@ const Template = ({color, variant, ...args}: SelectProps) => ( ); -const ClearableTemplate = ({color, variant, ...args}: SelectProps) => ( - -); - const DynamicTemplate = ({color, variant, ...args}: SelectProps) => ( + {animals.map((animal) => ( + {animal.label} + ))} + + + ); +} diff --git a/apps/docs/content/components/select/is-clearable.ts b/apps/docs/content/components/select/is-clearable.ts index 6e5b295e9b..803dd33068 100644 --- a/apps/docs/content/components/select/is-clearable.ts +++ b/apps/docs/content/components/select/is-clearable.ts @@ -1,79 +1,7 @@ -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 ( -
- -
- ); -}`; +import App from "./is-clearable.raw.jsx?raw"; const react = { "/App.jsx": App, - "/data.js": data, - "/PetBoldIcon.jsx": PetBoldIcon, }; export default {