{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "assignee-stack",
  "title": "Assignee Stack",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Pilha de responsáveis com excedente contado. Iniciais quando não há avatar.",
  "registryDependencies": [
    "utils",
    "avatar"
  ],
  "files": [
    {
      "path": "registry/visant/ui/assignee-stack.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { Avatar, AvatarImage, AvatarFallback } from \"@/components/ui/avatar\";\nimport { cn } from \"@/lib/utils\";\n\nexport type Assignee = { id: string; name: string; avatarUrl?: string };\n\nfunction initials(name: string): string {\n  return name\n    .trim()\n    .split(/\\s+/)\n    .slice(0, 2)\n    .map((w) => w[0]?.toUpperCase() ?? \"\")\n    .join(\"\");\n}\n\n/**\n * Tom da inicial, derivado do nome.\n *\n * Sem foto, todo mundo virava o mesmo círculo cinza com duas letras — cinco\n * pastilhas idênticas não são cinco pessoas, são um borrão. Um tom por pessoa,\n * estável (sai do id, não da posição na lista), devolve a identidade que a foto\n * daria. Sai dos tokens de gráfico do tema, então acompanha claro e escuro e a\n * marca de quem instala; nada de cor crua.\n *\n * O tom vai no TEXTO e o fundo é o tom diluído no card, em vez de círculo\n * chapado com letra branca. Não é gosto: `--chart-4` é um amarelo claro, e\n * letra branca em cima dele some.\n *\n * Mas o tom CRU no texto também não serve, e isso o portão de legibilidade\n * provou: `--chart-4` sobre o fundo diluído dele mesmo dá 1,3:1 — as duas\n * letras somem, justamente na única forma que resta quando não há foto. A\n * versão anterior deste comentário afirmava contraste nos cinco tons; era\n * afirmação, não medida.\n *\n * A tinta agora é o tom **puxado para a cor do texto do tema**: metade do\n * caminho até `--foreground`. A identidade continua (o amarelo continua sendo\n * o amarelo, e cada pessoa mantém o seu), e a legibilidade passa a vir do\n * tema — escurece no claro, clareia no escuro, sem tabela de exceção.\n */\nconst TONES = [\n  \"bg-[color-mix(in_oklab,var(--chart-1)_16%,var(--card))] text-[color-mix(in_oklab,var(--chart-1)_50%,var(--foreground))]\",\n  \"bg-[color-mix(in_oklab,var(--chart-2)_16%,var(--card))] text-[color-mix(in_oklab,var(--chart-2)_50%,var(--foreground))]\",\n  \"bg-[color-mix(in_oklab,var(--chart-3)_16%,var(--card))] text-[color-mix(in_oklab,var(--chart-3)_50%,var(--foreground))]\",\n  \"bg-[color-mix(in_oklab,var(--chart-4)_16%,var(--card))] text-[color-mix(in_oklab,var(--chart-4)_50%,var(--foreground))]\",\n  \"bg-[color-mix(in_oklab,var(--chart-5)_16%,var(--card))] text-[color-mix(in_oklab,var(--chart-5)_50%,var(--foreground))]\",\n];\n\nfunction toneOf(id: string): string {\n  let h = 0;\n  for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;\n  return TONES[h % TONES.length];\n}\n\nconst SIZES = {\n  sm: { box: \"h-7 w-7\", text: \"text-[10px]\", overlap: \"-space-x-2\" },\n  md: { box: \"h-8 w-8\", text: \"text-[11px]\", overlap: \"-space-x-2.5\" },\n  lg: { box: \"h-10 w-10\", text: \"text-[13px]\", overlap: \"-space-x-3\" },\n} as const;\n\n/**\n * Pilha de responsáveis — quem toca a tarefa, em círculos sobrepostos.\n *\n * ── O que estava errado ─────────────────────────────────────────────────────\n * A pilha se declarava `role=\"img\"`: para o leitor de tela era um desenho, e\n * para o teclado não existia. O nome de cada pessoa morava num `title` nativo,\n * que só aparece com mouse parado em cima por um segundo e nunca aparece no\n * celular. E o \"+N\" era um `<span>` — o excedente, que é justamente a\n * informação escondida, era a única parte inalcançável.\n *\n * Agora é um grupo de verdade: UM ponto de tabulação, setas ←/→ andando pelas\n * pessoas, `Home`/`End` nas pontas. Um ponto só porque uma lista de projetos\n * tem uma dessas por linha — um Tab por avatar transformaria a travessia da\n * página em dezenas de paradas antes do conteúdo.\n *\n * ── A micro-interação, e por que ela é utilidade ────────────────────────────\n * Parada, a pilha economiza espaço escondendo rosto atrás de rosto. Com o\n * ponteiro em cima (ou com o foco dentro) ela ABRE: os círculos deslizam para\n * os seus lugares e o nome de quem está sob o cursor aparece embaixo. É o\n * mesmo aperto que o `title` tentava resolver, resolvido onde dá para ver e\n * com teclado junto.\n *\n * Só `transform` anima — a abertura é `margin` virando `translateX`, nunca\n * largura. E `hover` não é a única porta: `focus-within` abre igual, senão em\n * touch a pilha nunca abriria.\n *\n * ── Por que a pilha abre para a ESQUERDA ────────────────────────────────────\n * A primeira versão abria empurrando todo mundo para a direita: o último\n * círculo — e principalmente o \"+N\", que é a informação que a pilha esconde —\n * andava para fora do container e estourava a borda do cartão. E não era acaso:\n * o lugar natural desta peça é encostada à direita da linha, então crescer para\n * a direita é crescer para fora, sempre.\n *\n * Agora o círculo mais à direita fica ANCORADO (deslocamento zero) e os outros\n * recuam para a esquerda. A pilha abre para dentro da linha, onde já existe\n * folga, e a borda direita nunca se move. O espaço que a abertura vai ocupar é\n * reservado em `padding-left` no repouso: `transform` não empurra layout, então\n * sem a reserva a pilha aberta passaria por cima do texto do vizinho.\n */\nexport function AssigneeStack({\n  assignees,\n  max = 4,\n  size = \"sm\",\n  onSelect,\n  className,\n}: {\n  assignees: Assignee[];\n  max?: number;\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** Quando presente, clicar/Enter numa pessoa devolve ela. Aditivo. */\n  onSelect?: (assignee: Assignee) => void;\n  className?: string;\n}) {\n  const [focus, setFocus] = React.useState(0);\n  const [hovered, setHovered] = React.useState<number | null>(null);\n  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);\n\n  const visible = assignees?.slice(0, max) ?? [];\n  const extra = (assignees?.length ?? 0) - visible.length;\n  // O \"+N\" é o último item navegável, então entra na conta do teclado.\n  const count = visible.length + (extra > 0 ? 1 : 0);\n\n  const move = React.useCallback(\n    (to: number) => {\n      const i = (to + count) % count;\n      setFocus(i);\n      refs.current[i]?.focus();\n    },\n    [count],\n  );\n\n  if (!assignees?.length) return null;\n\n  const s = SIZES[size];\n  const names = assignees.map((a) => a.name).join(\", \");\n  const rest = assignees.slice(max);\n  // No \"+N\" o rótulo lista quem ficou de fora: é a informação que a pilha\n  // escondeu, e mostrá-la é a razão de o \"+N\" ser alcançável.\n  const shown =\n    hovered == null\n      ? null\n      : hovered < visible.length\n        ? visible[hovered].name\n        : rest.map((a) => a.name).join(\", \");\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    switch (e.key) {\n      case \"ArrowLeft\":\n        e.preventDefault();\n        move(focus - 1);\n        break;\n      case \"ArrowRight\":\n        e.preventDefault();\n        move(focus + 1);\n        break;\n      case \"Home\":\n        e.preventDefault();\n        move(0);\n        break;\n      case \"End\":\n        e.preventDefault();\n        move(count - 1);\n        break;\n    }\n  };\n\n  // O passo da abertura e a reserva que ela consome. O deslocamento de cada\n  // círculo é negativo e cresce da direita para a esquerda: o último fica no\n  // lugar, o primeiro anda o percurso inteiro.\n  const passo = size === \"lg\" ? 12 : size === \"md\" ? 10 : 8;\n  const abertura = (count - 1) * passo;\n  const desloca = (i: number) => `${-(count - 1 - i) * passo}px`;\n\n  return (\n    <div className={cn(\"relative\", className)} style={{ paddingLeft: abertura }}>\n      <div\n        role=\"group\"\n        aria-label={`Colaboradores: ${names}`}\n        onKeyDown={onKeyDown}\n        onMouseLeave={() => setHovered(null)}\n        className={cn(\n          // `group/pilha` e não `group`: estas peças vivem dentro de cartões que\n          // já têm o seu `group`, e o hover do cartão inteiro abriria a pilha\n          // sem ninguém ter chegado perto dela.\n          \"group/pilha flex items-center\",\n          s.overlap,\n        )}\n      >\n        {visible.map((a, i) => (\n          <button\n            key={a.id}\n            ref={(el) => {\n              refs.current[i] = el;\n            }}\n            type=\"button\"\n            tabIndex={i === focus ? 0 : -1}\n            onFocus={() => {\n              setFocus(i);\n              setHovered(i);\n            }}\n            onBlur={() => setHovered(null)}\n            onMouseEnter={() => setHovered(i)}\n            onClick={() => onSelect?.(a)}\n            aria-label={a.name}\n            className={cn(\n              \"relative rounded-full ring-2 ring-card\",\n              // A abertura: cada círculo anda o suficiente para desfazer a\n              // sobreposição, e quem está mais à ESQUERDA anda mais — ver a\n              // nota sobre a direção.\n              \"transition-transform [transition-duration:var(--dur-base,200ms)] ease-[var(--ease-out)]\",\n              \"group-hover/pilha:translate-x-[var(--abre)] group-focus-within/pilha:translate-x-[var(--abre)]\",\n              // Quem está sob o cursor sobe na pilha para o rosto não ficar\n              // cortado pelo vizinho.\n              hovered === i ? \"z-20\" : \"z-10\",\n              !onSelect && \"cursor-default\",\n            )}\n            style={{ [\"--abre\" as string]: desloca(i) }}\n          >\n            <Avatar className={s.box}>\n              {a.avatarUrl && <AvatarImage src={a.avatarUrl} alt=\"\" />}\n              <AvatarFallback className={cn(s.text, \"font-semibold\", toneOf(a.id))}>\n                {initials(a.name)}\n              </AvatarFallback>\n            </Avatar>\n          </button>\n        ))}\n\n        {extra > 0 && (\n          <button\n            ref={(el) => {\n              refs.current[visible.length] = el;\n            }}\n            type=\"button\"\n            tabIndex={focus === visible.length ? 0 : -1}\n            onFocus={() => {\n              setFocus(visible.length);\n              setHovered(visible.length);\n            }}\n            onBlur={() => setHovered(null)}\n            onMouseEnter={() => setHovered(visible.length)}\n            // O excedente conta QUEM é: é a informação que a pilha escondeu, e\n            // era a única inalcançável por teclado.\n            aria-label={`Mais ${extra}: ${rest.map((a) => a.name).join(\", \")}`}\n            className={cn(\n              s.box,\n              s.text,\n              \"relative z-10 flex shrink-0 items-center justify-center rounded-full bg-muted font-semibold text-muted-foreground ring-2 ring-card\",\n              \"transition-[transform,color,background-color] [transition-duration:var(--dur-base,200ms)] ease-[var(--ease-out)]\",\n              \"group-hover/pilha:translate-x-[var(--abre)] group-focus-within/pilha:translate-x-[var(--abre)]\",\n              \"hover:bg-muted hover:text-foreground focus-visible:text-foreground\",\n            )}\n            // O \"+N\" é sempre o último da fila, então é ele que fica ancorado.\n            style={{ [\"--abre\" as string]: desloca(visible.length) }}\n          >\n            +{extra}\n          </button>\n        )}\n      </div>\n\n      {/*\n       * Quem é a pessoa. Absoluto e `pointer-events-none` de propósito: um\n       * rótulo que empurra layout faria a linha do cartão pular a cada vez que\n       * o mouse passa — o defeito que o `title` nativo evitava ficando inútil.\n       *\n       * Ancorado à direita pelo mesmo motivo da abertura: a pilha vive na borda\n       * direita da linha, e um rótulo que cresce para a direita cresce para fora\n       * do cartão no primeiro nome comprido.\n       */}\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          \"pointer-events-none absolute right-0 top-full z-30 mt-1 max-w-[14rem] truncate whitespace-nowrap rounded-[var(--r-control,0.5rem)] border border-border bg-card px-1.5 py-0.5 text-[11px] leading-tight text-foreground\",\n          \"transition-opacity [transition-duration:var(--dur-fast,160ms)] ease-[var(--ease-out)]\",\n          shown ? \"opacity-100\" : \"opacity-0\",\n        )}\n      >\n        {shown ?? \"\"}\n      </span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Mostrar responsáveis de tarefa ou projeto em pilha de avatares com excedente contado",
      "evite": "Lista de pessoas com detalhe e ação: lista do tema; contagem de reações, reactions"
    }
  },
  "categories": [
    "conteudo",
    "avatar",
    "equipe"
  ],
  "type": "registry:ui"
}