From 77b8ea115160f3c246155f5cb047f76026de9cec Mon Sep 17 00:00:00 2001 From: Jose Silva Date: Thu, 9 Jul 2026 23:06:41 +0100 Subject: [PATCH 1/2] fix(Command): rename item's data-selected attribute to data-highlighted Command.Item toggles its active-item attribute on every hover transition. Sharing the data-selected name with Calendar/Select/Pagination (persistent selection) means any consumer CSS containing :has([data-selected]) makes Chromium's :has() invalidator run large style recalcs per hover, freezing the page (#2044). data-highlighted matches the transient-state convention used by Select, Combobox, Menu, and RangeCalendar's hover preview. --- .changeset/heavy-hairs-hover.md | 5 +++++ .../demos/command-demo-dialog.svelte | 12 +++++------ .../lib/components/demos/command-demo.svelte | 12 +++++------ .../components/demos/command-grid-demo.svelte | 4 ++-- .../examples/command/framer/framer.css | 2 +- .../examples/command/linear/linear.css | 2 +- .../examples/command/raycast/raycast.css | 2 +- .../examples/command/vercel/vercel.css | 2 +- docs/src/lib/components/search.svelte | 2 +- .../lib/content/api-reference/command.api.ts | 4 ++-- docs/src/lib/styles/command/command.css | 2 +- .../src/lib/bits/command/command.svelte.ts | 4 ++-- .../command/command-grid.browser.test.ts | 20 +++++++++---------- .../command/command-scroll.browser.test.ts | 8 ++++---- .../src/tests/command/command.browser.test.ts | 18 ++++++++--------- 15 files changed, 52 insertions(+), 47 deletions(-) create mode 100644 .changeset/heavy-hairs-hover.md diff --git a/.changeset/heavy-hairs-hover.md b/.changeset/heavy-hairs-hover.md new file mode 100644 index 000000000..ad4ef0e6f --- /dev/null +++ b/.changeset/heavy-hairs-hover.md @@ -0,0 +1,5 @@ +--- +"bits-ui": minor +--- + +breaking(Command): rename `Command.Item`'s transient `data-selected` attribute to `data-highlighted` to prevent Chromium `:has([data-selected])` invalidation collisions with Calendar/Select persistent selection (#2044) — migrate `[data-selected]`/`data-selected:` styles on Command items to `data-highlighted`; `aria-selected` is unchanged diff --git a/docs/src/lib/components/demos/command-demo-dialog.svelte b/docs/src/lib/components/demos/command-demo-dialog.svelte index ee9b14ad4..8738d8d58 100644 --- a/docs/src/lib/components/demos/command-demo-dialog.svelte +++ b/docs/src/lib/components/demos/command-demo-dialog.svelte @@ -61,21 +61,21 @@ Introduction Delegation @@ -92,21 +92,21 @@ Calendar Radio Group diff --git a/docs/src/lib/components/demos/command-demo.svelte b/docs/src/lib/components/demos/command-demo.svelte index 259276c4e..0d6975256 100644 --- a/docs/src/lib/components/demos/command-demo.svelte +++ b/docs/src/lib/components/demos/command-demo.svelte @@ -28,21 +28,21 @@ Introduction Delegation @@ -57,21 +57,21 @@ Calendar Radio Group diff --git a/docs/src/lib/components/demos/command-grid-demo.svelte b/docs/src/lib/components/demos/command-grid-demo.svelte index c09bbfda4..66939a18a 100644 --- a/docs/src/lib/components/demos/command-grid-demo.svelte +++ b/docs/src/lib/components/demos/command-grid-demo.svelte @@ -184,7 +184,7 @@ {#each group.items as groupItem (groupItem)} @@ -216,7 +216,7 @@ {#each group.items as groupItem (groupItem)} { searchQuery = ""; open = false; diff --git a/docs/src/lib/content/api-reference/command.api.ts b/docs/src/lib/content/api-reference/command.api.ts index e8a992c65..3a190da96 100644 --- a/docs/src/lib/content/api-reference/command.api.ts +++ b/docs/src/lib/content/api-reference/command.api.ts @@ -242,8 +242,8 @@ const item = defineComponentApiSchema({ description: "Present when the item is disabled.", }), defineSimpleDataAttr({ - name: "selected", - description: "Present when the item is selected.", + name: "highlighted", + description: "Present when the item is highlighted, via keyboard navigation or pointer hover.", }), defineSimpleDataAttr({ name: "command-item", diff --git a/docs/src/lib/styles/command/command.css b/docs/src/lib/styles/command/command.css index 7777fb675..4df60b67b 100644 --- a/docs/src/lib/styles/command/command.css +++ b/docs/src/lib/styles/command/command.css @@ -231,7 +231,7 @@ height: 14px; } - &[data-selected] { + &[data-highlighted] { color: var(--gray12); &:hover .activeTheme { 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..607ee5cfd 100644 --- a/packages/bits-ui/src/lib/bits/command/command.svelte.ts +++ b/packages/bits-ui/src/lib/bits/command/command.svelte.ts @@ -439,7 +439,7 @@ export class CommandRootState { const node = this.opts.ref.current; if (!node) return; const selectedNode = node.querySelector( - `${COMMAND_VALID_ITEM_SELECTOR}[data-selected]` + `${COMMAND_VALID_ITEM_SELECTOR}[data-highlighted]` ); if (!selectedNode) return; return selectedNode; @@ -1477,7 +1477,7 @@ export class CommandItemState { "aria-disabled": boolToStr(this.opts.disabled.current), "aria-selected": boolToStr(this.isSelected), "data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current), - "data-selected": boolToEmptyStrOrUndef(this.isSelected), + "data-highlighted": boolToEmptyStrOrUndef(this.isSelected), "data-value": this.trueValue, "data-group": this.#group?.trueValue, [commandAttrs.item]: "", diff --git a/tests/src/tests/command/command-grid.browser.test.ts b/tests/src/tests/command/command-grid.browser.test.ts index 25e230c68..733a26b85 100644 --- a/tests/src/tests/command/command-grid.browser.test.ts +++ b/tests/src/tests/command/command-grid.browser.test.ts @@ -25,12 +25,12 @@ function setup(props: Partial> = {}) { it("should select the first item by default", async () => { setup(); - await expect.element(page.getByText("Introduction")).toHaveAttribute("data-selected"); + await expect.element(page.getByText("Introduction")).toHaveAttribute("data-highlighted"); }); it("should allow forcing the selected value", async () => { setup({ value: "Introduction" }); - await expect.element(page.getByText("Introduction")).toHaveAttribute("data-selected"); + await expect.element(page.getByText("Introduction")).toHaveAttribute("data-highlighted"); }); it("should render the separator when search is empty and remove it when search is not empty", async () => { @@ -74,13 +74,13 @@ it("should restore original order when search is cleared", async () => { input.focus(); await userEvent.keyboard("d"); await expect.element(t.input).toHaveValue("d"); - await expect.element(page.getByText("Delegation")).toHaveAttribute("data-selected"); + await expect.element(page.getByText("Delegation")).toHaveAttribute("data-highlighted"); await expect .element(page.getByTestId("group-a-items").element().children[0]) .toHaveTextContent("Delegation"); await userEvent.keyboard(kbd.BACKSPACE); await expect.element(t.input).toHaveValue(""); - await expect.element(page.getByText("Introduction")).toHaveAttribute("data-selected"); + await expect.element(page.getByText("Introduction")).toHaveAttribute("data-highlighted"); await expect .element(page.getByTestId("group-a-items").element().children[0]) .toHaveTextContent("Introduction"); @@ -92,18 +92,18 @@ it("should hide the group if all items are filtered out", async () => { await userEvent.type(t.input.element(), "radio"); await expect.element(page.getByTestId("group-a")).not.toBeVisible(); await expect.element(page.getByTestId("group-b")).toBeVisible(); - await expect.element(page.getByText("Radio Group")).toHaveAttribute("data-selected"); + await expect.element(page.getByText("Radio Group")).toHaveAttribute("data-highlighted"); }); it("should navigate to the correct column in the next row", async () => { const t = setup(); - await expect.element(page.getByText("Introduction")).toHaveAttribute("data-selected"); + await expect.element(page.getByText("Introduction")).toHaveAttribute("data-highlighted"); await userEvent.type(t.input.element(), kbd.ARROW_DOWN); - await expect.element(page.getByText("Calendar")).toHaveAttribute("data-selected"); + await expect.element(page.getByText("Calendar")).toHaveAttribute("data-highlighted"); await userEvent.type(t.input.element(), kbd.ARROW_UP); - await expect.element(page.getByText("Introduction")).toHaveAttribute("data-selected"); + await expect.element(page.getByText("Introduction")).toHaveAttribute("data-highlighted"); await userEvent.type(t.input.element(), kbd.ARROW_RIGHT); - await expect.element(page.getByText("Delegation")).toHaveAttribute("data-selected"); + await expect.element(page.getByText("Delegation")).toHaveAttribute("data-highlighted"); await userEvent.type(t.input.element(), kbd.ARROW_DOWN); - await expect.element(page.getByText("Radio Group")).toHaveAttribute("data-selected"); + await expect.element(page.getByText("Radio Group")).toHaveAttribute("data-highlighted"); }); diff --git a/tests/src/tests/command/command-scroll.browser.test.ts b/tests/src/tests/command/command-scroll.browser.test.ts index 3028a4933..5f4d815aa 100644 --- a/tests/src/tests/command/command-scroll.browser.test.ts +++ b/tests/src/tests/command/command-scroll.browser.test.ts @@ -24,7 +24,7 @@ it("should scroll initial value into view when it's not the first item", async ( setup({ value: "Popover" }); const item = page.getByText("Popover"); - await expect.element(item).toHaveAttribute("data-selected"); + await expect.element(item).toHaveAttribute("data-highlighted"); // check that the item is visible in the viewport const itemElement = item.element() as HTMLElement; @@ -45,7 +45,7 @@ it("should scroll initial value in the middle of the list into view", async () = setup({ value: "Radio Group" }); const item = page.getByText("Radio Group"); - await expect.element(item).toHaveAttribute("data-selected"); + await expect.element(item).toHaveAttribute("data-highlighted"); const itemElement = item.element() as HTMLElement; const viewport = page.getByTestId("viewport").element() as HTMLElement; @@ -65,7 +65,7 @@ it("should respect disableInitialScroll prop and not scroll", async () => { setup({ value: "Popover", disableInitialScroll: true }); const item = page.getByText("Popover"); - await expect.element(item).toHaveAttribute("data-selected"); + await expect.element(item).toHaveAttribute("data-highlighted"); const viewport = page.getByTestId("viewport").element() as HTMLElement; @@ -80,7 +80,7 @@ it("should not scroll when initial value is the first item", async () => { setup({ value: "Introduction" }); const item = page.getByText("Introduction"); - await expect.element(item).toHaveAttribute("data-selected"); + await expect.element(item).toHaveAttribute("data-highlighted"); const viewport = page.getByTestId("viewport").element() as HTMLElement; diff --git a/tests/src/tests/command/command.browser.test.ts b/tests/src/tests/command/command.browser.test.ts index df894036e..dc139aa92 100644 --- a/tests/src/tests/command/command.browser.test.ts +++ b/tests/src/tests/command/command.browser.test.ts @@ -27,29 +27,29 @@ it("should select the first item by default", async () => { // since we aren't hardcoding a value for the item, we need to wait for // the component to render before we can check if the first item is selected - await expect.element(page.getByText("Introduction")).toHaveAttribute("data-selected"); + await expect.element(page.getByText("Introduction")).toHaveAttribute("data-highlighted"); }); it("should allow forcing the selected value", async () => { setup({ value: "Introduction" }); - await expect.element(page.getByText("Introduction")).toHaveAttribute("data-selected"); + await expect.element(page.getByText("Introduction")).toHaveAttribute("data-highlighted"); }); it("should respect initial value when it's not the first item", async () => { setup({ value: "Radio Group" }); // ensure the initial value is selected, not the first item - await expect.element(page.getByText("Radio Group")).toHaveAttribute("data-selected"); - await expect.element(page.getByText("Introduction")).not.toHaveAttribute("data-selected"); + await expect.element(page.getByText("Radio Group")).toHaveAttribute("data-highlighted"); + await expect.element(page.getByText("Introduction")).not.toHaveAttribute("data-highlighted"); }); it("should respect initial value for items in the first group", async () => { setup({ value: "Styling" }); // ensure the provided value is selected, not the first item - await expect.element(page.getByText("Styling")).toHaveAttribute("data-selected"); - await expect.element(page.getByText("Introduction")).not.toHaveAttribute("data-selected"); + await expect.element(page.getByText("Styling")).toHaveAttribute("data-highlighted"); + await expect.element(page.getByText("Introduction")).not.toHaveAttribute("data-highlighted"); }); it("should render the separator when search is empty and remove it when search is not empty", async () => { @@ -88,13 +88,13 @@ it("should restore original order when search is cleared", async () => { (t.input.element() as HTMLElement).focus(); await userEvent.keyboard("d"); await expect.element(t.input).toHaveValue("d"); - await expect.element(page.getByText("Delegation")).toHaveAttribute("data-selected"); + await expect.element(page.getByText("Delegation")).toHaveAttribute("data-highlighted"); await expect .element(page.getByTestId("group-a-items").element().children[0]) .toHaveTextContent("Delegation"); await userEvent.keyboard(kbd.BACKSPACE); await expect.element(t.input).toHaveValue(""); - await expect.element(page.getByText("Introduction")).toHaveAttribute("data-selected"); + await expect.element(page.getByText("Introduction")).toHaveAttribute("data-highlighted"); await expect .element(page.getByTestId("group-a-items").element().children[0]) .toHaveTextContent("Introduction"); @@ -106,5 +106,5 @@ it("should hide the group if all items are filtered out", async () => { await userEvent.type(t.input, "radio"); await expect.element(page.getByTestId("group-a")).not.toBeVisible(); await expect.element(page.getByTestId("group-b")).toBeVisible(); - await expect.element(page.getByText("Radio Group")).toHaveAttribute("data-selected"); + await expect.element(page.getByText("Radio Group")).toHaveAttribute("data-highlighted"); }); From 9058e9288ef8963470ae4cb9ea67b97ce9b89fef Mon Sep 17 00:00:00 2001 From: Jose Silva Date: Thu, 9 Jul 2026 23:42:48 +0100 Subject: [PATCH 2/2] chore: retrigger CI (flaky combobox outside-click test)