A bare select control (built on Radix's `Select`) — the Radix trigger button is this component's own root element. It has no built-in label/hint/error; wrap it in `FormField` for that chrome.

pnpm add @behivetech/atoms.select

registry/behivetech/atoms/select

select-content.tsx
1import { type ReactNode } from "react";
2import * as RadixSelect from "@radix-ui/react-select";
3import { getClassName } from "@behivetech/get-class-name";
4import styles from "./select-content.module.scss";
5
6export interface SelectContentProps {
7 /** Whether the popup is open; adds `--open`, or `--closed` while it animates out */
8 open: boolean;
9 /** The option items */
10 children: ReactNode;
11}
12
13/**
14 * Select's popup. A block of its own (`BHT__SelectContent`) so the open and
15 * closed animations are styled through modifiers instead of Radix's
16 * `data-state`.
17 */
18export const SelectContent = ({ open, children }: SelectContentProps) => {
19 const [rootClass, getChildClass] = getClassName({
20 modifiers: { open, closed: !open },
21 rootClass: "BHT__SelectContent",
22 styles,
23 });
24
25 return (
26 <RadixSelect.Content className={rootClass} position="popper" sideOffset={4}>
27 <RadixSelect.Viewport className={getChildClass("viewport")}>
28 {children}
29 </RadixSelect.Viewport>
30 </RadixSelect.Content>
31 );
32};
select-item.tsx
1import { useState } from "react";
2import * as RadixSelect from "@radix-ui/react-select";
3import { getClassName } from "@behivetech/get-class-name";
4import styles from "./select-item.module.scss";
5
6export interface SelectItemProps {
7 /** Text shown for this option */
8 label: string;
9 /** Value submitted/reported when this option is chosen */
10 value: string;
11 /** Prevents this option from being selected; adds the `--disabled` modifier */
12 disabled?: boolean;
13 /** Marks this option as the current selection; adds the `--selected` modifier */
14 selected?: boolean;
15}
16
17/**
18 * One option in Select's popup. A block of its own so `disabled`, `highlighted`
19 * and `selected` are styled through modifiers instead of Radix's `data-*` attributes.
20 */
21export const SelectItem = ({
22 label,
23 value,
24 disabled = false,
25 selected = false,
26}: SelectItemProps) => {
27 // Radix highlights an option exactly while it has focus (pointer move and
28 // arrow keys both focus it), so focus and blur are the React signal.
29 const [highlighted, setHighlighted] = useState(false);
30 const [rootClass, getChildClass] = getClassName({
31 modifiers: { disabled, highlighted, selected },
32 rootClass: "BHT__SelectItem",
33 styles,
34 });
35
36 return (
37 <RadixSelect.Item
38 value={value}
39 disabled={disabled}
40 className={rootClass}
41 onFocus={() => setHighlighted(true)}
42 onBlur={() => setHighlighted(false)}
43 >
44 <RadixSelect.ItemText>{label}</RadixSelect.ItemText>
45 <RadixSelect.ItemIndicator className={getChildClass("indicator")}>
46 <svg
47 viewBox="0 0 12 10"
48 fill="none"
49 stroke="currentColor"
50 strokeWidth="2.5"
51 strokeLinecap="round"
52 strokeLinejoin="round"
53 aria-hidden="true"
54 >
55 <path d="M1 5L4.5 8.5L11 1" />
56 </svg>
57 </RadixSelect.ItemIndicator>
58 </RadixSelect.Item>
59 );
60};
select.tsx
1import * as RadixSelect from "@radix-ui/react-select";
2import { useId, useState } from "react";
3import { getClassName } from "@behivetech/get-class-name";
4import styles from "./select.module.scss";
5import { SelectContent } from "./select-content.js";
6import { SelectItem } from "./select-item.js";
7
8export interface SelectOption {
9 /** Text shown for this option */
10 label: string;
11 /** Value submitted/reported when this option is chosen */
12 value: string;
13 /** Prevents this option from being selected */
14 disabled?: boolean;
15}
16
17export interface SelectProps extends Omit<
18 RadixSelect.SelectTriggerProps,
19 "className"
20> {
21 /** Options rendered in the dropdown */
22 options: SelectOption[];
23 /** Controlled selected value */
24 value?: string;
25 /** Initial selected value for uncontrolled usage */
26 defaultValue?: string;
27 /** Called with the new value when the selection changes */
28 onValueChange?: (value: string) => void;
29 /** Text shown when no value is selected */
30 placeholder?: string;
31 /** Marks the field as required for form validation */
32 required?: boolean;
33 /** Additional class names to merge with the component root element */
34 className?: string;
35 /** Form field name submitted with the value */
36 name?: string;
37 /** Marks the field as invalid: adds the `--invalid` modifier and sets `aria-invalid="true"` */
38 invalid?: boolean;
39}
40
41/**
42 * Bare select control — the Radix select trigger button is this component's
43 * own root element, no built-in label/hint/error. Wrap it in `FormField` for
44 * that chrome: `<FormField label="..." htmlFor="x"><Select id="x" .../></FormField>`.
45 */
46export const Select = ({
47 options,
48 value,
49 defaultValue,
50 onValueChange,
51 placeholder,
52 disabled,
53 required,
54 className,
55 id,
56 name,
57 invalid = false,
58 "aria-invalid": ariaInvalid,
59 ...rest
60}: SelectProps) => {
61 const generatedId = useId();
62 const selectId = id ?? generatedId;
63
64 const isInvalid = invalid || ariaInvalid === true || ariaInvalid === "true";
65
66 // The controlled `value` wins; otherwise the component tracks its own value
67 // so `--placeholder` and the selected item work uncontrolled too.
68 const [internalValue, setInternalValue] = useState(defaultValue);
69 const currentValue = value ?? internalValue;
70
71 const handleValueChange = (next: string) => {
72 if (value === undefined) {
73 setInternalValue(next);
74 }
75 onValueChange?.(next);
76 };
77
78 const [isOpen, setIsOpen] = useState(false);
79
80 const [rootClass, getChildClass] = getClassName({
81 className,
82 modifiers: {
83 disabled: Boolean(disabled),
84 invalid: isInvalid,
85 open: isOpen,
86 placeholder: currentValue === undefined || currentValue === "",
87 },
88 rootClass: "BHT__Select",
89 styles,
90 });
91
92 return (
93 <RadixSelect.Root
94 value={currentValue ?? ""}
95 onValueChange={handleValueChange}
96 open={isOpen}
97 onOpenChange={setIsOpen}
98 disabled={disabled}
99 required={required}
100 name={name}
101 >
102 <RadixSelect.Trigger
103 {...rest}
104 id={selectId}
105 className={rootClass}
106 aria-invalid={isInvalid ? true : ariaInvalid}
107 >
108 <RadixSelect.Value placeholder={placeholder} />
109 <RadixSelect.Icon asChild>
110 <svg
111 className={getChildClass("chevron")}
112 viewBox="0 0 12 8"
113 fill="none"
114 stroke="currentColor"
115 strokeWidth="2"
116 strokeLinecap="round"
117 strokeLinejoin="round"
118 aria-hidden="true"
119 >
120 <path d="M1 1.5L6 6.5L11 1.5" />
121 </svg>
122 </RadixSelect.Icon>
123 </RadixSelect.Trigger>
124 <RadixSelect.Portal>
125 <SelectContent open={isOpen}>
126 {options.map((option) => (
127 <SelectItem
128 key={option.value}
129 label={option.label}
130 value={option.value}
131 disabled={option.disabled}
132 selected={option.value === currentValue}
133 />
134 ))}
135 </SelectContent>
136 </RadixSelect.Portal>
137 </RadixSelect.Root>
138 );
139};
select-content.module.scss
1// Material Design 3 menu for Select's popup. Its own block so the open and
2// closed animations are `--open` / `--closed` classes.
3
4.BHT__SelectContent {
5 // Renders in a portal to document.body, so it is a *sibling* of any Modal
6 // or Drawer it was opened from rather than a child — the fallback must stay
7 // above --z-modal's (400) or an unstyled consumer gets a popup hidden
8 // behind the modal that spawned it.
9 //
10 // MD3 menus separate from the page by tone and elevation, not a border.
11 background: var(
12 --md-sys-color-surface-container,
13 var(--color-surface-raised)
14 );
15 border-radius: var(--md-sys-shape-corner-extra-small, 4px);
16 box-shadow: var(--md-sys-elevation-2, var(--shadow-md));
17 overflow: hidden;
18 z-index: var(--z-dropdown, 450);
19 min-width: var(--radix-select-trigger-width);
20 max-height: var(--radix-select-content-available-height);
21
22 &--open {
23 &.BHT__SelectContent {
24 animation: select-in var(--md-sys-motion-duration-short-3, 150ms)
25 var(--md-sys-motion-easing-standard-decelerate, ease-out);
26 }
27 }
28
29 &--closed {
30 &.BHT__SelectContent {
31 animation: select-out var(--md-sys-motion-duration-short-2, 100ms)
32 var(--md-sys-motion-easing-standard-accelerate, ease-in);
33 }
34 }
35
36 &__viewport {
37 padding: var(--spacing-1, 0.25rem);
38 }
39}
40
41@keyframes select-in {
42 from {
43 opacity: 0;
44 transform: translateY(-0.25rem);
45 }
46
47 to {
48 opacity: 1;
49 transform: translateY(0);
50 }
51}
52
53@keyframes select-out {
54 from {
55 opacity: 1;
56 transform: translateY(0);
57 }
58
59 to {
60 opacity: 0;
61 transform: translateY(-0.25rem);
62 }
63}
select-item.module.scss
1// Material Design 3 menu item for Select's popup. Its own block so the
2// `--disabled`, `--highlighted` and `--selected` modifiers are real classes.
3
4.BHT__SelectItem {
5 position: relative;
6 isolation: isolate;
7 display: flex;
8 align-items: center;
9 justify-content: space-between;
10 gap: var(--spacing-2, 0.5rem);
11 padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
12 border-radius: var(--md-sys-shape-corner-extra-small, 4px);
13 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));
14 font-size: var(--md-sys-typescale-label-large-size, 0.875rem);
15 font-weight: var(--md-sys-typescale-label-large-weight, 500);
16 line-height: var(--md-sys-typescale-label-large-line-height, 1.25rem);
17 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);
18 color: var(--md-sys-color-on-surface, var(--color-on-surface));
19 cursor: pointer;
20 user-select: none;
21 outline: none;
22
23 // State layer. `--highlighted` follows Radix's highlight, set for both pointer
24 // hover and keyboard focus, so it stands in for :hover here.
25 &::after {
26 content: "";
27 position: absolute;
28 inset: 0;
29 border-radius: inherit;
30 background: currentColor;
31 opacity: 0;
32 pointer-events: none;
33 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)
34 var(--md-sys-motion-easing-standard, ease);
35 }
36
37 &--highlighted {
38 &.BHT__SelectItem::after {
39 opacity: var(--md-sys-state-hover-opacity, 0.08);
40 }
41 }
42
43 &:active::after {
44 opacity: var(--md-sys-state-pressed-opacity, 0.1);
45 }
46
47 // MD3 marks the selected menu item with a tonal container.
48 &--selected {
49 &.BHT__SelectItem {
50 background: var(
51 --md-sys-color-secondary-container,
52 var(--color-surface-light)
53 );
54 color: var(
55 --md-sys-color-on-secondary-container,
56 var(--color-on-surface)
57 );
58 }
59 }
60
61 &--disabled {
62 &.BHT__SelectItem {
63 opacity: 0.4;
64 cursor: not-allowed;
65 pointer-events: none;
66 }
67 }
68
69 &__indicator {
70 width: 0.75rem;
71 height: 0.75rem;
72 flex-shrink: 0;
73 display: flex;
74 align-items: center;
75 justify-content: center;
76
77 svg {
78 width: 100%;
79 height: 100%;
80 }
81 }
82}
select.module.scss
1// Material Design 3 outlined select: the trigger is an outlined text field and
2// the popup is a menu.
3//
4// The trigger's 2px focus/open edge is a 1px inset shadow on top of the 1px
5// border rather than a wider border, so the value never shifts.
6
7@mixin focus-edge {
8 outline: none;
9 border-color: var(--md-sys-color-primary, var(--color-border-focus));
10 box-shadow: inset 0 0 0 1px
11 var(--md-sys-color-primary, var(--color-border-focus));
12}
13
14.BHT__Select {
15 // Radix Select.Trigger renders as <button>
16 position: relative;
17 display: flex;
18 align-items: center;
19 justify-content: space-between;
20 gap: var(--spacing-2, 0.5rem);
21 width: 100%;
22 min-height: 2.5rem;
23 padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
24 font-family: var(--md-sys-typescale-body-large-font, var(--font-body));
25 font-size: var(--md-sys-typescale-body-large-size, 1rem);
26 font-weight: var(--md-sys-typescale-body-large-weight, 400);
27 line-height: var(--md-sys-typescale-body-large-line-height, 1.5rem);
28 letter-spacing: var(--md-sys-typescale-body-large-tracking, 0.0313em);
29 color: var(--md-sys-color-on-surface, var(--color-on-surface));
30 // Outlined fields have no container fill; they take the surface they sit on.
31 background: transparent;
32 border: 1px solid var(--md-sys-color-outline, var(--color-border-strong));
33 border-radius: var(--md-sys-shape-corner-extra-small, 4px);
34 cursor: pointer;
35 text-align: left;
36 transition:
37 border-color var(--md-sys-motion-duration-short-3, 150ms)
38 var(--md-sys-motion-easing-standard, ease),
39 box-shadow var(--md-sys-motion-duration-short-3, 150ms)
40 var(--md-sys-motion-easing-standard, ease);
41
42 // Touch target: extends the hit area to 48px without changing the drawn
43 // 40px height. px on purpose — a finger doesn't scale with font size.
44 &::before {
45 content: "";
46 position: absolute;
47 left: 0;
48 right: 0;
49 top: 50%;
50 translate: 0 -50%;
51 height: 100%;
52 min-height: 48px;
53 }
54
55 &--placeholder {
56 &.BHT__Select {
57 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));
58 }
59 }
60
61 &:hover:not(.BHT__Select--disabled, .BHT__Select--open, :focus-visible) {
62 border-color: var(--md-sys-color-on-surface, var(--color-on-surface));
63 }
64
65 &--open {
66 &.BHT__Select {
67 @include focus-edge;
68 }
69 }
70
71 &:focus-visible {
72 @include focus-edge;
73 }
74
75 &--open {
76 &.BHT__Select {
77 .BHT__Select {
78 &__chevron {
79 transform: rotate(180deg);
80 }
81 }
82 }
83 }
84
85 &--disabled {
86 &.BHT__Select {
87 opacity: 0.45;
88 cursor: not-allowed;
89 }
90 }
91
92 &--invalid {
93 &.BHT__Select {
94 border-color: var(--md-sys-color-error, var(--color-error));
95
96 &:hover:not(.BHT__Select--disabled, .BHT__Select--open, :focus-visible) {
97 border-color: var(
98 --md-sys-color-on-error-container,
99 var(--color-error)
100 );
101 }
102
103 &.BHT__Select--open,
104 &:focus-visible {
105 border-color: var(--md-sys-color-error, var(--color-error));
106 box-shadow: inset 0 0 0 1px
107 var(--md-sys-color-error, var(--color-error));
108 }
109 }
110 }
111
112 &__chevron {
113 width: 0.75rem;
114 height: 0.75rem;
115 flex-shrink: 0;
116 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));
117 transition: transform var(--md-sys-motion-duration-short-3, 150ms)
118 var(--md-sys-motion-easing-standard, ease);
119 }
120}
index.ts
1export { Select } from "./select.js";
2export type { SelectProps, SelectOption } from "./select.js";
select.example.tsx
1import { useState } from "react";
2import { FormField } from "@behivetech/forms.form-field";
3import { Select } from "./select.js";
4
5const colorOptions = [
6 { label: "Red", value: "red" },
7 { label: "Green", value: "green" },
8 { label: "Blue", value: "blue" },
9];
10
11const roleOptions = [
12 { label: "Admin", value: "admin" },
13 { label: "Editor", value: "editor" },
14 { label: "Viewer", value: "viewer" },
15 { label: "Guest (disabled)", value: "guest", disabled: true },
16];
17
18export const BasicSelect = () => {
19 const [value, setValue] = useState("");
20 return (
21 <Select
22 placeholder="Choose a color"
23 options={colorOptions}
24 value={value}
25 onValueChange={setValue}
26 />
27 );
28};
29
30export const SelectWithFormField = () => {
31 const [value, setValue] = useState("");
32 return (
33 <FormField
34 label="Role"
35 htmlFor="role"
36 hint="Determines what the user can access."
37 >
38 <Select
39 id="role"
40 placeholder="Select a role"
41 options={roleOptions}
42 value={value}
43 onValueChange={setValue}
44 />
45 </FormField>
46 );
47};
48
49export const SelectWithError = () => (
50 <FormField label="Status" htmlFor="status" error="Please select a status.">
51 <Select
52 id="status"
53 invalid
54 placeholder="Select status"
55 options={colorOptions}
56 />
57 </FormField>
58);
59
60export const DisabledSelect = () => (
61 <Select disabled options={colorOptions} value="red" />
62);
select.spec.tsx
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { Select } from "./select.js";
4
5const options = [
6 { label: "Option A", value: "a" },
7 { label: "Option B", value: "b" },
8 { label: "Option C", value: "c", disabled: true },
9];
10
11describe("Select", () => {
12 it("renders the trigger as a combobox", () => {
13 render(<Select options={options} />);
14 expect(screen.getByRole("combobox")).toBeTruthy();
15 });
16
17 it("renders placeholder text", () => {
18 render(<Select options={options} placeholder="Pick one" />);
19 expect(screen.getByText("Pick one")).toBeTruthy();
20 });
21
22 it("passes aria-invalid through to the root element", () => {
23 render(<Select options={options} aria-invalid="true" />);
24 expect(screen.getByRole("combobox").getAttribute("aria-invalid")).toBe(
25 "true",
26 );
27 });
28
29 it("adds the --invalid modifier and aria-invalid for the invalid prop", () => {
30 render(<Select invalid options={options} />);
31 const el = screen.getByRole("combobox");
32 expect(el.className).toContain("--invalid");
33 expect(el.getAttribute("aria-invalid")).toBe("true");
34 });
35
36 it("adds the --invalid modifier for a direct aria-invalid", () => {
37 render(<Select aria-invalid="true" options={options} />);
38 expect(screen.getByRole("combobox").className).toContain("--invalid");
39 });
40
41 it("has no --invalid modifier by default", () => {
42 render(<Select options={options} />);
43 expect(screen.getByRole("combobox").className).not.toContain("--invalid");
44 });
45
46 it("marks trigger as disabled", () => {
47 render(<Select options={options} disabled />);
48 expect(
49 screen.getByRole("combobox").getAttribute("data-disabled"),
50 ).not.toBeNull();
51 });
52
53 it("adds the --disabled modifier to a disabled trigger", () => {
54 render(<Select options={options} disabled />);
55 expect(screen.getByRole("combobox").className).toContain("--disabled");
56 });
57
58 it("adds the --disabled modifier to a disabled option only", async () => {
59 render(<Select options={options} />);
60 await userEvent.click(screen.getByRole("combobox"));
61 const disabled = await screen.findByRole("option", { name: "Option C" });
62 expect(disabled.className).toContain("--disabled");
63 expect(
64 screen.getByRole("option", { name: "Option A" }).className,
65 ).not.toContain("--disabled");
66 });
67
68 it("adds --placeholder while no value is selected", () => {
69 render(<Select options={options} placeholder="Pick one" />);
70 expect(screen.getByRole("combobox").className).toContain("--placeholder");
71 });
72
73 it("has no --placeholder with a controlled value", () => {
74 render(<Select options={options} value="a" onValueChange={() => {}} />);
75 expect(screen.getByRole("combobox").className).not.toContain(
76 "--placeholder",
77 );
78 });
79
80 it("has no --placeholder with a defaultValue", () => {
81 render(<Select options={options} defaultValue="b" />);
82 expect(screen.getByRole("combobox").className).not.toContain(
83 "--placeholder",
84 );
85 });
86
87 it("drops --placeholder after selecting an option", async () => {
88 const handleChange = vi.fn();
89 render(<Select options={options} onValueChange={handleChange} />);
90 const trigger = screen.getByRole("combobox");
91 expect(trigger.className).toContain("--placeholder");
92 await userEvent.click(trigger);
93 await userEvent.click(
94 await screen.findByRole("option", { name: "Option A" }),
95 );
96 expect(handleChange).toHaveBeenCalledWith("a");
97 expect(trigger.className).not.toContain("--placeholder");
98 });
99
100 it("adds --open to the trigger while open and removes it after", async () => {
101 render(<Select options={options} />);
102 const trigger = screen.getByRole("combobox");
103 expect(trigger.className).not.toContain("--open");
104 await userEvent.click(trigger);
105 await screen.findByRole("option", { name: "Option A" });
106 expect(trigger.className).toContain("--open");
107 expect(trigger.getAttribute("data-state")).toBe("open");
108 await userEvent.click(screen.getByRole("option", { name: "Option A" }));
109 expect(trigger.className).not.toContain("--open");
110 });
111
112 it("adds --open to the popup while open", async () => {
113 render(<Select options={options} />);
114 await userEvent.click(screen.getByRole("combobox"));
115 const listbox = await screen.findByRole("listbox");
116 expect(listbox.className).toContain("--open");
117 });
118
119 it("adds --highlighted to the focused option only", async () => {
120 render(<Select options={options} />);
121 await userEvent.click(screen.getByRole("combobox"));
122 await screen.findByRole("option", { name: "Option A" });
123 await userEvent.hover(screen.getByRole("option", { name: "Option B" }));
124 expect(
125 screen.getByRole("option", { name: "Option B" }).className,
126 ).toContain("--highlighted");
127 expect(
128 screen.getByRole("option", { name: "Option A" }).className,
129 ).not.toContain("--highlighted");
130 });
131
132 it("adds --selected to the chosen option only", async () => {
133 render(<Select options={options} defaultValue="b" />);
134 await userEvent.click(screen.getByRole("combobox"));
135 const selected = await screen.findByRole("option", { name: "Option B" });
136 expect(selected.className).toContain("--selected");
137 expect(selected.getAttribute("data-state")).toBe("checked");
138 expect(
139 screen.getByRole("option", { name: "Option A" }).className,
140 ).not.toContain("--selected");
141 });
142
143 it("moves --selected to a newly chosen option", async () => {
144 render(<Select options={options} />);
145 await userEvent.click(screen.getByRole("combobox"));
146 await userEvent.click(
147 await screen.findByRole("option", { name: "Option A" }),
148 );
149 await userEvent.click(screen.getByRole("combobox"));
150 expect(
151 (await screen.findByRole("option", { name: "Option A" })).className,
152 ).toContain("--selected");
153 });
154
155 it("keeps the modifiers in step with Radix when a controlled value becomes undefined", async () => {
156 const { rerender } = render(
157 <Select options={options} value="a" onValueChange={() => {}} />,
158 );
159 await userEvent.click(screen.getByRole("combobox"));
160 await userEvent.click(
161 await screen.findByRole("option", { name: "Option B" }),
162 );
163 rerender(
164 <Select options={options} value={undefined} onValueChange={() => {}} />,
165 );
166 const trigger = screen.getByRole("combobox");
167 expect(trigger.className).toContain("--placeholder");
168 expect(trigger.hasAttribute("data-placeholder")).toBe(true);
169 await userEvent.click(trigger);
170 const optionB = await screen.findByRole("option", { name: "Option B" });
171 expect(optionB.className).not.toContain("--selected");
172 expect(optionB.getAttribute("data-state")).toBe("unchecked");
173 });
174
175 it("applies additional className", () => {
176 render(<Select options={options} className="custom" />);
177 expect(screen.getByRole("combobox").className).toContain("custom");
178 });
179
180 it("opens and shows options when clicked", async () => {
181 render(<Select options={options} />);
182 await userEvent.click(screen.getByRole("combobox"));
183 expect(
184 await screen.findByRole("option", { name: "Option A" }),
185 ).toBeTruthy();
186 expect(screen.getByRole("option", { name: "Option B" })).toBeTruthy();
187 });
188
189 it("calls onValueChange when an option is selected", async () => {
190 const handleChange = vi.fn();
191 render(<Select options={options} onValueChange={handleChange} />);
192 await userEvent.click(screen.getByRole("combobox"));
193 await userEvent.click(
194 await screen.findByRole("option", { name: "Option A" }),
195 );
196 expect(handleChange).toHaveBeenCalledWith("a");
197 });
198});