Preview card

v0.0.10

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

The Mob Preview Card in Mishka Chelekom reveals a card of detail when a user holds a name on iOS and Android screens built with Mob. It is the native port of the headless Preview Card.

Overview of the Mob Preview Card component for iOS and Android

The web component has two parts: a trigger, and a popup that appears when the trigger is hovered or focused. A phone has no hover, so the user holds the trigger instead. Holding sends on_hold. A plain tap goes to on_tap, because the web trigger is usually a link and tapping a link should still follow it.

The screen owns open. The card never changes it, so a second hold can close the card or move it to another trigger, as your screen decides. A tap outside the open card sends on_dismiss. Without that handler, the user can close the card only from its trigger.

The trigger and the card form one anchored node. The trigger stays in the page flow and the card draws in its own window over the page. The card takes no room, it can sit above or beside its trigger, and no parent can clip it. Three props place it: side picks the side of the trigger, align picks the position along that side, and side_offset sets the gap. When the requested side has no room and the opposite side does, the card flips to the opposite side. The card is then clamped into the window with 8 dp of edge padding plus the safe-area inset.

The trigger and the card both hug their content. The card shows an optional avatar, a title, a subtitle and a description. Any other children become the card's footer, which is where actions belong. Write the trigger as <MishkaPreviewCardTrigger>, or pass it in the trigger prop when a list comprehension builds the cards.

Generate the preview card component

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

bash
mix mishka.ui.gen.mob preview_card

The generator registers the <PreviewCard> tag so you can use it in ~MOB markup. It also generates the Avatar and Popover components that the card uses, plus the shared Event and Anchored support modules. The examples on this page use the mishka_ prefix of the showcase app (--module-prefix mishka_), so the tag is <MishkaPreviewCard>. Without a prefix the tag is <PreviewCard>.

Hold a name to show its preview card

Hold either name to reveal its card, as the web reveals it on hover. A plain tap goes to on_tap instead.

The card owns no state. A hold asks for a change and the screen decides. In this screen, holding the open card closes it and holding the other name moves the card, so only one card is open at a time.

The tag form suits markup you write by hand. A list built with Enum.map/2 has no markup to put a tag in, so pass the trigger node in the trigger prop. Both forms build the same tree.

Hold a name to show its preview card

Hold a name card with outlined chips Elixir and Phoenix, each marked hold, above the lines Tapped: none and Followed: none, in the MishkaMob showcase app on Android
A preview card for Elixir with a violet EX avatar, the handle @elixir-lang, a short description and a Follow button.

Left: the example as it loads. Right: after a long press. Hold a name to reveal its card of detail.

elixir
<MishkaPreviewCard
  id={id}
  open={@open == id}
  on_hold={:hold}
  on_tap={:visit}
  arrow={true}
  title={name}
  subtitle={handle}
  initials={initials}
  avatar_color={0xFF7C3AED}
  description={blurb}
>
  <MishkaPreviewCardTrigger>
    {name_chip(name)}
  </MishkaPreviewCardTrigger>
  {[follow_button(id)]}
</MishkaPreviewCard>

# The tag is the form to reach for. A whole LIST of cards comes out of
# DATA instead, and then there is no markup to hang a tag in: the
# `trigger` prop takes the node the same helper already built. Both
# forms end up in trigger/3, so the tree is identical either way.
Enum.map(@people, fn {id, name} ->
  preview_card(%{id: id, open: @open == id, trigger: name_chip(name), on_hold: :hold})
end)

# The card owns no state. A hold ASKS for a change and the screen
# decides — here holding the open one closes it and holding the other
# moves the card, so only ever one is open.
def handle_info({:tap, {:hold, id}}, socket) do
  next = if socket.assigns.open == id, do: nil, else: id
  {:noreply, Mob.Socket.assign(socket, :open, next)}
end

# The tap the long press did NOT swallow — a link still follows.
def handle_info({:tap, {:visit, id}}, socket) do
  {:noreply, Mob.Socket.assign(socket, :visited, id)}
end

Preview card above the trigger

Set side={:top} to put the card above the trigger. align={:end} moves the card and its arrow toward the trigger's far edge. side_offset sets the gap between the two, and align_offset nudges the arrow along the card's edge. arrow_color={:primary} colors the arrow, which points down at the trigger.

Preview card above the trigger

Above the trigger card with a white rounded box reading Hold for the card above and no preview card open, in the MishkaMob showcase app on Android
A preview card titled Anchored upward sitting above its trigger with a downward pointing arrow.

Left: the example as it loads. Right: after a long press. The side prop places the card above its trigger.

elixir
<MishkaPreviewCard
  id="pc-top"
  open={@top?}
  side={:top}
  align={:end}
  side_offset={14}
  align_offset={6}
  arrow={true}
  arrow_color={:primary}
  on_hold={:top_hold}
  title="Anchored upward"
>
  <MishkaPreviewCardTrigger>
    {hint("Hold for the card above")}
  </MishkaPreviewCardTrigger>
</MishkaPreviewCard>

def handle_info({:tap, {:top_hold, _id}}, socket) do
  {:noreply, Mob.Socket.assign(socket, :top?, not socket.assigns.top?)}
end

Preview card beside the trigger

Set side={:right} to put the card beside the trigger. align={:start} sets where the card sits along the trigger's edge.

A card beside the trigger floats over the page like any other, so it takes no room in the layout. If the right side has no room and the left side does, the card flips to the left.

Preview card beside the trigger

Beside the trigger card with a white rounded box reading Hold me and no preview card open, in the MishkaMob showcase app on Android
A wide preview card titled Beside, open over its Hold me trigger.

Left: the example as it loads. Right: after a long press. The side prop places the card beside its trigger.

elixir
<MishkaPreviewCard
  id="pc-side"
  open={@side?}
  side={:right}
  align={:start}
  arrow={true}
  on_hold={:side_hold}
  title="Beside"
>
  <MishkaPreviewCardTrigger>{hint("Hold me")}</MishkaPreviewCardTrigger>
</MishkaPreviewCard>

def handle_info({:tap, {:side_hold, _id}}, socket) do
  {:noreply, Mob.Socket.assign(socket, :side?, not socket.assigns.side?)}
end

Preview card pinned open without a trigger

Leave out the trigger and the card renders in place as the popup alone. Use this when the screen already knows what to preview. open is a constant here, and there is nothing to hold, so the screen has no event to handle.

image gives the avatar a picture instead of initials. padding and corner_radius change the card's shell.

Preview card pinned open without a trigger

Pinned open, no trigger card with an open preview card for Ecto with a purple drop avatar, a subtitle and a description, above a card reading Every part is optional
elixir
<MishkaPreviewCard
  id="pc-static"
  open={true}
  image="https://avatars.githubusercontent.com/u/1481354"
  title="Ecto"
  subtitle="elixir-ecto/ecto"
  description="A toolkit for data mapping and queries."
  padding={:space_lg}
  corner_radius={:radius_lg}
/>

# Nothing to handle — with no trigger there is nothing to hold, and
# `open` is a constant.

Props

PropTypeDefaultDescription
idstringnilRoot of the native test tags (<id>-open, <id>-closed, <id>-trigger, <id>-popup-<side>, <id>-arrow-<align>) and the value that on_hold and on_tap report.
openbooleanfalseWhether the card is shown. The trigger renders either way.
<MishkaPreviewCardTrigger>slotNoneWhat the user holds, as markup. The card wires the hold, so the tag takes no props of its own.
triggernode or list of nodesnilThe same slot as data, for a card built by Enum.map/2. Omit both and only the card renders.
on_holdevent tagnilLong press on the trigger. Sent as {:tap, {tag, id}}.
on_tapevent tagnilPlain tap on the trigger. Sent as {:tap, {tag, id}}, so a link still follows.
on_dismissevent tagnilA tap outside the open card. Sent as {:tap, tag}.
side:bottom, :top, :left or :right:bottomWhich side of the trigger the card takes. It flips to the opposite side when the requested one has no room.
align:start, :center or :end:centerWhere the card sits along that side, measured from the trigger's own edge. The arrow sits at the same end.
side_offsetnumber8Gap between the trigger and the card, in dp.
align_offsetnumber0Nudges the arrow along the card's facing edge, in dp. It does not move the card.
arrowbooleanfalseDraws a pointer back at the trigger.
arrow_colorcolor token or ARGB integer:borderArrow color.
titlestringnilThe name.
subtitlestringnilA line of context under the title.
descriptionstringnilThe body text.
initials, imagestringnilAvatar content, drawn with the Avatar component. With neither, the card has no avatar.
avatar_colorcolor token or ARGB integer:primaryAvatar fill.
width, background, corner_radius, padding, border_*, fill_widthsee PopoverNonePassed to the Popover panel. The card hugs its content. Set width for a fixed card, or fill_width={true} for a filling one.

Of the Popover's positioning props, only side, align and side_offset reach the anchored node. flip, clamp, edge_padding and panel_offset_* are not forwarded, so setting them here does nothing.

Events

Your screen receives these messages in handle_info/2. on_hold and on_tap send {:tap, {tag, id}}, where id is the card's id, so one clause serves a whole list of triggers. on_dismiss sends {:tap, tag}.

elixir
def handle_info({:tap, {:hold, id}}, socket) do
  next = if socket.assigns.open == id, do: nil, else: id
  {:noreply, Mob.Socket.assign(socket, :open, next)}
end

def handle_info({:tap, {:visit, id}}, socket) do
  {:noreply, Mob.Socket.assign(socket, :visited, id)}
end

Differences from the web component

These parts of the headless Preview Card are not ported:

  • delay and close_delay, because a phone has no hover to delay and the long press has a hold time of its own.
  • close_on_escape, because a component cannot claim the system back gesture. The screen owns it.
  • The <:arrow> slot. The component draws the arrow glyph itself, and arrow switches it on.
  • The *_class attributes.