From 498ec29308ffcf5b3b5f2594f559ce3a3b9e162d Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Wed, 10 Jun 2026 15:58:04 -0400 Subject: [PATCH] fix(Command): item selection logic --- .changeset/nine-bugs-camp.md | 5 +++ .../src/lib/bits/command/command.svelte.ts | 31 +++++++++++---- .../command/command-rerender-test.svelte | 39 +++++++++++++++++++ .../src/tests/command/command.browser.test.ts | 24 ++++++++++++ 4 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 .changeset/nine-bugs-camp.md create mode 100644 tests/src/tests/command/command-rerender-test.svelte 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();