Toast

v0.0.10

v0.0.10StableMobStatelessApache-2.0
Installation mix mishka.ui.gen.mob toast
Source View Source

Toast is a Mishka Chelekom component for Mob that stacks short messages against the top or bottom edge of an iOS or Android screen. It is the native port of the headless Toast, drawn with Jetpack Compose on Android and SwiftUI on iOS.

Overview of the Mob Toast component for iOS and Android

<MishkaToast> renders a viewport of the toasts you give it. Each toast is a card with a colored accent bar, an optional title and description, and a dismiss control. The variant key sets the bar color: :info (the default), :success, :warning or :danger.

Your screen owns the toast list. Keep it in the screen's assigns, pass it as toasts, and change it from handle_info/2. An empty list renders an empty Column.

The web component runs a queue in JavaScript that caps the visible count, collapses repeats and dismisses toasts after a delay. On a phone those jobs belong to the screen, so the generated file also defines a nested Queue module of pure functions over a list of toasts. push/3 adds a toast and applies limit and dedup_key, dismiss/2 removes one by id, and expire/3 drops every toast that has outlived its duration.

Timers belong to the screen too. A component that started its own timer would start one on every render, so you schedule the sweep yourself with Process.send_after/3.

The viewport stacks the cards in a Column pinned to the top or bottom of a box that fills its parent, so it draws over the page the way the drawer and the dialog do. Return it from the root Box of your screen.

Generate the toast component

Run this from your Mob app's root to add the component to lib/<app>/components/:

bash
mix mishka.ui.gen.mob toast

The generator registers the tag, so you can use it in ~MOB markup. It defines the nested Toast.Queue module in the same file. The dismiss control is an Action Icon, so the generator creates that component too, along with the shared Event helpers. The examples on this page use the mishka_ prefix of the showcase app (--module-prefix mishka_), so the tag is <MishkaToast>. Without a prefix, the tag is <Toast>.

Stack a few toasts

The viewport renders whatever list the screen holds. Each button in this example pushes a toast with a different variant onto the list with Queue.push/3. The showcase draws the viewport from its overlay/1 callback, which puts it over the whole page.

The ✕ on a card sends {:tap, {tag, toast_id}}, so one :drop clause handles every card. The code on this page uses the showcase app's handle/2 callback. In your own screen, write these clauses as handle_info/2.

Stack a few toasts

Stack a few card: a two by two grid of white buttons labeled Info, Success, Warning and Danger, with no toast showing, in the MishkaMob showcase app on Android
Two toast cards stacked at the bottom of the screen, one titled Saved and one titled Failed.

Left: the example as it loads. Right: after a tap. Toasts stack at an edge of the screen.

elixir
# in overlay/1 — the viewport overlays the whole page
<MishkaToast toasts={@toasts} on_dismiss={:drop} />

def handle({:add, variant}, socket) do
  entry = %{id: socket.assigns.next, title: "Saved", variant: variant}

  socket
  |> Mob.Socket.assign(:toasts, Queue.push(socket.assigns.toasts, entry))
  |> Mob.Socket.assign(:next, socket.assigns.next + 1)
end

# the ✕ sends {:tap, {tag, toast_id}}, so one clause serves every card
def handle({:drop, id}, socket),
  do: Mob.Socket.assign(socket, :toasts, Queue.dismiss(socket.assigns.toasts, id))

Capped and deduplicated toasts

Pass limit: 3 to Queue.push/3 to keep only the newest three toasts and drop the oldest. Pass dedup_key: :title and a new toast replaces an existing one with the same title instead of stacking a duplicate. A toast whose key is nil never matches another, so toasts without that key stack normally.

Capped and deduplicated toasts

Capped and deduped card: a gray note about limit 3 and dedup_key title, a white Repeat me button and a black Clear all button
elixir
Queue.push(toasts, toast, limit: 3, dedup_key: :title)

Toast position

Set position to :top or :bottom (the default) to choose the edge the stack sits against. Keep the position in an assign and change it from a handler.

Toast position

Position card: two black buttons labeled Top and Bottom side by side, below a gray line about stacking against the top or bottom edge
elixir
<MishkaToast toasts={@toasts} position={@position} on_dismiss={:drop} />

def handle(:to_top, socket), do: Mob.Socket.assign(socket, :position, :top)
def handle(:to_bottom, socket), do: Mob.Socket.assign(socket, :position, :bottom)

Toast slots

Two slot tags let you write markup instead of building maps. <MishkaToastItem> is one toast written in markup. It takes the same keys as a queued toast (id, title, description, variant and duration) and its children are the body. A title stays as a header above the body. With no title, the body is the whole card. Static items render before the queued ones.

<MishkaToastClose> replaces the ✕ on every card with content you write, here a Dismiss label.

Toast slots

Slots card: a gray note about the markup toast and a full width black Toggle markup toast button, in the MishkaMob showcase app on Android
A toast card titled Welcome with the line Written in markup, not queued, and a Dismiss label.

Left: the example as it loads. Right: after a tap. A toast written in markup can carry its own body and dismiss label.

elixir
<MishkaToast toasts={@toasts} on_dismiss={:drop}>
  <MishkaToastItem id={:welcome} title="Welcome" variant={:info}>
    <Text text="Written in markup, not queued." text_size={:sm} />
  </MishkaToastItem>
  <MishkaToastClose>
    <Text text="Dismiss" text_size={:sm} text_color={:muted} />
  </MishkaToastClose>
</MishkaToast>

# A static item is not in @toasts, so Queue.dismiss cannot remove it —
# the screen drops it by flipping the flag that renders the markup.
def handle({:drop, :welcome}, socket),
  do: Mob.Socket.assign(socket, :static?, false)

def handle({:drop, id}, socket),
  do: Mob.Socket.assign(socket, :toasts, Queue.dismiss(socket.assigns.toasts, id))

Auto-dismiss toasts

Stamp each toast with :at, a monotonic time in milliseconds, and schedule a sweep with Process.send_after/3. Each sweep calls Queue.expire/3 with a default duration in milliseconds. A toast's own :duration wins over that default.

A toast with duration: 0, or with no :at, never expires. This matches duration: 0 in the web engine, which turns auto-dismiss off.

Auto-dismiss toasts

Auto-dismiss card: a gray note that Queue.expire/3 is a pure function over the list, with no controls, above the code for the timed sweep
elixir
# push with a stamp, and schedule the sweep
entry = %{id: id, title: "Saved", at: System.monotonic_time(:millisecond)}
Process.send_after(self(), :sweep, 1_000)

def handle(:sweep, socket) do
  Process.send_after(self(), :sweep, 1_000)
  Mob.Socket.assign(socket, :toasts, Queue.expire(socket.assigns.toasts, 5_000))
end

# this one never expires, matching the web engine's `duration: 0`
%{id: 9, title: "Sticky", duration: 0, at: now}

Props

PropTypeDefaultDescription
toastslist of maps[]The toasts to show, as maps with the keys listed below.
position:top or :bottom:bottomWhich edge to stack against.
on_dismissevent tagNoneThe tag sent as {:tap, {tag, toast_id}} when you tap a toast's ✕. Without it, no dismiss control is drawn.
paddingspacing token or number:space_lgPadding around the stack.
spacenumber10Gap between toasts.
close_iconstring"✕"Glyph for the dismiss control.
<MishkaToastItem>slotNoneA toast written in markup. Its attributes match a toast map and its children are the body. It is not in the queue, so it cannot dismiss itself.
<MishkaToastClose>slotNoneContent for every card's dismiss control, replacing close_icon.
Queue.push/3, Queue.dismiss/2, Queue.expire/3helpersNoneLimit, deduplication and auto-dismiss, driven by the screen.

Toast keys

Each toast is a map. <MishkaToastItem> accepts id, title, description, variant and duration as attributes, and its children become content.

KeyDescription
idIdentifies the toast. It comes back with the dismiss event and is matched by Queue.dismiss/2.
titleHeading text, a string.
descriptionSupporting text, a string.
variantAccent bar color: :info (the default), :success, :warning or :danger.
durationMilliseconds before Queue.expire/3 drops this toast. It overrides the default passed to expire/3, and 0 means the toast stays.
contentA list of nodes rendered as the body. With a title or description, they sit below it.
atA monotonic millisecond stamp that Queue.expire/3 reads. A toast without it never expires.

Events

on_dismiss takes an event tag. When you tap a toast's dismiss control, your screen receives {:tap, {tag, toast_id}}, where toast_id is the id of that toast. Pass an atom as the tag, as in on_dismiss={:drop}.

elixir
def handle_info({:tap, {:drop, id}}, socket) do
  {:noreply, Mob.Socket.assign(socket, :toasts, Queue.dismiss(socket.assigns.toasts, id))}
end

Differences from the web component

  • Limiting, deduplication and auto-dismiss are Queue functions that your screen drives, not options on the component.
  • The layered stack that peeks out behind the front toast is a stylesheet in the web showcase. It needs scale and opacity, which Mob's renderer does not offer on ordinary nodes, so toasts stack as a plain vertical list.
  • Expanding the stack on hover or focus is not ported. Mob has no hover, and no focus outside text inputs.
  • The *_class attributes and close_label (an aria-label) are not ported.
  • There is no aria-live region, because Mob has no live-region concept to announce into.
  • The <:trigger> slot is not ported. The viewport is an overlay over the whole page, so a button inside it would sit under an invisible box. Put the trigger on the page instead.