Explorer
KNOW-PAT-122

Web UI/UX - Status Indicator Dots : micro-dots colorés, click-to-expand panel, polling auto

Domaine
web-uiux
Type
pattern
Priorité
P2

Parent : [[INDEX-WEB-UIUX]]

Web UI/UX - Status Indicator Dots : micro-dots colorés, click-to-expand panel, polling auto

Problème

Un status textuel "En ligne" ou "5/5 services OK" prend trop de place dans un header compact. Un indicateur visuel minimal doit communiquer l'état instantanément.

Solution

4 micro-dots (1.5px × 1.5px) avec couleurs d'état : vert = OK, rouge = stale/error. Cliquer déploie un panel détaillé. Polling automatique toutes les 60 secondes.

function IndexHealthChip() {
  const [status, setStatus] = useState<IndexStatus | null>(null)
  const [open, setOpen] = useState(false)
  const ref = useRef<HTMLDivElement>(null)

  // Polling toutes les 60s
  useEffect(() => {
    load()
    const id = window.setInterval(load, 60_000)
    return () => window.clearInterval(id)
  }, [load])

  // Ferme au clic dehors
  useEffect(() => {
    if (!open) return
    const handler = (e: MouseEvent) => {
      if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
    }
    document.addEventListener('mousedown', handler)
    return () => document.removeEventListener('mousedown', handler)
  }, [open])

  const shards = status?.shards ?? []
  const staleCount = shards.filter((s) => !s.exists || s.stale).length

  return (
    <div ref={ref} className="relative shrink-0">
      {/* Bouton fermé : 4 dots */}
      <button
        onClick={() => setOpen((v) => !v)}
        className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-[4px] transition-colors"
        style={{ border: '1px solid var(--border)' }}
      >
        <span className="flex items-center gap-1">
          {status === null
            ? [0, 1, 2, 3].map((i) => (
                <span key={i} className="w-1.5 h-1.5 rounded-full" style={{ background: 'var(--border-hover)' }} />
              ))
            : shards.map((s) => (
                <span
                  key={s.source}
                  className="w-1.5 h-1.5 rounded-full transition-colors"
                  style={{ background: !s.exists || s.stale ? '#ef4444' : '#22c55e' }}
                />
              ))
          }
        </span>
        <span
          className="text-[9px]"
          style={{
            color: 'var(--text-3)',
            display: 'inline-block',
            transform: open ? 'rotate(180deg)' : 'rotate(0deg)',
            transition: 'transform 0.15s',
          }}
        >
          ▾
        </span>
      </button>

      {/* Panel déplié */}
      {open && (
        <div
          className="absolute right-0 top-full mt-2 animate-fade-in-up z-50 rounded-[6px] py-3 px-4 flex flex-col gap-3"
          style={{
            background: 'var(--surface)',
            border: '1px solid var(--border)',
            minWidth: '190px',
            boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
          }}
        >
          {shards.map((s) => {
            const ok = s.exists && !s.stale
            return (
              <div key={s.source} className="flex items-center gap-2">
                <span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ background: ok ? '#22c55e' : '#ef4444' }} />
                <span className="text-xs font-medium flex-1" style={{ color: ok ? 'var(--text)' : 'var(--text-2)' }}>
                  {s.source.toUpperCase()}
                </span>
                {!ok && (
                  <span className="text-[9px] font-mono px-1 rounded" style={{ background: 'rgba(239,68,68,0.12)', color: '#ef4444' }}>
                    stale
                  </span>
                )}
              </div>
            )
          })}

          {staleCount > 0 && (
            <>
              <div className="divider" />
              <button
                onClick={reloadStale}
                className="text-[11px] font-semibold w-full text-left transition-colors"
                style={{ color: 'var(--text-3)' }}
              >
                ↻ Reload {staleCount} shard{staleCount > 1 ? 's' : ''}
              </button>
            </>
          )}
        </div>
      )}
    </div>
  )
}

Points clés

  1. Micro-dots 1.5px : assez visibles, pas intrusifs dans un header
  2. Couleurs sémantiques : vert #22c55e = OK, rouge #ef4444 = KO
  3. Fallback gris : quand status === null (chargement initial)
  4. Chevron rotation : transform: rotate(180deg) indique l'état ouvert/fermé
  5. Panel absolute : right-0 top-full mt-2 → s'aligne sous le bouton
  6. Shadow élevée : 0 8px 32px rgba(0,0,0,0.4) → flotte au-dessus du contenu
  7. Polling 60s : assez fréquent pour être réactif, pas trop pour ne pas spammer
  8. Click-outside : referme le panel au clic ailleurs

Pourquoi

  • Les micro-dots communiquent 4 états en < 30px de largeur
  • Le panel détaillé ne pollue pas l'interface par défaut
  • Le polling automatique garde l'info fraîche sans action utilisateur

Quand l'utiliser

  • Dashboard monitoring, état de services, health checks
  • Header compact avec plusieurs sources

Quand NE PAS l'utiliser

  • Si un seul état à afficher → texte simple suffit
  • Si les états sont critiques et doivent toujours être visibles

Références

  • ProjectAlpha/IndexHealthChip.tsx L1-163