Skip to content

Commit 1c26e60

Browse files
authored
feat(Combobox): expose inputValue prop on Combobox.Root (#1517)
1 parent 37440ee commit 1c26e60

File tree

11 files changed

+164
-67
lines changed

11 files changed

+164
-67
lines changed

.changeset/twelve-carpets-juggle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"bits-ui": minor
3+
---
4+
5+
feat(Combobox): expose `inputValue` prop on `Combobox.Root` to synchronize input value with programmatic updates to the value from outside Bits UI

docs/src/lib/content/api-reference/combobox.api.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,6 @@ export const root = createApiSchema<ComboboxRootPropsWithoutHTML>({
120120
description:
121121
"Whether or not the user can deselect the selected item by pressing it in a single select.",
122122
}),
123-
124123
items: createPropSchema({
125124
type: {
126125
type: "array",
@@ -130,6 +129,10 @@ export const root = createApiSchema<ComboboxRootPropsWithoutHTML>({
130129
description:
131130
"Optionally provide an array of objects representing the items in the select for autofill capabilities. Only applicable to combobox's with type `single`",
132131
}),
132+
inputValue: createStringProp({
133+
description:
134+
"A read-only value that controls the text displayed in the combobox input. Use this to programmatically update the input value when the selection changes outside the component, ensuring the displayed text stays in sync with the actual value.",
135+
}),
133136
children: childrenSnippet(),
134137
},
135138
});
Lines changed: 11 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,15 @@
11
<script lang="ts">
2-
import { Select } from "bits-ui";
3-
import Check from "phosphor-svelte/lib/Check";
4-
import Palette from "phosphor-svelte/lib/Palette";
5-
import CaretUpDown from "phosphor-svelte/lib/CaretUpDown";
6-
import CaretDoubleUp from "phosphor-svelte/lib/CaretDoubleUp";
7-
import CaretDoubleDown from "phosphor-svelte/lib/CaretDoubleDown";
2+
import Combobox from "./combobox.svelte";
83
9-
let value = $state<string>("");
4+
let myValue = $state("");
5+
const testItems = [
6+
{ value: "mango", label: "Mango" },
7+
{ value: "watermelon", label: "Watermelon" },
8+
{ value: "apple", label: "Apple" },
9+
];
1010
</script>
1111

12-
<Select.Root type="single" onValueChange={(v) => (value = v)}>
13-
<Select.Trigger
14-
class="h-input rounded-9px border-border-input bg-background data-placeholder:text-foreground-alt/50 inline-flex w-[296px] select-none items-center border px-[11px] text-sm transition-colors"
15-
aria-label="Select a theme"
16-
>
17-
<Palette class="text-muted-foreground mr-[9px] size-6" />
18-
{value}
19-
<CaretUpDown class="text-muted-foreground ml-auto size-6" />
20-
</Select.Trigger>
21-
<Select.Portal>
22-
<Select.Content
23-
class="focus-override border-muted bg-background shadow-popover data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-hidden z-50 max-h-[var(--bits-select-content-available-height)] w-[var(--bits-select-anchor-width)] min-w-[var(--bits-select-anchor-width)] select-none rounded-xl border px-1 py-3 data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
24-
sideOffset={10}
25-
>
26-
<Select.ScrollUpButton class="flex w-full items-center justify-center">
27-
<CaretDoubleUp class="size-3" />
28-
</Select.ScrollUpButton>
29-
<Select.Viewport class="p-1">
30-
{#each { length: 100 } as _, i (i)}
31-
<Select.Item
32-
class="rounded-button data-highlighted:bg-muted outline-hidden data-disabled:opacity-50 flex h-10 w-full select-none items-center py-3 pl-5 pr-1.5 text-sm capitalize"
33-
value={`${i}`}
34-
label={`${i}`}
35-
disabled={false}
36-
>
37-
{#snippet children({ selected })}
38-
{i}
39-
{#if selected}
40-
<div class="ml-auto">
41-
<Check aria-label="check" />
42-
</div>
43-
{/if}
44-
{/snippet}
45-
</Select.Item>
46-
{/each}
47-
</Select.Viewport>
48-
<Select.ScrollDownButton class="flex w-full items-center justify-center">
49-
<CaretDoubleDown class="size-3" />
50-
</Select.ScrollDownButton>
51-
</Select.Content>
52-
</Select.Portal>
53-
</Select.Root>
12+
<div class="mt-4">
13+
<button onclick={() => (myValue = "apple")}> Select Apple </button>
14+
<Combobox items={testItems} type="single" bind:value={myValue} />
15+
</div>
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<script lang="ts">
2+
import { Combobox, type WithoutChildrenOrChild, mergeProps } from "bits-ui";
3+
import ChevronUpDown from "phosphor-svelte/lib/CaretUpDown";
4+
type Item = { value: string; label: string };
5+
type Props = Combobox.RootProps & {
6+
items: Item[];
7+
inputProps?: WithoutChildrenOrChild<Combobox.InputProps>;
8+
contentProps?: WithoutChildrenOrChild<Combobox.ContentProps>;
9+
};
10+
let {
11+
items,
12+
value = $bindable(),
13+
open = $bindable(false),
14+
inputProps,
15+
contentProps,
16+
type,
17+
...restProps
18+
}: Props = $props();
19+
let searchValue = $state("");
20+
const filteredItems = $derived.by(() => {
21+
if (searchValue === "") return items;
22+
return items.filter((item) => item.label.toLowerCase().includes(searchValue.toLowerCase()));
23+
});
24+
function handleInput(e: Event & { currentTarget: HTMLInputElement }) {
25+
searchValue = e.currentTarget.value;
26+
}
27+
function handleOpenChange(newOpen: boolean) {
28+
if (!newOpen) searchValue = "";
29+
}
30+
const mergedRootProps = $derived(mergeProps(restProps, { onOpenChange: handleOpenChange }));
31+
const mergedInputProps = $derived(mergeProps(inputProps, { oninput: handleInput }));
32+
33+
let inputValue = $derived.by(() => {
34+
return items.find((item) => item.value === value)?.label;
35+
});
36+
</script>
37+
38+
<Combobox.Root
39+
{inputValue}
40+
bind:value={value as never}
41+
bind:open
42+
{...mergedRootProps}
43+
{type}
44+
{items}
45+
>
46+
<div class="relative">
47+
<Combobox.Input
48+
{...mergedInputProps}
49+
class="border-input bg-background placeholder:text-muted-foreground flex h-10 w-full rounded-md border px-3 py-2 text-base file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
50+
/>
51+
<Combobox.Trigger class="absolute end-3 top-1/2 size-6 -translate-y-1/2"
52+
><ChevronUpDown class="text-muted-foreground h-5 w-5" /></Combobox.Trigger
53+
>
54+
</div>
55+
<Combobox.Portal>
56+
<Combobox.Content
57+
{...contentProps}
58+
class="z-50 mt-2 w-[var(--bits-combobox-anchor-width)] min-w-[var(--bits-combobox-anchor-width)] rounded-md border bg-white p-1 shadow-md outline-none"
59+
>
60+
{#each filteredItems as item (item.value)}
61+
<Combobox.Item
62+
value={item.value}
63+
label={item.label}
64+
class="relative flex w-full cursor-pointer select-none items-center rounded-sm p-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
65+
>
66+
{#snippet children({ selected })}
67+
<div class="flex w-full flex-col">
68+
<span class={selected ? "font-medium text-red-500" : ""}
69+
>{item.label}</span
70+
>
71+
</div>
72+
{/snippet}
73+
</Combobox.Item>
74+
{:else}
75+
<span> No results found </span>
76+
{/each}
77+
</Combobox.Content>
78+
</Combobox.Portal>
79+
</Combobox.Root>

packages/bits-ui/src/lib/bits/combobox/components/combobox-input.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@
2424
});
2525
2626
if (defaultValue) {
27-
inputState.root.inputValue = defaultValue;
27+
inputState.root.opts.inputValue.current = defaultValue;
2828
}
2929
3030
const mergedProps = $derived(
31-
mergeProps(restProps, inputState.props, { value: inputState.root.inputValue })
31+
mergeProps(restProps, inputState.props, { value: inputState.root.opts.inputValue.current })
3232
);
3333
</script>
3434

packages/bits-ui/src/lib/bits/combobox/components/combobox.svelte

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
required = false,
2121
items = [],
2222
allowDeselect = true,
23+
inputValue = "",
2324
children,
2425
}: ComboboxRootProps = $props();
2526
@@ -61,6 +62,10 @@
6162
isCombobox: true,
6263
items: box.with(() => items),
6364
allowDeselect: box.with(() => allowDeselect),
65+
inputValue: box.with(
66+
() => inputValue,
67+
(v) => (inputValue = v)
68+
),
6469
});
6570
</script>
6671

packages/bits-ui/src/lib/bits/combobox/types.ts

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,44 @@
11
import type { BitsPrimitiveInputAttributes } from "$lib/shared/attributes.js";
2+
import type {
3+
SelectBaseRootPropsWithoutHTML,
4+
SelectMultipleRootPropsWithoutHTML,
5+
SelectSingleRootPropsWithoutHTML,
6+
} from "$lib/bits/select/types.js";
27
import type { WithChild, Without } from "$lib/internal/types.js";
38

9+
export type ComboboxBaseRootPropsWithoutHTML = SelectBaseRootPropsWithoutHTML & {
10+
/**
11+
* A read-only value that can be used to programmatically
12+
* update the input value.
13+
*
14+
* This is useful for updating the displayed label/input
15+
* when the value changes outside of Bits UI.
16+
*/
17+
inputValue?: string;
18+
};
19+
20+
export type ComboboxSingleRootPropsWithoutHTML = ComboboxBaseRootPropsWithoutHTML &
21+
SelectSingleRootPropsWithoutHTML;
22+
23+
export type ComboboxSingleRootProps = ComboboxSingleRootPropsWithoutHTML;
24+
25+
export type ComboboxMultipleRootPropsWithoutHTML = ComboboxBaseRootPropsWithoutHTML &
26+
SelectMultipleRootPropsWithoutHTML;
27+
export type ComboboxMultipleRootProps = ComboboxMultipleRootPropsWithoutHTML;
28+
29+
export type ComboboxRootPropsWithoutHTML = ComboboxBaseRootPropsWithoutHTML &
30+
(ComboboxSingleRootPropsWithoutHTML | ComboboxMultipleRootPropsWithoutHTML);
31+
32+
export type ComboboxRootProps = ComboboxRootPropsWithoutHTML;
33+
434
export type {
5-
SelectBaseRootPropsWithoutHTML as ComboboxBaseRootPropsWithoutHTML,
635
SelectContentProps as ComboboxContentProps,
736
SelectContentPropsWithoutHTML as ComboboxContentPropsWithoutHTML,
837
SelectContentStaticProps as ComboboxContentStaticProps,
938
SelectContentStaticPropsWithoutHTML as ComboboxContentStaticPropsWithoutHTML,
1039
SelectItemProps as ComboboxItemProps,
1140
SelectItemPropsWithoutHTML as ComboboxItemPropsWithoutHTML,
1241
SelectItemSnippetProps as ComboboxItemSnippetProps,
13-
SelectMultipleRootProps as ComboboxMultipleRootProps,
14-
SelectMultipleRootPropsWithoutHTML as ComboboxMultipleRootPropsWithoutHTML,
15-
SelectRootProps as ComboboxRootProps,
16-
SelectRootPropsWithoutHTML as ComboboxRootPropsWithoutHTML,
17-
SelectSingleRootProps as ComboboxSingleRootProps,
18-
SelectSingleRootPropsWithoutHTML as ComboboxSingleRootPropsWithoutHTML,
1942
SelectTriggerProps as ComboboxTriggerProps,
2043
SelectTriggerPropsWithoutHTML as ComboboxTriggerPropsWithoutHTML,
2144
SelectGroupPropsWithoutHTML as ComboboxGroupPropsWithoutHTML,

packages/bits-ui/src/lib/bits/select/components/select.svelte

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
}
3939
);
4040
41+
let inputValue = $state("");
42+
4143
const rootState = useSelectRoot({
4244
type,
4345
value: box.with(
@@ -63,6 +65,10 @@
6365
isCombobox: false,
6466
items: box.with(() => items),
6567
allowDeselect: box.with(() => allowDeselect),
68+
inputValue: box.with(
69+
() => inputValue,
70+
(v) => (inputValue = v)
71+
),
6672
});
6773
</script>
6874

packages/bits-ui/src/lib/bits/select/select.svelte.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,14 @@ type SelectBaseRootStateProps = ReadableBoxedValues<{
4646
}> &
4747
WritableBoxedValues<{
4848
open: boolean;
49+
inputValue: string;
4950
}> & {
5051
isCombobox: boolean;
5152
};
5253

5354
class SelectBaseRootState {
5455
readonly opts: SelectBaseRootStateProps;
5556
touchedInput = $state(false);
56-
inputValue = $state<string>("");
5757
inputNode = $state<HTMLElement | null>(null);
5858
contentNode = $state<HTMLElement | null>(null);
5959
triggerNode = $state<HTMLElement | null>(null);
@@ -189,7 +189,7 @@ class SelectSingleRootState extends SelectBaseRootState {
189189

190190
toggleItem(itemValue: string, itemLabel: string = itemValue) {
191191
this.opts.value.current = this.includesItem(itemValue) ? "" : itemValue;
192-
this.inputValue = itemLabel;
192+
this.opts.inputValue.current = itemLabel;
193193
}
194194

195195
setInitialHighlightedNode() {
@@ -254,7 +254,7 @@ class SelectMultipleRootState extends SelectBaseRootState {
254254
} else {
255255
this.opts.value.current = [...this.opts.value.current, itemValue];
256256
}
257-
this.inputValue = itemLabel;
257+
this.opts.inputValue.current = itemLabel;
258258
}
259259

260260
setInitialHighlightedNode() {
@@ -305,10 +305,10 @@ class SelectInputState {
305305
if (!clearOnDeselect) return;
306306
if (Array.isArray(value) && Array.isArray(prevValue)) {
307307
if (value.length === 0 && prevValue.length !== 0) {
308-
this.root.inputValue = "";
308+
this.root.opts.inputValue.current = "";
309309
}
310310
} else if (value === "" && prevValue !== "") {
311-
this.root.inputValue = "";
311+
this.root.opts.inputValue.current = "";
312312
}
313313
}
314314
);
@@ -323,7 +323,7 @@ class SelectInputState {
323323
if (!this.root.opts.open.current) {
324324
if (INTERACTION_KEYS.includes(e.key)) return;
325325
if (e.key === kbd.TAB) return;
326-
if (e.key === kbd.BACKSPACE && this.root.inputValue === "") return;
326+
if (e.key === kbd.BACKSPACE && this.root.opts.inputValue.current === "") return;
327327
this.root.handleOpen();
328328
// we need to wait for a tick after the menu opens to ensure the highlighted nodes are
329329
// set correctly.
@@ -412,7 +412,7 @@ class SelectInputState {
412412
}
413413

414414
oninput(e: BitsEvent<Event, HTMLInputElement>) {
415-
this.root.inputValue = e.currentTarget.value;
415+
this.root.opts.inputValue.current = e.currentTarget.value;
416416
this.root.setHighlightedToFirstCandidate();
417417
}
418418

@@ -1354,6 +1354,7 @@ type InitSelectProps = {
13541354
}> &
13551355
WritableBoxedValues<{
13561356
open: boolean;
1357+
inputValue: string;
13571358
}> & {
13581359
isCombobox: boolean;
13591360
};

tests/src/tests/combobox/combobox-test.svelte

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
let {
2525
contentProps,
2626
portalProps,
27-
items,
27+
items = [],
2828
value = "",
2929
open = false,
3030
searchValue = "",
@@ -38,11 +38,16 @@
3838
? items
3939
: items.filter((item) => item.label.includes(searchValue.toLowerCase()))
4040
);
41+
42+
const inputValue = $derived.by(() => {
43+
return items.find((item) => item.value === value)?.label;
44+
});
4145
</script>
4246

4347
<main data-testid="main">
4448
<Combobox.Root
4549
type="single"
50+
{inputValue}
4651
bind:value
4752
bind:open
4853
{...restProps}
@@ -94,5 +99,6 @@
9499
{value}
95100
{/if}
96101
</button>
102+
<button data-testid="value-binding-3" onclick={() => (value = "3")}> set 3 </button>
97103
</main>
98104
<div data-testid="portal-target" id="portal-target"></div>

tests/src/tests/combobox/combobox.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,13 @@ describe("combobox - single", () => {
473473
expect(t.input).toHaveValue("");
474474
expect(t.input).not.toHaveValue("A");
475475
});
476+
477+
it("should allow programmatic updates to the value alongside `inputValue`", async () => {
478+
const t = setupSingle();
479+
const setter = t.getByTestId("value-binding-3");
480+
await t.user.click(setter);
481+
expect(t.input).toHaveValue("C");
482+
});
476483
});
477484

478485
////////////////////////////////////

0 commit comments

Comments
 (0)