Skip to content

Commit

Permalink
next: add allowDeselect prop to Combobox and Select (#819)
Browse files Browse the repository at this point in the history
  • Loading branch information
huntabyte authored Oct 27, 2024
1 parent 6d99923 commit 300fe0c
Show file tree
Hide file tree
Showing 9 changed files with 98 additions and 4 deletions.
5 changes: 5 additions & 0 deletions .changeset/six-toys-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"bits-ui": patch
---

Select/Combobox: add `allowDeselect` prop
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
controlledOpen = false,
controlledValue = false,
items = [],
allowDeselect = true,
children,
}: ComboboxRootProps = $props();
Expand Down Expand Up @@ -63,6 +64,7 @@
name: box.with(() => name),
isCombobox: true,
items: box.with(() => items),
allowDeselect: box.with(() => allowDeselect),
});
</script>

Expand Down
2 changes: 2 additions & 0 deletions packages/bits-ui/src/lib/bits/select/components/select.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
controlledOpen = false,
controlledValue = false,
items = [],
allowDeselect = true,
children,
}: SelectRootProps = $props();
Expand Down Expand Up @@ -63,6 +64,7 @@
name: box.with(() => name),
isCombobox: false,
items: box.with(() => items),
allowDeselect: box.with(() => allowDeselect),
});
</script>

Expand Down
39 changes: 35 additions & 4 deletions packages/bits-ui/src/lib/bits/select/select.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type SelectBaseRootStateProps = ReadableBoxedValues<{
loop: boolean;
scrollAlignment: "nearest" | "center";
items: { value: string; label: string; disabled?: boolean }[];
allowDeselect: boolean;
}> &
WritableBoxedValues<{
open: boolean;
Expand All @@ -51,6 +52,7 @@ class SelectBaseRootState {
open: SelectBaseRootStateProps["open"];
scrollAlignment: SelectBaseRootStateProps["scrollAlignment"];
items: SelectBaseRootStateProps["items"];
allowDeselect: SelectBaseRootStateProps["allowDeselect"];
touchedInput = $state(false);
inputValue = $state<string>("");
inputNode = $state<HTMLElement | null>(null);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -1207,6 +1237,7 @@ type InitSelectProps = {
scrollAlignment: "nearest" | "center";
name: string;
items: { value: string; label: string; disabled?: boolean }[];
allowDeselect: boolean;
}> &
WritableBoxedValues<{
open: boolean;
Expand Down
6 changes: 6 additions & 0 deletions packages/bits-ui/src/lib/bits/select/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
19 changes: 19 additions & 0 deletions packages/tests/src/tests/combobox/combobox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!);
});
});

////////////////////////////////////
Expand Down
19 changes: 19 additions & 0 deletions packages/tests/src/tests/select/select.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!);
});
});

////////////////////////////////////
Expand Down
5 changes: 5 additions & 0 deletions sites/docs/src/lib/content/api-reference/combobox.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ export const root = createApiSchema<ComboboxRootPropsWithoutHTML>({
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",
Expand Down
5 changes: 5 additions & 0 deletions sites/docs/src/lib/content/api-reference/select.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ export const root = createApiSchema<SelectRootPropsWithoutHTML>({
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:
Expand Down

0 comments on commit 300fe0c

Please sign in to comment.