supafast-ui · sub-skill: CÓDIGO BASE (botones, inputs, fields)
Código fuente verbatim de los componentes fundacionales de apps/performancetv/src/shared/components/ui/.
Esta es la base del sistema: cópialos tal cual para arrancar, extiende sus cva/clases, no los reinventes.
Dos idioms conviven: los componentes con estado/polimorfismo (Button, Badge, Switch) usan @base-ui/react (
useRender+mergeProps). Los inputs simples (Input, Textarea, Field) usanforwardRefplano. Ambos compartencn(), tokens semánticos y el mismo focus/invalid ring.
Button — ui/button.tsx
Idiom base-ui + CVA. Variantes y tamaños completos.
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/shared/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium outline-none transition-all leading-[normal] disabled:pointer-events-none disabled:opacity-50 disabled:active:scale-100 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90 border border-black',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border border-border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
xs: 'gap-1 px-3 py-1.5 has-[>svg]:px-2.5',
default: 'px-5 py-2 has-[>svg]:px-4',
sm: 'gap-1.5 px-4 py-2 has-[>svg]:px-3.5',
lg: 'px-6 py-2 has-[>svg]:px-5',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Button({
className,
variant,
size,
render,
...props
}: useRender.ComponentProps<"button"> & VariantProps<typeof buttonVariants>) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(buttonVariants({ variant, size, className })),
},
props
),
render,
state: {
slot: "button",
variant,
size,
},
})
}
export { Button, buttonVariants }
Input — ui/input.tsx
forwardRef plano. Fíjate: h-9, rounded-md, bg-transparent, shadow-xs,
transition-[color,box-shadow], focus ring de 3px y estado aria-invalid.
import { forwardRef } from 'react'
import type { ComponentProps } from 'react'
import { cn } from '@/shared/lib/utils'
const Input = forwardRef<HTMLInputElement, ComponentProps<'input'>>(({ className, ...props }, ref) => {
return (
<input
ref={ref}
className={cn(
'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base text-foreground shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20',
className,
)}
{...props}
/>
)
})
Input.displayName = 'Input'
export { Input }
Textarea — ui/textarea.tsx
Igual que Input pero min-h-24 y py-2.
import { forwardRef } from 'react'
import type { ComponentProps } from 'react'
import { cn } from '@/shared/lib/utils'
const Textarea = forwardRef<HTMLTextAreaElement, ComponentProps<'textarea'>>(
({ className, ...props }, ref) => {
return (
<textarea
ref={ref}
className={cn(
'flex min-h-24 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-2 text-base text-foreground shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20',
className,
)}
{...props}
/>
)
},
)
Textarea.displayName = 'Textarea'
export { Textarea }
PasswordInput — ui/password-input.tsx
⚠️ Los campos de contraseña SIEMPRE usan PasswordInput, nunca <Input type="password"> pelado.
Lleva el toggle del ojo (Eye/EyeOff de lucide) como botón ghost icon-sm superpuesto a la
derecha. Patrón: <div className="relative"> + Input con pr-11 + botón absoluto centrado vertical.
import { forwardRef, useState } from 'react'
import type { ComponentProps } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import { Button } from '@/shared/components/ui/button'
import { Input } from '@/shared/components/ui/input'
import { cn } from '@/shared/lib/utils'
type PasswordInputProps = ComponentProps<'input'> & {
showLabel?: string
hideLabel?: string
}
const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
(
{
className,
type: _type,
showLabel = 'Mostrar contenido',
hideLabel = 'Ocultar contenido',
...props
},
ref,
) => {
const [isVisible, setIsVisible] = useState(false)
return (
<div className="relative">
<Input
ref={ref}
type={isVisible ? 'text' : 'password'}
className={cn('pr-11', className)}
{...props}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="absolute top-1/2 right-1 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={isVisible ? hideLabel : showLabel}
aria-pressed={isVisible}
onClick={() => setIsVisible((prev) => !prev)}
>
{isVisible ? <EyeOff /> : <Eye />}
</Button>
</div>
)
},
)
PasswordInput.displayName = 'PasswordInput'
export { PasswordInput }
Claves: pr-11 en el input para dejar hueco al botón; botón absolute top-1/2 right-1 -translate-y-1/2;
aria-pressed refleja el estado; iconos en gris text-muted-foreground hover:text-foreground.
El type entrante se ignora (type: _type) — lo gobierna el toggle.
Field / FieldLabel / FieldDescription / FieldError — ui/field.tsx
El wrapper de formularios: grid gap-2 + label/descripción/error consistentes.
import { forwardRef } from 'react'
import type { ComponentProps } from 'react'
import { cn } from '@/shared/lib/utils'
const Field = forwardRef<HTMLDivElement, ComponentProps<'div'>>(({ className, ...props }, ref) => {
return <div ref={ref} className={cn('grid gap-2', className)} {...props} />
})
Field.displayName = 'Field'
const FieldLabel = forwardRef<HTMLLabelElement, ComponentProps<'label'>>(({ className, ...props }, ref) => {
return <label ref={ref} className={cn('text-sm font-medium text-foreground', className)} {...props} />
})
FieldLabel.displayName = 'FieldLabel'
const FieldDescription = forwardRef<HTMLParagraphElement, ComponentProps<'p'>>(
({ className, ...props }, ref) => {
return <p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
},
)
FieldDescription.displayName = 'FieldDescription'
const FieldError = forwardRef<HTMLParagraphElement, ComponentProps<'p'>>(({ className, ...props }, ref) => {
return <p ref={ref} className={cn('text-sm text-destructive', className)} {...props} />
})
FieldError.displayName = 'FieldError'
export { Field, FieldDescription, FieldError, FieldLabel }
Ejemplos de uso (cómo se combinan en una pantalla)
Botones
import { Button } from '@/shared/components/ui/button'
import { Plus, Trash2 } from 'lucide-react'
<Button>Guardar</Button> {/* default: primary + border-black */}
<Button variant="outline">Cancelar</Button>
<Button variant="secondary" size="sm">Filtrar</Button>
<Button variant="ghost" size="icon" aria-label="Borrar"><Trash2 /></Button>
<Button variant="destructive"><Trash2 /> Eliminar</Button>
{/* Polimórfico: renderiza como un Link de react-router via `render` (NO asChild) */}
<Button render={<NavLink to="/objectives" />}><Plus /> Nuevo objetivo</Button>
Campo de formulario completo (Field + Input + estados)
import { Field, FieldLabel, FieldDescription, FieldError } from '@/shared/components/ui/field'
import { Input } from '@/shared/components/ui/input'
<Field>
<FieldLabel htmlFor="name">Nombre de campaña</FieldLabel>
<Input id="name" placeholder="Ej. Black Friday 2026" />
<FieldDescription>Visible en los informes.</FieldDescription>
</Field>
{/* Estado de error: aria-invalid activa border/ring destructive automáticamente */}
<Field>
<FieldLabel htmlFor="budget">Presupuesto</FieldLabel>
<Input id="budget" aria-invalid defaultValue="-100" />
<FieldError>Debe ser un número positivo.</FieldError>
</Field>
Campo de contraseña (con ojo) — usar SIEMPRE PasswordInput
import { PasswordInput } from '@/shared/components/ui/password-input'
<Field>
<FieldLabel htmlFor="pwd">Contraseña</FieldLabel>
<PasswordInput id="pwd" autoComplete="current-password" placeholder="••••••••" />
</Field>
{/* ❌ NO: <Input type="password" /> — pierde el toggle del ojo */}
Reglas
- Para variar un botón → añade a
buttonVariants(variant/size), no clases sueltas en el call site. - Inputs/Textarea: deja
aria-invalidque el estado de error lo pinta el propio componente. - Agrupa siempre label+control con
Field(grid gap-2); no inventes spacing manual. text-base md:text-smen inputs es intencional (más grande en móvil para evitar zoom en iOS).