Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(select): add isClearable #3746

Open
wants to merge 18 commits into
base: canary
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
40579f1
feat(select): add core logic for isClearable
abhinav700 Sep 12, 2024
3954594
fix: fix alignment for clear button in select component
abhinav700 Sep 13, 2024
798ee73
docs: add docs for clear button, isClearable, onClear
abhinav700 Sep 13, 2024
16334a9
Merge branch 'nextui-org:canary' into adding-isClearable-to-select
abhinav700 Sep 13, 2024
6c0d2a3
chore: lint the code
abhinav700 Sep 13, 2024
c683694
chore: add changeset
abhinav700 Sep 13, 2024
179b530
chore: remove case for isClearable=false from docs and story
abhinav700 Sep 14, 2024
5635e4f
chore(select): code refactor
abhinav700 Sep 29, 2024
e7f481e
Merge branch 'nextui-org:canary' into adding-isClearable-to-select
abhinav700 Sep 30, 2024
961b942
Merge branch 'nextui-org:canary' into adding-isClearable-to-select
abhinav700 Oct 1, 2024
2d74e6d
chore(select): update logic for clear button and add docs
abhinav700 Oct 12, 2024
5d112ac
Merge branch 'canary' into adding-isClearable-to-select
abhinav700 Oct 12, 2024
450c77d
chore(select): implement wingkwong's suggestions
abhinav700 Oct 15, 2024
60ecd78
docs(select): pass onclear property to clear button
abhinav700 Oct 23, 2024
5f49a0d
chore(select): theme version update, docs update
abhinav700 Oct 26, 2024
4c7b06e
Merge branch 'nextui-org:canary' into adding-isClearable-to-select
abhinav700 Oct 26, 2024
50def02
fix(select): fix the focus behaviour of the clear button
abhinav700 Oct 26, 2024
3c07d24
Merge branch 'canary' into adding-isClearable-to-select
wingkwong Nov 6, 2024
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
6 changes: 6 additions & 0 deletions .changeset/olive-buckets-own.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@nextui-org/select": minor
abhinav700 marked this conversation as resolved.
Show resolved Hide resolved
"@nextui-org/theme": minor
---

add `isClearable` and `onClear` prop to Select component (#2239)
2 changes: 2 additions & 0 deletions apps/docs/content/components/select/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -58,4 +59,5 @@ export const selectContent = {
multipleWithChips,
customSelectorIcon,
customStyles,
isClearable,
};
83 changes: 83 additions & 0 deletions apps/docs/content/components/select/is-clearable.ts
Original file line number Diff line number Diff line change
@@ -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"}
];`;
abhinav700 marked this conversation as resolved.
Show resolved Hide resolved

const PetBoldIcon = `export const PetBoldIcon = (props) => (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="1em"
role="presentation"
viewBox="0 0 24 24"
width="1em"
{...props}
>
<path
d="M19.0803 15.7203C18.4903 12.1903 15.1003 9.32031 11.5203 9.32031C7.63028 9.32031 4.21028 12.4703 3.88028 16.3503C3.75028 17.8503 4.23028 19.2703 5.22028 20.3403C6.20028 21.4103 7.58028 22.0003 9.08028 22.0003H13.7603C15.4503 22.0003 16.9303 21.3403 17.9403 20.1503C18.9503 18.9603 19.3503 17.3803 19.0803 15.7203Z"
fill="currentColor"
/>
<path
d="M10.2796 7.86C11.8978 7.86 13.2096 6.54819 13.2096 4.93C13.2096 3.31181 11.8978 2 10.2796 2C8.66141 2 7.34961 3.31181 7.34961 4.93C7.34961 6.54819 8.66141 7.86 10.2796 7.86Z"
fill="currentColor"
/>
<path
d="M16.94 9.02844C18.2876 9.02844 19.38 7.93601 19.38 6.58844C19.38 5.24086 18.2876 4.14844 16.94 4.14844C15.5924 4.14844 14.5 5.24086 14.5 6.58844C14.5 7.93601 15.5924 9.02844 16.94 9.02844Z"
fill="currentColor"
/>
<path
d="M20.5496 12.9313C21.6266 12.9313 22.4996 12.0582 22.4996 10.9812C22.4996 9.90429 21.6266 9.03125 20.5496 9.03125C19.4727 9.03125 18.5996 9.90429 18.5996 10.9812C18.5996 12.0582 19.4727 12.9313 20.5496 12.9313Z"
fill="currentColor"
/>
<path
d="M3.94 10.9816C5.28757 10.9816 6.38 9.88914 6.38 8.54156C6.38 7.19399 5.28757 6.10156 3.94 6.10156C2.59243 6.10156 1.5 7.19399 1.5 8.54156C1.5 9.88914 2.59243 10.9816 3.94 10.9816Z"
fill="currentColor"
/>
</svg>
);`;
abhinav700 marked this conversation as resolved.
Show resolved Hide resolved

const App = `import {Select, SelectItem} from "@nextui-org/react";
import {animals} from "./data";
import {PetBoldIcon} from "./PetBoldIcon";

export default function App() {
return (
<div className="flex w-screen justify-center items-center">
<Select
className="max-w-xs my-5"
isClearable={true}
label="Favorite Animal"
endContent={<PetBoldIcon />}
Copy link
Member

Choose a reason for hiding this comment

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

I think it’s clearer not to specify endContent, and it’s more common as an example.

Copy link
Contributor Author

@abhinav700 abhinav700 Nov 7, 2024

Choose a reason for hiding this comment

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

@ryo-manba Should I make the same changes to story as well? Or leave it as it is for development purposes?

onClear={() => console.log("Select cleared")}
Copy link
Member

Choose a reason for hiding this comment

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

console.log is unnecessary.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should I remove the entire line or just leave the onClear as an empty function?

>
{animals.map((animal) => (
<SelectItem key={animal.key}>
{animal.label}
</SelectItem>
))}
</Select>
</div>
);
}`;
abhinav700 marked this conversation as resolved.
Show resolved Hide resolved

const react = {
"/App.jsx": App,
"/data.js": data,
"/PetBoldIcon.jsx": PetBoldIcon,
};

export default {
...react,
};
9 changes: 8 additions & 1 deletion apps/docs/content/docs/components/select.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ You can combine the `isInvalid` and `errorMessage` properties to show an invalid

<CodeDemo title="With Error Message" files={selectContent.errorMessage} />

### 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.

<CodeDemo title="Clear Button" files={selectContent.isClearable} />

### Controlled

You can use the `selectedKeys` and `onSelectionChange` / `onChange` properties to control the select value.
Expand Down Expand Up @@ -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` |
Expand All @@ -403,7 +410,7 @@ the popover and listbox components.
| onSelectionChange | `(keys: "all" \| Set<React.Key> & {anchorKey?: string; currentKey?: string}) => void` | Callback fired when the selected keys change. |
| onChange | `React.ChangeEvent<HTMLSelectElement>` | 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
Expand Down
2 changes: 1 addition & 1 deletion packages/components/select/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 25 additions & 2 deletions packages/components/select/src/select.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -29,8 +29,10 @@ function Select<T extends object>(props: Props<T>, ref: ForwardedRef<HTMLSelectE
endContent,
placeholder,
renderValue,
isClearable,
shouldLabelBeOutside,
disableAnimation,
getClearButtonProps,
getBaseProps,
getLabelProps,
getTriggerProps,
Expand All @@ -52,6 +54,27 @@ function Select<T extends object>(props: Props<T>, ref: ForwardedRef<HTMLSelectE

const clonedIcon = cloneElement(selectorIcon as ReactElement, getSelectorIconProps());

const clearButton = useMemo(() => {
if (isClearable && state.selectedItems?.length) {
return <button {...getClearButtonProps()}>{<CloseFilledIcon />}</button>;
}

return null;
}, [isClearable, getClearButtonProps, state.selectedItems?.length]);

const end = useMemo(() => {
if (clearButton) {
return (
<div className="flex end-18">
abhinav700 marked this conversation as resolved.
Show resolved Hide resolved
{clearButton}
{endContent && <span className="ms-3">{endContent}</span>}
</div>
);
}

return endContent && <span className="mb-4">{endContent}</span>;
}, [clearButton, endContent]);
abhinav700 marked this conversation as resolved.
Show resolved Hide resolved

const helperWrapper = useMemo(() => {
if (!hasHelper) return null;

Expand Down Expand Up @@ -127,7 +150,7 @@ function Select<T extends object>(props: Props<T>, ref: ForwardedRef<HTMLSelectE
{endContent && state.selectedItems && (
<VisuallyHidden elementType="span">,</VisuallyHidden>
)}
{endContent}
{end}
</div>
{renderIndicator}
</Component>
Expand Down
50 changes: 48 additions & 2 deletions packages/components/select/src/use-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -133,6 +133,11 @@ interface Props<T> extends Omit<HTMLNextUIProps<"select">, 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 {
Expand Down Expand Up @@ -187,6 +192,7 @@ export function useSelect<T extends object>(originalProps: UseSelectProps<T>) {
validationState,
onChange,
onClose,
onClear,
className,
classNames,
...otherProps
Expand Down Expand Up @@ -296,11 +302,24 @@ export function useSelect<T extends object>(originalProps: UseSelectProps<T>) {
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<SelectVariantProps["labelPlacement"]>(() => {
Expand All @@ -317,6 +336,7 @@ export function useSelect<T extends object>(originalProps: UseSelectProps<T>) {
(labelPlacement === "outside" && (hasPlaceholder || !!originalProps.isMultiline));
const shouldLabelBeInside = labelPlacement === "inside";
const isOutsideLeft = labelPlacement === "outside-left";
const isClearable = originalProps.isClearable;
abhinav700 marked this conversation as resolved.
Show resolved Hide resolved

const isFilled =
state.isOpen ||
Expand All @@ -335,11 +355,19 @@ export function useSelect<T extends object>(originalProps: UseSelectProps<T>) {
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
Expand Down Expand Up @@ -634,6 +662,22 @@ export function useSelect<T extends object>(originalProps: UseSelectProps<T>) {
[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,
Expand All @@ -651,6 +695,7 @@ export function useSelect<T extends object>(originalProps: UseSelectProps<T>) {
name,
triggerRef,
isLoading,
isClearable,
placeholder,
startContent,
endContent,
Expand All @@ -669,6 +714,7 @@ export function useSelect<T extends object>(originalProps: UseSelectProps<T>) {
errorMessage,
getBaseProps,
getTriggerProps,
getClearButtonProps,
getLabelProps,
getValueProps,
getListboxProps,
Expand Down
11 changes: 11 additions & 0 deletions packages/components/select/stories/select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1006,3 +1006,14 @@ export const CustomStyles = {
},
},
};

export const Clearable = {
render: Template,
args: {
...defaultProps,
isClearable: true,
endContent: <PetBoldIcon />,
// eslint-disable-next-line no-console
onClear: () => console.log("Select cleared"),
abhinav700 marked this conversation as resolved.
Show resolved Hide resolved
},
};
26 changes: 26 additions & 0 deletions packages/core/theme/src/components/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment on lines +32 to +38
Copy link
Member

Choose a reason for hiding this comment

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

Could you align the input's clear button?
It looks like there's no hover style applied.

"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",
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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: {
Expand Down