{
"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"
]
}