defmodule Corex.Toast do @moduledoc """ Phoenix implementation of [Zag.js Toast](https://zagjs.com/components/react/toast). Compatible with Phoenix Flash messages ## Examples Toast components is meant to be a replacement for the Core Components and Layout flash group and flash components. In your Layout App, you can replace the flash group `<.flash_group flash={@flash} />` components by the toast group ```heex <.toast_group id="layout-toast" flash={@flash} class="toast"> <:loading> <.heroicon name="hero-arrow-path" /> ``` ## API Control ***Client-side*** ```heex
``` ***Server-side*** ```elixir def handle_event("create_info_toast", _, socket) do {:noreply, Corex.Toast.push_toast(socket, "layout-toast", "This is an info toast", "This is an info toast description", :info, 5000)} end ``` ## Flash Messages You can use the `flash` attribute to display flash messages as toasts. You can use `%Corex.Flash.Info{}' and `%Corex.Flash.Error{}' to configure the flash messages title, type and duration. The descritpion will come from the Phoenix flash message ```heex <.toast_group id="layout-toast" class="toast" flash={@flash} flash_info={%Corex.Flash.Info{title: "Success", type: :success, duration: 5000}} flash_error={%Corex.Flash.Error{title: "Error", type: :error, duration: :infinity}}/> ``` ## Styling Use data attributes to target elements: ```css [data-scope="toast"][data-part="group"] {} [data-scope="toast"][data-part="root"] {} [data-scope="toast"][data-part="title"] {} [data-scope="toast"][data-part="description"] {} [data-scope="toast"][data-part="close-trigger"] {} ``` If you wish to use the default Corex styling, you can use the class `toast` on the component. This requires to install `Mix.Tasks.Corex.Design` first and import the component css file. ```css @import "../corex/main.css"; @import "../corex/tokens/themes/neo/light.css"; @import "../corex/components/toast.css"; ``` """ defmodule Translation do @moduledoc """ Translation struct for Toast component strings (default titles for flash messages). Without gettext: `translation={%Toast.Translation{ info: "Info", error: "Error" }}` With gettext: `translation={%Toast.Translation{ info: gettext("Info"), error: gettext("Error") }}` """ defstruct [:info, :error] end @doc type: :component use Phoenix.Component alias Phoenix.LiveView.JS import Corex.Gettext, only: [gettext: 1] alias Corex.Flash @doc """ Renders a toast group (toaster) that manages multiple toast notifications. This component should be rendered once in your layout. ## Examples ```heex <.toast_group id="layout-toast" class="toast" flash={@flash}> <:loading> <.heroicon name="hero-arrow-path" /> ``` ## Flash Messages You can use the `flash` attribute to display flash messages as toasts. You can use `%Corex.Flash.Info{}' and `%Corex.Flash.Error{}' to configure the flash messages title, type and duration. The descritpion will come from the Phoenix flash message ```heex <.toast_group id="layout-toast" class="toast" flash={@flash} flash_info={%Corex.Flash.Info{title: "Success", type: :success, duration: 5000}} flash_error={%Corex.Flash.Error{title: "Error", type: :error, duration: :infinity}}/> ``` """ attr(:id, :string, required: true, doc: "The id of the toast group") attr(:placement, :string, default: "bottom-end", values: ~w(top-start top top-end bottom-start bottom bottom-end), doc: "Where toasts appear on screen" ) attr(:overlap, :boolean, default: true, doc: "Whether toasts can overlap") attr(:max, :integer, default: 5, doc: "Maximum number of visible toasts") attr(:gap, :integer, default: nil, doc: "Gap between toasts in pixels") attr(:offset, :string, default: nil, doc: "Offset from viewport edge") attr(:pause_on_page_idle, :boolean, default: false, doc: "Pause duration when page is idle") attr(:flash, :map, default: %{}, doc: "The map of flash messages to display as toasts") attr(:flash_info, Flash.Info, doc: "configuration for info flash messages (Corex.Flash.Info struct)" ) attr(:flash_error, Flash.Error, doc: "configuration for error flash messages (Corex.Flash.Error struct)" ) attr(:translation, Corex.Toast.Translation, default: nil, doc: "Override default titles for info/error flash messages" ) slot :loading, doc: "the loading spinner icon to display when duration is infinity" do attr(:class, :string, required: false) end slot(:close, doc: "content placed in each toast close button (hidden template, cloned in the client)" ) attr(:rest, :global) def toast_group(assigns) do info_flash = Phoenix.Flash.get(assigns.flash, :info) error_flash = Phoenix.Flash.get(assigns.flash, :error) default_translation = %Translation{info: gettext("Info"), error: gettext("Error")} translation = merge_translation(assigns[:translation], default_translation) flash_info = Map.get(assigns, :flash_info) || %Flash.Info{title: translation.info, type: :info, duration: 5000} flash_error = Map.get(assigns, :flash_error) || %Flash.Error{title: translation.error, type: :error, duration: 5000} assigns = assigns |> assign_new(:loading, fn -> [] end) |> assign_new(:close, fn -> [] end) |> assign(:info_flash, info_flash) |> assign(:error_flash, error_flash) |> assign(:flash_info, flash_info) |> assign(:flash_error, flash_error) ~H"""
""" end @doc type: :component @doc """ Renders a div that creates a toast notification when a client error occurs. This component should be placed in your layout and will automatically create a toast when Phoenix LiveView detects a client-side connection error. ## Examples <.toast_client_error toast_group_id="layout-toast" title="We can't find the internet" description="Attempting to reconnect" type={:loading} duration={:infinity} /> """ attr(:id, :string, default: "client-error-toast") attr(:toast_group_id, :string, required: true) attr(:title, :string, required: true) attr(:description, :string, default: nil) attr(:type, :atom, default: :info, values: [:info, :success, :error]) attr(:duration, :any, default: :infinity) def toast_client_error(assigns) do type_str = case assigns.type do :info -> "info" :success -> "success" :error -> "error" _ -> "info" end duration_str = if assigns.duration == :infinity, do: "Infinity", else: assigns.duration assigns = assigns |> assign(:type_str, type_str) |> assign(:duration_str, duration_str) ~H"""
JS.dispatch("toast:create", to: "##{@toast_group_id}", detail: %{ title: @title, description: @description, type: @type_str, duration: @duration_str, loading: true } ) } phx-connected={JS.set_attribute({"hidden", ""}, to: "##{@id}")} hidden >
""" end @doc type: :component @doc """ Renders a div that creates a toast notification when a server error occurs. This component should be placed in your layout and will automatically create a toast when Phoenix LiveView detects a server-side connection error. ## Examples <.toast_server_error toast_group_id="layout-toast" title="Something went wrong!" description="Attempting to reconnect" type={:error} duration={:infinity} /> """ attr(:id, :string, default: "server-error-toast") attr(:toast_group_id, :string, required: true) attr(:title, :string, required: true) attr(:description, :string, default: nil) attr(:type, :atom, default: :error, values: [:info, :success, :error]) attr(:duration, :any, default: :infinity) def toast_server_error(assigns) do type_str = case assigns.type do :info -> "info" :success -> "success" :error -> "error" _ -> "error" end duration_str = if assigns.duration == :infinity, do: "Infinity", else: assigns.duration assigns = assigns |> assign(:type_str, type_str) |> assign(:duration_str, duration_str) ~H"""
JS.dispatch("toast:create", to: "##{@toast_group_id}", detail: %{ title: @title, description: @description, type: @type_str, duration: @duration_str, loading: true } ) } phx-connected={JS.set_attribute({"hidden", ""}, to: "##{@id}")} hidden >
""" end @doc type: :component @doc """ Renders a div that creates a toast notification when the connection is restored. This component should be placed in your layout and will automatically create a toast when Phoenix LiveView detects that the connection has been restored. ## Examples <.toast_connected toast_group_id="layout-toast" title="Connection restored" description="You're back online" type={:success} /> """ attr(:id, :string, default: "connected-toast") attr(:toast_group_id, :string, required: true) attr(:title, :string, required: true) attr(:description, :string, default: nil) attr(:type, :atom, default: :success, values: [:info, :success, :error]) attr(:duration, :any, default: 5000) def toast_connected(assigns) do type_str = case assigns.type do :info -> "info" :success -> "success" :error -> "error" _ -> "success" end duration_str = if assigns.duration == :infinity, do: "Infinity", else: assigns.duration assigns = assigns |> assign(:type_str, type_str) |> assign(:duration_str, duration_str) ~H""" """ end @doc type: :component @doc """ Renders a div that creates a toast notification when the connection is lost. This component should be placed in your layout and will automatically create a toast when Phoenix LiveView detects that the connection has been lost. ## Examples <.toast_disconnected toast_group_id="layout-toast" title="Connection lost" description="Attempting to reconnect" type={:warning} duration={:infinity} /> """ attr(:id, :string, default: "disconnected-toast") attr(:toast_group_id, :string, required: true) attr(:title, :string, required: true) attr(:description, :string, default: nil) attr(:type, :atom, default: :info, values: [:info, :success, :error]) attr(:duration, :any, default: :infinity) def toast_disconnected(assigns) do type_str = case assigns.type do :info -> "info" :success -> "success" :error -> "error" _ -> "info" end duration_str = if assigns.duration == :infinity, do: "Infinity", else: assigns.duration assigns = assigns |> assign(:type_str, type_str) |> assign(:duration_str, duration_str) ~H""" """ end defp merge_translation(nil, default), do: default defp merge_translation(partial, default) do %Translation{ info: partial.info || default.info, error: partial.error || default.error } end @doc type: :api @doc """ Creates a toast notification programmatically (client-side). This function returns a JS command that can be used in event handlers. ## Examples Option `loading: true` (default `false`) shows the loading slot; use it with `duration: :infinity` when you want a spinner, or with any duration to show the template. """ def create_toast(toast_group_id, title, description, type, opts) when is_binary(toast_group_id) do duration = Keyword.get(opts, :duration, 5000) loading = Keyword.get(opts, :loading, false) duration_str = if duration == :infinity, do: "Infinity", else: duration type_str = case type do :info -> "info" :success -> "success" :error -> "error" _ -> "info" end base_detail = %{ title: title, description: description, type: type_str, duration: duration_str } detail = if(loading, do: Map.put(base_detail, :loading, true), else: base_detail) JS.dispatch("toast:create", to: "##{toast_group_id}", detail: detail) end @doc type: :api @doc """ Server-side function to push a toast event to the client. Use this in your LiveView event handlers. ## Examples def handle_event("save", _params, socket) do {:noreply, Corex.Toast.push_toast(socket, "layout-toast", "Saved!", "Your changes have been saved.", :success, 5000)} end Optional opts: `loading: true` shows the `<:loading>` template in the toast (independent of duration). Default is `false`. push_toast(socket, "t", "Title", nil, :info, :infinity, loading: true) """ def push_toast(socket, toast_group_id, title, description, type, duration, opts \\ []) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(toast_group_id) and is_list(opts) do duration_str = if duration == :infinity, do: "Infinity", else: duration loading = Keyword.get(opts, :loading, false) type_str = case type do :info -> "info" :success -> "success" :error -> "error" _ -> "info" end base = %{ groupId: toast_group_id, title: title, description: description, type: type_str, duration: duration_str } data = if(loading, do: Map.put(base, :loading, true), else: base) Phoenix.LiveView.push_event(socket, "toast-create", data) end end