Select
v0.4.0A 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.
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";56export 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}1415export 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}3637/**38 * Bare select control — the Radix select trigger button is this component's39 * own root element, no built-in label/hint/error. Wrap it in `FormField` for40 * 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 ...rest54}: SelectProps) => {55 const generatedId = useId();56 const selectId = id ?? generatedId;5758 const [rootClass, getChildClass] = getClassName({59 className,60 rootClass: "BHT__Select",61 styles,62 });6364 return (65 <RadixSelect.Root66 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 <svg77 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.Content92 className={getChildClass("content")}93 position="popper"94 sideOffset={4}95 >96 <RadixSelect.Viewport className={getChildClass("viewport")}>97 {options.map((option) => (98 <RadixSelect.Item99 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.ItemIndicator106 className={getChildClass("item-indicator")}107 >108 <svg109 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 and2// the popup is a menu.3//4// The trigger's 2px focus/open edge is a 1px inset shadow on top of the 1px5// border rather than a wider border, so the value never shifts.67.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);3435 // Touch target: extends the hit area to 48px without changing the drawn36 // 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 }4748 &[data-placeholder] {49 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));50 }5152 &:hover:not([data-disabled], [data-state="open"], :focus-visible) {53 border-color: var(--md-sys-color-on-surface, var(--color-on-surface));54 }5556 &[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 1px61 var(--md-sys-color-primary, var(--color-border-focus));62 }6364 &[data-state="open"] {65 .BHT__Select {66 &__chevron {67 transform: rotate(180deg);68 }69 }70 }7172 &[data-disabled] {73 opacity: 0.45;74 cursor: not-allowed;75 }7677 &[aria-invalid="true"] {78 border-color: var(--md-sys-color-error, var(--color-error));7980 &:hover:not([data-disabled], [data-state="open"], :focus-visible) {81 border-color: var(--md-sys-color-on-error-container, var(--color-error));82 }8384 &[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 }9091 &__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 }99100 // --- Menu ------------------------------------------------------------------101102 &__content {103 // Renders in a portal to document.body, so it is a *sibling* of any Modal104 // or Drawer it was opened from rather than a child — the fallback must stay105 // above --z-modal's (400) or an unstyled consumer gets a popup hidden106 // 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);119120 &[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 }124125 &[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 }130131 &__viewport {132 padding: var(--spacing-1, 0.25rem);133 }134135 &__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;153154 // State layer. Radix sets data-highlighted for both pointer hover and155 // 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 }167168 &[data-highlighted]::after {169 opacity: var(--md-sys-state-hover-opacity, 0.08);170 }171172 &:active::after {173 opacity: var(--md-sys-state-pressed-opacity, 0.1);174 }175176 // 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 }187188 &[data-disabled] {189 opacity: 0.4;190 cursor: not-allowed;191 pointer-events: none;192 }193 }194195 &__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;202203 svg {204 width: 100%;205 height: 100%;206 }207 }208}209210@keyframes select-in {211 from {212 opacity: 0;213 transform: translateY(-0.25rem);214 }215216 to {217 opacity: 1;218 transform: translateY(0);219 }220}221222@keyframes select-out {223 from {224 opacity: 1;225 transform: translateY(0);226 }227228 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";45const colorOptions = [6 { label: "Red", value: "red" },7 { label: "Green", value: "green" },8 { label: "Blue", value: "blue" },9];1011const 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];1718export const BasicSelect = () => {19 const [value, setValue] = useState("");20 return (21 <Select22 placeholder="Choose a color"23 options={colorOptions}24 value={value}25 onValueChange={setValue}26 />27 );28};2930export const SelectWithFormField = () => {31 const [value, setValue] = useState("");32 return (33 <FormField34 label="Role"35 htmlFor="role"36 hint="Determines what the user can access."37 >38 <Select39 id="role"40 placeholder="Select a role"41 options={roleOptions}42 value={value}43 onValueChange={setValue}44 />45 </FormField>46 );47};4849export const SelectWithError = () => (50 <FormField label="Status" htmlFor="status" error="Please select a status.">51 <Select52 id="status"53 aria-invalid="true"54 placeholder="Select status"55 options={colorOptions}56 />57 </FormField>58);5960export 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";45const options = [6 { label: "Option A", value: "a" },7 { label: "Option B", value: "b" },8 { label: "Option C", value: "c", disabled: true },9];1011describe("Select", () => {12 it("renders the trigger as a combobox", () => {13 render(<Select options={options} />);14 expect(screen.getByRole("combobox")).toBeTruthy();15 });1617 it("renders placeholder text", () => {18 render(<Select options={options} placeholder="Pick one" />);19 expect(screen.getByText("Pick one")).toBeTruthy();20 });2122 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 });2829 it("marks trigger as disabled", () => {30 render(<Select options={options} disabled />);31 expect(32 screen.getByRole("combobox").getAttribute("data-disabled"),33 ).not.toBeNull();34 });3536 it("applies additional className", () => {37 render(<Select options={options} className="custom" />);38 expect(screen.getByRole("combobox").className).toContain("custom");39 });4041 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 });4950 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});