Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nine-bugs-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"bits-ui": patch
---

fix(Command): item selection logic
31 changes: 24 additions & 7 deletions packages/bits-ui/src/lib/bits/command/command.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down
39 changes: 39 additions & 0 deletions tests/src/tests/command/command-rerender-test.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<script lang="ts">
import { Command } from "bits-ui";
import type { ComponentProps } from "svelte";

let { ...rest }: ComponentProps<typeof Command.Root> = $props();

const statuses = ["Backlog", "Todo", "Done", "Canceled"];
let selectedStatuses = $state<string[]>([]);
let itemRenderKey = $state(0);

function toggleStatus(status: string) {
selectedStatuses = selectedStatuses.includes(status)
? selectedStatuses.filter((value) => value !== status)
: [...selectedStatuses, status];
itemRenderKey++;
}
</script>

<Command.Root {...rest} data-testid="root">
<Command.Input data-testid="input" aria-label="Search" />
<Command.List data-testid="list">
<Command.Viewport data-testid="viewport">
{#key itemRenderKey}
{#each statuses as status (status)}
<Command.Item
value={status}
data-testid={`item-${status.toLowerCase()}`}
onSelect={() => toggleStatus(status)}
>
<span data-testid={`selected-${status.toLowerCase()}`}>
{selectedStatuses.includes(status) ? "selected" : ""}
</span>
{status}
</Command.Item>
{/each}
{/key}
</Command.Viewport>
</Command.List>
</Command.Root>
24 changes: 24 additions & 0 deletions tests/src/tests/command/command.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -22,6 +23,16 @@ function setup(props: Partial<ComponentProps<typeof CommandTest>> = {}) {
};
}

function setupRerenderTest(props: Partial<ComponentProps<typeof CommandRerenderTest>> = {}) {
// 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();

Expand Down Expand Up @@ -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();

Expand Down
Loading