Skip to main content

priv/registry/sonner.json

{
  "files": [
    {
      "content": "defmodule Shadix.Components.Sonner do\n  @moduledoc ~S\"\"\"\n  Toast notifications, the Shadix take on shadcn's `sonner` integration.\n\n  shadcn delegates entirely to the `sonner` npm library (a React component with\n  its own imperative `toast()` API). There is no equivalent runtime in Phoenix,\n  so this module is a `Phoenix.LiveComponent` that owns a `:toasts` stream and\n  renders an accessible region: a visually-hidden polite region, a\n  visually-hidden assertive region, and the visible stream `<ol>` of `toast/1`s.\n\n  ## Usage\n\n  Mount once in your layout (or root LiveView):\n\n      <.live_component module={Shadix.Components.Sonner} id=\"shadix-toaster\" flash={@flash} />\n\n  Trigger a toast from a LiveView:\n\n      def handle_event(\"save\", _p, socket) do\n        Shadix.Components.Sonner.send_toast(title: \"Saved\", message: \"Done\", variant: \"success\")\n        {:noreply, socket}\n      end\n\n  Existing flashes also appear automatically and are cleared so they won't replay:\n\n      {:noreply, put_flash(socket, :error, \"Something went wrong\")}\n\n  ## Variants\n\n  Supported variant values are `default | success | info | warning | error`.\n  The `error` and `warning` variants are considered high-priority and use\n  `role=\"alertdialog\"` with an `aria-live=\"assertive\"` announcement channel.\n  All other variants use `role=\"dialog\"` with an `aria-live=\"polite\"` channel.\n\n  ## Accessibility\n\n  Each toast is `role=\"dialog\"` or `role=\"alertdialog\"`, `aria-modal=\"false\"`,\n  focusable (`tabindex=\"0\"`), and links its title/description via\n  `aria-labelledby`/`aria-describedby`. Two sr-only live regions (polite and\n  assertive) announce new toasts to screen readers. The `ShadixSonner` JS hook\n  handles: auto-dismiss after ~4 s, pause-on-hover/focus, Escape to dismiss\n  the focused toast, and F6 to move focus to the toast stack. Entry/exit\n  animation is pure-CSS via `data-state`; `prefers-reduced-motion` disables\n  transitions.\n\n  ## Simplifications vs. sonner\n\n  This v1 deliberately scopes out a lot of the sonner feature set:\n\n    * No swipe-to-dismiss, action buttons, promise toasts, programmatic\n      update/dismiss-by-id, stacking-collapse, or position configuration\n      (fixed bottom-right). The duration is a fixed ~4 s.\n  \"\"\"\n  use Phoenix.LiveComponent\n\n  import Shadix.Cn\n\n  @high_priority_variants ~w(error warning)\n\n  @impl true\n  def mount(socket) do\n    {:ok,\n     socket\n     |> stream(:toasts, [])\n     |> assign(polite: \"\", assertive: \"\", seen_flash: MapSet.new())}\n  end\n\n  @default_toaster_id \"shadix-toaster\"\n\n  @doc \"\"\"\n  Sends a toast to a mounted `Sonner` live component from anywhere with access to\n  the component (typically a LiveView `handle_event`/`handle_info`). `opts` are\n  passed to `build_toast/1` (`:title`, `:message`, `:variant`, optional `:id`).\n  \"\"\"\n  def send_toast(toaster_id \\\\ @default_toaster_id, opts) do\n    send_update(__MODULE__, id: toaster_id, add_toast: build_toast(opts))\n    :ok\n  end\n\n  @impl true\n  def update(%{add_toast: toast}, socket) do\n    {:ok, socket |> stream_insert(:toasts, toast) |> announce(toast)}\n  end\n\n  def update(assigns, socket) do\n    {:ok, socket |> assign(:id, assigns.id) |> assign_flash(assigns)}\n  end\n\n  defp announce(socket, toast) do\n    text = [toast.title, toast.message] |> Enum.reject(&is_nil/1) |> Enum.join(\" \")\n\n    if toast.high_priority?,\n      do: assign(socket, assertive: text, polite: \"\"),\n      else: assign(socket, polite: text, assertive: \"\")\n  end\n\n  @impl true\n  def render(assigns) do\n    ~H\"\"\"\n    <div id={@id} data-slot=\"toaster\" phx-hook=\"ShadixSonner\" phx-target={@myself}>\n      <div class=\"sr-only\" role=\"status\" aria-live=\"polite\" aria-atomic=\"true\">{@polite}</div>\n      <div class=\"sr-only\" role=\"alert\" aria-live=\"assertive\" aria-atomic=\"true\">{@assertive}</div>\n      <ol\n        id={\"#{@id}-list\"}\n        role=\"region\"\n        aria-label=\"Notifications\"\n        tabindex=\"-1\"\n        phx-update=\"stream\"\n        class=\"fixed bottom-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse gap-2 p-4 sm:bottom-4 sm:right-4 sm:top-auto sm:flex-col md:max-w-[420px]\"\n      >\n        <.toast\n          :for={{dom_id, toast} <- @streams.toasts}\n          id={dom_id}\n          variant={toast.variant}\n          title={toast.title}\n          message={toast.message}\n        />\n      </ol>\n    </div>\n    \"\"\"\n  end\n\n  @impl true\n  def handle_event(\"clear\", %{\"id\" => dom_id}, socket) do\n    {:noreply, stream_delete_by_dom_id(socket, :toasts, dom_id)}\n  end\n\n  @flash_variants %{\"info\" => \"info\", \"error\" => \"error\"}\n\n  defp assign_flash(socket, assigns) do\n    flash = Map.get(assigns, :flash, %{})\n\n    current =\n      for {kind, variant} <- @flash_variants,\n          message = Phoenix.Flash.get(flash, String.to_existing_atom(kind)),\n          is_binary(message) and message != \"\",\n          into: %{},\n          do: {kind, {variant, message}}\n\n    seen = socket.assigns.seen_flash\n\n    socket =\n      Enum.reduce(current, socket, fn {kind, {variant, message}}, socket ->\n        if MapSet.member?(seen, {kind, message}) do\n          socket\n        else\n          toast = build_toast(message: message, variant: variant)\n\n          socket\n          |> stream_insert(:toasts, toast)\n          |> announce(toast)\n          |> push_event(\"lv:clear-flash\", %{key: kind})\n        end\n      end)\n\n    # Keep only keys still present this render, so a cleared-then-retriggered\n    # identical flash counts as new next time.\n    new_seen = MapSet.new(current, fn {kind, {_variant, message}} -> {kind, message} end)\n    assign(socket, :seen_flash, new_seen)\n  end\n\n  @doc \"\"\"\n  Builds a normalized toast map from user-supplied options. Pure; used by\n  `send_toast/2` and the flash bridge to feed the `:toasts` stream.\n  \"\"\"\n  def build_toast(opts) do\n    opts = Map.new(opts)\n    variant = to_string(Map.get(opts, :variant, \"default\"))\n\n    %{\n      id: Map.get(opts, :id) || \"toast-#{System.unique_integer([:positive, :monotonic])}\",\n      variant: variant,\n      title: opts[:title],\n      message: opts[:message],\n      high_priority?: variant in @high_priority_variants\n    }\n  end\n\n  @doc ~S\"\"\"\n  A single toast notification. Rendered by the `Sonner` live component's stream\n  loop, but kept as a standalone function component so its accessibility markup\n  is testable in isolation.\n\n  `:variant` drives both styling and a11y priority: `error`/`warning` render as\n  `role=\"alertdialog\"` (high priority), the rest as `role=\"dialog\"`. The toast is\n  focusable (`tabindex=\"0\"`) and links its title/description via\n  `aria-labelledby`/`aria-describedby` only when those elements exist. Dismissal\n  is delegated to the `ShadixSonner` hook (the close button carries no\n  `phx-click`), and enter/exit is pure-CSS via `data-state`.\n  \"\"\"\n  attr(:id, :string, required: true)\n  attr(:variant, :string, default: \"default\", values: ~w(default success info warning error))\n  attr(:title, :string, default: nil)\n  attr(:message, :string, default: nil)\n  attr(:class, :string, default: nil)\n  attr(:rest, :global)\n  slot(:inner_block)\n\n  def toast(assigns) do\n    assigns =\n      assigns\n      |> assign(:high_priority?, assigns.variant in ~w(error warning))\n      |> assign(:title_id, assigns.title && \"#{assigns.id}-title\")\n      |> assign(:desc_id, assigns.message && \"#{assigns.id}-description\")\n\n    ~H\"\"\"\n    <li\n      id={@id}\n      data-slot=\"toast\"\n      data-variant={@variant}\n      data-state=\"closed\"\n      role={if @high_priority?, do: \"alertdialog\", else: \"dialog\"}\n      aria-modal=\"false\"\n      aria-labelledby={@title_id}\n      aria-describedby={@desc_id}\n      tabindex=\"0\"\n      class={\n        cn([\n          \"group pointer-events-auto relative flex w-full list-none items-start gap-2 rounded-md border p-4 pr-8 shadow-lg outline-none\",\n          \"transition-all duration-200 ease-out motion-reduce:transition-none\",\n          \"data-[state=open]:translate-y-0 data-[state=open]:opacity-100\",\n          \"data-[state=closed]:translate-y-2 data-[state=closed]:opacity-0\",\n          \"focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n          variant_class(@variant),\n          @class\n        ])\n      }\n      {@rest}\n    >\n      <div class=\"grid gap-1\"><div :if={@title} id={@title_id} data-slot=\"toast-title\" class=\"text-sm font-semibold\">{@title}</div><div :if={@message} id={@desc_id} data-slot=\"toast-description\" class=\"text-sm opacity-90\">{@message}</div>{render_slot(@inner_block)}</div>\n      <button\n        type=\"button\"\n        data-slot=\"toast-close\"\n        aria-label=\"Close notification\"\n        class=\"absolute right-2 top-2 rounded-md p-1 opacity-60 transition-opacity hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none\"\n      >\n        <svg\n          xmlns=\"http://www.w3.org/2000/svg\"\n          width=\"16\"\n          height=\"16\"\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          stroke-width=\"2\"\n          stroke-linecap=\"round\"\n          stroke-linejoin=\"round\"\n          aria-hidden=\"true\"\n        >\n          <path d=\"M18 6 6 18\" />\n          <path d=\"m6 6 12 12\" />\n        </svg>\n      </button>\n    </li>\n    \"\"\"\n  end\n\n  defp variant_class(\"success\"), do: \"bg-background text-foreground border-green-500/50\"\n  defp variant_class(\"info\"), do: \"bg-background text-foreground border-blue-500/50\"\n  defp variant_class(\"warning\"), do: \"bg-background text-foreground border-yellow-500/50\"\n  # White text on the destructive surface (matching the button's destructive\n  # variant). The theme's `--destructive-foreground` is a dark red in dark mode,\n  # which renders unreadably on the light-salmon dark-mode `--destructive`.\n  defp variant_class(\"error\"),\n    do: \"bg-destructive text-white border-destructive dark:bg-destructive/60\"\n\n  defp variant_class(_), do: \"bg-background text-foreground border\"\nend\n",
      "path": "sonner.ex"
    }
  ],
  "hooks": [
    {
      "content": "const DURATION = 4000;\nconst EXIT_FALLBACK = 400;\n\ninterface SonnerHook {\n  el: HTMLElement;\n  pushEventTo(target: HTMLElement, event: string, payload: object): void;\n  mounted(): void;\n  destroyed(): void;\n  _setDestroyed?: () => void;\n  _cleanup?: () => void;\n}\n\nexport const ShadixSonner = {\n  mounted(this: SonnerHook) {\n    const root = this.el;\n    const list = root.querySelector<HTMLElement>(`#${CSS.escape(root.id)}-list`);\n    if (!list) return;\n\n    const timers = new WeakMap<HTMLElement, number>();\n    const reduceMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    let destroyed = false;\n\n    const toastEls = () =>\n      Array.from(list.querySelectorAll<HTMLElement>('[data-slot=\"toast\"]'));\n\n    const clearTimer = (toast: HTMLElement) => {\n      const t = timers.get(toast);\n      if (t !== undefined) {\n        window.clearTimeout(t);\n        timers.delete(toast);\n      }\n    };\n\n    const startTimer = (toast: HTMLElement) => {\n      clearTimer(toast);\n      timers.set(toast, window.setTimeout(() => dismiss(toast), DURATION));\n    };\n\n    const dismiss = (toast: HTMLElement) => {\n      if (toast.getAttribute(\"data-state\") === \"closed\") return;\n      clearTimer(toast);\n      toast.setAttribute(\"data-state\", \"closed\");\n      const remove = () => {\n        if (destroyed) return;\n        this.pushEventTo(root, \"clear\", { id: toast.id });\n      };\n      if (reduceMotion) {\n        remove();\n      } else {\n        let done = false;\n        const once = () => {\n          if (done) return;\n          done = true;\n          remove();\n        };\n        toast.addEventListener(\"transitionend\", once, { once: true });\n        window.setTimeout(once, EXIT_FALLBACK);\n      }\n    };\n\n    const enter = (toast: HTMLElement) => {\n      // Flip to open on the next frame so the closed state paints first.\n      window.requestAnimationFrame(() => toast.setAttribute(\"data-state\", \"open\"));\n      startTimer(toast);\n    };\n\n    // Initialize any toasts already present, and observe future stream inserts.\n    toastEls().forEach(enter);\n\n    const observer = new MutationObserver((mutations) => {\n      for (const m of mutations) {\n        m.addedNodes.forEach((node) => {\n          if (node instanceof HTMLElement && node.matches('[data-slot=\"toast\"]')) {\n            enter(node);\n          }\n        });\n      }\n    });\n    observer.observe(list, { childList: true });\n\n    // Delegated close-button clicks.\n    const onClick = (e: Event) => {\n      const target = e.target as HTMLElement;\n      const closeBtn = target.closest('[data-slot=\"toast-close\"]');\n      const toast = target.closest<HTMLElement>('[data-slot=\"toast\"]');\n      if (closeBtn && toast) dismiss(toast);\n    };\n    list.addEventListener(\"click\", onClick);\n\n    // Pause auto-dismiss while a toast (or its children) has hover/focus.\n    const onPause = (e: Event) => {\n      const toast = (e.target as HTMLElement).closest<HTMLElement>('[data-slot=\"toast\"]');\n      if (toast) clearTimer(toast);\n    };\n    const onResume = (e: Event) => {\n      const toast = (e.target as HTMLElement).closest<HTMLElement>('[data-slot=\"toast\"]');\n      if (!toast) return;\n      const related = (e as MouseEvent | FocusEvent).relatedTarget as Node | null;\n      if (related && toast.contains(related)) return; // still inside — keep paused\n      if (toast.getAttribute(\"data-state\") !== \"closed\") startTimer(toast);\n    };\n    list.addEventListener(\"mouseover\", onPause);\n    list.addEventListener(\"focusin\", onPause);\n    list.addEventListener(\"mouseout\", onResume);\n    list.addEventListener(\"focusout\", onResume);\n\n    // Escape dismisses the focused toast.\n    const onKeyDown = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\") return;\n      const toast = (e.target as HTMLElement).closest<HTMLElement>('[data-slot=\"toast\"]');\n      if (toast) {\n        e.stopPropagation();\n        dismiss(toast);\n      }\n    };\n    list.addEventListener(\"keydown\", onKeyDown);\n\n    // F6 moves focus into the toast stack (most recent toast).\n    const onF6 = (e: KeyboardEvent) => {\n      if (e.key !== \"F6\") return;\n      const toasts = toastEls();\n      const latest = toasts[toasts.length - 1];\n      if (latest) {\n        e.preventDefault();\n        latest.focus();\n      }\n    };\n    window.addEventListener(\"keydown\", onF6);\n\n    this._setDestroyed = () => {\n      destroyed = true;\n    };\n\n    this._cleanup = () => {\n      observer.disconnect();\n      list.removeEventListener(\"click\", onClick);\n      list.removeEventListener(\"mouseover\", onPause);\n      list.removeEventListener(\"focusin\", onPause);\n      list.removeEventListener(\"mouseout\", onResume);\n      list.removeEventListener(\"focusout\", onResume);\n      list.removeEventListener(\"keydown\", onKeyDown);\n      window.removeEventListener(\"keydown\", onF6);\n    };\n  },\n\n  destroyed(this: SonnerHook) {\n    this._setDestroyed?.();\n    this._cleanup?.();\n  },\n};\n",
      "name": "ShadixSonner",
      "path": "sonner.ts"
    }
  ],
  "name": "sonner",
  "npm_deps": [],
  "registry_deps": [
    "cn"
  ]
}