Referencia / Sub-Skill

interaction patterns

UBICACIÓN: skills/supafast-ui/references/interaction-patterns.md

supafast-ui · sub-skill: PATRONES DE INTERACCIÓN (pills, tooltips, alturas, toasts)

Idioms transversales de UI destilados de sesiones reales sobre el stack supafast. No son componentes nuevos: son maneras de usar Button, Tooltip, Dialog y sonner que se repiten y que el usuario corrige una y otra vez hacia la misma forma. Aplícalos por defecto; ahorran iteraciones.


1. Pills de filtro / segmento de vista (estilo Linear «Active · Timeline · Board»)

Para alternar entre vistas de una misma lista (Recientes/Archivados, Activas/Monitoring/Archivadas, tipos de incidencia…) NO se usa un <Tabs> ni un track con fondo: son pills sueltas, rounded-full, debajo del titular (no arriba a la derecha). La activa va rellena (variant="secondary"); la inactiva es solo texto (variant="ghost", atenuada). Como Button reserva el borde en la base, alternar variante NO mueve el layout.

const pillCls = (active: boolean) => cn('rounded-full', !active && 'text-muted-foreground')

<div role="group" aria-label="Vista" className="flex items-center gap-2">
  <Button
    variant={showArchived ? 'ghost' : 'secondary'}
    size="xs"
    aria-pressed={!showArchived}
    onClick={() => setShowArchived(false)}
    className={pillCls(!showArchived)}
  >
    <Clock className="size-3.5" />
    Recientes
  </Button>
  <Button
    variant={showArchived ? 'secondary' : 'ghost'}
    size="xs"
    aria-pressed={showArchived}
    onClick={() => setShowArchived(true)}
    className={pillCls(showArchived)}
  >
    <Archive className="size-3.5" />
    Archivados
  </Button>
</div>

Con contador (p. ej. tipos de incidencia): mete un chip con el número dentro de la pill, coloreado según severidad (rojo error / ámbar aviso), y usa el estado para elegir la fuente de datos de la lista de abajo.

  • ❌ NO envolver las pills en un track (bg-muted p-0.5 rounded-lg) — el usuario lo llama antipatrón; las quiere sueltas.
  • ❌ NO ponerlas arriba a la derecha ni usar variant="outline" para la inactiva (el borde de la outline compite con las cards). Ghost = solo texto.

2. Botón ⓘ con tooltip en cards de KPI / ajuste

Para explicar una métrica o un parámetro NO se hace hover sobre toda la card: un botón ⓘ arriba a la derecha (mismo sitio que en las StatCard) con Tooltip (label + description). Gotcha crítico: el absolute va en el wrapper del Tooltip (su className), NO en el hijo — si lo pones en el hijo, el popup se ancla al wrapper vacío en el flujo y aparece lejos del icono.

<div className="relative rounded-xl border border-border/60 bg-card p-3">
  <Tooltip label={p.label} description={p.tooltip} className="absolute top-2 right-2">
    <span
      tabIndex={0}
      aria-label={`Qué es ${p.label}`}
      className="flex size-5 cursor-help items-center justify-center rounded-full
                 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
    >
      <Info className="size-3.5" />
    </span>
  </Tooltip>
  <p className="text-xs text-muted-foreground">{p.label}</p>
  <p className="mt-1 text-lg font-semibold tabular-nums text-foreground">{p.value}</p>
</div>

3. Alturas fijas: el layout no debe bailar al filtrar/alternar

Dialogs y grids de cards no cambian de tamaño al cambiar de vista o filtrar. Dos técnicas:

  • Dialog de altura fija con columnas que scrollean por dentro (gestor de líneas = h-[700px]): fija la altura en el Dialog, y dentro un flex flex-col; la lista es min-h-0 overflow-y-auto.
  • Reservar hueco con invisible en vez de desmontar: si un elemento (icono, chip) aparece solo en algunos estados, mantenlo en el DOM con invisible cuando no aplica, para que todas las cards midan lo mismo.
{/* el chip solo se ve activo; invisible reserva el hueco → misma altura en todas */}
<span className={cn('flex size-8 items-center justify-center rounded-lg bg-muted text-foreground',
                    !active && 'invisible')}>
  <Icon className="size-4" />
</span>

4. Iconos de estado: mostrarlos solo cuando comunican

En un grid mayoritariamente inactivo, repetir el mismo icono “apagado” en cada card es ruido — el usuario lo pide fuera. Muestra el icono solo cuando aporta señal (p. ej. campana con badge = monitoring activo; nada cuando está inactivo), y reserva su hueco con invisible (§3) para no romper la altura. Elige el icono por estado, no un fijo:

const active = enabled && !archived
const Icon = active ? BellBadgeIcon : BellSlashIcon   // campana con badge / tachada

5. Segmented control: w-fit, nunca ancho completo

El track de un segmented control (presets de frecuencia, días de la semana) debe abrazar su contenido. Dentro de un Field (que es flex-col y estira a sus hijos) un track sin w-fit se estira a todo el ancho — antipatrón. Fíjalo en el track:

const trackCls = 'inline-flex w-fit items-center gap-0.5 rounded-lg border border-border/60 bg-muted/40 p-0.5'

6. CTA de crear = primary, y toasts con «Deshacer»

  • El botón de crear (Nueva línea, Nuevo…) es Button primary (default), no outline. Es la acción principal de la vista.
  • Toda acción archivable/reversible lanza un toast de sonner con acción «Deshacer» — igual en informes, líneas y sites, por consistencia:
toast.success('Línea archivada', {
  description: 'Su monitoring queda parado hasta desarchivarla.',
  action: { label: 'Deshacer', onClick: () => unarchiveLine.mutate(line.id) },
})

7. Menú de acciones por fila/card = DropdownMenu con

Para archivar/desarchivar/eliminar por ítem, un Button variant="ghost" size="icon-sm" con EllipsisVertical que abre un DropdownMenu; la acción destructiva usa DropdownMenuItem variant="destructive" y va tras un DropdownMenuSeparator. El trigger aparece en hover (opacity-0 group-hover:opacity-100 data-popup-open:opacity-100). Eliminar siempre pasa por ConfirmDialog (destructive); archivar por el toast con Deshacer.


Checklist de interacción

  • ¿Los conmutadores de vista son pills sueltas rounded-full bajo el titular (activa secondary / inactiva ghost), sin track?
  • ¿Los tooltips explicativos cuelgan de un ⓘ con el absolute en el wrapper del Tooltip?
  • ¿El layout se queda quieto al filtrar/alternar (altura fija + invisible para reservar hueco)?
  • ¿Los iconos de estado solo aparecen cuando comunican algo?
  • ¿Los segmented controls son w-fit?
  • ¿El CTA de crear es primary y las acciones reversibles ofrecen «Deshacer» en el toast?