{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "wallet-stack",
  "title": "Wallet Stack",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Carteira de cartões empilhados em que um sobe pra mostrar saldo e movimento e os outros se recolhem no pé.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "@visant/motion"
  ],
  "files": [
    {
      "path": "registry/visant/ui/wallet-stack.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion, type Transition } from \"motion/react\";\nimport { ChevronLeft, Nfc } from \"lucide-react\";\n\nimport { DUR, INSTANT } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Wallet Stack — a carteira: cartões empilhados, um sobe e mostra o que tem dentro.\n *\n * Conta, cartão pré-pago, passe, clube de assinatura: quando a pessoa tem\n * TRÊS ou QUATRO dessas coisas, lista é burocrática e grade é fria. A carteira\n * é a metáfora que ela já carrega no bolso: o da frente inteiro, os de trás\n * mostrando só a borda de cima, o bastante pra contar quantos são.\n *\n * Três estados, uma pilha só:\n *\n * 1. **Repouso.** O da frente inteiro, os de trás num degrau curto.\n * 2. **Leque.** Passar o mouse (ou focar) abre o degrau e as bordas viram\n *    etiquetas legíveis. É a pré-visualização antes do gesto, e é o que avisa\n *    que os de trás também respondem a clique.\n * 3. **Aberto.** O cartão escolhido sobe pro topo e o detalhe dele (saldo,\n *    movimento) entra embaixo; os outros recolhem pro pé da caixa, ainda à\n *    vista, pra que trocar de cartão não exija voltar primeiro.\n *\n * ── Decisões que não são óbvias ────────────────────────────────────────────\n *\n * - **A pilha gira, não reordena a lista.** `ativo` é um índice; a profundidade\n *   de cada cartão é a distância até ele. Assim a ordem que o cliente passou é a\n *   ordem em que as setas andam, e nenhum cartão troca de chave no meio da mola.\n * - **Uma parada de Tab.** Só o cartão da frente entra no Tab. Os de trás são alvo de\n *   ponteiro e ficam `aria-hidden`: pelo teclado, ←/→ trazem o próximo, Enter\n *   abre, Esc devolve. Um aviso `aria-live` diz qual está na frente.\n * - **Altura fixa.** A caixa não cresce ao abrir; o detalhe ocupa o espaço que\n *   a pilha deixa. Carteira que empurra a página a cada clique parece quebrada.\n * - **Redução de movimento:** os estados acontecem, sem viagem de mola.\n */\n\nexport type WalletTone = \"brand\" | \"ink\" | \"card\" | \"muted\";\n\nexport interface WalletCard {\n  /** Chave estável. */\n  id: string;\n  /** Nome que aparece na borda visível (o emissor, o clube). */\n  title: string;\n  /** Qualificador curto à direita: \"Débito\", \"Passe\". */\n  kind?: string;\n  /** Os quatro últimos dígitos. */\n  last4?: string;\n  /** Nome impresso. */\n  holder?: string;\n  /** Validade, \"09/29\". */\n  expiry?: string;\n  /** Material do cartão, sempre por token. */\n  tone?: WalletTone;\n}\n\nexport interface WalletStackProps {\n  cards: WalletCard[];\n  /** Título da carteira, no cabeçalho e no rótulo acessível. */\n  label?: string;\n  /** O que aparece embaixo do cartão aberto. Sem isto, o cartão não abre. */\n  renderDetail?: (card: WalletCard) => React.ReactNode;\n  /** Cartão da frente ao montar. */\n  defaultIndex?: number;\n  /** Avisa quando o cartão da frente muda. */\n  onActiveChange?: (card: WalletCard) => void;\n  /** Avisa quando um cartão abre (com ele) ou fecha (`null`). */\n  onOpenChange?: (card: WalletCard | null) => void;\n  /** Altura da caixa dos cartões em px. Menor, o detalhe aberto tem menos espaço. Sem valor, 440px. */\n  height?: number;\n  className?: string;\n}\n\nconst LARG = 300;\nconst ALT = 189; // proporção ISO de cartão, 1,586\nconst CAIXA = 440;\n/** Quanto cada cartão de trás aparece acima do da frente: repouso, leque. */\nconst DEGRAU = 10;\nconst LEQUE = 34;\nconst ENCOLHE = 0.04;\n\nconst mola: Transition = { type: \"spring\", visualDuration: 0.3, bounce: 0.12 };\n\nconst material: Record<WalletTone, string> = {\n  brand: \"bg-[var(--brand)] text-[var(--brand-foreground)]\",\n  ink: \"bg-foreground text-background\",\n  card: \"bg-card text-foreground border border-border\",\n  muted: \"bg-muted text-foreground border border-border\",\n};\n\nfunction Face({ card }: { card: WalletCard }) {\n  return (\n    <div\n      className={cn(\n        \"flex h-full w-full flex-col justify-between rounded-[var(--r-surface)] p-4 [box-shadow:var(--e-raised)]\",\n        material[card.tone ?? \"card\"],\n      )}\n    >\n      <div className=\"flex items-baseline justify-between gap-3\">\n        <span className=\"truncate text-[15px] font-semibold\">{card.title}</span>\n        {card.kind && <span className=\"shrink-0 text-[11px] opacity-70\">{card.kind}</span>}\n      </div>\n      <div className=\"flex items-center gap-2\">\n        {/* O chip é um retângulo com borda: o material lê como cartão sem ilustração. */}\n        <span aria-hidden=\"true\" className=\"h-6 w-8 rounded-[5px] border border-current opacity-40\" />\n        <Nfc aria-hidden=\"true\" className=\"size-4 opacity-60\" strokeWidth={1.75} />\n      </div>\n      <div className=\"flex items-end justify-between gap-3 text-[12px]\">\n        <div className=\"min-w-0\">\n          {card.last4 && (\n            <div className=\"font-mono text-[15px] tabular-nums\">\n              {/* 60, não 50: com o claro neutro (fundo branco puro) o texto da marca\n                  ficou um pouco mais claro, e a 50% os pontos caíam a 2,98:1. A\n                  opacidade é relativa à cor da FACE do cartão, por isso não vira token. */}\n              <span className=\"opacity-60\">•••• </span>\n              {card.last4}\n            </div>\n          )}\n          {card.holder && <div className=\"mt-1 truncate opacity-70\">{card.holder}</div>}\n        </div>\n        {card.expiry && <span className=\"shrink-0 font-mono tabular-nums opacity-70\">{card.expiry}</span>}\n      </div>\n    </div>\n  );\n}\n\nexport function WalletStack({\n  cards,\n  label = \"Carteira\",\n  renderDetail,\n  defaultIndex = 0,\n  onActiveChange,\n  onOpenChange,\n  height = CAIXA,\n  className,\n}: WalletStackProps) {\n  const reduzir = useReducedMotion();\n  const total = cards.length;\n  const [ativo, setAtivo] = React.useState(() => Math.min(Math.max(defaultIndex, 0), Math.max(total - 1, 0)));\n  const [aberto, setAberto] = React.useState(false);\n  const [leque, setLeque] = React.useState(false);\n  const ativoRef = React.useRef(ativo);\n  ativoRef.current = ativo;\n\n  const trazer = (i: number) => {\n    const next = ((i % total) + total) % total;\n    if (next === ativoRef.current) return;\n    setAtivo(next);\n    onActiveChange?.(cards[next]);\n    if (aberto) onOpenChange?.(cards[next]);\n  };\n\n  const abrir = (sim: boolean) => {\n    if (sim && !renderDetail) return;\n    setAberto(sim);\n    onOpenChange?.(sim ? cards[ativoRef.current] : null);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowDown\") {\n      e.preventDefault();\n      trazer(ativo + 1);\n    } else if (e.key === \"ArrowLeft\" || e.key === \"ArrowUp\") {\n      e.preventDefault();\n      trazer(ativo - 1);\n    } else if (e.key === \"Escape\" && aberto) {\n      e.preventDefault();\n      abrir(false);\n    }\n  };\n\n  // O botão da frente muda de cartão: se o foco estava na carteira, ele segue a frente.\n  const raizRef = React.useRef<HTMLDivElement>(null);\n  React.useEffect(() => {\n    const raiz = raizRef.current;\n    if (raiz?.contains(document.activeElement)) raiz.querySelector<HTMLElement>(\"[data-frente]\")?.focus();\n  }, [ativo]);\n\n  if (total === 0) return null;\n  const atual = cards[ativo];\n\n  // Onde cada cartão mora, por profundidade (0 = frente).\n  const pousoRepouso = (height - ALT) / 2 + 24;\n  // Em caixa baixa o da frente desce ao abrir o leque, pra as etiquetas caberem até\n  // o topo. Os recolhidos mostram só a borda: etiqueta pela metade lê como corte.\n  const pousoLeque = Math.max(pousoRepouso, Math.min(height - ALT - 2, (total - 1) * LEQUE + 2));\n  const pousoFrente = aberto ? 0 : leque ? pousoLeque : pousoRepouso;\n  const baixa = height < 400;\n  const recolhido = 14;\n  const posicao = (prof: number) => {\n    if (prof === 0) return { y: pousoFrente, scale: 1 };\n    if (aberto) return { y: height - recolhido + (prof - 1) * 6, scale: 1 - prof * ENCOLHE };\n    return { y: pousoFrente - prof * (leque ? LEQUE : DEGRAU), scale: 1 - prof * ENCOLHE };\n  };\n\n  return (\n    <div\n      ref={raizRef}\n      role=\"group\"\n      aria-label={label}\n      className={cn(\"flex w-[320px] max-w-full flex-col gap-3\", className)}\n      onPointerEnter={() => setLeque(true)}\n      onPointerLeave={() => setLeque(false)}\n      onKeyDown={onKeyDown}\n    >\n      {/* A faixa de cima só tem trabalho com o cartão aberto (o \"voltar\"). O\n          nome do grupo e o \"1 de 4\" já estão no `aria-label` e no aviso\n          falado; escritos na tela, eram legenda em cima de uma pilha que se\n          explica sozinha. A faixa fica com a altura reservada pra abrir não\n          empurrar os cartões. */}\n      <div className=\"flex h-8 items-center justify-end px-1\">\n        {aberto ? (\n          <button\n            type=\"button\"\n            onClick={() => {\n              abrir(false);\n              raizRef.current?.querySelector<HTMLElement>(\"[data-frente]\")?.focus();\n            }}\n            className=\"inline-flex h-8 items-center gap-1 rounded-[var(--r-pill)] border border-border bg-background px-3 text-xs text-foreground [font:inherit] focus-visible:outline-none focus-visible:[box-shadow:var(--focus)]\"\n          >\n            <ChevronLeft aria-hidden=\"true\" className=\"size-3.5\" />\n            Todos\n          </button>\n        ) : null}\n      </div>\n\n      <div className=\"relative w-full overflow-hidden\" style={{ height }}>\n        {cards.map((card, i) => {\n          const prof = (i - ativo + total) % total;\n          const frente = prof === 0;\n          const { y, scale } = posicao(prof);\n          const props = {\n            className: cn(\n              \"absolute left-1/2 top-0 block p-0 text-left [font:inherit]\",\n              \"cursor-pointer rounded-[var(--r-surface)] focus-visible:outline-none focus-visible:[box-shadow:var(--focus)]\",\n            ),\n            style: { width: LARG, height: ALT, marginLeft: -LARG / 2, zIndex: total - prof, transformOrigin: \"50% 0%\" },\n            initial: false as const,\n            animate: { y, scale },\n            transition: reduzir ? INSTANT : mola,\n          };\n          return (\n            <motion.button\n              key={card.id}\n              data-frente={frente ? \"\" : undefined}\n              type=\"button\"\n              tabIndex={frente ? 0 : -1}\n              aria-hidden={frente ? undefined : true}\n              aria-label={frente ? `${card.title}${card.kind ? `, ${card.kind}` : \"\"}` : undefined}\n              aria-expanded={frente && renderDetail ? aberto : undefined}\n              onFocus={() => setLeque(true)}\n              onBlur={() => setLeque(false)}\n              onClick={() => (frente ? abrir(!aberto) : trazer(i))}\n              {...props}\n            >\n              <Face card={card} />\n            </motion.button>\n          );\n        })}\n\n        <AnimatePresence initial={false}>\n          {aberto && renderDetail && (\n            <motion.div\n              key={atual.id}\n              className=\"absolute inset-x-0 overflow-y-auto px-1\"\n              style={{ top: ALT + (baixa ? 10 : 14), bottom: recolhido + 6 }}\n              initial={reduzir ? { opacity: 0 } : { opacity: 0, y: 12 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, transition: { duration: DUR.press } }}\n              transition={reduzir ? { duration: DUR.press } : { ...mola, delay: 0.06 }}\n            >\n              {renderDetail(atual)}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <p className=\"sr-only\" aria-live=\"polite\">\n        {atual.title} na frente, {ativo + 1} de {total}\n        {aberto ? \", aberto\" : \"\"}\n      </p>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Carteira de cartões em app fintech: um sobe com saldo e movimento, os outros recolhem",
      "evite": "Pilha de depoimentos ou dicas: card-stack; recibo de pedido, folding-bill"
    }
  },
  "categories": [
    "conteudo",
    "carteira",
    "cartoes",
    "fintech",
    "pilha",
    "saldo"
  ],
  "type": "registry:ui"
}