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 21 commits into
base: beta/release-next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
21 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
6ad9f33
Merge branch 'nextui-org:canary' into adding-isClearable-to-select
abhinav700 Nov 25, 2024
0684bff
fix(select): fix hover, remove endContent, remove onClear
abhinav700 Nov 25, 2024
1a52faf
Merge branch 'beta/release-next' into pr/3746
wingkwong Nov 26, 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
5 changes: 5 additions & 0 deletions .changeset/olive-buckets-own.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nextui-org/select": minor
abhinav700 marked this conversation as resolved.
Show resolved Hide resolved
---

Added isClearable prop, and onClear event handler to select component
abhinav700 marked this conversation as resolved.
Show resolved Hide resolved
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 "./isClearable";

export const selectContent = {
usage,
Expand Down Expand Up @@ -58,4 +59,5 @@ export const selectContent = {
multipleWithChips,
customSelectorIcon,
customStyles,
isClearable,
};
45 changes: 45 additions & 0 deletions apps/docs/content/components/select/isClearable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
const data = `export const animals = [
abhinav700 marked this conversation as resolved.
Show resolved Hide resolved
{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 (
<div className="flex w-screen justify-center items-center">
<Select
className="max-w-xs my-5"
isClearable={true}
label="Favorite Animal"
>
{animals.map((animal) => (
<SelectItem key={animal.key}>
{animal.label}
</SelectItem>
))}
</Select>
</div>
);
}`;

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

export default {
...react,
};
10 changes: 9 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,13 @@ 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, 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.

<CodeDemo title="Clear Button" files={selectContent.isClearable} />
wingkwong marked this conversation as resolved.
Show resolved Hide resolved

### Controlled

You can use the `selectedKeys` and `onSelectionChange` / `onChange` properties to control the select value.
Expand Down Expand Up @@ -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` |
Expand All @@ -403,7 +411,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
16 changes: 14 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 @@ -30,7 +30,9 @@ function Select<T extends object>(props: Props<T>, ref: ForwardedRef<HTMLSelectE
placeholder,
renderValue,
isOutsideLeft,
abhinav700 marked this conversation as resolved.
Show resolved Hide resolved
abhinav700 marked this conversation as resolved.
Show resolved Hide resolved
isClearable,
disableAnimation,
getClearButtonProps,
getBaseProps,
getLabelProps,
getTriggerProps,
Expand All @@ -52,6 +54,14 @@ function Select<T extends object>(props: Props<T>, ref: ForwardedRef<HTMLSelectE

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

const clearButton = useMemo(() => {
if (isClearable) {
return state.selectedItems?.length ? (
<span {...getClearButtonProps()}>{endContent || <CloseFilledIcon />}</span>
) : null;
}
}, [isClearable, getClearButtonProps, state]);
wingkwong marked this conversation as resolved.
Show resolved Hide resolved

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

Expand Down Expand Up @@ -127,8 +137,10 @@ function Select<T extends object>(props: Props<T>, ref: ForwardedRef<HTMLSelectE
{endContent && state.selectedItems && (
<VisuallyHidden elementType="span">,</VisuallyHidden>
)}
{endContent}
{/* we display endContent only when we are not displaying clear button */}
{!isClearable && endContent}
wingkwong marked this conversation as resolved.
Show resolved Hide resolved
</div>
{clearButton}
{renderIndicator}
</Component>
{helperWrapper}
Expand Down
52 changes: 50 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 @@ -77,6 +77,9 @@ interface Props<T> extends Omit<HTMLNextUIProps<"select">, 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.
abhinav700 marked this conversation as resolved.
Show resolved Hide resolved
*/
endContent?: ReactNode;
/**
Expand Down Expand Up @@ -132,6 +135,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 @@ -186,6 +194,7 @@ export function useSelect<T extends object>(originalProps: UseSelectProps<T>) {
validationState,
onChange,
onClose,
onClear,
className,
classNames,
...otherProps
Expand Down Expand Up @@ -295,11 +304,23 @@ export function useSelect<T extends object>(originalProps: UseSelectProps<T>) {
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<SelectVariantProps["labelPlacement"]>(() => {
Expand All @@ -316,6 +337,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 = !!onClear || originalProps.isClearable;

const isFilled =
state.isOpen ||
Expand All @@ -334,11 +356,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 @@ -630,6 +660,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: 0,
wingkwong marked this conversation as resolved.
Show resolved Hide resolved
"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 @@ -647,6 +693,7 @@ export function useSelect<T extends object>(originalProps: UseSelectProps<T>) {
name,
triggerRef,
isLoading,
isClearable,
placeholder,
startContent,
endContent,
Expand All @@ -665,6 +712,7 @@ export function useSelect<T extends object>(originalProps: UseSelectProps<T>) {
errorMessage,
getBaseProps,
getTriggerProps,
getClearButtonProps,
getLabelProps,
getValueProps,
getListboxProps,
Expand Down
21 changes: 21 additions & 0 deletions packages/components/select/stories/select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ const Template = ({color, variant, ...args}: SelectProps) => (
</Select>
);

const ClearableTemplate = ({color, variant, ...args}: SelectProps) => (
<Select
className="max-w-xs my-4"
color={color}
isClearable={true}
label="Favorite Animal"
variant={variant}
{...args}
>
{items}
</Select>
);

const DynamicTemplate = ({color, variant, ...args}: SelectProps<Animal>) => (
<Select
className="max-w-xs"
Expand Down Expand Up @@ -1006,3 +1019,11 @@ export const CustomStyles = {
},
},
};

export const Clearable = {
render: ClearableTemplate,

args: {
...defaultProps,
},
};
27 changes: 27 additions & 0 deletions packages/core/theme/src/components/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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",
"absolute",
"end-10",
"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",
Expand Down Expand Up @@ -102,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: {
Expand Down Expand Up @@ -147,6 +167,12 @@ const select = tv({
base: "w-full",
},
},
isClearable: {
true: {
input: "peer pr-6 rtl:pr-0 rtl:pl-6",
wingkwong marked this conversation as resolved.
Show resolved Hide resolved
clearButton: "peer-data-[filled=true]:opacity-70 peer-data-[filled=true]:block",
},
},
isDisabled: {
true: {
base: "opacity-disabled pointer-events-none",
Expand Down Expand Up @@ -193,6 +219,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