Referencia / Sub-Skill

detail popover

UBICACIÓN: skills/supafast-ui/references/detail-popover.md

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) o Sheet (panel lateral).

Se usa típicamente como render de una celda de EntityDataTable (ver tables.md): el trigger vive en la celda y el detalle se abre en el popover. Recuerda stopPropagation para no disparar el onRowClick.

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 +N si hay más (rounded-full bg-muted text-[10px]).
  • Popover: PopoverContent ancho 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ítulo flex items-center gap-2 text-sm font-semibold con icono KeyRound size-4 text-muted-foreground.
  • Cada credencial: bloque rounded-xl px-4 py-3 hover:bg-muted/60, con sub-título tenue Credencial 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 con truncate), grid sm:grid-cols-2.
  • Secretos enmascarados: nunca el valor entero — 2343*******4... (ver maskSecret).
  • Copiar: a la derecha (ml-auto), botón ghost con borde h-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>
  )
}

CopyValueButtonobjectives/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 en uppercase 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 el navigator.clipboard por cada sitio.
  • El contenedor es Popover (base-ui), trigger con render={<Button/>}; en tablas, stopPropagation en clics para no disparar el onRowClick.
  • Header con icono lucide + border-b border-border/60; bloques rounded-xl hover:bg-muted/60.