COMPONENTS/INPUTS
Select.
A familiar choice, thoughtfully styled.
In practice
Compose SelectTrigger, SelectValue, SelectContent and SelectItem. Pass an items map to Select for display labels; values may be different. Coal handles selection and keyboard navigation.
Installation
Install Coal once. Components and TypeScript declarations are included. React 19 and React DOM 19 are peer dependencies.
Terminal · local package
npm install http://localhost:3100/downloads/chlohal-coal-ui-0.5.0.tgzThis installs the local package. It is not yet published on npm. See installation for stylesheet setup and integration.
Usage
example.tsx
"use client";
import * as React from "react";
import "@chlohal/coal-ui/styles.css";
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Label } from "@chlohal/coal-ui";
export default function Example() {
const id = React.useId();
return (<div>
<Label id={id}>Your workspace</Label>
<Select defaultValue="studio" items={{ personal: "Personal", studio: "Studio", team: "Team" }}>
<SelectTrigger aria-labelledby={id}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["personal", "studio", "team"].map((s) => (<SelectItem key={s} value={s}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</SelectItem>))}
</SelectContent>
</Select>
</div>);
}
Source
The original Coal implementation. Use the package export to integrate it.
packages/react/src/select.tsx
"use client";
import * as React from "react";
import {
Action,
cn,
useRequired,
useValue,
moveFocus,
type ActionProps,
} from "./internal.js";
import {
FloatingRoot,
FloatingTrigger,
FloatingContent,
useFloating,
type FloatingContentProps,
} from "./floating.js";
type State = {
value: string;
set: (v: string) => void;
items: Record<string, React.ReactNode>;
disabled?: boolean;
};
const Context = React.createContext<State | undefined>(undefined);
export type SelectProps = {
children: React.ReactNode;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
items?: Record<string, React.ReactNode>;
name?: string;
disabled?: boolean;
required?: boolean;
};
export function Select({
children,
value,
defaultValue = "",
onValueChange,
items = {},
name,
disabled,
required,
}: SelectProps) {
const [v, set] = useValue(value, defaultValue, onValueChange);
return (
<Context.Provider value={{ value: v, set, items, disabled }}>
<FloatingRoot>
<SelectFormValue name={name} required={required} />
{children}
</FloatingRoot>
</Context.Provider>
);
}
function SelectFormValue({
name,
required,
}: {
name?: string;
required?: boolean;
}) {
const c = useRequired(Context, "Select");
const f = useFloating();
return (
<select
className="coal-sr-only"
tabIndex={-1}
aria-hidden="true"
name={name}
value={c.value}
required={required}
disabled={c.disabled}
onChange={(e) => c.set(e.target.value)}
onInvalid={(e) => {
e.preventDefault();
f.anchor.current?.focus();
f.set(true);
}}
>
<option value="" />
{Array.from(new Set([...Object.keys(c.items), c.value]))
.filter(Boolean)
.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
);
}
export function SelectTrigger({ className, ...props }: ActionProps) {
const c = useRequired(Context, "SelectTrigger");
return (
<FloatingTrigger
role="combobox"
aria-haspopup="listbox"
disabled={c.disabled}
className={cn("coal-select-trigger", className)}
{...props}
/>
);
}
export function SelectValue({
placeholder = "Choose an option",
...props
}: React.ComponentPropsWithRef<"span"> & { placeholder?: string }) {
const c = useRequired(Context, "SelectValue");
return (
<span {...props}>
{c.items[c.value] ?? (c.value || placeholder)}
<span aria-hidden="true" className="coal-select-chevron">
⌄
</span>
</span>
);
}
export function SelectContent({ onKeyDown, ...props }: FloatingContentProps) {
return (
<FloatingContent
role="listbox"
className="coal-select-content"
{...props}
onKeyDown={(e) => {
onKeyDown?.(e);
if (!e.defaultPrevented) {
moveFocus(e, '[role="option"]');
if (e.key.length === 1) {
const option = Array.from(
e.currentTarget.querySelectorAll<HTMLElement>('[role="option"]'),
).find(
(el) =>
!el.hasAttribute("disabled") &&
el.textContent
?.trim()
.toLowerCase()
.startsWith(e.key.toLowerCase()),
);
option?.focus();
}
}
}}
/>
);
}
export function SelectItem({
value,
className,
onClick,
...props
}: ActionProps & { value: string }) {
const c = useRequired(Context, "SelectItem");
const f = useFloating();
return (
<Action
{...props}
role="option"
aria-selected={c.value === value}
tabIndex={c.value === value ? 0 : -1}
className={cn("coal-option", className)}
onClick={(e) => {
onClick?.(e);
if (!e.defaultPrevented) {
c.set(value);
f.close();
}
}}
/>
);
}
export function SelectGroup(props: React.ComponentPropsWithRef<"div">) {
return <div role="group" {...props} />;
}
export function SelectLabel(props: React.ComponentPropsWithRef<"div">) {
return <div className="coal-menu-label" {...props} />;
}
export function SelectSeparator(props: React.ComponentPropsWithRef<"hr">) {
return <hr className="coal-separator" {...props} />;
}
Accessibility checklist
- Provide an accessible name for controls, including icon-only buttons.
- Check keyboard navigation, visible focus and disabled states in your application.
- Do not rely on color alone to communicate status.