COMPONENTS/INPUTS
Option select.
A familiar choice, with a custom menu.
In practice
Custom keyboard-accessible single selection. The NativeSelect export accepts option children for compatibility; no operating-system menu is shown.
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 { Label, NativeSelect } from "@chlohal/coal-ui";
export default function Example() {
const id = React.useId();
return (<div>
<Label htmlFor={id}>Choose a format</Label>
<NativeSelect id={id} defaultValue="svg">
<option value="svg">SVG vector</option>
<option value="png">PNG image</option>
<option value="pdf">PDF document</option>
</NativeSelect>
</div>);
}
Source
The original Coal implementation. Use the package export to integrate it.
packages/react/src/native-select.tsx
"use client";
import * as React from "react";
import { useFormReset } from "./internal.js";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "./select.js";
/** Compatibility name; the visible control and option list are fully custom. */
export type NativeSelectProps = Omit<
React.ComponentPropsWithRef<"select">,
"multiple" | "size"
>;
export function NativeSelect({
children,
className,
value,
defaultValue,
onChange,
disabled,
required,
name,
id,
ref,
...props
}: NativeSelectProps) {
const options: {
value: string;
label: React.ReactNode;
disabled?: boolean;
}[] = [];
const collect = (nodes: React.ReactNode, groupDisabled = false) =>
React.Children.forEach(nodes, (node) => {
if (!React.isValidElement(node)) return;
const p = node.props as {
value?: string;
children?: React.ReactNode;
disabled?: boolean;
};
if (node.type === "option")
options.push({
value: String(p.value ?? p.children ?? ""),
label: p.children,
disabled: groupDisabled || p.disabled,
});
else collect(p.children, groupDisabled || p.disabled);
});
collect(children);
const [local, setLocal] = React.useState(
String(defaultValue ?? options[0]?.value ?? ""),
);
const selected = String(value ?? local);
const proxy = React.useRef<HTMLSelectElement>(null);
useFormReset(proxy, () =>
setLocal(String(defaultValue ?? options[0]?.value ?? "")),
);
return (
<>
<select
{...props}
ref={(node) => {
proxy.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}}
hidden
aria-hidden="true"
tabIndex={-1}
name={name}
disabled={disabled}
value={selected}
onChange={(e) => {
onChange?.(e);
if (!e.defaultPrevented) setLocal(e.target.value);
}}
>
{children}
</select>
<Select
value={selected}
required={required}
disabled={disabled}
items={Object.fromEntries(options.map((o) => [o.value, o.label]))}
onValueChange={(next) => {
const node = proxy.current;
if (node) {
Object.getOwnPropertyDescriptor(
HTMLSelectElement.prototype,
"value",
)?.set?.call(node, next);
node.dispatchEvent(new Event("change", { bubbles: true }));
}
}}
>
<SelectTrigger
id={id}
className={className}
aria-label={props["aria-label"]}
aria-labelledby={props["aria-labelledby"]}
aria-describedby={props["aria-describedby"]}
{...(props["aria-invalid"] !== undefined
? { "aria-invalid": props["aria-invalid"] }
: {})}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.value} value={o.value} disabled={o.disabled}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
);
}
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.