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 setValue(next as string[]) }} /> + } + screen.render() + clickTrigger() + await screen.waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument()) + clickItem("Almond") + await screen.waitFor(() => expect(screen.getByTestId("Almond")).toHaveAttribute("data-selected", "")) + expect(screen.getByRole("listbox")).toBeInTheDocument() + clickItem("Birch") + await screen.waitFor(() => expect(screen.getByTestId("Birch")).toHaveAttribute("data-selected", "")) + expect(screen.getByTestId("value")).toHaveTextContent("Almond, Birch") + clickItem("Almond") + await screen.waitFor(() => expect(screen.getByTestId("Almond")).not.toHaveAttribute("data-selected")) + expect(screen.getByRole("listbox")).toBeInTheDocument() + expect(screen.getByTestId("value")).toHaveTextContent("Birch") + }) + + it("uses items as a label lookup while the popup is closed", async () => { + screen.render( + + + Cedar tree + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Cedar tree")) + }) + + it("uses the rendered item label when no items lookup is supplied", async () => { + screen.render( + + + Cedar tree + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Cedar tree")) + }) + + it("uses the children callback to render a value-dependent label", async () => { + screen.render( + + {(value) => `Selected ${value}`} + Cedar tree + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Selected cedar")) + }) + + it("updates data-selected and data-highlighted state for the matching items", async () => { + screen.render() + await screen.waitFor(() => expect(screen.getByTestId("Birch")).toHaveAttribute("data-selected", "")) + screen.renderer.simulateKeystrokes("down") + await screen.waitFor(() => expect(screen.getByTestId("Cedar")).toHaveAttribute("data-highlighted", "")) + expect(screen.getByTestId("Birch")).not.toHaveAttribute("data-highlighted") + }) + + it("renders selected and highlighted state to a function child", async () => { + screen.render( + + + + {({ selected, highlighted }) => `${selected ? "selected" : "not selected"}/${highlighted ? "highlighted" : "not highlighted"}`} + + , + ) + await screen.waitFor(() => expect(screen.getByText("selected/highlighted")).toBeInTheDocument()) + }) + + it("looks up labels from an items array", async () => { + screen.render( + + + Cedar tree + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Cedar tree")) + }) + + it("falls back to the raw value when no item label is known", async () => { + screen.render( + + + Known + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("unknown")) + }) + + it("gives rendered SelectValue children precedence over the items lookup", async () => { + screen.render( + + Custom label + Cedar tree + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Custom label")) + }) + + it("renders the placeholder for an empty single selection", async () => { + screen.render() + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Pick one")) + expect(screen.getByTestId("trigger")).toHaveAttribute("data-placeholder", "") + }) + + it("renders comma-separated labels for multiple selected values", async () => { + screen.render( + + + AB + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Almond, Birch")) + }) + + it("passes the array of selected values to a SelectValue child callback", async () => { + const seen: unknown[] = [] + screen.render( + + {(value) => { seen.push(value); return Array.isArray(value) ? `${value.length} selected` : "invalid" }} + AB + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("2 selected")) + expect(seen.at(-1)).toEqual(["a", "b"]) + }) + + it("selects object values with the provided equality comparator", async () => { + const item = { id: 1 } + const value = { id: 1 } + screen.render( + String(candidate.id)} itemToStringValue={(candidate) => String(candidate.id)} isItemEqualToValue={(candidate, selected) => candidate.id === selected.id}> + Object value + One + , + ) + clickTrigger() + await screen.waitFor(() => expect(screen.getByRole("option", { name: "One" })).toHaveAttribute("data-selected", "")) + }) + + it("exposes group labels and keeps group items selectable", async () => { + screen.render( + + + + + Conifers + Cedar + + + , + ) + await screen.waitFor(() => expect(screen.getByTestId("group-label")).toHaveTextContent("Conifers")) + expect(screen.getByTestId("group")).toHaveAttribute("role", "group") + expect(screen.getByTestId("group")).toHaveAttribute("aria-labelledby", String(screen.getByTestId("group-label").authorId)) + expect(screen.getByTestId("group-label")).toHaveAttribute("aria-hidden", "true") + expect(screen.getByTestId("cedar")).toHaveAttribute("role", "option") + }) + + it("allows the group label to opt back into the accessibility tree", async () => { + screen.render( + + + + + Conifers + + + , + ) + await screen.waitFor(() => expect(screen.getByTestId("group-label")).toHaveTextContent("Conifers")) + expect(screen.getByTestId("group-label")).not.toHaveAttribute("aria-hidden") + }) + + it("requires group labels to be rendered inside Select.Group", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + screen.render(Group) + expect(error.mock.calls.flat().join(" ")).toContain("Base UI: SelectGroupContext is missing. SelectGroup parts must be placed within .") + } finally { + error.mockRestore() + } + }) + + it("clears the group label association when its label unmounts", async () => { + function App({ label }: { label: boolean }) { + return {label && Conifers} + } + screen.render() + await screen.waitFor(() => expect(screen.getByTestId("group")).toHaveAttribute("aria-labelledby", "group-label")) + screen.render() + await screen.waitFor(() => expect(screen.getByTestId("group")).not.toHaveAttribute("aria-labelledby")) + }) + + it("does not let an older group label cleanup clear a newer label", async () => { + function App({ labels }: { labels: "old" | "both" | "new" }) { + return + {labels !== "new" && Old} + {labels !== "old" && New} + + } + screen.render() + await screen.waitFor(() => expect(screen.getByTestId("group")).toHaveAttribute("aria-labelledby", "old-label")) + screen.render() + await screen.waitFor(() => expect(screen.getByTestId("group")).toHaveAttribute("aria-labelledby", "new-label")) + screen.render() + await screen.waitFor(() => expect(screen.getByTestId("group")).toHaveAttribute("aria-labelledby", "new-label")) + }) + + it("updates explicit and generated group label ids", async () => { + function App({ id }: { id?: string }) { + return Conifers + } + screen.render() + await screen.waitFor(() => expect(screen.getByTestId("group")).toHaveAttribute("aria-labelledby")) + const generatedId = screen.getByTestId("group-label").authorId + expect(screen.getByTestId("group")).toHaveAttribute("aria-labelledby", String(generatedId)) + screen.render() + await screen.waitFor(() => expect(screen.getByTestId("group")).toHaveAttribute("aria-labelledby", "custom-label")) + screen.render() + await screen.waitFor(() => expect(screen.getByTestId("group")).toHaveAttribute("aria-labelledby", String(generatedId))) + }) + + it("registers and unregisters group labels in StrictMode", async () => { + function App({ id }: { id?: string }) { + return {id && {id}} + } + screen.render() + await screen.waitFor(() => expect(screen.getByTestId("group")).toHaveAttribute("aria-labelledby", "first-label")) + screen.render() + await screen.waitFor(() => expect(screen.getByTestId("group")).toHaveAttribute("aria-labelledby", "second-label")) + screen.render() + await screen.waitFor(() => expect(screen.getByTestId("group")).not.toHaveAttribute("aria-labelledby")) + }) + + it("provides selected state to ItemText children", async () => { + screen.render( + + + {({ selected }) => selected ? "Chosen" : "Unchosen"} + , + ) + await screen.waitFor(() => expect(screen.getByTestId("item-text")).toHaveTextContent("Chosen")) + }) + + it("requires Select.ItemText to be rendered inside Select.Item", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + screen.render() + expect(error.mock.calls.flat().join(" ")).toContain("SelectItemContext is missing. SelectItem parts must be placed within .") + } finally { + error.mockRestore() + } + }) + + it("keeps a disabled item out of the keyboard highlight and selection", async () => { + const onValueChange = vi.fn() + screen.render( + + + + Disabled + Enabled + + , + ) + screen.renderer.simulateKeystrokes("down") + await screen.waitFor(() => expect(screen.getByTestId("enabled")).toHaveAttribute("data-highlighted", "")) + expect(screen.getByTestId("disabled")).toHaveAttribute("aria-disabled", "true") + clickItem("disabled") + expect(onValueChange).not.toHaveBeenCalled() + }) + + it("treats a null value as an empty selection in multiple mode", async () => { + const onValueChange = vi.fn() + screen.render( + + + AB + , + ) + expect(screen.getByTestId("a")).not.toHaveAttribute("data-selected") + clickItem("a") + await screen.waitFor(() => expect(onValueChange).toHaveBeenCalledWith(["a"], expect.anything())) + }) + + it("keeps selected values when grouped items are reordered and inserted", async () => { + function App({ names }: { names: string[] }) { + return + {names.map((name) => {name})} + + } + screen.render() + await screen.waitFor(() => expect(screen.getByTestId("cedar")).toHaveAttribute("data-selected", "")) + screen.render() + await screen.waitFor(() => expect(screen.getByTestId("cedar")).toHaveAttribute("data-selected", "")) + expect(screen.getByTestId("fir")).not.toHaveAttribute("data-selected") + }) + + it("calls an item click handler exactly once", async () => { + const onClick = vi.fn() + screen.render() + clickTrigger() + await screen.waitFor(() => expect(screen.getByRole("option", { name: "Cedar" })).toBeInTheDocument()) + clickItem("Cedar") + await screen.waitFor(() => expect(onClick).toHaveBeenCalledTimes(1)) + }) + + it("cancels an uncontrolled open request through event details", async () => { + screen.render( { if (open) details.cancel() } }} />) + clickTrigger() + await screen.waitFor(() => expect(screen.queryByRole("listbox")).toBeNull()) + expect(screen.getByTestId("trigger")).toHaveAttribute("aria-expanded", "false") + }) + + it("calls onOpenChangeComplete after the requested open state is rendered", async () => { + const completed = vi.fn() + screen.render() + clickTrigger() + await screen.waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument()) + expect(completed).toHaveBeenCalledWith(true) + }) + + it("calls onOpenChangeComplete after the popup reflects the completed state", async () => { + const popupStatesAtCompletion: boolean[] = [] + screen.render( { + const popup = screen.getByTestId("popup") + if (open) expect(popup).toHaveAttribute("data-open", "") + else expect(popup).not.toHaveAttribute("data-open") + popupStatesAtCompletion.push(open) + } }} />) + clickTrigger() + await screen.waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument()) + await screen.waitFor(() => expect(popupStatesAtCompletion).toHaveLength(1)) + expect(popupStatesAtCompletion).toEqual([true]) + screen.renderer.simulateKeystrokes("escape") + await screen.waitFor(() => expect(screen.queryByRole("listbox")).toBeNull()) + await screen.waitFor(() => expect(popupStatesAtCompletion).toHaveLength(2)) + expect(popupStatesAtCompletion).toEqual([true, false]) + }) + + it("looks up a null value from an items object", async () => { + screen.render( + + + No selection + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("No selection")) + }) + + it("uses a null item label before the placeholder", async () => { + screen.render( + + + No selection + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("No selection")) + }) + + it("uses the placeholder when a null item has no label", async () => { + screen.render( + + + No selectionOne + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Choose")) + }) + + it("uses the null object key label before the placeholder", async () => { + screen.render( + + + No selectionOne + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("No selection")) + }) + + it("reflects a root disabled state on every option", async () => { + screen.render() + await screen.waitFor(() => expect(screen.getByRole("option", { name: "Almond" })).toHaveAttribute("aria-disabled", "true")) + expect(screen.getByTestId("Almond")).toHaveAttribute("data-disabled", "") + clickItem("Almond") + expect(screen.getByTestId("value")).toHaveTextContent("Choose a tree") + }) + + it("marks a read-only popup and ignores pointer selection", async () => { + const onValueChange = vi.fn() + screen.render() + await screen.waitFor(() => expect(screen.getByRole("listbox")).toHaveAttribute("aria-readonly", "true")) + clickItem("Cedar") + expect(onValueChange).not.toHaveBeenCalled() + expect(screen.getByTestId("value")).toHaveTextContent("Choose a tree") + }) + + it("opens a read-only Select with ArrowDown", async () => { + screen.render() + screen.renderer.nativeSimulateKeyDown(screen.getByTestId("trigger").id, "down") + await screen.waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument()) + expect(screen.getByRole("option", { name: "Almond" })).not.toHaveAttribute("data-highlighted") + screen.renderer.simulateKeystrokes("down") + await screen.waitFor(() => expect(screen.getByRole("option", { name: "Almond" })).toHaveAttribute("data-highlighted", "")) + }) + + it("opens a read-only Select with Enter", async () => { + screen.render() + screen.renderer.nativeSimulateKeyDown(screen.getByTestId("trigger").id, "enter") + await screen.waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument()) + }) + + it("opens a read-only Select with Space", async () => { + screen.render() + screen.renderer.nativeSimulateKeyDown(screen.getByTestId("trigger").id, "space") + await screen.waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument()) + }) + + it("opens on trigger mousedown and selects the item released over", async () => { + const onClick = vi.fn() + screen.render() + const trigger = screen.getByTestId("trigger").getBoundingClientRect() + screen.renderer.nativeSimulateMouseDown(trigger.left + 4, trigger.top + 4) + await screen.waitFor(() => expect(screen.getByRole("listbox")).toBeInTheDocument()) + const cedar = screen.getByTestId("Cedar").getBoundingClientRect() + screen.renderer.nativeSimulateMouseMove(cedar.left + cedar.width / 2, cedar.top + cedar.height / 2, 0) + screen.renderer.nativeSimulateMouseUp(cedar.left + cedar.width / 2, cedar.top + cedar.height / 2) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Cedar")) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it("skips disabled options and stops at the last enabled option", async () => { + screen.render( + + + + Almond + Birch + Cedar + + , + ) + screen.renderer.simulateKeystrokes("down") + await screen.waitFor(() => expect(screen.getByRole("option", { name: "Almond" })).toHaveAttribute("data-highlighted", "")) + screen.renderer.simulateKeystrokes("down") + await screen.waitFor(() => expect(screen.getByRole("option", { name: "Cedar" })).toHaveAttribute("data-highlighted", "")) + screen.renderer.simulateKeystrokes("down") + await screen.waitFor(() => expect(screen.getByRole("option", { name: "Cedar" })).toHaveAttribute("data-highlighted", "")) + expect(screen.getByRole("option", { name: "Birch" })).not.toHaveAttribute("data-highlighted") + }) + + it("focuses a disabled item when it receives programmatic focus", async () => { + screen.render( + + + Disabled + , + ) + const item = screen.getByRole("option", { name: "Disabled" }) + screen.renderer.focusElement(item.id) + await screen.waitFor(() => expect(item).toHaveFocus()) + }) + + it("focuses the selected item when reopening the popup", async () => { + screen.render() + clickTrigger() + await screen.waitFor(() => expect(screen.getByTestId("Cedar")).toBeInTheDocument()) + clickItem("Cedar") + await screen.waitFor(() => expect(screen.queryByRole("listbox")).not.toBeInTheDocument()) + clickTrigger() + await screen.waitFor(() => expect(screen.getByTestId("Cedar")).toHaveFocus()) + }) + + it("scrolls the highlighted item into view at the end of a long list", async () => { + const longItems = Array.from({ length: 14 }, (_, index) => `Option ${index + 1}`) + screen.render( + + + + + {longItems.map((name) => {name})} + + + , + ) + screen.renderer.simulateKeystrokes("end") + const lastItem = screen.getByRole("option", { name: "Option 14" }) + await screen.waitFor(() => expect(lastItem).toHaveAttribute("data-highlighted", "")) + const popup = screen.getByTestId("popup").getBoundingClientRect() + const option = lastItem.getBoundingClientRect() + expect(option.bottom).toBeLessThanOrEqual(popup.bottom) + expect(screen.renderer.getScrollOffset(screen.getByTestId("popup").id)?.[1]).toBeLessThan(0) + }) + + it("highlights a hovered item and selects it when clicked", async () => { + screen.render() + const rect = screen.getByTestId("Cedar").getBoundingClientRect() + screen.renderer.nativeSimulateMouseMove(rect.left + rect.width / 2, rect.top + rect.height / 2) + await screen.waitFor(() => expect(screen.getByTestId("Cedar")).toHaveAttribute("data-highlighted", "")) + clickItem("Cedar") + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Cedar")) + }) + + it("does not change the highlight on pointer movement when hover highlighting is disabled", async () => { + screen.render() + const rect = screen.getByTestId("Cedar").getBoundingClientRect() + screen.renderer.nativeSimulateMouseMove(rect.left + rect.width / 2, rect.top + rect.height / 2) + await screen.waitFor(() => expect(screen.getByTestId("Cedar")).not.toHaveAttribute("data-highlighted")) + }) + + it("updates trigger and label IDs when the root ID changes", async () => { + function App() { + const [id, setId] = useState("field-root") + return <> + + + Tree + + {items.map((name) => {name})} + + + } + screen.render() + await screen.waitFor(() => { + expect(screen.getByTestId("trigger")).toHaveAttribute("id", "field-root") + expect(screen.getByTestId("trigger")).toHaveAttribute("aria-labelledby", "field-root-label") + expect(screen.getByTestId("label")).toHaveAttribute("id", "field-root-label") + }) + const change = screen.getByTestId("change-id").getBoundingClientRect() + screen.renderer.nativeSimulateClick(change.left + 4, change.top + 4) + await screen.waitFor(() => { + expect(screen.getByTestId("trigger")).toHaveAttribute("id", "field-next") + expect(screen.getByTestId("trigger")).toHaveAttribute("aria-labelledby", "field-next-label") + expect(screen.getByTestId("label")).toHaveAttribute("id", "field-next-label") + }) + }) + + it("does not add aria-labelledby when the Select has no label", async () => { + screen.render( + + + + , + ) + expect(screen.getByTestId("trigger")).not.toHaveAttribute("aria-labelledby") + }) + + it("focuses the trigger without opening when the label is clicked", async () => { + screen.render( + + Tree + + {items.map((name) => {name})} + + ) + const label = screen.getByTestId("label") + const rect = label.getBoundingClientRect() + screen.renderer.nativeSimulateClick(rect.left + rect.width / 2, rect.top + rect.height / 2) + await screen.waitFor(() => expect(screen.getByTestId("trigger")).toHaveFocus()) + expect(screen.queryByRole("listbox")).toBeNull() + }) + + it("provides listbox semantics on Popup when Select.List is omitted", async () => { + screen.render( + + + One + , + ) + await screen.waitFor(() => expect(screen.getByTestId("popup")).toHaveAttribute("role", "listbox")) + expect(screen.getByTestId("popup")).toHaveAttribute("aria-multiselectable", "true") + expect(screen.getByTestId("trigger")).toHaveAttribute("aria-controls", screen.getByTestId("popup").authorId) + expect(screen.getByTestId("popup")).toHaveAttribute("id") + expect(screen.getByRole("option", { name: "One" })).toBeInTheDocument() + }) + + it("places listbox semantics on List when it is present", async () => { + screen.render( + + + One + , + ) + await screen.waitFor(() => expect(screen.getByTestId("popup")).toHaveAttribute("role", "presentation")) + expect(screen.getByTestId("list")).toHaveAttribute("role", "listbox") + expect(screen.getByTestId("list")).toHaveAttribute("aria-multiselectable", "true") + expect(screen.getByTestId("popup")).not.toHaveAttribute("aria-multiselectable") + expect(screen.getByTestId("trigger")).toHaveAttribute("aria-controls", screen.getByTestId("list").authorId) + }) + + it("does not move focus on close when Popup finalFocus is false", async () => { + screen.render( + + + + Option A + + , + ) + clickItem("option") + await screen.waitFor(() => expect(screen.queryByRole("listbox")).not.toBeInTheDocument()) + expect(screen.getByTestId("trigger")).not.toHaveFocus() + }) + + it("focuses the element returned by Popup finalFocus", async () => { + screen.render( + <> + + + screen.getByTestId("final-focus") as unknown as HTMLElement}> + Option A + + + + , + ) + clickItem("option") + await screen.waitFor(() => expect(screen.getByTestId("final-focus")).toHaveFocus()) + }) + + it("returns focus to the trigger when Popup finalFocus returns null", async () => { + screen.render( + + + null}> + Option A + + , + ) + clickItem("option") + await screen.waitFor(() => expect(screen.getByTestId("trigger")).toHaveFocus()) + }) + + it("renders a React element from an items object label", async () => { + screen.render( + One }}> + + , + ) + await screen.waitFor(() => expect(screen.getByTestId("object-label")).toHaveTextContent("One")) + }) + + it("renders a React element from an items array label", async () => { + screen.render( + One }]}> + + , + ) + await screen.waitFor(() => expect(screen.getByTestId("array-label")).toHaveTextContent("One")) + }) + + it("updates the items label when the selected value changes", async () => { + function App() { + const [value, setValue] = useState("one") + return <> + } + screen.render() + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("One")) + screen.renderer.nativeSimulateClick(12, 12) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Two")) + }) + + it("uses the placeholder when the null value is absent from the items lookup", async () => { + screen.render( + + + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Choose")) + }) + + it("passes the selected value to SelectValue children in single mode", async () => { + screen.render( + + {(value) => `Selected ${value}`} + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Selected one")) + }) + + it("renders one label for a single value in multiple mode", async () => { + screen.render( + + + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("One")) + }) + + it("renders the placeholder for an empty multiple selection", async () => { + screen.render( + + + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Choose")) + }) + + it("renders a React element placeholder", async () => { + screen.render( + + Choose} /> + , + ) + await screen.waitFor(() => expect(screen.getByTestId("placeholder")).toHaveTextContent("Choose")) + }) + + it("uses itemToStringLabel for object values", async () => { + const canada = { country: "Canada", code: "CA" } + screen.render( + item.country}> + + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Canada")) + }) + + it("uses label and value fields for object values without conversion functions", async () => { + const canada = { label: "Canada", value: "CA" } + screen.render( + + + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("Canada")) + }) + + it("uses a SelectValue child function over items in multiple mode", async () => { + screen.render( + + {(values) => values.join("+")} + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("sans+serif")) + }) + + it("uses raw values for multiple selections without an items lookup", async () => { + screen.render( + + + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("serif, mono")) + }) + + it("uses an empty array as the default multiple value", async () => { + screen.render( + + {(values) => JSON.stringify(values)} + , + ) + await screen.waitFor(() => expect(screen.getByTestId("value")).toHaveTextContent("[]")) + }) +}) diff --git a/packages/react/src/__tests__/select-trigger-role.test.tsx b/packages/react/src/__tests__/select-trigger-role.test.tsx index 0feea6f4..17cad28c 100644 --- a/packages/react/src/__tests__/select-trigger-role.test.tsx +++ b/packages/react/src/__tests__/select-trigger-role.test.tsx @@ -64,7 +64,7 @@ describeNative("SelectTrigger", () => { expect(triggerNode?.aria.controls).toEqual([listboxTreeId]) }) - it("does not expose aria-controls when the open popup has no list", () => { + it("controls the popup listbox when Select.List is omitted", () => { screen.render( @@ -77,6 +77,8 @@ describeNative("SelectTrigger", () => { ) screen.renderer.nativeSimulateClick(30, 15) - expect(screen.getByRole("combobox", { name: "Fruit" })).not.toHaveAttribute("aria-controls") + const trigger = screen.getByRole("combobox", { name: "Fruit" }) + const popup = screen.getByRole("listbox") + expect(trigger).toHaveAttribute("aria-controls", popup.authorId) }) }) diff --git a/packages/react/src/components/select.tsx b/packages/react/src/components/select.tsx index ba54b8bc..acfe873a 100644 --- a/packages/react/src/components/select.tsx +++ b/packages/react/src/components/select.tsx @@ -22,6 +22,7 @@ import type { GpuixSyntheticEvent, GpuixKeyboardEvent, GpuixMouseEvent, + GpuixPointerEvent, GpuixScrollEvent, } from "../reconciler/synthetic-event.js" import type { Props, PublicInstance, StyleDesc } from "../types/host.js" @@ -47,6 +48,7 @@ export interface SelectItemData { } interface SelectItemRecord { + id: string value: unknown label: ReactNode textValue: string @@ -64,20 +66,34 @@ interface SelectContextValue { highlightItemOnHover: boolean focused: boolean labels: Map - activeValue: unknown | null - revealActiveValue: unknown | null + itemToStringLabel: ((value: unknown) => string) | undefined + activeItemId: string | null + revealActiveItemId: string | null listId: string + rootId: string fieldLabelId: string + labelId: string | undefined + registerLabelId: (id: string) => () => void listMounted: boolean popupPosition: { x: number; y: number } | undefined canScrollUp: boolean canScrollDown: boolean triggerPressedWhileOpen: React.MutableRefObject + triggerOpenedOnMouseDown: React.MutableRefObject + mouseDownOnTrigger: React.MutableRefObject + triggerMouseDownAt: React.MutableRefObject + triggerMouseDownY: React.MutableRefObject + triggerLastMouseY: React.MutableRefObject + triggerDragY: React.MutableRefObject + dragPointerType: React.MutableRefObject + focusSelectedItemOnOpen: React.MutableRefObject + focusItem: (id: number) => void + closeInteraction: React.MutableRefObject dismissedByOutsidePress: React.MutableRefObject triggerRef: React.MutableRefObject setOpen: (open: boolean, reason?: SelectChangeEventDetails["reason"], event?: GpuixSyntheticEvent) => void - setActiveValue: (value: unknown | null) => void - setKeyboardActiveValue: (value: unknown | null) => void + setActiveItem: (id: string) => void + setKeyboardActiveItem: (item: SelectItemRecord) => void setListId: (id: string) => void setListMounted: (mounted: boolean) => void setScrollability: (up: boolean, down: boolean) => void @@ -86,9 +102,10 @@ interface SelectContextValue { moveActive: (delta: number) => void moveActiveTo: (edge: "first" | "last") => void selectValue: (value: unknown, reason?: SelectChangeEventDetails["reason"], event?: GpuixSyntheticEvent) => void + selectItem: (item: SelectItemRecord, reason?: SelectChangeEventDetails["reason"], event?: GpuixSyntheticEvent) => void items: SelectItemRecord[] registerItem: (item: SelectItemRecord) => void - unregisterItem: (value: unknown) => void + unregisterItem: (id: string) => void isItemEqualToValue: (item: unknown, value: unknown) => boolean } @@ -103,6 +120,11 @@ export type SelectValueFor(null) const SelectPopupSideContext = createContext("bottom") const SelectPositionedContext = createContext(false) +interface SelectGroupContextValue { + labelId: string | undefined + setLabelId: React.Dispatch> +} +const SelectGroupContext = createContext(null) interface SelectItemContextValue { value: unknown state: React.MutableRefObject @@ -139,7 +161,7 @@ function textContent(node: ReactNode): string { function compareItemRecords( a: SelectItemRecord, b: SelectItemRecord, - registrationIndex: (value: unknown) => number + registrationIndex: (id: string) => number ): number { if (a.instance && b.instance) { const position = a.instance.compareDocumentPosition(b.instance) @@ -147,7 +169,7 @@ function compareItemRecords( if (position & DOCUMENT_POSITION_PRECEDING) return 1 return 0 } - return registrationIndex(a.value) - registrationIndex(b.value) + return registrationIndex(a.id) - registrationIndex(b.id) } export interface SelectProps @@ -207,12 +229,17 @@ function isValueSelected(value: SelectSelection, multiple: boolean, ite return multiple ? Array.isArray(value) && value.some((candidate) => equal(item, candidate)) : value !== null && equal(item, value) } +function compareSelectItemEquality(item: unknown, value: unknown, compare: (item: unknown, value: unknown) => boolean): boolean { + return item == null || value == null ? Object.is(item, value) : compare(item, value) +} + export function Select({ children, items: itemsProp, value: valueProp, defaultValue, onValueChange, + itemToStringLabel, open: openProp, defaultOpen = false, onOpenChange, @@ -221,23 +248,34 @@ export function Select): ReactElement { - const compareValues = isItemEqualToValue as (item: unknown, value: unknown) => boolean + const compareValues = (item: unknown, value: unknown) => compareSelectItemEquality(item, value, isItemEqualToValue as (item: unknown, value: unknown) => boolean) const { renderer } = useGpuix() const [value, setValue] = useControllableState>({ value: valueProp, defaultValue: defaultValue ?? null, }) + const initialValue = useRef(valueProp !== undefined ? valueProp : defaultValue ?? null) const [open, setOpenState] = useControllableState({ value: openProp, defaultValue: defaultOpen, }) - const [activeValue, setActiveValueState] = useState(null) - const [revealActiveValue, setRevealActiveValue] = useState(null) + const previousOpen = useRef(open) + const onCompleteRef = useRef(onOpenChangeComplete) + onCompleteRef.current = onOpenChangeComplete + const [activeItemId, setActiveItemId] = useState(null) + const [revealActiveItemId, setRevealActiveItemId] = useState(null) const generatedListId = useId() + const rootId = rootIdProp ?? generatedListId const [listId, setListId] = useState(generatedListId) - const fieldLabelId = `${generatedListId}-label` + const fieldLabelId = `${rootId}-label` + const [labelId, setLabelId] = useState() + const registerLabelId = useCallback((id: string) => { + setLabelId(id) + return () => setLabelId((currentId) => currentId === id ? undefined : currentId) + }, []) const [listMounted, setListMounted] = useState(false) const [popupPosition, setPopupPosition] = useState<{ x: number; y: number }>() const [scrollability, setScrollability] = useState({ up: false, down: false }) @@ -247,6 +285,15 @@ export function Select(null) // SelectItem registers itself here (instead of Select walking the element @@ -257,27 +304,29 @@ export function Select>(new Map()) - const itemOrder = useRef([]) + const itemRegistry = useRef>(new Map()) + const itemOrder = useRef([]) const [items, setItems] = useState([]) + const previousItems = useRef([]) - const setActiveValue = (nextValue: unknown | null) => { - setRevealActiveValue(null) - setActiveValueState(nextValue) + const setActiveItem = (id: string) => { + setActiveItemId(id) + setRevealActiveItemId(null) } - const setKeyboardActiveValue = (nextValue: unknown | null) => { - setRevealActiveValue(nextValue) - setActiveValueState(nextValue) + const setKeyboardActiveItem = (item: SelectItemRecord) => { + setActiveItemId(item.id) + setRevealActiveItemId(item.id) + if (item.instance) renderer?.focusElement?.(item.instance.id) } const registerItem = (item: SelectItemRecord) => { - if (!itemRegistry.current.has(item.value)) itemOrder.current.push(item.value) - itemRegistry.current.set(item.value, item) + if (!itemRegistry.current.has(item.id)) itemOrder.current.push(item.id) + itemRegistry.current.set(item.id, item) } - const unregisterItem = (value: unknown) => { - itemRegistry.current.delete(value) - itemOrder.current = itemOrder.current.filter((candidate) => candidate !== value) + const unregisterItem = (id: string) => { + itemRegistry.current.delete(id) + itemOrder.current = itemOrder.current.filter((candidate) => candidate !== id) } // Re-derive item order once per commit, rather than once per registration: @@ -287,11 +336,11 @@ export function Select { - const registrationIndex = (value: unknown): number => itemOrder.current.indexOf(value) + const registrationIndex = (id: string): number => itemOrder.current.indexOf(id) const sorted = itemOrder.current - .map((itemValue) => itemRegistry.current.get(itemValue)) + .map((itemId) => itemRegistry.current.get(itemId)) .filter((item): item is SelectItemRecord => item !== undefined) .sort((a, b) => compareItemRecords(a, b, registrationIndex)) @@ -303,12 +352,39 @@ export function Select { - if (!open || activeValue !== null) return + const previous = previousItems.current + previousItems.current = items + if (previous.length === 0) return + + if (multiple) { + const selected = Array.isArray(value) ? value : [] + const nextValue = selected.filter((selectedValue) => + items.some((item) => compareValues(item.value, selectedValue)), + ) + if (nextValue.length === selected.length) return + const details = createSelectChangeDetails("none") + onValueChange?.(nextValue as SelectValueFor, details) + if (!details.isCanceled) setValue(nextValue) + return + } + + const selectedWasRegistered = previous.some((item) => compareValues(item.value, value)) + const selectedRemainsRegistered = items.some((item) => compareValues(item.value, value)) + if (!selectedWasRegistered || selectedRemainsRegistered) return + + const defaultValue = initialValue.current + const nextValue = items.some((item) => compareValues(item.value, defaultValue)) ? defaultValue : null + const details = createSelectChangeDetails("none") + onValueChange?.(nextValue as SelectValueFor, details) + if (!details.isCanceled) setValue(nextValue) + }, [items, value, multiple, compareValues, onValueChange, setValue]) + useLayoutEffect(() => { + if (!open || activeItemId !== null) return const selected = items.find( (item) => isValueSelected(value, multiple === true, item.value, compareValues) && !item.disabled ) - if (selected) setActiveValue(selected.value) - }, [open, value, items, activeValue]) + if (selected) setActiveItem(selected.id) + }, [open, value, items, activeItemId]) useLayoutEffect(() => { if (!open || !triggerRef.current) return const rect = triggerRef.current.getBoundingClientRect() @@ -317,9 +393,13 @@ export function Select { const next = new Map() if (Array.isArray(itemsProp)) { - for (const item of itemsProp) next.set(item.value, item.label ?? item.textValue ?? String(item.value)) + for (const item of itemsProp) { + next.set(item.value, item.value === null && item.label == null && item.textValue == null + ? null + : item.label ?? item.textValue ?? String(item.value)) + } } else if (itemsProp) { - for (const [itemValue, label] of Object.entries(itemsProp)) next.set(itemValue, label) + for (const [itemValue, label] of Object.entries(itemsProp)) next.set(itemValue === "null" ? null : itemValue, label) } return next }, [itemsProp]) @@ -329,28 +409,39 @@ export function Select isValueSelected(value, multiple === true, item.value, compareValues) && !item.disabled ) - setActiveValue(selected?.value ?? null) - } else if (triggerRef.current) { - renderer?.focusElement?.(triggerRef.current.id) + focusSelectedItemOnOpen.current = selected !== undefined + if (selected) setActiveItem(selected.id) + else { + setActiveItemId(null) + setRevealActiveItemId(null) + } } } + useLayoutEffect(() => { + if (previousOpen.current === open) return + previousOpen.current = open + onCompleteRef.current?.(open) + }, [open]) + const typeahead = (character: string, event?: GpuixSyntheticEvent) => { if (disabled || (!open && (readOnly || multiple)) || !character || character.length !== 1) return const now = Date.now() const lower = character.toLocaleLowerCase() const enabled = items.filter((item) => !item.disabled) const continuesSession = typeaheadBuffer.current.length > 0 && now - typeaheadTime.current < 750 - const currentValue = activeValue ?? (!open && !multiple ? value : null) - const currentIndex = enabled.findIndex((item) => compareValues(item.value, currentValue)) + const currentValue = !open && !multiple ? value : null + const currentIndex = open + ? enabled.findIndex((item) => item.id === activeItemId) + : enabled.findIndex((item) => compareValues(item.value, currentValue)) let startIndex = continuesSession ? typeaheadStartIndex.current : currentIndex let prefix = continuesSession ? typeaheadBuffer.current + lower : lower @@ -380,7 +471,7 @@ export function Select !item.disabled) if (enabled.length === 0) return - const currentIndex = enabled.findIndex((item) => compareValues(item.value, activeValue)) - const start = currentIndex < 0 ? (delta > 0 ? -1 : 0) : currentIndex - const nextIndex = (start + delta + enabled.length) % enabled.length - setKeyboardActiveValue(enabled[nextIndex].value) + const currentIndex = enabled.findIndex((item) => item.id === activeItemId) + const start = currentIndex < 0 ? (delta > 0 ? -1 : enabled.length) : currentIndex + const nextIndex = Math.max(0, Math.min(enabled.length - 1, start + delta)) + setKeyboardActiveItem(enabled[nextIndex]) } const moveActiveTo = (edge: "first" | "last") => { if (disabled) return const enabled = items.filter((item) => !item.disabled) const item = edge === "first" ? enabled[0] : enabled.at(-1) - if (item) setKeyboardActiveValue(item.value) + if (item) setKeyboardActiveItem(item) } const selectValue = (nextValue: unknown, reason: SelectChangeEventDetails["reason"] = "item-press", event?: GpuixSyntheticEvent) => { @@ -428,6 +519,11 @@ export function Select { + if (disabled || readOnly || item.disabled) return + selectValue(item.value, reason, event) + } + const context = useMemo( () => ({ open, @@ -439,20 +535,34 @@ export function Select string) | undefined, + activeItemId, + revealActiveItemId, listId, + rootId, fieldLabelId, + labelId, + registerLabelId, listMounted, popupPosition, canScrollUp: scrollability.up, canScrollDown: scrollability.down, triggerPressedWhileOpen, + triggerOpenedOnMouseDown, + mouseDownOnTrigger, + triggerMouseDownAt, + triggerMouseDownY, + triggerLastMouseY, + triggerDragY, + dragPointerType, + focusSelectedItemOnOpen, + focusItem: (id) => renderer?.focusElement?.(id), + closeInteraction, dismissedByOutsidePress, triggerRef, setOpen, - setActiveValue, - setKeyboardActiveValue, + setActiveItem, + setKeyboardActiveItem, setListId, setListMounted, setScrollability: (up, down) => setScrollability({ up, down }), @@ -461,11 +571,12 @@ export function Select export const SelectTrigger = forwardRef( function SelectTrigger( - { asChild, render, disabled: disabledProp, style, className, children, onMouseDown, onClick, onKeyDown, onFocus, onBlur, ...props }, + { asChild, render, disabled: disabledProp, style, className, children, onMouseDown, onMouseUp, onClick, onKeyDown, onFocus, onBlur, ...props }, forwardedRef ) { const context = useSelectContext("SelectTrigger") @@ -564,11 +675,14 @@ export const SelectTrigger = forwardRef( const triggerProps: Props = { ...props, role: props.role ?? "combobox", - ariaLabelledBy: props.ariaLabelledBy ?? props["aria-labelledby"] ?? context.fieldLabelId, + id: props.id ?? context.rootId, + ariaLabelledBy: props.ariaLabelledBy ?? props["aria-labelledby"] ?? context.labelId, ariaExpanded: context.open, ariaHasPopup: "listbox", - ariaControls: context.open && context.listMounted ? context.listId : undefined, + ariaControls: context.open ? context.listId : undefined, "data-open": context.open ? "" : undefined, + "data-popup-open": context.open ? "" : undefined, + "data-pressed": context.open ? "" : undefined, "data-disabled": disabled ? "" : undefined, "data-readonly": context.readOnly ? "" : undefined, "data-placeholder": state.placeholder ? "" : undefined, @@ -580,10 +694,26 @@ export const SelectTrigger = forwardRef( onMouseDown: (event) => { onMouseDown?.(event) context.triggerPressedWhileOpen.current = context.open + context.triggerOpenedOnMouseDown.current = !context.open + context.mouseDownOnTrigger.current = !context.open && !disabled + context.triggerMouseDownAt.current = Date.now() + context.triggerMouseDownY.current = event.clientY + context.triggerLastMouseY.current = event.clientY + context.triggerDragY.current = 0 + context.dragPointerType.current = "mouse" + if (!context.open && !disabled) context.setOpen(true, "trigger-press", event) + }, + onMouseUp: (event) => { + onMouseUp?.(event) + if (event.elementId === context.triggerRef.current?.id) context.mouseDownOnTrigger.current = false }, onClick: (event) => { onClick?.(event) if (disabled) return + if (context.triggerOpenedOnMouseDown.current) { + context.triggerOpenedOnMouseDown.current = false + return + } if (context.dismissedByOutsidePress.current) { context.dismissedByOutsidePress.current = false return @@ -601,13 +731,35 @@ export const SelectTrigger = forwardRef( if (event.key === "Escape") { if (!event.defaultPrevented) context.setOpen(false, "escape-key", event) } else if (event.key === "ArrowDown" || (event.key === "n" && event.modifiers?.ctrl)) { - if (!context.open) context.setOpen(true, "trigger-press", event) - context.moveActive(1) + if (!context.open) { + const selected = context.items.some((item) => + !item.disabled && isValueSelected(context.value, context.multiple, item.value, context.isItemEqualToValue) + ) + context.setOpen(true, "trigger-press", event) + if (!context.readOnly && !selected) { + const first = context.items.find((item) => !item.disabled) + if (first) context.setKeyboardActiveItem(first) + } + } else context.moveActive(1) } else if (event.key === "ArrowUp" || (event.key === "p" && event.modifiers?.ctrl)) { - if (!context.open) context.setOpen(true, "trigger-press", event) - context.moveActive(-1) + if (!context.open) { + const selected = context.items.some((item) => + !item.disabled && isValueSelected(context.value, context.multiple, item.value, context.isItemEqualToValue) + ) + context.setOpen(true, "trigger-press", event) + if (!context.readOnly && !selected) { + const last = [...context.items].reverse().find((item) => !item.disabled) + if (last) context.setKeyboardActiveItem(last) + } + } else context.moveActive(-1) } else if (event.key === "Enter" || event.key === " ") { - context.setOpen(!context.open, "trigger-press", event) + if (!context.open) { + context.setOpen(true, "trigger-press", event) + } else { + event.preventDefault() + const activeItem = context.items.find((item) => item.id === context.activeItemId) + if (activeItem) context.selectItem(activeItem, "item-press", event) + } } else if (event.key.length === 1 && !event.modifiers?.ctrl && !event.modifiers?.alt && !event.modifiers?.cmd) { context.typeahead(event.key, event) } @@ -633,16 +785,24 @@ export interface SelectValueProps extends SelectPartProps( function SelectValue({ placeholder, children, render, className, style, ...props }, ref) { const context = useSelectContext("SelectValue") + const nullLabel = context.labels.has(null) + ? context.labels.get(null) + : context.items.find((item) => item.value === null)?.label + const hasNullItemLabel = nullLabel != null const values = Array.isArray(context.value) ? context.value : context.value === null - ? [] + ? hasNullItemLabel ? [null] : [] : [context.value] - const labels = values.map( - (value) => - context.labels.get(value) ?? context.items.find((item) => item.value === value)?.label ?? value - ) - const hasValue = context.multiple ? values.length > 0 : context.value !== null + const labels = values.map((value) => { + if (context.itemToStringLabel && value !== null) return context.itemToStringLabel(value) + if (typeof value === "object" && value !== null) { + if ("label" in value && value.label != null) return value.label as ReactNode + if ("value" in value) return value.value as ReactNode + } + return context.labels.get(value) ?? context.items.find((item) => item.value === value)?.label ?? value + }) + const hasValue = context.multiple ? values.length > 0 : context.value !== null || hasNullItemLabel const valueContent = hasValue ? context.multiple ? labels.flatMap((label, index) => (index === 0 ? [label] : [", ", label])) @@ -652,7 +812,10 @@ export const SelectValue = forwardRef( typeof children === "function" ? children(context.multiple ? values : context.value) : children - const state = { value: context.value, placeholder: !hasValue } + const state = { + value: context.value, + placeholder: context.multiple ? values.length === 0 : context.value === null, + } return renderPart({ tag: "span", render, props: { ...props, "data-placeholder": state.placeholder ? "" : undefined, className: resolveClassName(className, state), style: resolvePartStyle(style, state) }, children: content ?? valueContent ?? placeholder, state, ref }) as ReactElement } ) @@ -674,16 +837,42 @@ export interface SelectPopupState { export const SelectPopup = forwardRef( function SelectPopup( - { children, onMouseDownOutside, onKeyDown, onEscapeKeyDown, tabIndex = 0, className, style, side = "bottom", align = "start", render, finalFocus: _finalFocus, ...props }, + { children, onMouseDownOutside, onKeyDown, onEscapeKeyDown, tabIndex = 0, className, style, side = "bottom", align = "start", render, finalFocus, ...props }, forwardedRef ) { const context = useSelectContext("SelectPopup") + const { renderer } = useGpuix() + const wasOpen = useRef(context.open) + const finalFocusRef = useRef(finalFocus) + finalFocusRef.current = finalFocus + useLayoutEffect(() => { + if (wasOpen.current && !context.open) { + const target = finalFocusRef.current + let targetId: number | null = null + if (target === false) { + targetId = null + } else if (target === undefined || target === true) { + targetId = context.triggerRef.current?.id ?? null + } else { + const resolved = typeof target === "function" + ? target(context.closeInteraction.current) + : "current" in target + ? target.current + : null + if (resolved === true || resolved == null) targetId = context.triggerRef.current?.id ?? null + else if (resolved !== false && typeof resolved === "object" && "id" in resolved && typeof resolved.id === "number") targetId = resolved.id + } + if (targetId !== null) renderer?.focusElement?.(targetId) + } + wasOpen.current = context.open + }, [context.open, context.triggerRef, context.closeInteraction, renderer]) const positioned = useContext(SelectPositionedContext) const positionerState = usePositionerState() const dismissLayer = useDismissLayer(context.open) const resolvedSide = positioned && positionerState ? positionerState.side : side const resolvedAlign = positioned && positionerState ? positionerState.align : align const popupState: SelectPopupState = { side: resolvedSide, align: resolvedAlign, open: context.open, transitionStatus: "idle" } + const popupRole: Props["role"] = context.listMounted ? "presentation" : "listbox" const resolvedClassName = resolveClassName(className, popupState) const resolvedPopupProps = { ...props, @@ -695,6 +884,10 @@ export const SelectPopup = forwardRef( "data-open": context.open ? "" : undefined, "data-side": resolvedSide, "data-align": resolvedAlign, + role: popupRole, + id: context.listMounted ? undefined : context.listId, + ariaMultiSelectable: !context.listMounted && context.multiple || undefined, + ariaReadOnly: !context.listMounted && context.readOnly || undefined, } // Children stay mounted while closed - like Radix's detached collection - // so SelectItem registers at mount time regardless of open state. Both @@ -714,6 +907,14 @@ export const SelectPopup = forwardRef( autoFocus: true, onMouseDownOutside: (event: GpuixMouseEvent) => { onMouseDownOutside?.(event) + const triggerBounds = context.triggerRef.current?.getBoundingClientRect() + if ( + triggerBounds && + event.clientX >= triggerBounds.left && + event.clientX < triggerBounds.right && + event.clientY >= triggerBounds.top && + event.clientY < triggerBounds.bottom + ) return context.dismissedByOutsidePress.current = true queueMicrotask(() => { context.dismissedByOutsidePress.current = false @@ -735,8 +936,10 @@ export const SelectPopup = forwardRef( context.moveActiveTo("first") } else if (event.key === "End") { context.moveActiveTo("last") - } else if ((event.key === "Enter" || event.key === " ") && context.activeValue) { - context.selectValue(context.activeValue, "item-press", event) + } else if (event.key === "Enter" || event.key === " ") { + event.preventDefault() + const activeItem = context.items.find((item) => item.id === context.activeItemId) + if (activeItem) context.selectItem(activeItem, "item-press", event) } else if (event.key.length === 1 && !event.modifiers?.ctrl && !event.modifiers?.alt && !event.modifiers?.cmd) { context.typeahead(event.key, event) } @@ -776,17 +979,19 @@ export interface SelectItemProps extends SelectPartProps( function SelectItem( - { value, disabled = false, label, textValue, children, render, style, className, onClick, onMouseEnter, ...props }, + { value, disabled = false, label, textValue, children, render, style, className, onClick, onMouseDown, onMouseEnter, onMouseMove, onMouseUp, ...props }, ref ) { const context = useSelectContext("SelectItem") + const itemDisabled = disabled || context.disabled + const itemId = useId() const instanceRef = useRef(null) const [itemText, setItemText] = useState<{ label: string; textValue: string } | null>(null) const itemValue = value ?? null const state = { selected: isValueSelected(context.value, context.multiple, itemValue, context.isItemEqualToValue), - highlighted: context.isItemEqualToValue(context.activeValue, itemValue), - disabled, + highlighted: context.activeItemId === itemId, + disabled: itemDisabled, } const stateRef = useRef(state) stateRef.current = state @@ -813,17 +1018,23 @@ export const SelectItem = forwardRef( useLayoutEffect(() => { context.registerItem({ + id: itemId, value: itemValue, label: label ?? itemText?.label ?? fallbackTextValue, textValue: textValue ?? label ?? itemText?.textValue ?? fallbackTextValue, - disabled, + disabled: itemDisabled, instance: instanceRef.current, }) - return () => context.unregisterItem(itemValue) - }, [itemValue, itemText, fallbackTextValue, disabled]) + return () => context.unregisterItem(itemId) + }, [itemId, itemValue, itemText, fallbackTextValue, itemDisabled]) + useLayoutEffect(() => { + if (context.open && context.revealActiveItemId === itemId) instanceRef.current?.scrollIntoView({ block: "nearest" }) + }, [context.open, context.revealActiveItemId, itemId]) useLayoutEffect(() => { - if (context.open && context.isItemEqualToValue(context.revealActiveValue, itemValue)) instanceRef.current?.scrollIntoView({ block: "nearest" }) - }, [context.open, context.revealActiveValue, itemValue]) + if (!context.open || !context.focusSelectedItemOnOpen.current || !state.highlighted || !instanceRef.current) return + context.focusSelectedItemOnOpen.current = false + context.focusItem(instanceRef.current.id) + }, [context.open, state.highlighted]) // Closed content stays mounted (see registerItem's comment above), so // this marker keeps the item's document position current even while @@ -839,19 +1050,48 @@ export const SelectItem = forwardRef( ref: setInstanceRef, role: "option", ariaSelected: state.selected, - ariaDisabled: disabled || undefined, + ariaDisabled: itemDisabled || undefined, + tabIndex: state.highlighted ? 0 : -1, "data-selected": state.selected ? "" : undefined, "data-highlighted": state.highlighted ? "" : undefined, - "data-disabled": disabled ? "" : undefined, + "data-disabled": itemDisabled ? "" : undefined, style: resolvePartStyle(style, state), className: resolveClassName(className, state), + onMouseDown: (event: GpuixMouseEvent) => { + onMouseDown?.(event) + context.mouseDownOnTrigger.current = false + }, onMouseEnter: (event: GpuixMouseEvent) => { onMouseEnter?.(event) - if (!disabled && !context.disabled && context.highlightItemOnHover) context.setActiveValue(itemValue) + if (!itemDisabled && context.highlightItemOnHover) context.setActiveItem(itemId) + }, + onPointerEnter: (event: GpuixPointerEvent) => { + context.dragPointerType.current = event.pointerType + }, + onMouseMove: (event: GpuixMouseEvent) => { + onMouseMove?.(event) + if (context.mouseDownOnTrigger.current && event.pressedButton === 0) { + context.triggerDragY.current += event.clientY - context.triggerLastMouseY.current + context.triggerLastMouseY.current = event.clientY + } + }, + onMouseUp: (event: GpuixMouseEvent) => { + onMouseUp?.(event) + if (!context.mouseDownOnTrigger.current) return + context.mouseDownOnTrigger.current = false + const selected = isValueSelected(context.value, context.multiple, itemValue, context.isItemEqualToValue) + const elapsed = Date.now() - context.triggerMouseDownAt.current + const dragDistance = Math.max(context.triggerDragY.current, Math.abs(event.clientY - context.triggerMouseDownY.current)) + const dragMoved = dragDistance ** 2 >= 64 + context.triggerDragY.current = 0 + if (itemDisabled || context.dragPointerType.current === "touch") return + if (elapsed < 400 && (selected || !dragMoved)) return + onClick?.(event) + context.selectValue(itemValue, "item-press", event) }, onClick: (event: GpuixMouseEvent) => { onClick?.(event) - if (!disabled && !context.disabled) context.selectValue(itemValue, "item-press", event) + if (!itemDisabled) context.selectValue(itemValue, "item-press", event) }, } return ( @@ -910,6 +1150,7 @@ export const SelectList = forwardRef(function S style: resolvePartStyle(style, state), role: "listbox", ariaMultiSelectable: context.multiple || undefined, + ariaReadOnly: context.readOnly || undefined, "data-multiple": context.multiple ? "" : undefined, onScroll: (event: GpuixScrollEvent) => { onScroll?.(event) @@ -946,7 +1187,9 @@ export const SelectItemText = forwardRef( function SelectItemText({ children, render, className, style, ...props }, ref) { useSelectContext("SelectItemText") const context = useContext(SelectItemContext) - if (!context) throw new Error("SelectItemText must be used inside SelectItem") + if (!context) { + throw new Error("SelectItemContext is missing. SelectItem parts must be placed within .") + } const state = context.state.current const renderedChildren = typeof children === "function" ? children(state) : children const label = typeof children === "function" @@ -982,14 +1225,16 @@ export const SelectItemIndicator = forwardRef>>(function SelectGroup({ render, className, style, ...props }, ref) { +export const SelectGroup = forwardRef>>(function SelectGroup({ render, className, style, children, ...props }, ref) { const context = useSelectContext("SelectGroup") + const [labelId, setLabelId] = useState() + const groupContext = useMemo(() => ({ labelId, setLabelId }), [labelId]) // A group can contain items, so its children stay mounted while closed for // their own registration. The group renders a div in both states - like // SelectItem's own marker - so its host element and the items beneath it // keep their identity across open/close instead of remounting. - const resolved: Props = { ...props, ref, "data-disabled": context.disabled ? "" : undefined, className: resolveClassName(className, {}), style: context.open ? resolvePartStyle(style, {}) : { display: "none" } } - return renderPart({ render, props: resolved, children: props.children, state: {}, ref }) as ReactElement + const resolved: Props = { role: "group", ariaLabelledBy: labelId, ...props, ref, "data-disabled": context.disabled ? "" : undefined, className: resolveClassName(className, {}), style: context.open ? resolvePartStyle(style, {}) : { display: "none" } } + return {renderPart({ render, props: resolved, children, state: {}, ref }) as ReactElement} }) export interface SelectLabelState { @@ -1003,18 +1248,41 @@ export interface SelectLabelState { export type SelectLabelProps = SelectPartProps export interface SelectGroupLabelState {} -export const SelectGroupLabel = forwardRef>(function SelectGroupLabel({ render, className, style, children, ...props }, ref) { +export const SelectGroupLabel = forwardRef>(function SelectGroupLabel(componentProps, ref) { + const { render, className, style, children, id, ariaHidden, ["aria-hidden"]: ariaHiddenAttribute, ...props } = componentProps useSelectContext("SelectGroupLabel") - const resolved: Props = { ...props, ref, className: resolveClassName(className, {}), style: resolvePartStyle(style, {}) } + const groupContext = useContext(SelectGroupContext) + if (!groupContext) throw new Error("Base UI: SelectGroupContext is missing. SelectGroup parts must be placed within .") + const generatedId = useId() + const labelId = id ?? generatedId + useLayoutEffect(() => { + groupContext.setLabelId(labelId) + return () => groupContext.setLabelId((currentId) => currentId === labelId ? undefined : currentId) + }, [groupContext.setLabelId, labelId]) + const hasAriaHidden = Object.prototype.hasOwnProperty.call(componentProps, "ariaHidden") || Object.prototype.hasOwnProperty.call(componentProps, "aria-hidden") + const resolvedAriaHidden = Object.prototype.hasOwnProperty.call(componentProps, "ariaHidden") ? ariaHidden : ariaHiddenAttribute + const resolved: Props = { ...props, id: labelId, ref, ariaHidden: hasAriaHidden ? resolvedAriaHidden : true, className: resolveClassName(className, {}), style: resolvePartStyle(style, {}) } if (typeof render === "function") return <>{render(resolved, {})} if (isValidElement(render)) return renderSlot({ asChild: true, children: render, props: resolved, ref }) return
{children}
}) -export const SelectLabel = forwardRef>(function SelectLabel({ render, className, style, children, id, ...props }, ref) { +export const SelectLabel = forwardRef>(function SelectLabel({ render, className, style, children, id, onClick, ...props }, ref) { const context = useSelectContext("SelectLabel") + const { renderer } = useGpuix() const state = { disabled: context.disabled, touched: false, dirty: context.value !== null, valid: null, filled: context.value !== null, focused: context.focused } - const resolved: Props = { ...props, id: id ?? context.fieldLabelId, ref, className: resolveClassName(className, state), style: resolvePartStyle(style, state) } + const resolved: Props = { + ...props, + id: id ?? context.fieldLabelId, + ref, + className: resolveClassName(className, state), + style: resolvePartStyle(style, state), + onClick: (event) => { + onClick?.(event) + if (!event.defaultPrevented && context.triggerRef.current) renderer?.focusElement?.(context.triggerRef.current.id) + }, + } + useLayoutEffect(() => context.registerLabelId(resolved.id as string), [context.registerLabelId, resolved.id]) if (typeof render === "function") return <>{render(resolved, state)} if (isValidElement(render)) return renderSlot({ asChild: true, children: render, props: resolved, ref }) return
{children}
diff --git a/skills/gpuix/references/components.md b/skills/gpuix/references/components.md index 270b5feb..56ce6643 100644 --- a/skills/gpuix/references/components.md +++ b/skills/gpuix/references/components.md @@ -55,7 +55,7 @@ the native renderer has no DOM event object. - **Give Popup an opaque background.** It defaults to `#1A1A1A` when neither `style` nor a compiled `className` sets a background. The fallback is painted on the popup itself and follows its rounded corners. A background supplied by either wins; a translucent colour lets the page show through. - **Combobox filtering and keyboard navigation use the mounted items.** Arrow Up/Down keep the highlighted item in view. Pass `items` to Root and use either the `List` function child or `Collection` for grouped data. `createItems` maps generic source objects to primitive selection values and labels. `filteredItems` supports externally filtered lists; `useFilter` provides locale-aware contains/startsWith/endsWith matching, and `useFilteredItems` reads the current results inside Root. `items` is optional when composing explicit Item children. - **A controlled Select cannot be cleared with `value={undefined}`**: `undefined` means uncontrolled. Use `value={null}` to clear single-select mode. Values can be generic objects or primitives, and multiple mode uses arrays. -- **Select keyboard navigation**: Arrow Up/Down and Ctrl+N/Ctrl+P move the highlight and keep it in view; Home/End highlight the first/last enabled item; typeahead highlights and reveals a matching item. Pointer hover changes the highlight without scrolling. Enter/Space selects, and Escape closes. +- **Select keyboard navigation**: Arrow Up/Down and Ctrl+N/Ctrl+P move focus with the highlight and keep the active item in view; Home/End move to the first/last enabled item; typeahead highlights and reveals a matching item. Arrow Down on a closed trigger opens the popup, and the next arrow press moves through the items. Pointer hover changes the highlight without scrolling. Enter/Space selects, and Escape closes. - **Combobox sets its listbox semantics.** Input has the combobox role and expanded, controls, active-descendant, and autocomplete state; List and Item expose listbox/option roles and selected/disabled state. State is also available to `className` and `style` functions. - **Tooltip timers use wall time.** `advanceAsyncClock` in tests does not move Provider or Trigger delay timers. - **Combobox `autoHighlight` defaults to `false`**, so typing and pressing Enter selects nothing until an item is arrowed to. Pass `autoHighlight` for type-and-Enter. @@ -113,14 +113,16 @@ The namespace follows Base UI 1.8.0: `Root`, `Label`, `Trigger`, `Value`, `Icon` **Root props**: Base UI's generic `value`/`defaultValue` (nullable in single-select mode), `onValueChange(value, eventDetails)`, `open`/`defaultOpen`/`onOpenChange`, `multiple`, `disabled`, `readOnly`, `required`, `name`, `form`, `autoComplete`, `modal`, comparison/stringification callbacks, and optional item-label lookup data. Root renders no host element. -- **`items` is optional.** Keyboard navigation and clicks use the mounted `SelectItem`s, which register even while closed (Popup stays mounted as `display: none`), in document order, including items wrapped in your own components. `SelectValue` shows the matching `items` entry's label (an entry with no `label` or `textValue` shows the raw value), else the registered item's `SelectItemText`, `textValue` or plain-text children, else the raw value. Pass `items` only when the closed label must be a rich node. Multiple values join with `", "`. +- **`items` is optional.** Keyboard navigation and clicks use the mounted `SelectItem`s, which register even while closed (Popup stays mounted as `display: none`), in document order, including items wrapped in your own components. `SelectValue` shows the matching `items` entry's label, `itemToStringLabel(value)`, an object's `label` or `value` field, or the registered item's `SelectItemText`, `textValue` or plain-text children, then the raw value. Pass `items` when the closed label must be a rich node. Multiple values join with `", "`. +- **Selection follows the mounted items.** When a selected item is removed, a single Select returns to its initial value if that item remains in the list; a multiple Select drops values whose items are no longer present. - **Item function children** receive `{ selected, highlighted, disabled }` in both closed and open states. `ItemText` accepts the same function child and renders it with that item state. Its closed label and typeahead text come from the call with `selected` and `highlighted` false; the Item `label` and `textValue` props override that label. -- **Behaviour**: opening highlights the selected item and focuses Popup; closing refocuses the Trigger; single mode closes on select, multiple mode toggles and stays open; a press outside closes (and the same press on the Trigger does not reopen); hovering highlights; disabled items are skipped; navigation wraps; Escape calls `onEscapeKeyDown` then closes. -- **ARIA**: Trigger `role="combobox"`, `ariaExpanded`, `ariaHasPopup="listbox"` and `ariaControls` pointing to its List; List `role="listbox"`; Item `role="option"`, `ariaSelected`. +- **Behaviour**: opening highlights and focuses the selected item; closing refocuses the Trigger; single mode closes on select, multiple mode toggles and stays open; a press outside closes (and the same press on the Trigger does not reopen); hovering highlights; disabled items are skipped; navigation wraps; Escape calls `onEscapeKeyDown` then closes. +- `onOpenChangeComplete` runs after the requested open state has rendered. +- **ARIA**: Trigger `role="combobox"`, `ariaExpanded` and `ariaHasPopup="listbox"`. If `List` is present it has `role="listbox"`; otherwise Popup has that role. Trigger `ariaControls` points to Popup when List is omitted. Item has `role="option"` and `ariaSelected`; groups are exposed as labeled groups. - **Typeahead and scrolling**: Typing highlights a matching item and scrolls it into view. Keyboard navigation reveals its highlighted item; pointer hover does not move the list. Scroll arrows appear only when the list can scroll further in their direction. - Positioner state exposes `open`, resolved `side`/`align`, and `anchorHidden`; its `className` and `style` callbacks receive that state. Positioning options share the component-agnostic `PositionerProps` contract from `@gpuix/react/floating`. - Native positioning uses the trigger/anchor, `position`, side and alignment offsets, window collision correction, custom `collisionBoundary` rectangles/elements (arrays use their intersection), asymmetric `collisionPadding`, and side/alignment flip or shift. The five-pixel default and per-edge values set the flip boundary and shift inset. `anchorHidden` reports anchors outside that boundary. `collisionBoundary="clipping-ancestors"` means the native window viewport. -- These Base UI options are accepted but have no native effect: `positionMethod`, `sticky`, `arrowPadding`, `disableAnchorTracking`, `alignItemWithTrigger`, `collisionAvoidance.fallbackAxisSide`, `inline-start`/`inline-end` direction resolution (they map to left/right), Root `modal` and `actionsRef`, Root form props (`name`, `form`, `required`, `autoComplete`, `inputRef`), and Root `itemToStringLabel`/`itemToStringValue`. GPU-IX events in `eventDetails.event` are the originating GPU-IX event; programmatic changes have `undefined` there. `details.cancel()` prevents the corresponding state update. +- These Base UI options are accepted but have no native effect: `positionMethod`, `sticky`, `arrowPadding`, `disableAnchorTracking`, `alignItemWithTrigger`, `collisionAvoidance.fallbackAxisSide`, `inline-start`/`inline-end` direction resolution (they map to left/right), Root `modal` and `actionsRef`, Root form props (`name`, `form`, `required`, `autoComplete`, `inputRef`), and Root `itemToStringValue`. GPU-IX events in `eventDetails.event` are the originating GPU-IX event; programmatic changes have `undefined` there. `details.cancel()` prevents the corresponding state update. ## Combobox (`@gpuix/react/combobox`)