{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toast-stack",
  "title": "Toast Stack",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Avisos que se empilham no canto e abrem em leque quando o ponteiro chega, com o carregando que vira feito no lugar.",
  "dependencies": [
    "motion",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "@visant/motion"
  ],
  "files": [
    {
      "path": "registry/visant/ui/toast-stack.tsx",
      "content": "\"use client\";\n\n// Adaptado de Toast stack — https://uiarc.dev/components/toast-stack — Copyright (c) 2026 Elia Kuratli, licença MIT (texto integral em ATTRIBUTIONS.md do @visant/ui).\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion, type Transition } from \"motion/react\";\nimport { CircleCheck, CircleX, Info, LoaderCircle, TriangleAlert, X } from \"lucide-react\";\n\nimport { DUR, EASE_OUT, INSTANT } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Toast Stack: os avisos se empilham no canto e abrem em leque quando alguém\n * vai até eles.\n *\n * Toast solto resolve um aviso; o problema é o terceiro. Empilhados em coluna,\n * três toasts comem meia tela de telefone. Aqui o mais novo fica na frente e os\n * antigos se escondem atrás dele, mostrando só a borda. Passar o ponteiro (ou o\n * foco, ou um toque) abre o leque, e aí cada um tem a própria altura e a\n * própria ação.\n *\n * ── O contrato ─────────────────────────────────────────────────────────────\n *\n * `ToastStackProvider` guarda a fila, `useToastStack()` dispara, atualiza e\n * dispensa de qualquer lugar abaixo dele, e `<ToastStack />` é a vitrine. A fila\n * é uma store externa (`useSyncExternalStore`): disparar um toast não renderiza\n * a árvore inteira de quem chamou, só a pilha.\n *\n * ── Decisões que não são óbvias ────────────────────────────────────────────\n *\n * 1. **Fechado, todo mundo tem a altura do da frente.** As bordas de trás\n *    ficam alinhadas, e o conteúdo delas some, senão o texto vazaria embaixo.\n *    Aberto, cada um volta à própria altura com um respiro entre eles.\n * 2. **O relógio para enquanto alguém lê.** Ponteiro em cima, foco dentro ou\n *    aba escondida congelam o tempo que falta; ninguém perde um aviso por estar\n *    lendo o anterior. `loading` não tem relógio: espera o `update`.\n * 3. **`update` transforma no lugar.** \"Publicando\" vira \"Publicado\" sem sair e\n *    entrar de novo; o título troca com um deslize curto e o relógio recomeça.\n * 4. **Arrastar para a direita dispensa.** Para a esquerda resiste, porque é o\n *    lado errado. Botão de fechar e Esc fazem o mesmo para quem não arrasta.\n * 5. **`contained` prende a pilha no ancestral posicionado** em vez da janela,\n *    para painel, preview e a própria vitrine.\n */\n\nexport type ToastType = \"success\" | \"info\" | \"warning\" | \"error\" | \"loading\";\n\nexport interface ToastOptions {\n  /** Reusar um id atualiza o toast no lugar em vez de empilhar outro. */\n  id?: string;\n  type?: ToastType;\n  title: string;\n  description?: string;\n  /** Ação curta, tipo Desfazer ou Ver. O toast fecha depois, a menos que a ação o atualize. */\n  action?: { label: string; onClick: (id: string) => void };\n  /** Milissegundos até fechar sozinho. `Infinity` mantém até fechar à mão. */\n  duration?: number;\n}\n\nexport interface ToastStackApi {\n  toast: (options: ToastOptions) => string;\n  update: (id: string, patch: Partial<Omit<ToastOptions, \"id\">>) => void;\n  dismiss: (id?: string) => void;\n}\n\ntype Registro = Required<Pick<ToastOptions, \"id\" | \"type\" | \"title\" | \"duration\">> &\n  Pick<ToastOptions, \"description\" | \"action\"> & { seq: number; versao: number };\n\ntype Store = ToastStackApi & {\n  subscribe: (fn: () => void) => () => void;\n  get: () => Registro[];\n  rodarAcao: (id: string) => void;\n};\n\nconst VAZIO: Registro[] = [];\n/** Quanto cada toast escondido aparece acima do da frente. */\nconst PEEK = 14;\n/** Respiro entre toasts com o leque aberto. */\nconst GAP = 10;\n/** Quanto cada degrau para trás encolhe. */\nconst ENCOLHE = 0.05;\n\nconst rotulos: Record<ToastType, string> = {\n  success: \"Feito\",\n  info: \"Aviso\",\n  warning: \"Atenção\",\n  error: \"Erro\",\n  loading: \"Em andamento\",\n};\nconst icones = { success: CircleCheck, info: Info, warning: TriangleAlert, error: CircleX, loading: LoaderCircle };\n\n// Aviso e erro pedem mais leitura; loading espera o update.\nconst tempoPara = (tipo: ToastType, base: number) =>\n  tipo === \"loading\" ? Infinity : tipo === \"warning\" || tipo === \"error\" ? base * 1.6 : base;\n\nfunction criarStore(base: number, limite: number): Store {\n  let fila: Registro[] = [];\n  let seq = 0;\n  const ouvintes = new Set<() => void>();\n  const gravar = (nova: Registro[]) => {\n    fila = nova;\n    ouvintes.forEach((fn) => fn());\n  };\n  const update: ToastStackApi[\"update\"] = (id, patch) => {\n    const achado = fila.find((t) => t.id === id);\n    if (!achado) return;\n    const type = patch.type ?? achado.type;\n    const novo: Registro = { ...achado, ...patch, type, duration: patch.duration ?? tempoPara(type, base), versao: achado.versao + 1 };\n    gravar(fila.map((t) => (t.id === id ? novo : t)));\n  };\n  const toast: ToastStackApi[\"toast\"] = ({ id, ...op }) => {\n    if (id && fila.some((t) => t.id === id)) {\n      update(id, op);\n      return id;\n    }\n    const type = op.type ?? \"info\";\n    seq += 1;\n    const novo: Registro = { ...op, id: id ?? `toast-${seq}`, type, duration: op.duration ?? tempoPara(type, base), seq, versao: 0 };\n    gravar([novo, ...fila].slice(0, limite));\n    return novo.id;\n  };\n  const dismiss: ToastStackApi[\"dismiss\"] = (id) => {\n    if (id === undefined) return void (fila.length && gravar([]));\n    if (fila.some((t) => t.id === id)) gravar(fila.filter((t) => t.id !== id));\n  };\n  return {\n    toast,\n    update,\n    dismiss,\n    rodarAcao: (id) => {\n      const achado = fila.find((t) => t.id === id);\n      if (!achado?.action) return;\n      achado.action.onClick(id);\n      // Se a ação não atualizou o toast, ele cumpriu o papel e fecha.\n      if (fila.find((t) => t.id === id)?.versao === achado.versao) dismiss(id);\n    },\n    subscribe: (fn) => {\n      ouvintes.add(fn);\n      return () => void ouvintes.delete(fn);\n    },\n    get: () => fila,\n  };\n}\n\nconst Ctx = React.createContext<Store | null>(null);\n\nfunction useStore() {\n  const s = React.useContext(Ctx);\n  if (!s) throw new Error(\"Use a pilha de toasts dentro de <ToastStackProvider>.\");\n  return s;\n}\n\nexport interface ToastStackProviderProps {\n  children: React.ReactNode;\n  /** Vida base de um toast, em ms. Aviso e erro ficam 1,6 vez mais. */\n  duration?: number;\n  /** Acima disso, os mais antigos saem da fila. */\n  limit?: number;\n}\n\n/** Escopo de uma fila. Duas pilhas na mesma página nunca dividem toasts. */\nexport function ToastStackProvider({ children, duration = 5000, limit = 12 }: ToastStackProviderProps) {\n  const [store] = React.useState(() => criarStore(duration, limit));\n  return <Ctx.Provider value={store}>{children}</Ctx.Provider>;\n}\n\n/** Dispara, atualiza e dispensa toasts de qualquer lugar abaixo do provider. */\nexport function useToastStack(): ToastStackApi & { count: number } {\n  const s = useStore();\n  const count = React.useSyncExternalStore(s.subscribe, () => s.get().length, () => 0);\n  return { toast: s.toast, update: s.update, dismiss: s.dismiss, count };\n}\n\nconst assinarVisibilidade = (fn: () => void) => {\n  document.addEventListener(\"visibilitychange\", fn);\n  return () => document.removeEventListener(\"visibilitychange\", fn);\n};\n\ninterface ItemProps {\n  t: Registro;\n  index: number;\n  y: number;\n  altura: number;\n  aberto: boolean;\n  visivel: boolean;\n  pausado: boolean;\n  reduce: boolean;\n  store: Store;\n  onMedir: (id: string, h: number) => void;\n}\n\nconst mola: Transition = { type: \"spring\", visualDuration: 0.3, bounce: 0.1 };\nconst curto: Transition = { duration: DUR.fast, ease: EASE_OUT };\nconst parado: Transition = INSTANT;\n\nfunction ToastItem({ t, index, y, altura, aberto, visivel, pausado, reduce, store, onMedir }: ItemProps) {\n  const conteudo = React.useRef<HTMLDivElement>(null);\n  const restante = React.useRef(t.duration);\n  const frente = index === 0;\n  const degrau = Math.min(index, 3);\n\n  React.useLayoutEffect(() => {\n    const el = conteudo.current;\n    if (!el) return;\n    const medir = () => onMedir(t.id, el.offsetHeight + 2);\n    medir();\n    const ro = new ResizeObserver(medir);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [t.id, onMedir]);\n\n  // Update recomeça o relógio; pausa guarda o que falta (decisão 2).\n  React.useEffect(() => {\n    restante.current = t.duration;\n  }, [t.versao, t.duration]);\n  React.useEffect(() => {\n    if (pausado || !Number.isFinite(restante.current)) return;\n    const inicio = performance.now();\n    const timer = window.setTimeout(() => store.dismiss(t.id), Math.max(restante.current, 0));\n    return () => {\n      window.clearTimeout(timer);\n      restante.current -= performance.now() - inicio;\n    };\n  }, [pausado, store, t.id, t.versao, t.duration]);\n\n  const Icone = icones[t.type];\n  const troca = reduce\n    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: curto }\n    : { initial: { opacity: 0, y: \"0.3em\" }, animate: { opacity: 1, y: \"0em\" }, exit: { opacity: 0, y: \"-0.3em\" }, transition: curto };\n\n  return (\n    <motion.li\n      className=\"absolute inset-x-0 bottom-0 origin-bottom\"\n      style={{ zIndex: t.seq }}\n      initial={reduce ? { opacity: 0 } : { opacity: 0, y: altura || 48 }}\n      // motion-lint-disable-next-line layout-property-animation  cada aviso tem altura própria e a pilha recolhe na altura do da frente\n      animate={{\n        y,\n        scale: aberto ? 1 : 1 - ENCOLHE * degrau,\n        height: altura || \"auto\",\n        opacity: visivel ? 1 : 0,\n      }}\n      exit={reduce ? { opacity: 0, transition: curto } : { opacity: 0, scale: 0.96, transition: { duration: DUR.fast } }}\n      transition={reduce ? parado : mola}\n      // `inert` como atributo cru: o React 18 não conhece a prop e só repassa string.\n      {...({ inert: !visivel || (!aberto && !frente) ? \"\" : undefined } as object)}\n      onKeyDown={(e) => {\n        if (e.key !== \"Escape\") return;\n        e.stopPropagation();\n        store.dismiss(t.id);\n      }}\n    >\n      <motion.div\n        drag={reduce ? false : \"x\"}\n        dragSnapToOrigin\n        dragElastic={{ left: 0.08, right: 0.9 }}\n        dragConstraints={{ left: 0, right: 0 }}\n        onDragEnd={(_, info) => {\n          if (info.offset.x > 110 || info.velocity.x > 600) store.dismiss(t.id);\n        }}\n        className=\"h-full touch-pan-y overflow-hidden rounded-[var(--r-surface)] border border-border bg-card text-foreground [box-shadow:var(--e-raised)]\"\n      >\n        <motion.div\n          ref={conteudo}\n          className=\"flex items-start gap-3 pointer-coarse:gap-4 py-3 pl-3.5 pr-2\"\n          initial={false}\n          animate={{ opacity: aberto || frente ? 1 : 0 }}\n          transition={reduce ? parado : curto}\n        >\n          <span aria-hidden=\"true\" className=\"mt-0.5 grid size-5 shrink-0 place-items-center text-muted-foreground\">\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              <motion.span key={t.type} {...troca} className=\"grid place-items-center\">\n                <Icone className={cn(\"size-[18px]\", t.type === \"loading\" && \"animate-spin\", t.type === \"error\" && \"text-destructive\")} />\n              </motion.span>\n            </AnimatePresence>\n          </span>\n          <div className=\"min-w-0 flex-1\">\n            <span className=\"sr-only\">{rotulos[t.type]}: </span>\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              <motion.p key={t.title} {...troca} className=\"text-sm font-medium leading-5\">\n                {t.title}\n              </motion.p>\n            </AnimatePresence>\n            {t.description && <p className=\"mt-0.5 text-xs leading-4 text-muted-foreground\">{t.description}</p>}\n          </div>\n          {t.action && (\n            <button\n              type=\"button\"\n              onClick={() => store.rodarAcao(t.id)}\n              className=\"relative h-7 pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:-inset-y-2.5 shrink-0 rounded-[var(--r-control)] border border-border bg-background px-2.5 text-xs font-medium outline-none transition-[background-color] duration-[var(--dur-fast)] ease-[var(--ease-out)] hover:bg-muted focus-visible:[box-shadow:var(--focus)]\"\n            >\n              {t.action.label}\n            </button>\n          )}\n          <button\n            type=\"button\"\n            aria-label=\"Dispensar aviso\"\n            onClick={() => store.dismiss(t.id)}\n            className=\"relative grid size-7 pointer-coarse:after:absolute pointer-coarse:after:-inset-2 shrink-0 place-items-center rounded-[var(--r-control)] text-muted-foreground outline-none transition-[background-color,color] duration-[var(--dur-fast)] ease-[var(--ease-out)] hover:bg-muted hover:text-foreground focus-visible:[box-shadow:var(--focus)]\"\n          >\n            <X className=\"size-4\" aria-hidden=\"true\" />\n          </button>\n        </motion.div>\n      </motion.div>\n    </motion.li>\n  );\n}\n\nexport interface ToastStackProps {\n  /** Nome acessível da região. */\n  label?: string;\n  position?: \"bottom-right\" | \"bottom-center\" | \"bottom-left\";\n  /** Prende a pilha no ancestral posicionado em vez da janela. */\n  contained?: boolean;\n  /** Quantos aparecem de uma vez. Os demais esperam atrás. */\n  visibleToasts?: number;\n  /** Abre o leque sem precisar de ponteiro. Útil para vitrine e documentação. */\n  defaultExpanded?: boolean;\n  className?: string;\n}\n\nexport function ToastStack({\n  label = \"Avisos\",\n  position = \"bottom-right\",\n  contained = false,\n  visibleToasts = 3,\n  defaultExpanded = false,\n  className,\n}: ToastStackProps) {\n  const store = useStore();\n  const fila = React.useSyncExternalStore(store.subscribe, store.get, () => VAZIO);\n  const reduce = useReducedMotion() ?? false;\n  const escondida = React.useSyncExternalStore(assinarVisibilidade, () => document.visibilityState === \"hidden\", () => false);\n  const [hover, setHover] = React.useState(false);\n  const [foco, setFoco] = React.useState(false);\n  const [toque, setToque] = React.useState(false);\n  const [alturas, setAlturas] = React.useState<Record<string, number>>({});\n\n  const onMedir = React.useCallback((id: string, h: number) => {\n    setAlturas((a) => (a[id] === h ? a : { ...a, [id]: h }));\n  }, []);\n\n  const aberto = defaultExpanded || hover || foco || toque;\n  const pausado = hover || foco || escondida;\n\n  // Fila vazia fecha o leque de toque, senão o próximo toast já nasceria aberto.\n  React.useEffect(() => {\n    if (!fila.length) setToque(false);\n  }, [fila.length]);\n\n  // Geometria (decisão 1): fechado, degraus de PEEK com a altura do da frente; aberto, cada um na sua altura.\n  const hFrente = fila[0] ? alturas[fila[0].id] ?? 0 : 0;\n  let acumulado = 0;\n  const alvos = fila.map((t, i) => {\n    const h = alturas[t.id] ?? 0;\n    const visivel = i < visibleToasts;\n    const alvo = aberto\n      ? { y: -acumulado, altura: h }\n      : { y: -PEEK * Math.min(i, visibleToasts), altura: i === 0 ? h : hFrente };\n    if (visivel) acumulado += h + GAP;\n    return { ...alvo, visivel };\n  });\n  const alturaLista = !fila.length\n    ? 0\n    : aberto\n      ? acumulado - GAP\n      : hFrente + PEEK * (Math.min(fila.length, visibleToasts) - 1);\n\n  return (\n    <section\n      aria-label={label}\n      className={cn(\n        contained ? \"absolute\" : \"fixed\",\n        \"bottom-4 z-50 w-[min(22.5rem,calc(100%-2rem))]\",\n        position === \"bottom-right\" && \"right-4\",\n        position === \"bottom-left\" && \"left-4\",\n        position === \"bottom-center\" && \"left-1/2 -translate-x-1/2\",\n        className,\n      )}\n    >\n      <motion.ol\n        aria-live=\"polite\"\n        aria-relevant=\"additions text\"\n        className=\"relative\"\n        initial={false}\n        // motion-lint-disable-next-line layout-property-animation  a caixa acompanha a pilha aberta para o clique não vazar\n        animate={{ height: alturaLista }}\n        transition={reduce ? parado : mola}\n        onPointerEnter={(e) => e.pointerType === \"mouse\" && setHover(true)}\n        onPointerLeave={(e) => e.pointerType === \"mouse\" && setHover(false)}\n        onPointerUp={(e) => {\n          // Toque não tem hover: tocar no cartão abre ou fecha o leque, tocar num botão só aperta o botão.\n          if (e.pointerType === \"mouse\" || (e.target as Element).closest(\"button\")) return;\n          setToque((v) => !v);\n        }}\n        onFocus={() => setFoco(true)}\n        onBlur={(e) => {\n          if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFoco(false);\n        }}\n      >\n        <AnimatePresence initial={false}>\n          {fila.map((t, i) => (\n            <ToastItem\n              key={t.id}\n              t={t}\n              index={i}\n              y={alvos[i].y}\n              altura={alvos[i].altura}\n              aberto={aberto}\n              visivel={alvos[i].visivel}\n              pausado={pausado}\n              reduce={reduce}\n              store={store}\n              onMedir={onMedir}\n            />\n          ))}\n        </AnimatePresence>\n      </motion.ol>\n    </section>\n  );\n}\n\nexport default ToastStack;\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Avisos passageiros empilhados no canto, com o carregando que vira feito no lugar",
      "evite": "Atividade em curso com controles: dynamic-island; erro de formulário vai no próprio campo"
    }
  },
  "categories": [
    "feedback",
    "avisos",
    "toast",
    "notificacao",
    "pilha"
  ],
  "type": "registry:ui"
}