Skip to content

Commit 35b4c3f

Browse files
authored
fix(Command): scroll initial selected into view (#1896)
1 parent dbd227f commit 35b4c3f

File tree

4 files changed

+212
-0
lines changed

4 files changed

+212
-0
lines changed

.changeset/curly-buses-speak.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"bits-ui": patch
3+
---
4+
5+
fix(Command): scroll initial selected into view

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ export class CommandRootState {
193193
// we select the first item.
194194
if (!this._commandState.value || !this.#isInitialMount) {
195195
this.#selectFirstItem();
196+
} else if (this.#isInitialMount && this._commandState.value) {
197+
// scroll the initial value into view if it exists
198+
this.#scrollInitialValue();
196199
}
197200
return;
198201
}
@@ -297,6 +300,20 @@ export class CommandRootState {
297300
});
298301
}
299302

303+
/**
304+
* Scrolls the initial value into view if it exists and is not the first item.
305+
* Called during initial mount when a value is provided.
306+
*/
307+
#scrollInitialValue(): void {
308+
afterTick(() => {
309+
const shouldPreventScroll = this.opts.disableInitialScroll.current;
310+
if (!shouldPreventScroll) {
311+
this.#scrollSelectedIntoView();
312+
}
313+
this.#isInitialMount = false;
314+
});
315+
}
316+
300317
/**
301318
* Updates filtered items/groups based on search.
302319
* Recalculates scores and filtered count.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<script lang="ts">
2+
import { Command } from "bits-ui";
3+
import type { ComponentProps } from "svelte";
4+
5+
let {
6+
separatorProps,
7+
...rest
8+
}: ComponentProps<typeof Command.Root> & {
9+
separatorProps?: ComponentProps<typeof Command.Separator>;
10+
} = $props();
11+
</script>
12+
13+
<Command.Root {...rest} data-testid="root">
14+
<Command.Input data-testid="input" aria-label="Search" />
15+
<Command.List data-testid="list">
16+
<Command.Viewport data-testid="viewport" style="max-height: 150px; overflow-y: auto;">
17+
<Command.Empty data-testid="empty">No results found.</Command.Empty>
18+
<Command.Group data-testid="group-a">
19+
<Command.GroupHeading data-testid="group-a-heading">
20+
Suggestions
21+
</Command.GroupHeading>
22+
<Command.GroupItems data-testid="group-a-items">
23+
<Command.Item
24+
data-testid="item-introduction"
25+
keywords={["getting started", "tutorial"]}
26+
style="height: 40px;"
27+
>
28+
Introduction
29+
</Command.Item>
30+
<Command.Item
31+
data-testid="item-delegation"
32+
keywords={["child", "custom element", "snippets"]}
33+
style="height: 40px;"
34+
>
35+
Delegation
36+
</Command.Item>
37+
<Command.Item
38+
data-testid="item-styling"
39+
keywords={["css", "theme", "colors", "fonts", "tailwind"]}
40+
style="height: 40px;"
41+
>
42+
Styling
43+
</Command.Item>
44+
</Command.GroupItems>
45+
</Command.Group>
46+
<Command.Separator data-testid="separator" {...separatorProps} />
47+
<Command.Group data-testid="group-b">
48+
<Command.GroupHeading data-testid="group-b-heading">Components</Command.GroupHeading
49+
>
50+
<Command.GroupItems data-testid="group-b-items">
51+
<Command.Item
52+
data-testid="item-calendar"
53+
keywords={["dates", "times"]}
54+
style="height: 40px;"
55+
>
56+
Calendar
57+
</Command.Item>
58+
<Command.Item
59+
data-testid="item-radio-group"
60+
keywords={["buttons", "forms"]}
61+
style="height: 40px;"
62+
>
63+
Radio Group
64+
</Command.Item>
65+
<Command.Item
66+
data-testid="item-combobox"
67+
keywords={["inputs", "text", "autocomplete"]}
68+
style="height: 40px;"
69+
>
70+
Combobox
71+
</Command.Item>
72+
<Command.Item
73+
data-testid="item-select"
74+
keywords={["dropdown", "picker"]}
75+
style="height: 40px;"
76+
>
77+
Select
78+
</Command.Item>
79+
<Command.Item
80+
data-testid="item-dialog"
81+
keywords={["modal", "popup"]}
82+
style="height: 40px;"
83+
>
84+
Dialog
85+
</Command.Item>
86+
<Command.Item
87+
data-testid="item-popover"
88+
keywords={["tooltip", "overlay"]}
89+
style="height: 40px;"
90+
>
91+
Popover
92+
</Command.Item>
93+
</Command.GroupItems>
94+
</Command.Group>
95+
</Command.Viewport>
96+
</Command.List>
97+
</Command.Root>
98+
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { page } from "@vitest/browser/context";
2+
import { expect, it } from "vitest";
3+
import { render } from "vitest-browser-svelte";
4+
import type { ComponentProps } from "svelte";
5+
import CommandScrollTest from "./command-scroll-test.svelte";
6+
7+
function setup(props: Partial<ComponentProps<typeof CommandScrollTest>> = {}) {
8+
// oxlint-disable-next-line no-explicit-any
9+
const returned = render(CommandScrollTest, props as any);
10+
const input = page.getByTestId("input");
11+
const root = page.getByTestId("root");
12+
const list = page.getByTestId("list");
13+
const viewport = page.getByTestId("viewport");
14+
return {
15+
...returned,
16+
root,
17+
input,
18+
list,
19+
viewport,
20+
};
21+
}
22+
23+
it("should scroll initial value into view when it's not the first item", async () => {
24+
setup({ value: "Popover" });
25+
26+
const item = page.getByText("Popover");
27+
await expect.element(item).toHaveAttribute("data-selected");
28+
29+
// check that the item is visible in the viewport
30+
const itemElement = item.element() as HTMLElement;
31+
const viewport = page.getByTestId("viewport").element() as HTMLElement;
32+
33+
// wait for any scroll animations to complete
34+
await new Promise((resolve) => setTimeout(resolve, 150));
35+
36+
const itemRect = itemElement.getBoundingClientRect();
37+
const viewportRect = viewport.getBoundingClientRect();
38+
39+
// verify the selected item is within the viewport bounds
40+
expect(itemRect.top).toBeGreaterThanOrEqual(viewportRect.top - 1); // allow 1px tolerance
41+
expect(itemRect.bottom).toBeLessThanOrEqual(viewportRect.bottom + 1); // allow 1px tolerance
42+
});
43+
44+
it("should scroll initial value in the middle of the list into view", async () => {
45+
setup({ value: "Radio Group" });
46+
47+
const item = page.getByText("Radio Group");
48+
await expect.element(item).toHaveAttribute("data-selected");
49+
50+
const itemElement = item.element() as HTMLElement;
51+
const viewport = page.getByTestId("viewport").element() as HTMLElement;
52+
53+
// wait for any scroll animations to complete
54+
await new Promise((resolve) => setTimeout(resolve, 150));
55+
56+
const itemRect = itemElement.getBoundingClientRect();
57+
const viewportRect = viewport.getBoundingClientRect();
58+
59+
// verify the selected item is within the viewport bounds
60+
expect(itemRect.top).toBeGreaterThanOrEqual(viewportRect.top - 1);
61+
expect(itemRect.bottom).toBeLessThanOrEqual(viewportRect.bottom + 1);
62+
});
63+
64+
it("should respect disableInitialScroll prop and not scroll", async () => {
65+
setup({ value: "Popover", disableInitialScroll: true });
66+
67+
const item = page.getByText("Popover");
68+
await expect.element(item).toHaveAttribute("data-selected");
69+
70+
const viewport = page.getByTestId("viewport").element() as HTMLElement;
71+
72+
// wait a bit to ensure no scrolling happens
73+
await new Promise((resolve) => setTimeout(resolve, 150));
74+
75+
// with disableInitialScroll, viewport should remain at the top
76+
expect(viewport.scrollTop).toBe(0);
77+
});
78+
79+
it("should not scroll when initial value is the first item", async () => {
80+
setup({ value: "Introduction" });
81+
82+
const item = page.getByText("Introduction");
83+
await expect.element(item).toHaveAttribute("data-selected");
84+
85+
const viewport = page.getByTestId("viewport").element() as HTMLElement;
86+
87+
// wait a bit
88+
await new Promise((resolve) => setTimeout(resolve, 150));
89+
90+
// viewport should remain at the top since first item is already visible
91+
expect(viewport.scrollTop).toBe(0);
92+
});

0 commit comments

Comments
 (0)