Current section

Files

Jump to
corex lib components toast.ex
Raw

lib/components/toast.ex

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" />
</:loading>
</.toast_group>
```
## API Control
***Client-side***
```heex
<button phx-click={Corex.Toast.create_toast("layout-toast", "This is an info toast", "This is an info toast description", :info, [])} class="button">
Create Info Toast
</button>
<div phx-disconnected={Corex.Toast.create_toast("layout-toast", "We can't find the internet", "Attempting to reconnect", :info, [duration: :infinity, loading: true])}></div>
```
***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: Corex.Gettext.gettext("Info"), error: Corex.Gettext.gettext("Error") }}`
"""
defstruct [:info, :error]
end
@doc type: :component
use Phoenix.Component
alias Corex.Flash
alias Phoenix.LiveView.JS
@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" />
</:loading>
</.toast_group>
```
## 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: Corex.Gettext.gettext("Info"),
error: Corex.Gettext.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"""
<div
id={@id}
phx-hook="Toast"
data-placement={@placement}
data-max={@max}
data-gap={@gap}
data-offset={@offset}
data-overlap={@overlap}
data-flash-info={@info_flash}
data-flash-info-title={@flash_info.title}
data-flash-error={@error_flash}
data-flash-error-title={@flash_error.title}
data-flash-info-duration={@flash_info.duration}
data-flash-error-duration={@flash_error.duration}
{@rest}
>
<div data-scope="toast" data-part="group">
</div>
<div :if={@loading != []} id={"#{@id}-loading-icon"} data-loading-icon-template style="display: none;">
{render_slot(@loading)}
</div>
<div :if={@close != []} id={"#{@id}-close-icon"} data-close-icon-template style="display: none;">
{render_slot(@close)}
</div>
<div
:if={@info_flash}
phx-mounted={create_toast(@id, @flash_info.title, @info_flash, @flash_info.type, [duration: @flash_info.duration])}
>
</div>
<div
:if={@error_flash}
phx-mounted={create_toast(@id, @flash_error.title, @error_flash, @flash_error.type, [duration: @flash_error.duration])}
>
</div>
</div>
"""
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"""
<div
id={@id}
phx-disconnected={
JS.remove_attribute("hidden", to: "##{@id}")
|> 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
>
</div>
"""
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"""
<div
id={@id}
phx-disconnected={
JS.remove_attribute("hidden", to: "##{@id}")
|> 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
>
</div>
"""
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"""
<div
id={@id}
phx-connected={
JS.dispatch("toast:create",
to: "##{@toast_group_id}",
detail: %{
title: @title,
description: @description,
type: @type_str,
duration: @duration_str
}
)
}
hidden
>
</div>
"""
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"""
<div
id={@id}
phx-disconnected={
JS.dispatch("toast:create",
to: "##{@toast_group_id}",
detail: %{
title: @title,
description: @description,
type: @type_str,
duration: @duration_str,
loading: true
}
)
}
hidden
>
</div>
"""
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
<button phx-click={Corex.Toast.create_toast("layout-toast", "Saved!", "Your changes have been saved.", :success, [])}>
Save
</button>
<button phx-click={Corex.Toast.create_toast("layout-toast", "Loading...", nil, :info, [duration: :infinity, loading: true])}>
Show Loading
</button>
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