supafast-ui · sub-skill: AMPLIAR DETALLE (popover desplegable, no modal)
Cuando quieres ampliar el detalle de algo resumido (una celda de tabla, un valor truncado, un
campo), NO se abre un modal a pantalla completa: se despliega un Popover anclado al propio
elemento. Ese es el patrón. En la tabla se ve un resumen (un valor + pill +N); al pulsar, el
popover se abre justo ahí y muestra el detalle completo.
La forma del contenido es una tarjeta key-value: header (icono lucide + título), filas con label
en mayúsculas pequeñas (uppercase tracking-[0.14em]), valores en fuente mono (secretos
enmascarados), y un botón Copiar con feedback “Copiado”. El ejemplo de referencia es la
captura de “Credenciales técnicas”, pero sirve para cualquier dato (IDs, tokens, endpoints,
metadatos…) que quieras expandir desde un sitio compacto.
Cuándo usar qué para “ver detalle”:
- Resumen key-value / valores copiables anclados al elemento → este popover (lo de abajo).
- Detalle grande: formulario, varias secciones, edición →
Dialog(modal centrado) oSheet(panel lateral).Se usa típicamente como
renderde una celda deEntityDataTable(vertables.md): el trigger vive en la celda y el detalle se abre en el popover. RecuerdastopPropagationpara no disparar elonRowClick.
Anatomía (ejemplo de referencia: “Credenciales técnicas”)
- Trigger:
Button variant="ghost" size="sm"mono (font-mono text-xs text-foreground/80), muestra el primer valor truncado + pill+Nsi hay más (rounded-full bg-muted text-[10px]). - Popover:
PopoverContentancho fijo (w-96), sin padding propio (gap-0 p-0) para controlar el interior. - Header:
border-b border-border/60 px-4 py-3, títuloflex items-center gap-2 text-sm font-semiboldcon iconoKeyRound size-4 text-muted-foreground. - Cada credencial: bloque
rounded-xl px-4 py-3 hover:bg-muted/60, con sub-título tenueCredencial N(text-[11px] text-muted-foreground/80). - Labels:
text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground(el tracking ancho en mayúsculas es la firma del patrón). - Valores:
font-mono text-sm text-foreground(truncados contruncate), gridsm:grid-cols-2. - Secretos enmascarados: nunca el valor entero —
2343*******4...(vermaskSecret). - Copiar: a la derecha (
ml-auto), botón ghost con bordeh-8 rounded-lg border border-border px-2.5 text-[11px] text-muted-foreground hover:text-foreground, aparece reforzado en hover de la fila (group-hover:text-foreground).
Componente verbatim — ObjectiveTechnicalCredentialsCell.tsx
import { CopyValueButton } from '@/objectives/detail/components/CopyValueButton'
import type { Objective } from '@/objectives/types'
import { Button } from '@/shared/components/ui/button'
import {
Popover, PopoverContent, PopoverHeader, PopoverTitle, PopoverTrigger,
} from '@/shared/components/ui/popover'
import { KeyRound } from 'lucide-react'
type TechnicalCredential = { measurementId: string; apiSecret: string }
function maskSecret(value?: string) {
if (!value?.trim()) return '-'
const trimmed = value.trim()
if (trimmed.length <= 8) return `${trimmed.slice(0, 2)}***${trimmed.slice(-2)}`
return `${trimmed.slice(0, 4)}${'*'.repeat(Math.max(4, trimmed.length - 8))}${trimmed.slice(-4)}`
}
export function ObjectiveTechnicalCredentialsCell({ objective }: { objective: Objective }) {
const credentials = getTechnicalCredentials(objective) // → TechnicalCredential[]
const firstMeasurementId = credentials[0]?.measurementId
if (credentials.length === 0) return <span className="text-sm text-muted-foreground">—</span>
const remainingCount = credentials.length - 1
const triggerLabel = firstMeasurementId || 'Credencial tecnica'
return (
<Popover>
<PopoverTrigger
render={
<Button
type="button" variant="ghost" size="sm"
className="h-8 max-w-[220px] justify-start gap-2 px-2 font-mono text-xs text-foreground/80 hover:bg-muted/60"
onClick={(event) => event.stopPropagation()}
>
<span className="truncate">{triggerLabel}</span>
{remainingCount > 0 ? (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 font-sans text-[10px] font-semibold text-muted-foreground">
+{remainingCount}
</span>
) : null}
</Button>
}
/>
<PopoverContent align="start" className="w-96 gap-0 p-0" onClick={(e) => e.stopPropagation()}>
<PopoverHeader className="border-b border-border/60 px-4 py-3">
<PopoverTitle className="flex items-center gap-2 text-sm font-semibold">
<KeyRound className="size-4 text-muted-foreground" />
Credenciales tecnicas
</PopoverTitle>
</PopoverHeader>
<div className="max-h-80 overflow-y-auto p-2">
{credentials.map((credential, index) => (
<div
key={`${credential.measurementId}-${index}`}
className="group rounded-xl px-4 py-3 text-left transition-colors duration-200 hover:bg-muted/60"
>
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-[11px] font-medium tracking-wide text-muted-foreground/80">
Credencial {index + 1}
</p>
</div>
<div className="flex items-center">
<div className="grid gap-3 sm:grid-cols-2">
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Measurement ID
</p>
<p className="mt-1 truncate font-mono text-sm text-foreground">
{credential.measurementId || '-'}
</p>
</div>
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
API Secret
</p>
<p className="mt-1 truncate font-mono text-sm text-foreground">
{maskSecret(credential.apiSecret)}
</p>
</div>
</div>
<CopyValueButton
value={`Measurement ID: ${credential.measurementId || '-'}\nAPI Secret: ${credential.apiSecret || '-'}`}
label="Copiar"
size="sm"
variant="ghost"
className="ml-auto h-8 rounded-lg border border-border px-2.5 text-[11px] font-medium text-muted-foreground transition-colors duration-200 hover:bg-background hover:text-foreground group-hover:text-foreground"
/>
</div>
</div>
))}
</div>
</PopoverContent>
</Popover>
)
}
CopyValueButton — objectives/detail/components/CopyValueButton.tsx
El botón de copiar reutilizable: copia al portapapeles y muestra “Copiado” 1.8 s.
import { Button } from '@/shared/components/ui/button'
import { Copy } from 'lucide-react'
import type { ComponentProps } from 'react'
import { useState } from 'react'
type CopyValueButtonProps = {
value: string
label: string
} & Pick<ComponentProps<typeof Button>, 'className' | 'size' | 'variant'>
export function CopyValueButton({ value, label, className, size = 'default', variant = 'outline' }: CopyValueButtonProps) {
const [copied, setCopied] = useState(false)
return (
<Button
type="button" variant={variant} size={size} className={className}
onClick={async () => {
await navigator.clipboard.writeText(value)
setCopied(true)
window.setTimeout(() => setCopied(false), 1800)
}}
>
<Copy data-icon="inline-start" />
{copied ? 'Copiado' : label}
</Button>
)
}
El Popover que lo sostiene — ui/popover.tsx
@base-ui/react/popover (NO Radix). Popup rounded-lg bg-popover p-2.5 shadow-md ring-1 ring-foreground/10
con animaciones data-open/data-closed. Exporta Popover, PopoverTrigger, PopoverContent,
PopoverHeader, PopoverTitle, PopoverDescription. El trigger se compone con render={<Button … />}.
Cómo reutilizar el patrón para otros datos
Para una tarjeta key→value copiable genérica, replica la estructura: PopoverHeader (icono + título) +
filas con label uppercase tracking-[0.14em] text-[10px] + valor font-mono text-sm + CopyValueButton.
<PopoverContent align="start" className="w-96 gap-0 p-0">
<PopoverHeader className="border-b border-border/60 px-4 py-3">
<PopoverTitle className="flex items-center gap-2 text-sm font-semibold">
<KeyRound className="size-4 text-muted-foreground" /> Datos de conexión
</PopoverTitle>
</PopoverHeader>
<div className="p-2">
<div className="group flex items-center rounded-xl px-4 py-3 hover:bg-muted/60">
<div className="min-w-0">
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">Endpoint</p>
<p className="mt-1 truncate font-mono text-sm text-foreground">https://api.…</p>
</div>
<CopyValueButton value="https://api.…" label="Copiar" size="sm" variant="ghost"
className="ml-auto h-8 rounded-lg border border-border px-2.5 text-[11px] text-muted-foreground hover:text-foreground" />
</div>
</div>
</PopoverContent>
Reglas
- Valores técnicos siempre en
font-mono; labels enuppercase tracking-[0.14em] text-[10px] text-muted-foreground. - Secretos/tokens van enmascarados en pantalla (patrón
maskSecret); el valor completo solo va al portapapeles. - Copiar =
CopyValueButton(feedback “Copiado”), no reinventes elnavigator.clipboardpor cada sitio. - El contenedor es
Popover(base-ui), trigger conrender={<Button/>}; en tablas,stopPropagationen clics para no disparar elonRowClick. - Header con icono lucide +
border-b border-border/60; bloquesrounded-xl hover:bg-muted/60.