diff --git a/.changeset/match-select-base-ui-behaviour.md b/.changeset/match-select-base-ui-behaviour.md
new file mode 100644
index 00000000..143102e7
--- /dev/null
+++ b/.changeset/match-select-base-ui-behaviour.md
@@ -0,0 +1,8 @@
+---
+'@gpuix/react': patch
+---
+
+Match Select with Base UI when items have no explicit value, when keyboard
+navigation changes the active item, and when items are removed from the list.
+Preserve group and label accessibility semantics and report the correct missing
+ItemText context.
diff --git a/README.md b/README.md
index 78299339..d4a3bbf9 100644
--- a/README.md
+++ b/README.md
@@ -3666,8 +3666,19 @@ so item content can reflect its current state. `Select.ItemText` accepts the
same function child; it renders with the current item state while the label
used by the trigger and typeahead comes from the unhighlighted, unselected
call. Item `label` and `textValue` props override that function-derived label.
-Arrow Up/Down, Home/End and typeahead keep the highlighted item in view. Pointer
-hover updates the highlight without changing the popup's scroll position.
+`Select.Root`'s `itemToStringLabel` resolves object values for the closed
+trigger; without it, values with `label` or `value` fields use those fields.
+`Select.Popup` supplies the listbox role when no `Select.List` is rendered;
+with a `List`, the role belongs to the list and the trigger does not set
+`aria-controls`.
+Opening the popup focuses the selected item, and closing it returns focus to
+the trigger.
+Arrow Up/Down, Home/End and typeahead move focus with the highlighted item and
+keep it in view. Arrow Down on a closed trigger opens the popup; the next arrow
+press moves through the items. Pointer hover updates the highlight without
+changing the popup's scroll position. If a selected item is removed from the
+list, a single Select returns to its initial value when that item remains;
+multiple Select removes values whose items are no longer present.
### Build a local Select
@@ -3793,6 +3804,8 @@ the trigger. Disabled items are skipped. The highlighted item scrolls into
view, and scroll arrows appear only while the list can scroll further in that
direction. Home/End and PageUp/PageDown are not handled.
+`onOpenChangeComplete` runs after the requested open state has rendered.
+
Select parts accept `className` and `style` functions with state matching the
corresponding Base UI part. `Root` renders no element and takes neither prop.
When a part accepts `render={}`, the element's `className` is kept
diff --git a/packages/react/src/__tests__/select-parity.test.tsx b/packages/react/src/__tests__/select-parity.test.tsx
new file mode 100644
index 00000000..3a61dc05
--- /dev/null
+++ b/packages/react/src/__tests__/select-parity.test.tsx
@@ -0,0 +1,1213 @@
+import React, { useState } from "react"
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import * as Select from "../components/select"
+import { createTestRoot, isNativeTestRendererAvailable, type TestRoot } from "../testing.js"
+import { gpuixMatchers, type GpuixMatchers } from "../testing-expect.js"
+
+expect.extend(gpuixMatchers)
+
+declare module "vitest" {
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
+ interface Matchers = void | Promise, T = unknown>
+ extends GpuixMatchers {}
+}
+
+const describeNative = isNativeTestRendererAvailable() ? describe : describe.skip
+const items = ["Almond", "Birch", "Cedar", "Dogwood"]
+
+function Menu({
+ root = {},
+ value = {},
+ item = {},
+}: {
+ root?: Partial>
+ value?: Partial
+ item?: Partial
+}) {
+ return (
+
+
+
+
+
+
+ {items.map((name) => (
+
+ {name}
+
+ ))}
+
+
+
+ )
+}
+
+describeNative("Select Base UI 1.8.0 parity", () => {
+ let screen: TestRoot
+
+ beforeEach(() => {
+ screen = createTestRoot({ width: 480, height: 420 })
+ })
+
+ function clickTrigger() {
+ const rect = screen.getByTestId("trigger").getBoundingClientRect()
+ screen.renderer.nativeSimulateClick(rect.left + rect.width / 2, rect.top + rect.height / 2)
+ }
+
+ function clickItem(name: string) {
+ const rect = screen.getByTestId(name).getBoundingClientRect()
+ screen.renderer.nativeSimulateClick(rect.left + rect.width / 2, rect.top + rect.height / 2)
+ }
+
+ it("selects the item specified by defaultValue", async () => {
+ screen.render()
+ clickTrigger()
+ await screen.waitFor(() => expect(screen.getByTestId("Birch")).toHaveAttribute("data-selected", ""))
+ expect(screen.getByTestId("value")).toHaveTextContent("Birch")
+ })
+
+ it("selects the item specified by a controlled value", async () => {
+ screen.render()
+ clickTrigger()
+ await screen.waitFor(() => expect(screen.getByTestId("Cedar")).toHaveAttribute("data-selected", ""))
+ expect(screen.getByTestId("value")).toHaveTextContent("Cedar")
+ })
+
+ it("updates the controlled selection when value changes", async () => {
+ function App() {
+ const [value, setValue] = useState("Almond")
+ return <>>
+ }
+ screen.render()
+ screen.renderer.nativeSimulateClick(12, 12)
+ await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Dogwood"))
+ clickTrigger()
+ await screen.waitFor(() => expect(screen.getByTestId("Dogwood")).toHaveAttribute("data-selected", ""))
+ })
+
+ it("calls onValueChange once with item-press details when an item is clicked", async () => {
+ const onValueChange = vi.fn()
+ screen.render()
+ clickTrigger()
+ await screen.waitFor(() => expect(screen.getByRole("option", { name: "Almond" })).toBeInTheDocument())
+ clickItem("Cedar")
+ await screen.waitFor(() => expect(onValueChange).toHaveBeenCalledTimes(1))
+ expect(onValueChange.mock.calls[0]?.[0]).toBe("Cedar")
+ expect(onValueChange.mock.calls[0]?.[1].reason).toBe("item-press")
+ expect(screen.getByTestId("value")).toHaveTextContent("Cedar")
+ })
+
+ it("commits an uncontrolled selection and closes the popup", async () => {
+ screen.render()
+ clickTrigger()
+ await screen.waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument())
+ clickItem("Birch")
+ await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Birch"))
+ expect(screen.queryByRole("listbox")).toBeNull()
+ })
+
+ it("reports open and Escape close changes", async () => {
+ const onOpenChange = vi.fn()
+ screen.render()
+ clickTrigger()
+ await screen.waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument())
+ screen.renderer.simulateKeystrokes("escape")
+ await screen.waitFor(() => expect(screen.queryByRole("listbox")).toBeNull())
+ expect(onOpenChange.mock.calls.map(([open]) => open)).toEqual([true, false])
+ expect(onOpenChange.mock.calls.map(([, details]) => details.reason)).toEqual(["trigger-press", "escape-key"])
+ })
+
+ it("opens by default and closes when an item is selected", async () => {
+ screen.render()
+ await screen.waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument())
+ clickItem("Dogwood")
+ await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Dogwood"))
+ expect(screen.queryByRole("listbox")).toBeNull()
+ })
+
+ it("opens with ArrowDown, highlights the first item once, and commits with Enter", async () => {
+ screen.render()
+ screen.renderer.focusElement(screen.getByTestId("trigger").id)
+ screen.renderer.simulateKeystrokes("down")
+ await screen.waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument())
+ await screen.waitFor(() => expect(screen.getByTestId("Almond")).toHaveAttribute("data-highlighted", ""))
+ screen.renderer.simulateKeystrokes("down")
+ await screen.waitFor(() => expect(screen.getByTestId("Birch")).toHaveAttribute("data-highlighted", ""))
+ screen.renderer.simulateKeystrokes("enter")
+ await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Birch"))
+ expect(screen.queryByRole("listbox")).toBeNull()
+ })
+
+ it("moves focus with keyboard navigation between valued items", async () => {
+ screen.render()
+ clickTrigger()
+ await screen.waitFor(() => expect(screen.getByTestId("Almond")).toHaveFocus())
+ screen.renderer.simulateKeystrokes("down")
+ await screen.waitFor(() => expect(screen.getByTestId("Birch")).toHaveAttribute("data-highlighted", ""))
+ expect(screen.getByTestId("Birch")).toHaveFocus()
+ })
+
+ it("moves to the first and last items with Home and End", async () => {
+ screen.render()
+ screen.renderer.simulateKeystrokes("end")
+ await screen.waitFor(() => expect(screen.getByTestId("Dogwood")).toHaveAttribute("data-highlighted", ""))
+ screen.renderer.simulateKeystrokes("home")
+ await screen.waitFor(() => expect(screen.getByTestId("Almond")).toHaveAttribute("data-highlighted", ""))
+ })
+
+ it("uses typeahead to highlight and commit a matching item", async () => {
+ screen.render()
+ screen.renderer.simulateKeystrokes("d")
+ await screen.waitFor(() => expect(screen.getByTestId("Dogwood")).toHaveAttribute("data-highlighted", ""))
+ screen.renderer.simulateKeystrokes("enter")
+ await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Dogwood"))
+ })
+
+ it("commits the next enabled typeahead match from the closed trigger", async () => {
+ screen.render(
+
+
+
+ Apricot
+ Avocado
+
+ ,
+ )
+ screen.renderer.focusElement(screen.getByTestId("trigger").id)
+ screen.renderer.simulateKeystrokes("a")
+ await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Avocado"))
+ expect(screen.queryByRole("listbox")).toBeNull()
+ })
+
+ it("closes on Escape without changing the value", async () => {
+ screen.render()
+ clickTrigger()
+ await screen.waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument())
+ screen.renderer.simulateKeystrokes("escape")
+ await screen.waitFor(() => expect(screen.queryByRole("listbox")).toBeNull())
+ expect(screen.getByTestId("value")).toHaveTextContent("Almond")
+ })
+
+ it("does not open or change value when disabled", async () => {
+ const onValueChange = vi.fn()
+ screen.render()
+ clickTrigger()
+ expect(screen.queryByRole("listbox")).toBeNull()
+ expect(screen.getByTestId("trigger")).toHaveAttribute("data-disabled", "")
+ expect(onValueChange).not.toHaveBeenCalled()
+ })
+
+ it("exposes Base UI popup-open and pressed state on the trigger", async () => {
+ screen.render()
+ clickTrigger()
+ await screen.waitFor(() => expect(screen.getByTestId("trigger")).toHaveAttribute("data-popup-open", ""))
+ expect(screen.getByTestId("trigger")).toHaveAttribute("data-pressed", "")
+ })
+
+ it("marks a null single value as a placeholder on the trigger", async () => {
+ screen.render()
+ expect(screen.getByTestId("trigger")).toHaveAttribute("data-placeholder", "")
+ expect(screen.getByTestId("value")).toHaveAttribute("data-placeholder", "")
+ })
+
+ it("marks a selected null item as a placeholder while showing its label", async () => {
+ screen.render(
+
+
+ ,
+ )
+ expect(screen.getByTestId("trigger")).toHaveAttribute("data-placeholder", "")
+ expect(screen.getByTestId("value")).toHaveAttribute("data-placeholder", "")
+ expect(screen.getByTestId("value")).toHaveTextContent("Select font")
+ })
+
+ it("does not expose the restored item while the popup remains closed", async () => {
+ function DynamicMenu() {
+ const [menuItems, setMenuItems] = useState(["a", "b", "c"])
+ const [value, setValue] = useState("a")
+ return (
+ <>
+
+
+
+
+ {menuItems.map((item) => {item})}
+
+ >
+ )
+ }
+ screen.render()
+ screen.renderer.focusElement(screen.getByTestId("trigger").id)
+ screen.renderer.simulateKeystrokes("down")
+ await screen.waitFor(() => expect(screen.getByRole("option", { name: "a" })).toHaveAttribute("data-selected", ""))
+ const remove = screen.getByTestId("remove").getBoundingClientRect()
+ screen.renderer.nativeSimulateClick(remove.left + remove.width / 2, remove.top + remove.height / 2)
+ await screen.waitFor(() => expect(screen.queryByRole("listbox")).toBeNull())
+ const restore = screen.getByTestId("restore").getBoundingClientRect()
+ screen.renderer.nativeSimulateClick(restore.left + restore.width / 2, restore.top + restore.height / 2)
+ expect(screen.queryByRole("option", { name: "a" })).toBeNull()
+ })
+
+ it("highlights only the active item when navigating items without explicit values", async () => {
+ screen.render(
+
+ Toggle
+
+ Favorite
+ Share
+ Copy
+
+ ,
+ )
+ screen.renderer.simulateKeystrokes("down")
+ await screen.waitFor(() => expect(screen.getByTestId("favorite")).toHaveAttribute("data-highlighted", ""))
+ screen.renderer.simulateKeystrokes("down")
+ await screen.waitFor(() => expect(screen.getByTestId("share")).toHaveAttribute("data-highlighted", ""))
+ expect(screen.getByTestId("favorite")).not.toHaveAttribute("data-highlighted")
+ expect(screen.getByTestId("share")).toHaveFocus()
+ })
+
+ it("keeps keyboard navigation on a remaining item after earlier items are removed", async () => {
+ function DynamicMenu() {
+ const [itemsFiltered, setItemsFiltered] = useState(false)
+ return (
+ {
+ if (open) setTimeout(() => setItemsFiltered(true), 0)
+ }}>
+ Toggle
+
+ Add to Library
+ {!itemsFiltered && <>
+ Add to Playlist
+ Play Next
+ Play Last
+ >}
+ Favorite
+ Share
+
+
+ )
+ }
+ screen.render()
+ screen.renderer.focusElement(screen.getByTestId("trigger").id)
+ screen.renderer.simulateKeystrokes("down")
+ await screen.waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument())
+ await screen.waitFor(() => expect(screen.queryByText("Add to Playlist")).toBeNull())
+ screen.renderer.simulateKeystrokes("down")
+ screen.renderer.simulateKeystrokes("down")
+ screen.renderer.simulateKeystrokes("down")
+ await screen.waitFor(() => expect(screen.getByTestId("share")).toHaveFocus())
+ })
+
+ it("restores the default value when the selected item is removed", async () => {
+ function DynamicMenu() {
+ const [visibleItems, setVisibleItems] = useState(items)
+ return (
+ <>
+
+
+
+ {visibleItems.map((item) => {item})}
+
+
+
+ >
+ )
+ }
+
+ screen.render()
+ clickTrigger()
+ await screen.waitFor(() => expect(screen.getByTestId("Cedar")).toBeInTheDocument())
+ const dogwood = screen.getByTestId("Dogwood").getBoundingClientRect()
+ screen.renderer.nativeSimulateClick(dogwood.left + dogwood.width / 2, dogwood.top + dogwood.height / 2)
+ await screen.waitFor(() => expect(screen.getByTestId("trigger")).toHaveTextContent("Dogwood"))
+ const remove = screen.getByTestId("remove-dogwood").getBoundingClientRect()
+ screen.renderer.nativeSimulateClick(remove.left + remove.width / 2, remove.top + remove.height / 2)
+ await screen.waitFor(() => expect(screen.getByTestId("trigger")).toHaveTextContent("Birch"))
+ })
+
+ it("restores the default value when the selected item is replaced", async () => {
+ function DynamicMenu() {
+ const [visibleItems, setVisibleItems] = useState(items)
+ return (
+ <>
+
+
+
+ {visibleItems.map((item) => {item})}
+
+
+
+ >
+ )
+ }
+
+ screen.render()
+ clickTrigger()
+ await screen.waitFor(() => expect(screen.getByTestId("Dogwood")).toBeInTheDocument())
+ const dogwood = screen.getByTestId("Dogwood").getBoundingClientRect()
+ screen.renderer.nativeSimulateClick(dogwood.left + dogwood.width / 2, dogwood.top + dogwood.height / 2)
+ await screen.waitFor(() => expect(screen.getByTestId("trigger")).toHaveTextContent("Dogwood"))
+ const replace = screen.getByTestId("replace-dogwood").getBoundingClientRect()
+ screen.renderer.nativeSimulateClick(replace.left + replace.width / 2, replace.top + replace.height / 2)
+ await screen.waitFor(() => expect(screen.getByTestId("trigger")).toHaveTextContent("Birch"))
+ })
+
+ it("removes multiple selections when their items are removed", async () => {
+ function DynamicMenu() {
+ const [visibleItems, setVisibleItems] = useState(items)
+ return (
+ <>
+
+
+
+ {visibleItems.map((item) => {item})}
+
+
+
+ >
+ )
+ }
+
+ screen.render()
+ await screen.waitFor(() => expect(screen.getByTestId("trigger")).toHaveTextContent("Almond, Dogwood"))
+ const remove = screen.getByTestId("remove-dogwood").getBoundingClientRect()
+ screen.renderer.nativeSimulateClick(remove.left + remove.width / 2, remove.top + remove.height / 2)
+ await screen.waitFor(() => expect(screen.getByTestId("trigger")).toHaveTextContent("Almond"))
+ })
+
+ it("clears a controlled selection when its item is removed", async () => {
+ function DynamicMenu() {
+ const [visibleItems, setVisibleItems] = useState(items)
+ const [value, setValue] = useState("Dogwood")
+ return (
+ <>
+
+
+
+ {visibleItems.map((item) => {item})}
+
+
+
+ >
+ )
+ }
+
+ screen.render()
+ await screen.waitFor(() => expect(screen.getByTestId("trigger")).toHaveTextContent("Dogwood"))
+ const remove = screen.getByTestId("remove-dogwood").getBoundingClientRect()
+ screen.renderer.nativeSimulateClick(remove.left + remove.width / 2, remove.top + remove.height / 2)
+ await screen.waitFor(() => expect(screen.getByTestId("trigger")).toHaveTextContent(/^$/))
+ })
+
+ it("preserves a controlled selection when its removal is cancelled", async () => {
+ function DynamicMenu() {
+ const [visibleItems, setVisibleItems] = useState(items)
+ return (
+ <>
+ details.cancel()}
+ >
+
+
+ {visibleItems.map((item) => {item})}
+
+
+
+
+ >
+ )
+ }
+
+ screen.render()
+ await screen.waitFor(() => expect(screen.getByTestId("trigger")).toHaveTextContent("Dogwood"))
+ const remove = screen.getByTestId("remove-dogwood").getBoundingClientRect()
+ screen.renderer.nativeSimulateClick(remove.left + remove.width / 2, remove.top + remove.height / 2)
+ await screen.waitFor(() => expect(screen.queryByRole("option", { name: "Dogwood" })).toBeNull())
+ const restore = screen.getByTestId("restore-dogwood").getBoundingClientRect()
+ screen.renderer.nativeSimulateClick(restore.left + restore.width / 2, restore.top + restore.height / 2)
+ await screen.waitFor(() => expect(screen.getByTestId("trigger")).toHaveTextContent("Dogwood"))
+ })
+
+
+ it("does not mark a selected single value as a placeholder", async () => {
+ screen.render()
+ expect(screen.getByTestId("trigger")).not.toHaveAttribute("data-placeholder")
+ expect(screen.getByTestId("value")).not.toHaveAttribute("data-placeholder")
+ })
+
+ it("does not mark a nonempty multiple selection as a placeholder", async () => {
+ screen.render()
+ expect(screen.getByTestId("trigger")).not.toHaveAttribute("data-placeholder")
+ expect(screen.getByTestId("value")).not.toHaveAttribute("data-placeholder")
+ })
+
+ it("allows browsing in read-only mode but does not commit a selection", async () => {
+ const onValueChange = vi.fn()
+ screen.render()
+ clickTrigger()
+ await screen.waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument())
+ screen.renderer.simulateKeystrokes("d")
+ await screen.waitFor(() => expect(screen.getByTestId("Dogwood")).toHaveAttribute("data-highlighted", ""))
+ screen.renderer.simulateKeystrokes("enter")
+ expect(screen.getByTestId("value")).toHaveTextContent("Choose a tree")
+ expect(onValueChange).not.toHaveBeenCalled()
+ })
+
+ it("keeps the popup open while multiple items are selected and toggled", async () => {
+ function App() {
+ const [value, setValue] = useState([])
+ return