Select
v0.5.0StableA 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
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";45export 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}1516/**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 });3132 return (33 <RadixSelect.Item value={value} disabled={disabled} className={rootClass}>34 <RadixSelect.ItemText>{label}</RadixSelect.ItemText>35 <RadixSelect.ItemIndicator className={getChildClass("indicator")}>36 <svg37 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";67export 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}1516export 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}3940/**41 * Bare select control — the Radix select trigger button is this component's42 * own root element, no built-in label/hint/error. Wrap it in `FormField` for43 * 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 ...rest59}: SelectProps) => {60 const generatedId = useId();61 const selectId = id ?? generatedId;6263 const isInvalid = invalid || ariaInvalid === true || ariaInvalid === "true";6465 // The controlled `value` wins; otherwise the component tracks its own value66 // so `--placeholder` and the selected item work uncontrolled too.67 const [internalValue, setInternalValue] = useState(defaultValue);68 const currentValue = value ?? internalValue;6970 const handleValueChange = (next: string) => {71 if (value === undefined) {72 setInternalValue(next);73 }74 onValueChange?.(next);75 };7677 const [isOpen, setIsOpen] = useState(false);7879 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 });9091 return (92 <RadixSelect.Root93 value={currentValue ?? ""}94 onValueChange={handleValueChange}95 open={isOpen}96 onOpenChange={setIsOpen}97 disabled={disabled}98 required={required}99 name={name}100 >101 <RadixSelect.Trigger102 {...rest}103 id={selectId}104 className={rootClass}105 aria-invalid={isInvalid ? true : ariaInvalid}106 >107 <RadixSelect.Value placeholder={placeholder} />108 <RadixSelect.Icon asChild>109 <svg110 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.Content125 className={getChildClass("content")}126 position="popper"127 sideOffset={4}128 >129 <RadixSelect.Viewport className={getChildClass("viewport")}>130 {options.map((option) => (131 <SelectItem132 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 the2// `--disabled` and `--selected` modifiers are real classes; Radix-runtime states3// (`data-highlighted`) stay attribute selectors.45.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;2324 // State layer. Radix sets data-highlighted for both pointer hover and25 // 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 }3738 &[data-highlighted]::after {39 opacity: var(--md-sys-state-hover-opacity, 0.08);40 }4142 &:active::after {43 opacity: var(--md-sys-state-pressed-opacity, 0.1);44 }4546 // 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 }5960 &--disabled {61 &.BHT__SelectItem {62 opacity: 0.4;63 cursor: not-allowed;64 pointer-events: none;65 }66 }6768 &__indicator {69 width: 0.75rem;70 height: 0.75rem;71 flex-shrink: 0;72 display: flex;73 align-items: center;74 justify-content: center;7576 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 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@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 1px11 var(--md-sys-color-primary, var(--color-border-focus));12}1314.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);4142 // Touch target: extends the hit area to 48px without changing the drawn43 // 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 }5455 &--placeholder {56 &.BHT__Select {57 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));58 }59 }6061 &:hover:not(.BHT__Select--disabled, .BHT__Select--open, :focus-visible) {62 border-color: var(--md-sys-color-on-surface, var(--color-on-surface));63 }6465 &--open {66 &.BHT__Select {67 @include focus-edge;68 }69 }7071 &:focus-visible {72 @include focus-edge;73 }7475 &--open {76 &.BHT__Select {77 .BHT__Select {78 &__chevron {79 transform: rotate(180deg);80 }81 }82 }83 }8485 &--disabled {86 &.BHT__Select {87 opacity: 0.45;88 cursor: not-allowed;89 }90 }9192 &--invalid {93 &.BHT__Select {94 border-color: var(--md-sys-color-error, var(--color-error));9596 &: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 }102103 &.BHT__Select--open,104 &:focus-visible {105 border-color: var(--md-sys-color-error, var(--color-error));106 box-shadow: inset 0 0 0 1px107 var(--md-sys-color-error, var(--color-error));108 }109 }110 }111112 &__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 }120121 // --- Menu ------------------------------------------------------------------122123 &__content {124 // Renders in a portal to document.body, so it is a *sibling* of any Modal125 // or Drawer it was opened from rather than a child — the fallback must stay126 // above --z-modal's (400) or an unstyled consumer gets a popup hidden127 // 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);140141 &[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 }145146 &[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 }151152 &__viewport {153 padding: var(--spacing-1, 0.25rem);154 }155}156157@keyframes select-in {158 from {159 opacity: 0;160 transform: translateY(-0.25rem);161 }162163 to {164 opacity: 1;165 transform: translateY(0);166 }167}168169@keyframes select-out {170 from {171 opacity: 1;172 transform: translateY(0);173 }174175 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";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 invalid54 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("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 });3536 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 });4041 it("has no --invalid modifier by default", () => {42 render(<Select options={options} />);43 expect(screen.getByRole("combobox").className).not.toContain("--invalid");44 });4546 it("marks trigger as disabled", () => {47 render(<Select options={options} disabled />);48 expect(49 screen.getByRole("combobox").getAttribute("data-disabled"),50 ).not.toBeNull();51 });5253 it("adds the --disabled modifier to a disabled trigger", () => {54 render(<Select options={options} disabled />);55 expect(screen.getByRole("combobox").className).toContain("--disabled");56 });5758 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 });6768 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 });7273 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 });7980 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 });8687 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 });99100 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 });111112 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 });122123 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 });134135 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 });154155 it("applies additional className", () => {156 render(<Select options={options} className="custom" />);157 expect(screen.getByRole("combobox").className).toContain("custom");158 });159160 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 });168169 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});