Behive Tech Registry

Select

v0.4.0

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 · depends on @behivetech/get-class-name

select.tsx
1import * as RadixSelect from "@radix-ui/react-select";
2import { useId } from "react";
3import { getClassName } from "@behivetech/get-class-name";
4import styles from "./select.module.scss";
5
6export interface SelectOption {
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 */
12 disabled?: boolean;
13}
14
15export interface SelectProps extends Omit<
16 RadixSelect.SelectTriggerProps,
17 "className"
18> {
19 /** Options rendered in the dropdown */
20 options: SelectOption[];
21 /** Controlled selected value */
22 value?: string;
23 /** Initial selected value for uncontrolled usage */
24 defaultValue?: string;
25 /** Called with the new value when the selection changes */
26 onValueChange?: (value: string) => void;
27 /** Text shown when no value is selected */
28 placeholder?: string;
29 /** Marks the field as required for form validation */
30 required?: boolean;
31 /** Additional class names to merge with the component root element */
32 className?: string;
33 /** Form field name submitted with the value */
34 name?: string;
35}
36
37/**
38 * Bare select control — the Radix select trigger button is this component's
39 * own root element, no built-in label/hint/error. Wrap it in `FormField` for
40 * that chrome: `<FormField label="..." htmlFor="x"><Select id="x" .../></FormField>`.
41 */
42export const Select = ({
43 options,
44 value,
45 defaultValue,
46 onValueChange,
47 placeholder,
48 disabled,
49 required,
50 className,
51 id,
52 name,
53 ...rest
54}: SelectProps) => {
55 const generatedId = useId();
56 const selectId = id ?? generatedId;
57
58 const [rootClass, getChildClass] = getClassName({
59 className,
60 rootClass: "BHT__Select",
61 styles,
62 });
63
64 return (
65 <RadixSelect.Root
66 value={value}
67 defaultValue={defaultValue}
68 onValueChange={onValueChange}
69 disabled={disabled}
70 required={required}
71 name={name}
72 >
73 <RadixSelect.Trigger {...rest} id={selectId} className={rootClass}>
74 <RadixSelect.Value placeholder={placeholder} />
75 <RadixSelect.Icon asChild>
76 <svg
77 className={getChildClass("chevron")}
78 viewBox="0 0 12 8"
79 fill="none"
80 stroke="currentColor"
81 strokeWidth="2"
82 strokeLinecap="round"
83 strokeLinejoin="round"
84 aria-hidden="true"
85 >
86 <path d="M1 1.5L6 6.5L11 1.5" />
87 </svg>
88 </RadixSelect.Icon>
89 </RadixSelect.Trigger>
90 <RadixSelect.Portal>
91 <RadixSelect.Content
92 className={getChildClass("content")}
93 position="popper"
94 sideOffset={4}
95 >
96 <RadixSelect.Viewport className={getChildClass("viewport")}>
97 {options.map((option) => (
98 <RadixSelect.Item
99 key={option.value}
100 value={option.value}
101 disabled={option.disabled}
102 className={getChildClass("item")}
103 >
104 <RadixSelect.ItemText>{option.label}</RadixSelect.ItemText>
105 <RadixSelect.ItemIndicator
106 className={getChildClass("item-indicator")}
107 >
108 <svg
109 viewBox="0 0 12 10"
110 fill="none"
111 stroke="currentColor"
112 strokeWidth="2.5"
113 strokeLinecap="round"
114 strokeLinejoin="round"
115 aria-hidden="true"
116 >
117 <path d="M1 5L4.5 8.5L11 1" />
118 </svg>
119 </RadixSelect.ItemIndicator>
120 </RadixSelect.Item>
121 ))}
122 </RadixSelect.Viewport>
123 </RadixSelect.Content>
124 </RadixSelect.Portal>
125 </RadixSelect.Root>
126 );
127};
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.BHT__Select {
8 // Radix Select.Trigger renders as <button>
9 position: relative;
10 display: flex;
11 align-items: center;
12 justify-content: space-between;
13 gap: var(--spacing-2, 0.5rem);
14 width: 100%;
15 min-height: 2.5rem;
16 padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
17 font-family: var(--md-sys-typescale-body-large-font, var(--font-body));
18 font-size: var(--md-sys-typescale-body-large-size, 1rem);
19 font-weight: var(--md-sys-typescale-body-large-weight, 400);
20 line-height: var(--md-sys-typescale-body-large-line-height, 1.5rem);
21 letter-spacing: var(--md-sys-typescale-body-large-tracking, 0.0313em);
22 color: var(--md-sys-color-on-surface, var(--color-on-surface));
23 // Outlined fields have no container fill; they take the surface they sit on.
24 background: transparent;
25 border: 1px solid var(--md-sys-color-outline, var(--color-border-strong));
26 border-radius: var(--md-sys-shape-corner-extra-small, 4px);
27 cursor: pointer;
28 text-align: left;
29 transition:
30 border-color var(--md-sys-motion-duration-short-3, 150ms)
31 var(--md-sys-motion-easing-standard, ease),
32 box-shadow var(--md-sys-motion-duration-short-3, 150ms)
33 var(--md-sys-motion-easing-standard, ease);
34
35 // Touch target: extends the hit area to 48px without changing the drawn
36 // 40px height. px on purpose — a finger doesn't scale with font size.
37 &::before {
38 content: "";
39 position: absolute;
40 left: 0;
41 right: 0;
42 top: 50%;
43 translate: 0 -50%;
44 height: 100%;
45 min-height: 48px;
46 }
47
48 &[data-placeholder] {
49 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));
50 }
51
52 &:hover:not([data-disabled], [data-state="open"], :focus-visible) {
53 border-color: var(--md-sys-color-on-surface, var(--color-on-surface));
54 }
55
56 &[data-state="open"],
57 &:focus-visible {
58 outline: none;
59 border-color: var(--md-sys-color-primary, var(--color-border-focus));
60 box-shadow: inset 0 0 0 1px
61 var(--md-sys-color-primary, var(--color-border-focus));
62 }
63
64 &[data-state="open"] {
65 .BHT__Select {
66 &__chevron {
67 transform: rotate(180deg);
68 }
69 }
70 }
71
72 &[data-disabled] {
73 opacity: 0.45;
74 cursor: not-allowed;
75 }
76
77 &[aria-invalid="true"] {
78 border-color: var(--md-sys-color-error, var(--color-error));
79
80 &:hover:not([data-disabled], [data-state="open"], :focus-visible) {
81 border-color: var(--md-sys-color-on-error-container, var(--color-error));
82 }
83
84 &[data-state="open"],
85 &:focus-visible {
86 border-color: var(--md-sys-color-error, var(--color-error));
87 box-shadow: inset 0 0 0 1px var(--md-sys-color-error, var(--color-error));
88 }
89 }
90
91 &__chevron {
92 width: 0.75rem;
93 height: 0.75rem;
94 flex-shrink: 0;
95 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));
96 transition: transform var(--md-sys-motion-duration-short-3, 150ms)
97 var(--md-sys-motion-easing-standard, ease);
98 }
99
100 // --- Menu ------------------------------------------------------------------
101
102 &__content {
103 // Renders in a portal to document.body, so it is a *sibling* of any Modal
104 // or Drawer it was opened from rather than a child — the fallback must stay
105 // above --z-modal's (400) or an unstyled consumer gets a popup hidden
106 // behind the modal that spawned it.
107 //
108 // MD3 menus separate from the page by tone and elevation, not a border.
109 background: var(
110 --md-sys-color-surface-container,
111 var(--color-surface-raised)
112 );
113 border-radius: var(--md-sys-shape-corner-extra-small, 4px);
114 box-shadow: var(--md-sys-elevation-2, var(--shadow-md));
115 overflow: hidden;
116 z-index: var(--z-dropdown, 450);
117 min-width: var(--radix-select-trigger-width);
118 max-height: var(--radix-select-content-available-height);
119
120 &[data-state="open"] {
121 animation: select-in var(--md-sys-motion-duration-short-3, 150ms)
122 var(--md-sys-motion-easing-standard-decelerate, ease-out);
123 }
124
125 &[data-state="closed"] {
126 animation: select-out var(--md-sys-motion-duration-short-2, 100ms)
127 var(--md-sys-motion-easing-standard-accelerate, ease-in);
128 }
129 }
130
131 &__viewport {
132 padding: var(--spacing-1, 0.25rem);
133 }
134
135 &__item {
136 position: relative;
137 isolation: isolate;
138 display: flex;
139 align-items: center;
140 justify-content: space-between;
141 gap: var(--spacing-2, 0.5rem);
142 padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
143 border-radius: var(--md-sys-shape-corner-extra-small, 4px);
144 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));
145 font-size: var(--md-sys-typescale-label-large-size, 0.875rem);
146 font-weight: var(--md-sys-typescale-label-large-weight, 500);
147 line-height: var(--md-sys-typescale-label-large-line-height, 1.25rem);
148 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);
149 color: var(--md-sys-color-on-surface, var(--color-on-surface));
150 cursor: pointer;
151 user-select: none;
152 outline: none;
153
154 // State layer. Radix sets data-highlighted for both pointer hover and
155 // keyboard focus, so it stands in for :hover here.
156 &::after {
157 content: "";
158 position: absolute;
159 inset: 0;
160 border-radius: inherit;
161 background: currentColor;
162 opacity: 0;
163 pointer-events: none;
164 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)
165 var(--md-sys-motion-easing-standard, ease);
166 }
167
168 &[data-highlighted]::after {
169 opacity: var(--md-sys-state-hover-opacity, 0.08);
170 }
171
172 &:active::after {
173 opacity: var(--md-sys-state-pressed-opacity, 0.1);
174 }
175
176 // MD3 marks the selected menu item with a tonal container.
177 &[data-state="checked"] {
178 background: var(
179 --md-sys-color-secondary-container,
180 var(--color-surface-light)
181 );
182 color: var(
183 --md-sys-color-on-secondary-container,
184 var(--color-on-surface)
185 );
186 }
187
188 &[data-disabled] {
189 opacity: 0.4;
190 cursor: not-allowed;
191 pointer-events: none;
192 }
193 }
194
195 &__item-indicator {
196 width: 0.75rem;
197 height: 0.75rem;
198 flex-shrink: 0;
199 display: flex;
200 align-items: center;
201 justify-content: center;
202
203 svg {
204 width: 100%;
205 height: 100%;
206 }
207 }
208}
209
210@keyframes select-in {
211 from {
212 opacity: 0;
213 transform: translateY(-0.25rem);
214 }
215
216 to {
217 opacity: 1;
218 transform: translateY(0);
219 }
220}
221
222@keyframes select-out {
223 from {
224 opacity: 1;
225 transform: translateY(0);
226 }
227
228 to {
229 opacity: 0;
230 transform: translateY(-0.25rem);
231 }
232}
index.ts
1export { Select } from "./select.js";
2export type { SelectProps, SelectOption } from "./select.js";
select.composition.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 aria-invalid="true"
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("marks trigger as disabled", () => {
30 render(<Select options={options} disabled />);
31 expect(
32 screen.getByRole("combobox").getAttribute("data-disabled"),
33 ).not.toBeNull();
34 });
35
36 it("applies additional className", () => {
37 render(<Select options={options} className="custom" />);
38 expect(screen.getByRole("combobox").className).toContain("custom");
39 });
40
41 it("opens and shows options when clicked", async () => {
42 render(<Select options={options} />);
43 await userEvent.click(screen.getByRole("combobox"));
44 expect(
45 await screen.findByRole("option", { name: "Option A" }),
46 ).toBeTruthy();
47 expect(screen.getByRole("option", { name: "Option B" })).toBeTruthy();
48 });
49
50 it("calls onValueChange when an option is selected", async () => {
51 const handleChange = vi.fn();
52 render(<Select options={options} onValueChange={handleChange} />);
53 await userEvent.click(screen.getByRole("combobox"));
54 await userEvent.click(
55 await screen.findByRole("option", { name: "Option A" }),
56 );
57 expect(handleChange).toHaveBeenCalledWith("a");
58 });
59});