Behive Tech Component Registry

Select

v0.5.0Stable

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-item.tsx
1import * as RadixSelect from "@radix-ui/react-select";
2import { getClassName } from "@bht-component-registry/class-names";
3import styles from "./select-item.module.scss";
4
5export interface SelectItemProps {
6 /** Text shown for this option */
7 label: string;
8 /** Value submitted/reported when this option is chosen */
9 value: string;
10 /** Prevents this option from being selected; adds the `--disabled` modifier */
11 disabled?: boolean;
12 /** Marks this option as the current selection; adds the `--selected` modifier */
13 selected?: boolean;
14}
15
16/**
17 * One option in Select's popup. A block of its own so `disabled` and `selected`
18 * are styled through modifiers instead of Radix's `data-*` attributes.
19 */
20export const SelectItem = ({
21 label,
22 value,
23 disabled = false,
24 selected = false,
25}: SelectItemProps) => {
26 const [rootClass, getChildClass] = getClassName({
27 modifiers: { disabled, selected },
28 rootClass: "BHT__SelectItem",
29 styles,
30 });
31
32 return (
33 <RadixSelect.Item value={value} disabled={disabled} className={rootClass}>
34 <RadixSelect.ItemText>{label}</RadixSelect.ItemText>
35 <RadixSelect.ItemIndicator className={getChildClass("indicator")}>
36 <svg
37 viewBox="0 0 12 10"
38 fill="none"
39 stroke="currentColor"
40 strokeWidth="2.5"
41 strokeLinecap="round"
42 strokeLinejoin="round"
43 aria-hidden="true"
44 >
45 <path d="M1 5L4.5 8.5L11 1" />
46 </svg>
47 </RadixSelect.ItemIndicator>
48 </RadixSelect.Item>
49 );
50};
select.tsx
1import * as RadixSelect from "@radix-ui/react-select";
2import { useId, useState } from "react";
3import { getClassName } from "@bht-component-registry/class-names";
4import styles from "./select.module.scss";
5import { SelectItem } from "./select-item.js";
6
7export interface SelectOption {
8 /** Text shown for this option */
9 label: string;
10 /** Value submitted/reported when this option is chosen */
11 value: string;
12 /** Prevents this option from being selected */
13 disabled?: boolean;
14}
15
16export interface SelectProps extends Omit<
17 RadixSelect.SelectTriggerProps,
18 "className"
19> {
20 /** Options rendered in the dropdown */
21 options: SelectOption[];
22 /** Controlled selected value */
23 value?: string;
24 /** Initial selected value for uncontrolled usage */
25 defaultValue?: string;
26 /** Called with the new value when the selection changes */
27 onValueChange?: (value: string) => void;
28 /** Text shown when no value is selected */
29 placeholder?: string;
30 /** Marks the field as required for form validation */
31 required?: boolean;
32 /** Additional class names to merge with the component root element */
33 className?: string;
34 /** Form field name submitted with the value */
35 name?: string;
36 /** Marks the field as invalid: adds the `--invalid` modifier and sets `aria-invalid="true"` */
37 invalid?: boolean;
38}
39
40/**
41 * Bare select control — the Radix select trigger button is this component's
42 * own root element, no built-in label/hint/error. Wrap it in `FormField` for
43 * that chrome: `<FormField label="..." htmlFor="x"><Select id="x" .../></FormField>`.
44 */
45export const Select = ({
46 options,
47 value,
48 defaultValue,
49 onValueChange,
50 placeholder,
51 disabled,
52 required,
53 className,
54 id,
55 name,
56 invalid = false,
57 "aria-invalid": ariaInvalid,
58 ...rest
59}: SelectProps) => {
60 const generatedId = useId();
61 const selectId = id ?? generatedId;
62
63 const isInvalid = invalid || ariaInvalid === true || ariaInvalid === "true";
64
65 // The controlled `value` wins; otherwise the component tracks its own value
66 // so `--placeholder` and the selected item work uncontrolled too.
67 const [internalValue, setInternalValue] = useState(defaultValue);
68 const currentValue = value ?? internalValue;
69
70 const handleValueChange = (next: string) => {
71 if (value === undefined) {
72 setInternalValue(next);
73 }
74 onValueChange?.(next);
75 };
76
77 const [isOpen, setIsOpen] = useState(false);
78
79 const [rootClass, getChildClass] = getClassName({
80 className,
81 modifiers: {
82 disabled: Boolean(disabled),
83 invalid: isInvalid,
84 open: isOpen,
85 placeholder: currentValue === undefined || currentValue === "",
86 },
87 rootClass: "BHT__Select",
88 styles,
89 });
90
91 return (
92 <RadixSelect.Root
93 value={currentValue ?? ""}
94 onValueChange={handleValueChange}
95 open={isOpen}
96 onOpenChange={setIsOpen}
97 disabled={disabled}
98 required={required}
99 name={name}
100 >
101 <RadixSelect.Trigger
102 {...rest}
103 id={selectId}
104 className={rootClass}
105 aria-invalid={isInvalid ? true : ariaInvalid}
106 >
107 <RadixSelect.Value placeholder={placeholder} />
108 <RadixSelect.Icon asChild>
109 <svg
110 className={getChildClass("chevron")}
111 viewBox="0 0 12 8"
112 fill="none"
113 stroke="currentColor"
114 strokeWidth="2"
115 strokeLinecap="round"
116 strokeLinejoin="round"
117 aria-hidden="true"
118 >
119 <path d="M1 1.5L6 6.5L11 1.5" />
120 </svg>
121 </RadixSelect.Icon>
122 </RadixSelect.Trigger>
123 <RadixSelect.Portal>
124 <RadixSelect.Content
125 className={getChildClass("content")}
126 position="popper"
127 sideOffset={4}
128 >
129 <RadixSelect.Viewport className={getChildClass("viewport")}>
130 {options.map((option) => (
131 <SelectItem
132 key={option.value}
133 label={option.label}
134 value={option.value}
135 disabled={option.disabled}
136 selected={option.value === currentValue}
137 />
138 ))}
139 </RadixSelect.Viewport>
140 </RadixSelect.Content>
141 </RadixSelect.Portal>
142 </RadixSelect.Root>
143 );
144};
select-item.module.scss
1// Material Design 3 menu item for Select's popup. Its own block so the
2// `--disabled` and `--selected` modifiers are real classes; Radix-runtime states
3// (`data-highlighted`) stay attribute selectors.
4
5.BHT__SelectItem {
6 position: relative;
7 isolation: isolate;
8 display: flex;
9 align-items: center;
10 justify-content: space-between;
11 gap: var(--spacing-2, 0.5rem);
12 padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
13 border-radius: var(--md-sys-shape-corner-extra-small, 4px);
14 font-family: var(--md-sys-typescale-label-large-font, var(--font-body));
15 font-size: var(--md-sys-typescale-label-large-size, 0.875rem);
16 font-weight: var(--md-sys-typescale-label-large-weight, 500);
17 line-height: var(--md-sys-typescale-label-large-line-height, 1.25rem);
18 letter-spacing: var(--md-sys-typescale-label-large-tracking, 0.0071em);
19 color: var(--md-sys-color-on-surface, var(--color-on-surface));
20 cursor: pointer;
21 user-select: none;
22 outline: none;
23
24 // State layer. Radix sets data-highlighted for both pointer hover and
25 // keyboard focus, so it stands in for :hover here.
26 &::after {
27 content: "";
28 position: absolute;
29 inset: 0;
30 border-radius: inherit;
31 background: currentColor;
32 opacity: 0;
33 pointer-events: none;
34 transition: opacity var(--md-sys-motion-duration-short-2, 100ms)
35 var(--md-sys-motion-easing-standard, ease);
36 }
37
38 &[data-highlighted]::after {
39 opacity: var(--md-sys-state-hover-opacity, 0.08);
40 }
41
42 &:active::after {
43 opacity: var(--md-sys-state-pressed-opacity, 0.1);
44 }
45
46 // MD3 marks the selected menu item with a tonal container.
47 &--selected {
48 &.BHT__SelectItem {
49 background: var(
50 --md-sys-color-secondary-container,
51 var(--color-surface-light)
52 );
53 color: var(
54 --md-sys-color-on-secondary-container,
55 var(--color-on-surface)
56 );
57 }
58 }
59
60 &--disabled {
61 &.BHT__SelectItem {
62 opacity: 0.4;
63 cursor: not-allowed;
64 pointer-events: none;
65 }
66 }
67
68 &__indicator {
69 width: 0.75rem;
70 height: 0.75rem;
71 flex-shrink: 0;
72 display: flex;
73 align-items: center;
74 justify-content: center;
75
76 svg {
77 width: 100%;
78 height: 100%;
79 }
80 }
81}
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
121 // --- Menu ------------------------------------------------------------------
122
123 &__content {
124 // Renders in a portal to document.body, so it is a *sibling* of any Modal
125 // or Drawer it was opened from rather than a child — the fallback must stay
126 // above --z-modal's (400) or an unstyled consumer gets a popup hidden
127 // behind the modal that spawned it.
128 //
129 // MD3 menus separate from the page by tone and elevation, not a border.
130 background: var(
131 --md-sys-color-surface-container,
132 var(--color-surface-raised)
133 );
134 border-radius: var(--md-sys-shape-corner-extra-small, 4px);
135 box-shadow: var(--md-sys-elevation-2, var(--shadow-md));
136 overflow: hidden;
137 z-index: var(--z-dropdown, 450);
138 min-width: var(--radix-select-trigger-width);
139 max-height: var(--radix-select-content-available-height);
140
141 &[data-state="open"] {
142 animation: select-in var(--md-sys-motion-duration-short-3, 150ms)
143 var(--md-sys-motion-easing-standard-decelerate, ease-out);
144 }
145
146 &[data-state="closed"] {
147 animation: select-out var(--md-sys-motion-duration-short-2, 100ms)
148 var(--md-sys-motion-easing-standard-accelerate, ease-in);
149 }
150 }
151
152 &__viewport {
153 padding: var(--spacing-1, 0.25rem);
154 }
155}
156
157@keyframes select-in {
158 from {
159 opacity: 0;
160 transform: translateY(-0.25rem);
161 }
162
163 to {
164 opacity: 1;
165 transform: translateY(0);
166 }
167}
168
169@keyframes select-out {
170 from {
171 opacity: 1;
172 transform: translateY(0);
173 }
174
175 to {
176 opacity: 0;
177 transform: translateY(-0.25rem);
178 }
179}
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 --selected to the chosen option only", async () => {
113 render(<Select options={options} defaultValue="b" />);
114 await userEvent.click(screen.getByRole("combobox"));
115 const selected = await screen.findByRole("option", { name: "Option B" });
116 expect(selected.className).toContain("--selected");
117 expect(selected.getAttribute("data-state")).toBe("checked");
118 expect(
119 screen.getByRole("option", { name: "Option A" }).className,
120 ).not.toContain("--selected");
121 });
122
123 it("moves --selected to a newly chosen option", async () => {
124 render(<Select options={options} />);
125 await userEvent.click(screen.getByRole("combobox"));
126 await userEvent.click(
127 await screen.findByRole("option", { name: "Option A" }),
128 );
129 await userEvent.click(screen.getByRole("combobox"));
130 expect(
131 (await screen.findByRole("option", { name: "Option A" })).className,
132 ).toContain("--selected");
133 });
134
135 it("keeps the modifiers in step with Radix when a controlled value becomes undefined", async () => {
136 const { rerender } = render(
137 <Select options={options} value="a" onValueChange={() => {}} />,
138 );
139 await userEvent.click(screen.getByRole("combobox"));
140 await userEvent.click(
141 await screen.findByRole("option", { name: "Option B" }),
142 );
143 rerender(
144 <Select options={options} value={undefined} onValueChange={() => {}} />,
145 );
146 const trigger = screen.getByRole("combobox");
147 expect(trigger.className).toContain("--placeholder");
148 expect(trigger.hasAttribute("data-placeholder")).toBe(true);
149 await userEvent.click(trigger);
150 const optionB = await screen.findByRole("option", { name: "Option B" });
151 expect(optionB.className).not.toContain("--selected");
152 expect(optionB.getAttribute("data-state")).toBe("unchecked");
153 });
154
155 it("applies additional className", () => {
156 render(<Select options={options} className="custom" />);
157 expect(screen.getByRole("combobox").className).toContain("custom");
158 });
159
160 it("opens and shows options when clicked", async () => {
161 render(<Select options={options} />);
162 await userEvent.click(screen.getByRole("combobox"));
163 expect(
164 await screen.findByRole("option", { name: "Option A" }),
165 ).toBeTruthy();
166 expect(screen.getByRole("option", { name: "Option B" })).toBeTruthy();
167 });
168
169 it("calls onValueChange when an option is selected", async () => {
170 const handleChange = vi.fn();
171 render(<Select options={options} onValueChange={handleChange} />);
172 await userEvent.click(screen.getByRole("combobox"));
173 await userEvent.click(
174 await screen.findByRole("option", { name: "Option A" }),
175 );
176 expect(handleChange).toHaveBeenCalledWith("a");
177 });
178});