diff --git a/.changeset/six-toys-sit.md b/.changeset/six-toys-sit.md new file mode 100644 index 000000000..f373fa62f --- /dev/null +++ b/.changeset/six-toys-sit.md @@ -0,0 +1,5 @@ +--- +"bits-ui": patch +--- + +Select/Combobox: add `allowDeselect` prop diff --git a/packages/bits-ui/src/lib/bits/combobox/components/combobox.svelte b/packages/bits-ui/src/lib/bits/combobox/components/combobox.svelte index a901a319d..3a6e0ff2c 100644 --- a/packages/bits-ui/src/lib/bits/combobox/components/combobox.svelte +++ b/packages/bits-ui/src/lib/bits/combobox/components/combobox.svelte @@ -20,6 +20,7 @@ controlledOpen = false, controlledValue = false, items = [], + allowDeselect = true, children, }: ComboboxRootProps = $props(); @@ -63,6 +64,7 @@ name: box.with(() => name), isCombobox: true, items: box.with(() => items), + allowDeselect: box.with(() => allowDeselect), }); diff --git a/packages/bits-ui/src/lib/bits/select/components/select.svelte b/packages/bits-ui/src/lib/bits/select/components/select.svelte index ba2e5a4d9..a79a1f3d7 100644 --- a/packages/bits-ui/src/lib/bits/select/components/select.svelte +++ b/packages/bits-ui/src/lib/bits/select/components/select.svelte @@ -20,6 +20,7 @@ controlledOpen = false, controlledValue = false, items = [], + allowDeselect = true, children, }: SelectRootProps = $props(); @@ -63,6 +64,7 @@ name: box.with(() => name), isCombobox: false, items: box.with(() => items), + allowDeselect: box.with(() => allowDeselect), }); diff --git a/packages/bits-ui/src/lib/bits/select/select.svelte.ts b/packages/bits-ui/src/lib/bits/select/select.svelte.ts index c2f1765af..48836acdc 100644 --- a/packages/bits-ui/src/lib/bits/select/select.svelte.ts +++ b/packages/bits-ui/src/lib/bits/select/select.svelte.ts @@ -36,6 +36,7 @@ type SelectBaseRootStateProps = ReadableBoxedValues<{ loop: boolean; scrollAlignment: "nearest" | "center"; items: { value: string; label: string; disabled?: boolean }[]; + allowDeselect: boolean; }> & WritableBoxedValues<{ open: boolean; @@ -51,6 +52,7 @@ class SelectBaseRootState { open: SelectBaseRootStateProps["open"]; scrollAlignment: SelectBaseRootStateProps["scrollAlignment"]; items: SelectBaseRootStateProps["items"]; + allowDeselect: SelectBaseRootStateProps["allowDeselect"]; touchedInput = $state(false); inputValue = $state(""); inputNode = $state(null); @@ -84,6 +86,7 @@ class SelectBaseRootState { this.scrollAlignment = props.scrollAlignment; this.isCombobox = props.isCombobox; this.items = props.items; + this.allowDeselect = props.allowDeselect; this.bitsAttrs = getSelectBitsAttrs(this); @@ -329,10 +332,18 @@ class SelectInputState { if (e.key === kbd.ENTER && !e.isComposing) { e.preventDefault(); const highlightedValue = this.root.highlightedValue; + + const isCurrentSelectedValue = highlightedValue === this.root.value.current; + + if (!this.root.allowDeselect.current && isCurrentSelectedValue && !this.root.isMulti) { + this.root.handleClose(); + return; + } + if (highlightedValue) { this.root.toggleItem(highlightedValue, this.root.highlightedLabel ?? undefined); } - if (!this.root.isMulti) { + if (!this.root.isMulti && !isCurrentSelectedValue) { this.root.handleClose(); } } @@ -379,7 +390,9 @@ class SelectInputState { #oninput = (e: Event & { currentTarget: HTMLInputElement }) => { this.root.inputValue = e.currentTarget.value; - this.root.setHighlightedToFirstCandidate(); + afterTick(() => { + this.root.setHighlightedToFirstCandidate(); + }); }; props = $derived.by( @@ -545,10 +558,19 @@ class SelectTriggerState { if ((e.key === kbd.ENTER || e.key === kbd.SPACE) && !e.isComposing) { e.preventDefault(); const highlightedValue = this.root.highlightedValue; + + const isCurrentSelectedValue = highlightedValue === this.root.value.current; + + if (!this.root.allowDeselect.current && isCurrentSelectedValue && !this.root.isMulti) { + this.root.handleClose(); + return; + } + if (highlightedValue) { this.root.toggleItem(highlightedValue, this.root.highlightedLabel ?? undefined); } - if (!this.root.isMulti) { + + if (!this.root.isMulti && !isCurrentSelectedValue) { this.root.handleClose(); } } @@ -838,8 +860,16 @@ class SelectItemState { e.preventDefault(); if (this.disabled.current) return; const isCurrentSelectedValue = this.value.current === this.root.value.current; - this.root.toggleItem(this.value.current, this.label.current); + // if allowDeselect is false and the item is already selected and we're not in a + // multi select, do nothing and close the menu + if (!this.root.allowDeselect.current && isCurrentSelectedValue && !this.root.isMulti) { + this.root.handleClose(); + return; + } + + // otherwise, toggle the item and if we're not in a multi select, close the menu + this.root.toggleItem(this.value.current, this.label.current); if (!this.root.isMulti && !isCurrentSelectedValue) { this.root.handleClose(); } @@ -1207,6 +1237,7 @@ type InitSelectProps = { scrollAlignment: "nearest" | "center"; name: string; items: { value: string; label: string; disabled?: boolean }[]; + allowDeselect: boolean; }> & WritableBoxedValues<{ open: boolean; diff --git a/packages/bits-ui/src/lib/bits/select/types.ts b/packages/bits-ui/src/lib/bits/select/types.ts index 25621d2ea..88d94bd80 100644 --- a/packages/bits-ui/src/lib/bits/select/types.ts +++ b/packages/bits-ui/src/lib/bits/select/types.ts @@ -99,6 +99,12 @@ export type SelectBaseRootPropsWithoutHTML = WithChildren<{ * IMPORTANT: This functionality is only available for single-select listboxes. */ items?: { value: string; label: string; disabled?: boolean }[]; + + /** + * Whether to allow the user to deselect an item by clicking on an already selected item. + * This is only applicable to `type="single"` selects/comboboxes. + */ + allowDeselect?: boolean; }>; export type SelectSingleRootPropsWithoutHTML = { diff --git a/packages/tests/src/tests/combobox/combobox.test.ts b/packages/tests/src/tests/combobox/combobox.test.ts index 039e40c35..847f7b3bc 100644 --- a/packages/tests/src/tests/combobox/combobox.test.ts +++ b/packages/tests/src/tests/combobox/combobox.test.ts @@ -424,6 +424,25 @@ describe("combobox - single", () => { const content = getByTestId("content"); expect(content).toBeVisible(); }); + + it("should not allow deselecting an item when `allowDeselect` is false", async () => { + const { getByTestId, user, trigger } = await openSingle({ + allowDeselect: false, + }); + + const [item0] = getItems(getByTestId); + await user.click(item0!); + expectSelected(item0!); + await user.click(trigger); + + const [item0v2] = getItems(getByTestId); + + await user.click(item0v2!); + expectSelected(item0v2!); + await user.click(trigger); + const [item0v3] = getItems(getByTestId); + expectSelected(item0v3!); + }); }); //////////////////////////////////// diff --git a/packages/tests/src/tests/select/select.test.ts b/packages/tests/src/tests/select/select.test.ts index 370ddc69a..fa38f067b 100644 --- a/packages/tests/src/tests/select/select.test.ts +++ b/packages/tests/src/tests/select/select.test.ts @@ -439,6 +439,25 @@ describe("select - single", () => { await user.click(item0v2!); expectNotSelected(item0v2!); }); + + it("should not allow deselecting an item when `allowDeselect` is false", async () => { + const { getByTestId, user, trigger } = await openSingle({ + allowDeselect: false, + }); + + const [item0] = getItems(getByTestId); + await user.click(item0!); + expectSelected(item0!); + await user.click(trigger); + + const [item0v2] = getItems(getByTestId); + + await user.click(item0v2!); + expectSelected(item0v2!); + await user.click(trigger); + const [item0v3] = getItems(getByTestId); + expectSelected(item0v3!); + }); }); //////////////////////////////////// diff --git a/sites/docs/src/lib/content/api-reference/combobox.api.ts b/sites/docs/src/lib/content/api-reference/combobox.api.ts index ffe3f9bb1..7de0374ce 100644 --- a/sites/docs/src/lib/content/api-reference/combobox.api.ts +++ b/sites/docs/src/lib/content/api-reference/combobox.api.ts @@ -112,6 +112,11 @@ export const root = createApiSchema({ default: C.FALSE, description: "Whether or not the combobox menu should loop through items.", }), + allowDeselect: createBooleanProp({ + default: C.TRUE, + description: + "Whether or not the user can deselect the selected item by pressing it in a single select.", + }), items: createPropSchema({ type: { type: "array", diff --git a/sites/docs/src/lib/content/api-reference/select.api.ts b/sites/docs/src/lib/content/api-reference/select.api.ts index 70936dd3a..cee9f727a 100644 --- a/sites/docs/src/lib/content/api-reference/select.api.ts +++ b/sites/docs/src/lib/content/api-reference/select.api.ts @@ -114,6 +114,11 @@ export const root = createApiSchema({ default: C.FALSE, description: "Whether or not the select menu should loop through items.", }), + allowDeselect: createBooleanProp({ + default: C.TRUE, + description: + "Whether or not the user can deselect the selected item by pressing it in a single select.", + }), items: createObjectProp({ definition: ItemsProp, description: