fix: themed Radix-based Select (dark/gold popover) replacing native <select> (v0.3.0)
ci / build-and-design (push) Failing after 1s
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:
+4
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@jason/ui-kit",
|
||||
"version": "0.2.0",
|
||||
"description": "Shared design foundation \u2014 branded tokens, Tailwind preset, shadcn-style components, motion, app shell + marketing blocks. Guarded by Impeccable.",
|
||||
"version": "0.3.0",
|
||||
"description": "Shared design foundation — branded tokens, Tailwind preset, shadcn-style components, motion, app shell + marketing blocks. Guarded by Impeccable.",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"author": "Jason <jason@messagepoint.tv>",
|
||||
@@ -45,6 +45,7 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-select": "^2.3.3",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -65,8 +66,8 @@
|
||||
"impeccable": "^3.2.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tsup": "^8.3.5",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
+115
-13
@@ -1,27 +1,129 @@
|
||||
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 { 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(
|
||||
"w-full rounded border border-border bg-bg/40 text-text transition-shadow duration-base ease-out " +
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:border-brand/60 " +
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"inline-flex w-full items-center justify-between gap-2 rounded border border-border bg-bg/40 text-text " +
|
||||
"transition-shadow duration-base ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 " +
|
||||
"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" },
|
||||
}
|
||||
);
|
||||
|
||||
export interface SelectProps
|
||||
extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "size">,
|
||||
VariantProps<typeof selectVariants> {}
|
||||
const EMPTY = "__ui_select_empty__";
|
||||
const toRadix = (v?: string) => (v === "" ? EMPTY : v);
|
||||
const fromRadix = (v: string) => (v === EMPTY ? "" : v);
|
||||
|
||||
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, size, children, ...props }, ref) => (
|
||||
<select ref={ref} className={cn(selectVariants({ size }), className)} {...props}>
|
||||
{children}
|
||||
</select>
|
||||
)
|
||||
type OptionData = { value: string; label: React.ReactNode; disabled?: boolean };
|
||||
|
||||
function collectOptions(children: React.ReactNode): OptionData[] {
|
||||
const out: OptionData[] = [];
|
||||
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";
|
||||
export { selectVariants };
|
||||
|
||||
Reference in New Issue
Block a user