{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "px-toaster",
  "title": "Toasts",
  "description": "Toast stack with pixel type icons and a draining timer bar that pauses on hover or focus; it can be pinned to a viewport corner.",
  "registryDependencies": [
    "https://elements.lumesec.ai/r/lumesec-theme.json",
    "https://elements.lumesec.ai/r/lumesec-math.json",
    "https://elements.lumesec.ai/r/lumesec-motion.json",
    "https://elements.lumesec.ai/r/lumesec-pixel-canvas.json",
    "https://elements.lumesec.ai/r/lumesec-pixel-font.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/components/lumesec/px-toaster.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { clamp } from \"@/lib/lumesec/math\";\nimport { prefersReducedMotion } from \"@/lib/lumesec/motion\";\nimport { usePixelCanvas, type PixelFrame } from \"@/lib/lumesec/pixel-canvas\";\nimport { STATUS_COLORS, STATUS_ICONS, type StatusTone } from \"@/lib/lumesec/pixel-font\";\nimport { cn } from \"@/lib/utils\";\n\n/** Toast type: picks the pixel icon and the colour. */\nexport type PxToastType = StatusTone;\n\n/** Viewport corner the stack is pinned to. */\nexport type PxToasterPosition = \"bottom-right\" | \"bottom-left\" | \"top-right\";\n\n/** A button on the toast. Clicking it calls `onClick` and dismisses the toast. */\nexport interface PxToastAction {\n  /** Button text. */\n  label: string;\n  /** Called when the button is clicked, before the toast is dismissed. */\n  onClick?: () => void;\n}\n\n/** What `show()` accepts. */\nexport interface PxToastOptions {\n  /** Bold first line. */\n  title?: React.ReactNode;\n  /** Muted second line. */\n  description?: React.ReactNode;\n  /** Default `info`. */\n  type?: PxToastType;\n  /** Milliseconds before the toast leaves, default 4500. 0 or less keeps it until dismissed. */\n  duration?: number;\n  /** Optional action button. */\n  action?: PxToastAction;\n}\n\n/** Imperative handle of `PxToaster`, passed through `ref`. */\nexport interface PxToasterHandle {\n  /** Adds a toast and returns its id. Showing more than `max` dismisses the oldest. */\n  show(toast?: PxToastOptions): number;\n  /** Starts the exit of the toast with that id. Unknown or leaving ids are ignored. */\n  dismiss(id: number): void;\n}\n\n/** Props of `PxToaster`. Other `div` props go to the root element; `ref` is the handle. */\nexport interface PxToasterProps extends Omit<React.ComponentProps<\"div\">, \"ref\" | \"children\"> {\n  /** Handle with `show()` and `dismiss()`. */\n  ref?: React.Ref<PxToasterHandle>;\n  /** Pins the stack to a viewport corner with `position: fixed`, up to 360 px wide. Without it the stack stays in the flow. */\n  position?: PxToasterPosition;\n  /** Maximum toasts on screen, at least 1; one more dismisses the oldest. */\n  max?: number;\n  /** Called when a toast is added. */\n  onShow?: (id: number, type: PxToastType) => void;\n  /** Called when a toast starts leaving: timer, dismiss button, action button, overflow or `dismiss()`. */\n  onDismiss?: (id: number) => void;\n}\n\ninterface ToastRecord {\n  id: number;\n  type: PxToastType;\n  title?: React.ReactNode;\n  description?: React.ReactNode;\n  action?: PxToastAction;\n  leaving: boolean;\n}\n\n/**\n * A toast's countdown. It runs on a timeout rather than in the canvas loop, so toasts expire on time while\n * the stack is off screen; the loop only draws the bar.\n */\ninterface ToastTimer {\n  /** Full duration in ms; 0 or less never expires. */\n  duration: number;\n  /** Time left in ms when the countdown last started or stopped. */\n  remaining: number;\n  /** `performance.now()` when the countdown last started, or `null` while it is stopped. */\n  startedAt: number | null;\n  /** The pending expiry timeout. */\n  timeout: number;\n}\n\nconst DEFAULT_DURATION = 4500;\n/** How long a dismissed toast takes to slide out and collapse. */\nconst EXIT_MS = 320;\n\nconst POSITION_CLASSES: Record<PxToasterPosition, string> = {\n  \"bottom-right\": \"right-4 bottom-[calc(16px+env(safe-area-inset-bottom,0px))]\",\n  \"bottom-left\": \"left-4 bottom-[calc(16px+env(safe-area-inset-bottom,0px))]\",\n  \"top-right\": \"right-4 top-[calc(16px+env(safe-area-inset-top,0px))]\",\n};\n\n/**\n * Toast stack. Each toast has a pixel type icon and a timer bar that drains along its bottom edge;\n * hovering or focusing the stack pauses every timer. Toasts are added through the `show()` handle, and\n * the stack can be pinned to a viewport corner.\n *\n * @example\n * const toaster = React.useRef<PxToasterHandle>(null);\n * <PxToaster ref={toaster} position=\"bottom-right\" />\n * toaster.current?.show({ title: \"Changes saved\", type: \"success\" });\n */\nexport function PxToaster({ ref, position, max = 4, onShow, onDismiss, className, ...props }: PxToasterProps) {\n  const [toasts, setToasts] = React.useState<ToastRecord[]>([]);\n  // the list is also kept in a ref, so several show() calls in one tick see each other\n  const list = React.useRef<ToastRecord[]>([]);\n  const timers = React.useRef(new Map<number, ToastTimer>());\n  const removals = React.useRef(new Map<number, number>());\n  const nodes = React.useRef(new Map<number, HTMLLIElement>());\n  const regionRef = React.useRef<HTMLDivElement>(null);\n  const sequence = React.useRef(0);\n  // hover and focus each pause every timer; both have to end before the timers run again\n  const hovered = React.useRef(false);\n  const focused = React.useRef(false);\n  /** Until when (`performance.now()`) the stack is still collapsing around a dismissed toast, so the icons follow it. */\n  const settleUntil = React.useRef(0);\n\n  const settings = React.useRef({ max, onShow, onDismiss });\n  React.useEffect(() => {\n    settings.current = { max, onShow, onDismiss };\n  });\n\n  const commit = React.useCallback((next: ToastRecord[]) => {\n    list.current = next;\n    setToasts(next);\n  }, []);\n\n  const { ref: canvasRef, redraw } = usePixelCanvas(draw);\n\n  const scheduleRemoval = React.useCallback(\n    (id: number) => {\n      const removal = window.setTimeout(\n        () => {\n          removals.current.delete(id);\n          timers.current.delete(id);\n          commit(list.current.filter((item) => item.id !== id));\n          redraw();\n        },\n        prefersReducedMotion() ? 0 : EXIT_MS,\n      );\n      removals.current.set(id, removal);\n    },\n    [commit, redraw],\n  );\n\n  // A leaving toast is inert, which would drop focus to the page. Move focus first: to the close button of\n  // the toast below it (ids grow down the stack), else the one above it, else the stack itself.\n  const releaseFocus = React.useCallback((id: number) => {\n    if (!nodes.current.get(id)?.contains(document.activeElement)) return;\n    const others = list.current.filter((item) => !item.leaving && item.id !== id);\n    const neighbour = others.find((item) => item.id > id) ?? others.filter((item) => item.id < id).pop();\n    const close =\n      neighbour && nodes.current.get(neighbour.id)?.querySelector<HTMLElement>('[data-slot=\"px-toaster-close\"]');\n    (close || regionRef.current)?.focus();\n  }, []);\n\n  const dismiss = React.useCallback(\n    (id: number) => {\n      const toast = list.current.find((item) => item.id === id);\n      if (!toast || toast.leaving) return;\n      releaseFocus(id);\n      const timer = timers.current.get(id);\n      if (timer) stopTimer(timer);\n      commit(list.current.map((item) => (item.id === id ? { ...item, leaving: true } : item)));\n      scheduleRemoval(id);\n      settleUntil.current = performance.now() + EXIT_MS + 40;\n      settings.current.onDismiss?.(id);\n      redraw();\n    },\n    [commit, redraw, releaseFocus, scheduleRemoval],\n  );\n\n  const startTimer = React.useCallback(\n    (id: number) => {\n      const timer = timers.current.get(id);\n      if (!timer || timer.duration <= 0 || timer.startedAt !== null) return;\n      timer.startedAt = performance.now();\n      timer.timeout = window.setTimeout(() => dismiss(id), Math.max(0, timer.remaining));\n    },\n    [dismiss],\n  );\n\n  /** Stops every countdown while the stack is hovered or holds focus, and restarts them afterwards. */\n  const syncPause = React.useCallback(() => {\n    const paused = hovered.current || focused.current;\n    for (const toast of list.current) {\n      const timer = timers.current.get(toast.id);\n      if (!timer || toast.leaving) continue;\n      if (paused) stopTimer(timer);\n      else startTimer(toast.id);\n    }\n    redraw();\n  }, [redraw, startTimer]);\n\n  const show = React.useCallback(\n    (options: PxToastOptions = {}) => {\n      const type = options.type && options.type in STATUS_ICONS ? options.type : \"info\";\n      const id = ++sequence.current;\n      const duration = options.duration ?? DEFAULT_DURATION;\n      timers.current.set(id, { duration, remaining: duration, startedAt: null, timeout: 0 });\n      commit([\n        ...list.current,\n        { id, type, title: options.title, description: options.description, action: options.action, leaving: false },\n      ]);\n      if (!hovered.current && !focused.current) startTimer(id);\n      const live = list.current.filter((item) => !item.leaving);\n      if (live.length > Math.max(1, settings.current.max)) dismiss(live[0].id);\n      settings.current.onShow?.(id, type);\n      redraw();\n      return id;\n    },\n    [commit, dismiss, redraw, startTimer],\n  );\n\n  React.useImperativeHandle(ref, () => ({ show, dismiss }), [show, dismiss]);\n\n  React.useEffect(() => {\n    // (re)start what the cleanup stopped: StrictMode unmounts and remounts once in development\n    for (const toast of list.current) {\n      if (toast.leaving) {\n        if (!removals.current.has(toast.id)) scheduleRemoval(toast.id);\n      } else if (!hovered.current && !focused.current) {\n        startTimer(toast.id);\n      }\n    }\n    const pendingTimers = timers.current;\n    const pendingRemovals = removals.current;\n    return () => {\n      pendingTimers.forEach(stopTimer);\n      pendingRemovals.forEach((removal) => window.clearTimeout(removal));\n      pendingRemovals.clear();\n    };\n  }, [scheduleRemoval, startTimer]);\n\n  const register = React.useCallback(\n    (id: number, node: HTMLLIElement | null) => {\n      if (node) nodes.current.set(id, node);\n      else nodes.current.delete(id);\n      // the icon is drawn at the toast's position, which only exists once it is in the DOM\n      redraw();\n    },\n    [redraw],\n  );\n\n  // icons and draining timer bars, drawn over the toasts; the timeouts above do the expiring\n  function draw({ palette, clear, dot }: PixelFrame) {\n    clear();\n    const now = performance.now();\n    let draining = false;\n    for (const toast of list.current) {\n      if (toast.leaving) continue;\n      const node = nodes.current.get(toast.id);\n      if (!node) continue;\n      const x0 = node.offsetLeft;\n      const y0 = node.offsetTop;\n      const width = node.offsetWidth;\n      const height = node.offsetHeight;\n      const color = palette[STATUS_COLORS[toast.type]];\n      for (const [x, y] of STATUS_ICONS[toast.type]) dot(x0 + 13 + x * 2.4, y0 + 13 + y * 2.4, color, 1, 2.1);\n      const timer = timers.current.get(toast.id);\n      if (timer && timer.duration > 0) {\n        const running = timer.startedAt !== null;\n        if (running) draining = true;\n        const share = clamp(timeLeft(timer, now) / timer.duration, 0, 1);\n        for (let x = x0 + 6; x < x0 + 6 + (width - 12) * share; x += 3)\n          dot(x, y0 + height - 5, color, running ? 0.8 : 0.35, 2);\n      }\n    }\n    return draining || now < settleUntil.current;\n  }\n\n  return (\n    <div\n      data-slot=\"px-toaster\"\n      data-position={position}\n      className={cn(\n        \"block text-foreground\",\n        position && \"pointer-events-none fixed z-[1000] w-[min(360px,calc(100vw-32px))]\",\n        position && POSITION_CLASSES[position],\n        className,\n      )}\n      {...props}\n    >\n      {/* focusable from code only: it takes focus when the last toast holding focus leaves */}\n      <div\n        ref={regionRef}\n        role=\"region\"\n        aria-label=\"Notifications\"\n        tabIndex={-1}\n        className=\"relative outline-none\"\n        onPointerEnter={() => {\n          hovered.current = true;\n          syncPause();\n        }}\n        onPointerLeave={() => {\n          hovered.current = false;\n          syncPause();\n        }}\n        onFocus={() => {\n          focused.current = true;\n          syncPause();\n        }}\n        onBlur={(event) => {\n          if (event.currentTarget.contains(event.relatedTarget)) return;\n          focused.current = false;\n          syncPause();\n        }}\n      >\n        <canvas\n          ref={canvasRef}\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 z-[2] block h-full w-full [image-rendering:pixelated]\"\n        />\n        {/* the only live region: toasts carry no role of their own, so each one is announced once */}\n        <ol aria-live=\"polite\" className=\"m-0 flex list-none flex-col gap-2 p-0\">\n          {toasts.map((toast) => (\n            <Toast key={toast.id} toast={toast} register={register} onDismiss={dismiss} />\n          ))}\n        </ol>\n      </div>\n    </div>\n  );\n}\n\n/** Stops a running countdown and keeps the time it had left. */\nfunction stopTimer(timer: ToastTimer) {\n  if (timer.startedAt === null) return;\n  window.clearTimeout(timer.timeout);\n  timer.remaining -= performance.now() - timer.startedAt;\n  timer.startedAt = null;\n}\n\n/** Milliseconds a countdown has left at `now`. */\nfunction timeLeft(timer: ToastTimer, now: number) {\n  return timer.startedAt === null ? timer.remaining : timer.remaining - (now - timer.startedAt);\n}\n\nconst ENTER_KEYFRAMES: Keyframe[] = [\n  { opacity: 0, transform: \"translateY(14px) scale(.96)\" },\n  { opacity: 1, transform: \"none\" },\n];\n\n/** One toast. The icon and timer bar are drawn by the stack's canvas, over the empty first column. */\nfunction Toast({\n  toast,\n  register,\n  onDismiss,\n}: {\n  toast: ToastRecord;\n  register: (id: number, node: HTMLLIElement | null) => void;\n  onDismiss: (id: number) => void;\n}) {\n  const node = React.useRef<HTMLLIElement>(null);\n  const { id, type, title, description, action, leaving } = toast;\n\n  React.useLayoutEffect(() => {\n    const element = node.current;\n    if (!element) return;\n    register(id, element);\n    if (!prefersReducedMotion())\n      element.animate(ENTER_KEYFRAMES, { duration: 320, easing: \"cubic-bezier(.2,.9,.3,1.15)\" });\n    return () => register(id, null);\n  }, [id, register]);\n\n  return (\n    <li\n      ref={node}\n      data-state={leaving ? \"closed\" : \"open\"}\n      data-type={type}\n      inert={leaving}\n      className={cn(\n        \"pointer-events-auto grid max-h-[140px] grid-cols-[20px_1fr_auto] items-start gap-2.5 overflow-hidden rounded-xl border border-border bg-card pt-3 pr-2.5 pb-3.5 pl-3 shadow-[0_12px_28px_-16px_rgba(0,0,0,.5)]\",\n        \"[transition:opacity_.25s,transform_.3s,margin_.3s,max-height_.3s,padding_.3s] motion-reduce:transition-none\",\n        leaving && \"-mb-2 max-h-0 [transform:translateX(24px)] pt-0 pb-0 opacity-0\",\n      )}\n    >\n      <span />\n      <span className=\"grid min-w-0 gap-[2px]\">\n        <b className=\"text-[13.5px] font-semibold\">{title}</b>\n        {description ? <span className=\"text-[12.5px] text-muted-foreground\">{description}</span> : null}\n      </span>\n      <span className=\"flex items-center gap-1\">\n        {action ? (\n          <button\n            type=\"button\"\n            onClick={() => {\n              action.onClick?.();\n              onDismiss(id);\n            }}\n            className={cn(\n              \"inline-flex h-[26px] cursor-pointer items-center justify-center gap-1.5 rounded-lg border border-border bg-card px-[9px] font-[inherit] text-[12px] font-medium whitespace-nowrap text-foreground select-none hover:bg-muted\",\n              \"[transition:transform_.15s_cubic-bezier(.2,.8,.2,1),background-color_.2s,border-color_.2s,color_.2s] active:[transform:scale(.95)]\",\n              \"outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lumesec focus-visible:outline-solid\",\n            )}\n          >\n            {action.label}\n          </button>\n        ) : null}\n        <button\n          type=\"button\"\n          data-slot=\"px-toaster-close\"\n          aria-label=\"Dismiss\"\n          onClick={() => onDismiss(id)}\n          className=\"grid size-6 cursor-pointer place-items-center rounded-md border-0 bg-transparent p-0 text-muted-foreground outline-none select-none hover:bg-foreground/8 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lumesec focus-visible:outline-solid\"\n        >\n          <svg\n            viewBox=\"0 0 12 12\"\n            aria-hidden=\"true\"\n            className=\"size-[11px] fill-none stroke-current [stroke-width:1.8] [stroke-linecap:round]\"\n          >\n            <path d=\"M3 3l6 6M9 3l-6 6\" />\n          </svg>\n        </button>\n      </span>\n    </li>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/lumesec/px-toaster.tsx"
    }
  ],
  "meta": {
    "tag": "px-toaster",
    "collection": "pixel-ui",
    "react": "PxToaster",
    "kind": "react"
  },
  "docs": "Toasts docs: https://elements.lumesec.ai/components/pixel-ui/toaster",
  "categories": [
    "pixel-ui",
    "actions-feedback"
  ],
  "type": "registry:component"
}