fix: themed Radix-based Select (dark/gold popover) replacing native <select> (v0.3.0)
ci / build-and-design (push) Failing after 1s

Native <select> open menus are OS-drawn (grey/blue) and can't be themed.
Rebuilt Select on @radix-ui/react-select with a fully styled popover while
keeping the native drop-in API (value/onChange/<option> children). Empty-string
option values stay selectable via an internal sentinel.
This commit is contained in:
Jason
2026-07-16 07:45:39 -05:00
parent 9ee7368841
commit ea311c502a
2 changed files with 119 additions and 16 deletions
+4 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "@jason/ui-kit", "name": "@jason/ui-kit",
"version": "0.2.0", "version": "0.3.0",
"description": "Shared design foundation \u2014 branded tokens, Tailwind preset, shadcn-style components, motion, app shell + marketing blocks. Guarded by Impeccable.", "description": "Shared design foundation branded tokens, Tailwind preset, shadcn-style components, motion, app shell + marketing blocks. Guarded by Impeccable.",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"author": "Jason <jason@messagepoint.tv>", "author": "Jason <jason@messagepoint.tv>",
@@ -45,6 +45,7 @@
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.4", "@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-select": "^2.3.3",
"@radix-ui/react-slot": "^1.1.1", "@radix-ui/react-slot": "^1.1.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -65,8 +66,8 @@
"impeccable": "^3.2.1", "impeccable": "^3.2.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tsup": "^8.3.5",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tsup": "^8.3.5",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }
} }
+115 -13
View File
@@ -1,27 +1,129 @@
import * as React from "react"; import * as React from "react";
import * as RS from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../lib/cn"; import { cn } from "../lib/cn";
/**
* Themed Select — a drop-in for a native <select>, but the open menu is a fully
* styleable popover (dark/gold), unlike the OS-drawn native dropdown.
*
* Keeps the native API on purpose so it swaps 1:1 with existing code:
* <Select value={x} onChange={(e) => setX(e.target.value)}>
* <option value="">None</option>
* <option value="a">A</option>
* </Select>
*
* Radix forbids empty-string item values, but apps use <option value=""> for
* real, selectable choices (e.g. "No procedure"). We map "" to a sentinel
* internally and translate back on change, so empty options stay selectable.
*/
const selectVariants = cva( const selectVariants = cva(
"w-full rounded border border-border bg-bg/40 text-text transition-shadow duration-base ease-out " + "inline-flex w-full items-center justify-between gap-2 rounded border border-border bg-bg/40 text-text " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:border-brand/60 " + "transition-shadow duration-base ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 " +
"disabled:cursor-not-allowed disabled:opacity-50", "focus-visible:border-brand/60 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted",
{ {
variants: { size: { sm: "h-8 px-2 py-1 text-sm", md: "h-10 px-3 py-2 text-sm" } }, variants: { size: { sm: "h-8 px-2.5 text-sm", md: "h-10 px-3 text-sm" } },
defaultVariants: { size: "md" }, defaultVariants: { size: "md" },
} }
); );
export interface SelectProps const EMPTY = "__ui_select_empty__";
extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "size">, const toRadix = (v?: string) => (v === "" ? EMPTY : v);
VariantProps<typeof selectVariants> {} const fromRadix = (v: string) => (v === EMPTY ? "" : v);
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>( type OptionData = { value: string; label: React.ReactNode; disabled?: boolean };
({ className, size, children, ...props }, ref) => (
<select ref={ref} className={cn(selectVariants({ size }), className)} {...props}> function collectOptions(children: React.ReactNode): OptionData[] {
{children} const out: OptionData[] = [];
</select> const walk = (nodes: React.ReactNode) =>
) React.Children.forEach(nodes, (child) => {
if (!React.isValidElement(child)) return;
if (child.type === "option") {
const p = child.props as { value?: string | number; children?: React.ReactNode; disabled?: boolean };
out.push({ value: String(p.value ?? ""), label: p.children, disabled: p.disabled });
} else if (child.type === "optgroup") {
walk((child.props as { children?: React.ReactNode }).children);
}
});
walk(children);
return out;
}
export interface SelectProps extends VariantProps<typeof selectVariants> {
value?: string;
defaultValue?: string;
onChange?: (e: { target: { value: string } }) => void;
onValueChange?: (value: string) => void;
disabled?: boolean;
name?: string;
placeholder?: string;
className?: string;
children?: React.ReactNode;
"aria-label"?: string;
id?: string;
}
export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
(
{ value, defaultValue, onChange, onValueChange, disabled, name, placeholder, size, className, children, id, ...rest },
ref
) => {
const options = collectOptions(children);
const handle = (v: string) => {
const real = fromRadix(v);
onValueChange?.(real);
onChange?.({ target: { value: real } });
};
return (
<RS.Root
value={value === undefined ? undefined : toRadix(value)}
defaultValue={defaultValue === undefined ? undefined : toRadix(defaultValue)}
onValueChange={handle}
disabled={disabled}
name={name}
>
<RS.Trigger ref={ref} id={id} aria-label={rest["aria-label"]} className={cn(selectVariants({ size }), className)}>
<span className="min-w-0 truncate text-left">
<RS.Value placeholder={placeholder} />
</span>
<RS.Icon asChild>
<ChevronDown className="size-4 shrink-0 opacity-70" />
</RS.Icon>
</RS.Trigger>
<RS.Portal>
<RS.Content
position="popper"
sideOffset={6}
className={cn(
"z-50 max-h-[min(24rem,var(--radix-select-content-available-height))] min-w-[var(--radix-select-trigger-width)]",
"overflow-hidden rounded-md border border-border bg-surface shadow-lg data-[state=open]:animate-scale-in"
)}
>
<RS.Viewport className="p-1">
{options.map((o, i) => (
<RS.Item
key={`${o.value}-${i}`}
value={toRadix(o.value)!}
disabled={o.disabled}
className={cn(
"relative flex cursor-pointer select-none items-center rounded px-2 py-1.5 pr-8 text-sm text-text outline-none",
"data-[highlighted]:bg-brand/15 data-[highlighted]:text-brand",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
)}
>
<RS.ItemText>{o.label}</RS.ItemText>
<RS.ItemIndicator className="absolute right-2 inline-flex">
<Check className="size-4" />
</RS.ItemIndicator>
</RS.Item>
))}
</RS.Viewport>
</RS.Content>
</RS.Portal>
</RS.Root>
);
}
); );
Select.displayName = "Select"; Select.displayName = "Select";
export { selectVariants }; export { selectVariants };