refactor: migrar Select y SimpleSelect a native HTML select

Select reescrito de @base-ui/react primitives a <select> nativo con wrapper
para mantener la misma API visual (ChevronDown, estilos tema). SimpleSelect
actualizado para usar <select>/<optgroup> directamente sin intermediarios.
Checkbox corregido: import CheckboxIndicator separado reemplazado por
CheckboxPrimitive.Indicator para consistencia.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-03 15:02:24 +02:00
parent cd517c705d
commit 145a6fce8f
3 changed files with 115 additions and 105 deletions
+2 -3
View File
@@ -1,6 +1,5 @@
import * as React from "react" import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox" import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { CheckboxIndicator } from "@base-ui/react/checkbox"
import { cn } from "../core/cn" import { cn } from "../core/cn"
interface CheckboxProps extends CheckboxPrimitive.Root.Props { interface CheckboxProps extends CheckboxPrimitive.Root.Props {
@@ -30,7 +29,7 @@ function Checkbox({ className, label, id, indeterminate, ...props }: CheckboxPro
indeterminate={indeterminate} indeterminate={indeterminate}
{...props} {...props}
> >
<CheckboxIndicator <CheckboxPrimitive.Indicator
data-slot="checkbox-indicator" data-slot="checkbox-indicator"
className="flex items-center justify-center text-current" className="flex items-center justify-center text-current"
> >
@@ -60,7 +59,7 @@ function Checkbox({ className, label, id, indeterminate, ...props }: CheckboxPro
<polyline points="20 6 9 17 4 12" /> <polyline points="20 6 9 17 4 12" />
</svg> </svg>
)} )}
</CheckboxIndicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
{label && ( {label && (
<label <label
+71 -68
View File
@@ -1,88 +1,91 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { ChevronDown, Check } from "lucide-react"
import { cn } from "../core/cn" import { cn } from "../core/cn"
import { ChevronDown } from "lucide-react"
function Select<T>({ ...props }: SelectPrimitive.Root.Props<T>) { // ── Native select wrapper ─────────────────────────────────────────────────
return <SelectPrimitive.Root data-slot="select" {...props} />
interface SelectProps extends React.ComponentPropsWithoutRef<"select"> {
placeholder?: string
} }
function SelectValue({ ...props }: SelectPrimitive.Value.Props) { const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
return <SelectPrimitive.Value data-slot="select-value" {...props} /> ({ className, children, placeholder, ...props }, ref) => {
return (
<div className="relative">
<select
ref={ref}
data-slot="select"
className={cn(
"flex h-8 w-full appearance-none items-center rounded-lg border border-input bg-transparent px-2.5 pr-8 py-1 text-sm transition-colors outline-none",
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
className,
)}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{children}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
</div>
)
},
)
Select.displayName = "Select"
// ── Sub-components (thin wrappers for API compatibility) ──────────────────
function SelectItem({
className,
...props
}: React.ComponentPropsWithoutRef<"option">) {
return <option className={className} {...props} />
} }
function SelectTrigger({ className, children, ...props }: SelectPrimitive.Trigger.Props) { function SelectGroup({
return ( className,
<SelectPrimitive.Trigger ...props
data-slot="select-trigger" }: React.ComponentPropsWithoutRef<"optgroup">) {
className={cn( return <optgroup className={className} {...props} />
"flex h-8 w-full items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-2.5 py-1 text-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon>
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
} }
function SelectPortal({ ...props }: SelectPrimitive.Portal.Props) { function SelectGroupLabel(_props: { children?: React.ReactNode }) {
return <SelectPrimitive.Portal data-slot="select-portal" {...props} /> // optgroup uses `label` attr, this is a no-op for compatibility
return null
} }
function SelectContent({ className, children, ...props }: SelectPrimitive.Positioner.Props) { // Stubs for barrel export compatibility — these are no-ops with native select
return ( function SelectContent({ children }: { children?: React.ReactNode; className?: string }) {
<SelectPortal> return <>{children}</>
<SelectPrimitive.Positioner
data-slot="select-content"
className={cn(
"relative z-50 max-h-[300px] min-w-[8rem] overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md",
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95",
"data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
sideOffset={4}
{...props}
>
<SelectPrimitive.Popup className="w-full p-1">{children}</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPortal>
)
} }
function SelectTrigger({ children }: { children?: React.ReactNode; className?: string }) {
function SelectGroup({ ...props }: SelectPrimitive.Group.Props) { return <>{children}</>
return <SelectPrimitive.Group data-slot="select-group" {...props} />
} }
function SelectValue(_props: { placeholder?: string }) {
function SelectGroupLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) { return null
return <SelectPrimitive.GroupLabel data-slot="select-group-label" className={cn("px-2 py-1.5 text-xs font-medium text-muted-foreground", className)} {...props} />
} }
function SelectPortal({ children }: { children?: React.ReactNode }) {
function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) { return <>{children}</>
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator><Check className="size-4" /></SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
} }
function SelectSeparator({ className, ...props }: React.ComponentProps<"div">) { function SelectSeparator({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="select-separator" className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} /> return <div className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
} }
export { Select, SelectContent, SelectGroup, SelectGroupLabel, SelectItem, SelectPortal, SelectSeparator, SelectTrigger, SelectValue } export {
Select,
SelectContent,
SelectGroup,
SelectGroupLabel,
SelectItem,
SelectPortal,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+42 -34
View File
@@ -2,15 +2,7 @@
import * as React from "react" import * as React from "react"
import { cn } from "../core/cn" import { cn } from "../core/cn"
import { import { ChevronDown } from "lucide-react"
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
SelectGroup,
SelectGroupLabel,
} from "./select"
export interface SimpleSelectOption { export interface SimpleSelectOption {
value: string value: string
@@ -31,12 +23,14 @@ interface SimpleSelectProps {
options: SimpleSelectOptions options: SimpleSelectOptions
placeholder?: string placeholder?: string
disabled?: boolean disabled?: boolean
size?: 'sm' | 'default' size?: "sm" | "default"
className?: string className?: string
} }
function isGrouped(options: SimpleSelectOptions): options is SimpleSelectGroup[] { function isGrouped(
return options.length > 0 && 'group' in options[0] options: SimpleSelectOptions,
): options is SimpleSelectGroup[] {
return options.length > 0 && "group" in options[0]
} }
function SimpleSelect({ function SimpleSelect({
@@ -45,39 +39,53 @@ function SimpleSelect({
options, options,
placeholder = "Select...", placeholder = "Select...",
disabled = false, disabled = false,
size = 'default', size = "default",
className, className,
}: SimpleSelectProps) { }: SimpleSelectProps) {
return ( return (
<Select value={value} onValueChange={onValueChange} disabled={disabled}> <div className={cn("relative", className)}>
<SelectTrigger <select
value={value}
onChange={(e) => onValueChange(e.target.value)}
disabled={disabled}
className={cn( className={cn(
size === 'sm' && 'h-7 text-xs px-2', "flex w-full appearance-none items-center rounded-lg border border-input bg-transparent px-2.5 pr-8 text-sm transition-colors outline-none",
className "focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50",
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
size === "sm" ? "h-7 text-xs px-2" : "h-8 py-1",
)} )}
> >
<SelectValue placeholder={placeholder} /> {placeholder && !value && (
</SelectTrigger> <option value="" disabled>
<SelectContent> {placeholder}
</option>
)}
{isGrouped(options) {isGrouped(options)
? options.map(g => ( ? options.map((g) => (
<SelectGroup key={g.group}> <optgroup key={g.group} label={g.group}>
<SelectGroupLabel>{g.group}</SelectGroupLabel> {g.items.map((item) => (
{g.items.map(item => ( <option
<SelectItem key={item.value} value={item.value} disabled={item.disabled}> key={item.value}
value={item.value}
disabled={item.disabled}
>
{item.label} {item.label}
</SelectItem> </option>
))} ))}
</SelectGroup> </optgroup>
)) ))
: (options as SimpleSelectOption[]).map(item => ( : (options as SimpleSelectOption[]).map((item) => (
<SelectItem key={item.value} value={item.value} disabled={item.disabled}> <option
key={item.value}
value={item.value}
disabled={item.disabled}
>
{item.label} {item.label}
</SelectItem> </option>
)) ))}
} </select>
</SelectContent> <ChevronDown className="pointer-events-none absolute right-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
</Select> </div>
) )
} }