diff --git a/.changeset/nine-bugs-camp.md b/.changeset/nine-bugs-camp.md
new file mode 100644
index 000000000..1a817964e
--- /dev/null
+++ b/.changeset/nine-bugs-camp.md
@@ -0,0 +1,5 @@
+---
+"bits-ui": patch
+---
+
+fix(Command): item selection logic
diff --git a/packages/bits-ui/src/lib/bits/command/command.svelte.ts b/packages/bits-ui/src/lib/bits/command/command.svelte.ts
index 9c0c631ca..8be7c6157 100644
--- a/packages/bits-ui/src/lib/bits/command/command.svelte.ts
+++ b/packages/bits-ui/src/lib/bits/command/command.svelte.ts
@@ -148,7 +148,7 @@ export class CommandRootState {
if (key === "search") {
// Filter synchronously before emitting back to children
this.#filterItems();
- this.#sort();
+ this.#sort({ forceSelectFirst: value === "" && !this.#isInitialMount });
} else if (key === "value") {
if (!preventScroll) this.#scrollSelectedIntoView();
}
@@ -185,13 +185,13 @@ export class CommandRootState {
/**
* Sorts items and groups based on search scores.
* Groups are sorted by their highest scoring item.
- * When no search active, selects first item.
+ * When no search is active, preserves the current item unless a reset is requested.
*/
- #sort(): void {
+ #sort(opts: { forceSelectFirst?: boolean } = {}): void {
if (!this._commandState.search || this.opts.shouldFilter.current === false) {
// if no search and no initial value set or when clearing search,
// we select the first item.
- if (!this._commandState.value || !this.#isInitialMount) {
+ if (!this._commandState.value || opts.forceSelectFirst) {
this.#selectFirstItem();
} else if (this.#isInitialMount && this._commandState.value) {
// scroll the initial value into view if it exists
@@ -300,6 +300,20 @@ export class CommandRootState {
});
}
+ #selectFirstItemIfValueMissing(value: string): void {
+ afterTick(() => {
+ if (this._commandState.value !== value) return;
+
+ const selectedItemExists = this.getValidItems().some(
+ (item) => item.getAttribute(COMMAND_VALUE_ATTR) === value
+ );
+
+ if (!selectedItemExists) {
+ this.#selectFirstItem();
+ }
+ });
+ }
+
/**
* Scrolls the initial value into view if it exists and is not the first item.
* Called during initial mount when a value is provided.
@@ -675,10 +689,13 @@ export class CommandRootState {
this.#filterItems();
- // The item removed have been the selected one,
- // so selection should be moved to the first
if (selectedItem?.getAttribute("id") === id) {
- this.#selectFirstItem();
+ const selectedValue = selectedItem.getAttribute(COMMAND_VALUE_ATTR);
+ if (selectedValue) {
+ this.#selectFirstItemIfValueMissing(selectedValue);
+ } else {
+ this.#selectFirstItem();
+ }
}
this.#scheduleUpdate();
diff --git a/tests/src/tests/command/command-rerender-test.svelte b/tests/src/tests/command/command-rerender-test.svelte
new file mode 100644
index 000000000..b52431422
--- /dev/null
+++ b/tests/src/tests/command/command-rerender-test.svelte
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+ {#key itemRenderKey}
+ {#each statuses as status (status)}
+ toggleStatus(status)}
+ >
+
+ {selectedStatuses.includes(status) ? "selected" : ""}
+
+ {status}
+
+ {/each}
+ {/key}
+
+
+
diff --git a/tests/src/tests/command/command.browser.test.ts b/tests/src/tests/command/command.browser.test.ts
index df894036e..48eb8eb92 100644
--- a/tests/src/tests/command/command.browser.test.ts
+++ b/tests/src/tests/command/command.browser.test.ts
@@ -4,6 +4,7 @@ import { render } from "vitest-browser-svelte";
import type { ComponentProps } from "svelte";
import { getTestKbd } from "../utils.js";
import CommandTest from "./command-test.svelte";
+import CommandRerenderTest from "./command-rerender-test.svelte";
import { expectExists, expectNotExists } from "../browser-utils";
const kbd = getTestKbd();
@@ -22,6 +23,16 @@ function setup(props: Partial> = {}) {
};
}
+function setupRerenderTest(props: Partial> = {}) {
+ // oxlint-disable-next-line no-explicit-any
+ const returned = render(CommandRerenderTest, props as any);
+ const input = page.getByTestId("input");
+ return {
+ ...returned,
+ input,
+ };
+}
+
it("should select the first item by default", async () => {
setup();
@@ -52,6 +63,19 @@ it("should respect initial value for items in the first group", async () => {
await expect.element(page.getByText("Introduction")).not.toHaveAttribute("data-selected");
});
+it("should keep the clicked item active on initial click", async () => {
+ const t = setupRerenderTest();
+
+ await userEvent.click(page.getByTestId("item-done"));
+ await expect.element(page.getByTestId("selected-done")).toHaveTextContent("selected");
+ await expect.element(page.getByText("Done")).toHaveAttribute("data-selected");
+ await expect.element(page.getByText("Backlog")).not.toHaveAttribute("data-selected");
+
+ (t.input.element() as HTMLElement).focus();
+ await userEvent.keyboard(kbd.ARROW_DOWN);
+ await expect.element(page.getByText("Canceled")).toHaveAttribute("data-selected");
+});
+
it("should render the separator when search is empty and remove it when search is not empty", async () => {
const t = setup();