{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toast",
  "type": "registry:component",
  "title": "Toast",
  "description": "Reports a brief status in the bottom-right corner. Polite live region; pauses on hover and closes on demand.",
  "registryDependencies": [
    "https://vlak.dev/r/vlak-base.json",
    "https://vlak.dev/r/inter.json",
    "https://vlak.dev/r/vlak-lib.json",
    "https://vlak.dev/r/icons.json"
  ],
  "dependencies": [
    "@stylexjs/stylex"
  ],
  "devDependencies": [
    "@stylexjs/babel-plugin"
  ],
  "docs": "Vlak leaves are StyleX. Compile them with @stylexjs/babel-plugin (Vite: @stylexjs/unplugin, Next: @stylexjs/nextjs-plugin). If you would rather not run a compiler, import @noorddev/vlak-react instead: it ships precompiled with one stylesheet.",
  "files": [
    {
      "path": "vlak/toast.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { vlak, mq } from \"./tokens.stylex\";\nimport { rs } from \"./rs\";\nimport { Icon } from \"./icons\";\n\nexport interface ToastOptions {\n  description?: React.ReactNode;\n  /** Overrides the computed lifetime, in milliseconds. */\n  duration?: number;\n}\n\ninterface ToastItem {\n  id: number;\n  title: React.ReactNode;\n  description?: React.ReactNode;\n  duration?: number;\n}\n\nlet nextId = 1;\nconst listeners = new Set<(item: ToastItem) => void>();\n/** Fired before any <Toaster /> mounted; flushed by the first one. */\nconst pending: ToastItem[] = [];\n\n/** Fire a toast from anywhere; a mounted <Toaster /> renders it. */\nexport function toast(title: React.ReactNode, options?: ToastOptions): void {\n  const item: ToastItem = { id: nextId++, title, description: options?.description, duration: options?.duration };\n  if (listeners.size === 0) {\n    pending.push(item);\n    return;\n  }\n  for (const listener of listeners) listener(item);\n}\n\nexport interface ToasterProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** The shortest a toast stays, in milliseconds. Longer text stays longer. */\n  duration?: number;\n  /** Accessible name of the close button on every toast. */\n  closeLabel?: string;\n}\n\n/** Characters in a node tree: what the lifetime scales with. */\nfunction textLength(node: React.ReactNode): number {\n  if (node == null || typeof node === \"boolean\") return 0;\n  if (typeof node === \"string\" || typeof node === \"number\") return String(node).length;\n  if (Array.isArray(node)) return node.reduce<number>((n, child) => n + textLength(child), 0);\n  if (React.isValidElement<{ children?: React.ReactNode }>(node)) return textLength(node.props.children);\n  return 0;\n}\n\nconst PER_CHAR = 50;\nconst CAP = 15000;\n\nfunction lifetime(item: ToastItem, min: number): number {\n  if (item.duration != null) return item.duration;\n  const chars = textLength(item.title) + textLength(item.description);\n  return Math.min(Math.max(min, CAP), min + chars * PER_CHAR);\n}\n\ninterface Timer {\n  remaining: number;\n  started: number;\n  handle: ReturnType<typeof setTimeout> | null;\n  holds: Set<string>;\n}\n\nconst toastIn = stylex.keyframes({\n  from: { opacity: 0 },\n  to: { opacity: 1 },\n});\n\nconst styles = stylex.create({\n  stack: {\n    position: \"fixed\",\n    bottom: {\n      default: \"1.25rem\",\n      [mq.phone]: \"max(0.5rem, env(safe-area-inset-bottom))\",\n    },\n    insetInlineEnd: {\n      default: \"1.25rem\",\n      [mq.phone]: \"0.5rem\",\n    },\n    insetInlineStart: {\n      default: null,\n      [mq.phone]: \"0.5rem\",\n    },\n    zIndex: vlak.zToast,\n    display: \"flex\",\n    flexDirection: \"column\",\n    alignItems: {\n      default: \"flex-end\",\n      [mq.phone]: \"stretch\",\n    },\n    gap: {\n      default: \"0.5rem\",\n      [mq.phone]: \"0.5rem\",\n    },\n  },\n  toast: {\n    display: \"flex\",\n    alignItems: \"flex-start\",\n    gap: \"0.625rem\",\n    backgroundColor: vlak.paper,\n    borderWidth: vlak.hairline,\n    borderStyle: \"solid\",\n    borderColor: vlak.divider,\n    borderRadius: {\n      default: vlak.radius,\n      [mq.phone]: vlak.radiusSm,\n    },\n    boxShadow: {\n      default: \"0 8px 24px rgba(0,0,0,0.06)\",\n      [mq.phone]: \"none\",\n    },\n    paddingTop: {\n      default: \"0.625rem\",\n      [mq.phone]: \"1rem\",\n    },\n    paddingInline: {\n      default: \"0.875rem\",\n      [mq.phone]: \"1.25rem\",\n    },\n    paddingBottom: {\n      default: \"0.625rem\",\n      [mq.phone]: \"calc(16px + env(safe-area-inset-bottom, 0px))\",\n    },\n    maxWidth: {\n      default: \"21.25rem\",\n      [mq.phone]: \"none\",\n    },\n    width: {\n      default: null,\n      [mq.phone]: \"100%\",\n    },\n    animationName: {\n      default: toastIn,\n      [mq.reduce]: \"none\",\n    },\n    animationDuration: vlak.durationConfirm,\n    animationTimingFunction: vlak.ease,\n  },\n  title: {\n    display: \"block\",\n    fontSize: {\n      default: \"0.8125rem\",\n      [mq.phone]: \"1rem\",\n    },\n    fontWeight: 600,\n    letterSpacing: \"-0.01em\",\n    color: vlak.ink,\n  },\n  body: {\n    fontSize: {\n      default: \"0.78125rem\",\n      [mq.phone]: \"0.9375rem\",\n    },\n    lineHeight: 1.45,\n    letterSpacing: \"-0.01em\",\n    color: vlak.gray,\n    marginTop: {\n      default: 1,\n      [mq.phone]: \"0.25rem\",\n    },\n    marginBottom: 0,\n    marginInline: 0,\n  },\n  close: {\n    transition: {\n      default: vlak.transition,\n      [mq.reduce]: \"none\",\n    },\n    boxSizing: \"border-box\",\n    display: \"inline-flex\",\n    alignItems: \"center\",\n    justifyContent: \"center\",\n    flexShrink: 0,\n    marginInlineStart: \"auto\",\n    marginTop: {\n      default: \"-0.25rem\",\n      [mq.phone]: \"-0.625rem\",\n    },\n    marginInlineEnd: {\n      default: \"-0.375rem\",\n      [mq.phone]: \"-0.75rem\",\n    },\n    width: {\n      default: vlak.hit,\n      [mq.phone]: vlak.hit,\n    },\n    height: {\n      default: vlak.hit,\n      [mq.phone]: vlak.hit,\n    },\n    padding: 0,\n    borderWidth: 0,\n    borderRadius: vlak.radiusSm,\n    backgroundColor: {\n      default: \"transparent\",\n      \":hover\": { default: null, [mq.hover]: vlak.controlFill },\n    },\n    color: vlak.ink,\n    cursor: \"pointer\",\n    outlineWidth: {\n      default: null,\n      \":focus-visible\": 2,\n    },\n    outlineStyle: {\n      default: null,\n      \":focus-visible\": \"solid\",\n    },\n    outlineColor: {\n      default: null,\n      \":focus-visible\": vlak.ink,\n    },\n    outlineOffset: {\n      default: null,\n      \":focus-visible\": 2,\n    },\n  },\n});\n\nexport const Toaster = React.forwardRef<HTMLDivElement, ToasterProps>(function Toaster(\n  { duration = 4000, closeLabel = \"Dismiss\", className, style, ...props },\n  ref,\n) {\n  const [items, setItems] = React.useState<ToastItem[]>([]);\n  const timers = React.useRef(new Map<number, Timer>());\n  const minRef = React.useRef(duration);\n  minRef.current = duration;\n\n  const remove = React.useCallback((id: number) => {\n    const timer = timers.current.get(id);\n    if (timer?.handle) clearTimeout(timer.handle);\n    timers.current.delete(id);\n    setItems((current) => current.filter((i) => i.id !== id));\n  }, []);\n\n  const run = React.useCallback(\n    (id: number) => {\n      const timer = timers.current.get(id);\n      if (!timer || timer.handle || timer.holds.size) return;\n      timer.started = Date.now();\n      timer.handle = setTimeout(() => remove(id), timer.remaining);\n    },\n    [remove],\n  );\n\n  /** Hover and focus each hold the timer; it resumes when both let go. */\n  const hold = React.useCallback((id: number, reason: string) => {\n    const timer = timers.current.get(id);\n    if (!timer) return;\n    timer.holds.add(reason);\n    if (!timer.handle) return;\n    clearTimeout(timer.handle);\n    timer.handle = null;\n    timer.remaining = Math.max(0, timer.remaining - (Date.now() - timer.started));\n  }, []);\n\n  const release = React.useCallback(\n    (id: number, reason: string) => {\n      const timer = timers.current.get(id);\n      if (!timer) return;\n      timer.holds.delete(reason);\n      run(id);\n    },\n    [run],\n  );\n\n  React.useEffect(() => {\n    const timerMap = timers.current;\n    const add = (item: ToastItem) => {\n      timerMap.set(item.id, { remaining: lifetime(item, minRef.current), started: 0, handle: null, holds: new Set() });\n      setItems((current) => [...current, item]);\n      run(item.id);\n    };\n    listeners.add(add);\n    pending.splice(0).forEach(add);\n    return () => {\n      listeners.delete(add);\n      for (const timer of timerMap.values()) if (timer.handle) clearTimeout(timer.handle);\n      timerMap.clear();\n    };\n  }, [run]);\n\n  const stack = rs([\"rs-toasts\", className], styles.stack);\n  return (\n    <div ref={ref} {...props} className={stack.className} style={{ ...stack.style, ...style }} role=\"status\" aria-live=\"polite\">\n      {items.map((item) => {\n        const card = rs([\"rs-toast\"], styles.toast);\n        const heading = rs([\"rs-toast-title\"], styles.title);\n        const body = rs([\"rs-toast-body\"], styles.body);\n        const close = rs([\"rs-toast-close\"], styles.close);\n        return (\n          <div\n            key={item.id}\n            className={card.className}\n            style={card.style}\n            onPointerEnter={() => hold(item.id, \"hover\")}\n            onPointerLeave={() => release(item.id, \"hover\")}\n            onFocus={() => hold(item.id, \"focus\")}\n            onBlur={(e) => {\n              if (!e.currentTarget.contains(e.relatedTarget as Node | null)) release(item.id, \"focus\");\n            }}\n          >\n            <div>\n              <span className={heading.className} style={heading.style}>\n                {item.title}\n              </span>\n              {item.description != null && (\n                <p className={body.className} style={body.style}>\n                  {item.description}\n                </p>\n              )}\n            </div>\n            <button\n              type=\"button\"\n              aria-label={closeLabel}\n              className={close.className}\n              style={close.style}\n              onClick={() => remove(item.id)}\n            >\n              <Icon name=\"close\" size={12} />\n            </button>\n          </div>\n        );\n      })}\n    </div>\n  );\n});\n",
      "type": "registry:component",
      "target": "components/vlak/toast.tsx"
    },
    {
      "path": "vlak/styles/toast.css",
      "content": "/* ── toast: generated from packages/react/src/components/toast.tsx ── */\n@keyframes rs-anim-toast-in{from{opacity:0}to{opacity:1}}\n.rs-toasts{position:fixed;bottom:1.25rem;inset-inline-end:1.25rem;z-index:var(--z-toast);display:flex;flex-direction:column;align-items:flex-end;gap:0.5rem}\n@media (max-width: 640px){.rs-toasts{bottom:max(0.5rem, env(safe-area-inset-bottom));inset-inline-end:0.5rem;inset-inline-start:0.5rem;align-items:stretch;gap:0.5rem}}\n.rs-toast{display:flex;align-items:flex-start;gap:0.625rem;background-color:var(--bg);border-width:1px;border-style:solid;border-color:var(--divider);border-radius:var(--radius);box-shadow:0 8px 24px rgba(0,0,0,0.06);padding-top:0.625rem;padding-inline:0.875rem;padding-bottom:0.625rem;max-width:21.25rem;animation-name:rs-anim-toast-in;animation-duration:var(--duration-confirm);animation-timing-function:var(--ease)}\n@media (max-width: 640px){.rs-toast{border-radius:var(--radius-sm);box-shadow:none;padding-top:1rem;padding-inline:1.25rem;padding-bottom:calc(16px + env(safe-area-inset-bottom, 0px));max-width:none;width:100%}}\n@media (prefers-reduced-motion: reduce){.rs-toast{animation-name:none}}\n.rs-toast-title{display:block;font-size:0.8125rem;font-weight:600;letter-spacing:-0.01em;color:var(--text)}\n@media (max-width: 640px){.rs-toast-title{font-size:1rem}}\n.rs-toast-body{font-size:0.78125rem;line-height:1.45;letter-spacing:-0.01em;color:var(--text-secondary);margin-top:1px;margin-bottom:0;margin-inline:0}\n@media (max-width: 640px){.rs-toast-body{font-size:0.9375rem;margin-top:0.25rem}}\n.rs-toast-close{transition:var(--transition);box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin-inline-start:auto;margin-top:-0.25rem;margin-inline-end:-0.375rem;width:var(--hit);height:var(--hit);padding:0;border-width:0;border-radius:var(--radius-sm);background-color:transparent;color:var(--text);cursor:pointer}\n.rs-toast-close:focus-visible{outline-width:2px;outline-style:solid;outline-color:var(--text);outline-offset:2px}\n@media (prefers-reduced-motion: reduce){.rs-toast-close{transition:none}}\n@media (max-width: 640px){.rs-toast-close{margin-top:-0.625rem;margin-inline-end:-0.75rem;width:var(--hit);height:var(--hit)}}\n@media (hover: hover) and (pointer: fine){.rs-toast-close:hover{background-color:var(--control-fill)}}\n",
      "type": "registry:file",
      "target": "styles/vlak/toast.css"
    }
  ],
  "meta": {
    "vlak": {
      "category": "feedback",
      "classes": [
        "rs-toasts",
        "rs-toast",
        "rs-toast-title",
        "rs-toast-body",
        "rs-toast-close"
      ],
      "snippet": "<div class=\"rs-toasts\" role=\"status\" aria-live=\"polite\"><div class=\"rs-toast\"><div><span class=\"rs-toast-title\">Saved</span><p class=\"rs-toast-body\">Your changes are live.</p></div><button class=\"rs-toast-close\" type=\"button\" aria-label=\"Dismiss\">&times;</button></div></div>",
      "cssOnly": false,
      "registryDependencies": [
        "vlak-lib",
        "icons"
      ],
      "aliases": [
        "Toast",
        "Sonner",
        "Snackbar",
        "Notification"
      ],
      "example": "import { Toaster, toast } from \"@noorddev/vlak-react\";\n\n// once, in your layout\n<Toaster duration={4000} closeLabel=\"Dismiss\" />\n\n// from anywhere\ntoast(\"Saved\", { description: \"Your changes are live.\" });",
      "usage": {
        "use": [
          "Confirming something that already happened: saved, sent, copied.",
          "One line; a description only when the title needs it."
        ],
        "avoid": [
          "Errors the user must act on; use Alert or AlertDialog.",
          "Anything with a link or a button inside."
        ]
      },
      "keyboard": [
        {
          "keys": "Tab",
          "does": "Reaches the close button of each toast"
        },
        {
          "keys": "Enter, Space",
          "does": "Dismisses the toast"
        }
      ],
      "a11y": [
        "The stack is role=\"status\" with aria-live=\"polite\".",
        "Each toast has a labelled close button (closeLabel, \"Dismiss\" by default).",
        "Lifetime scales with the text length and pauses while hovered or focused; duration on the call overrides it."
      ]
    }
  }
}
