Referencia / Sub-Skill

base components

UBICACIÓN: skills/supafast-ui/references/base-components.md

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) usan forwardRef plano. Ambos comparten cn(), 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-invalid que 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-sm en inputs es intencional (más grande en móvil para evitar zoom en iOS).