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 elDialog, y dentro unflex flex-col; la lista esmin-h-0 overflow-y-auto. - Reservar hueco con
invisibleen vez de desmontar: si un elemento (icono, chip) aparece solo en algunos estados, mantenlo en el DOM coninvisiblecuando 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
Buttonprimary (default), nooutline. Es la acción principal de la vista. - Toda acción archivable/reversible lanza un toast de
sonnercon 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-fullbajo el titular (activa secondary / inactiva ghost), sin track? - ¿Los tooltips explicativos cuelgan de un ⓘ con el
absoluteen el wrapper delTooltip? - ¿El layout se queda quieto al filtrar/alternar (altura fija +
invisiblepara reservar hueco)? - ¿Los iconos de estado solo aparecen cuando comunican algo?
- ¿Los segmented controls son
w-fit? - ¿El CTA de crear es
primaryy las acciones reversibles ofrecen «Deshacer» en el toast?