Tooltip

v0.0.10

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

The Mob Tooltip component from Mishka Chelekom is a short hint about the control it wraps, revealed by pressing and holding that control on iOS and Android. It is the native port of the headless Tooltip for screens built with Mob, and your screen decides when the hint is open.

Overview of the Mob Tooltip component for iOS and Android

The web tooltip appears on pointer enter or focus, and a phone has neither. The touch equivalent is the gesture that platforms already use to reveal more: press and hold. The tooltip's children are its trigger, and holding one sends on_open_change with the state the tooltip wants next. Mob.Renderer registers on_long_press, so holding a control works on both platforms.

This inverts the web layout. On the web, the default slot is the content and <:trigger> is a named slot. Here the children are the trigger, and the hint is one short string in text.

Your screen owns the state. Keep the open tooltip in the screen's assigns and pass it as open. Your handle_info/2 clause stores the value that on_open_change sends, so one clause opens and closes the hint. Holding an open tooltip closes it again.

The hint floats over the page

The trigger is child 0 of an :anchored node and renders in the page flow. The bubble is child 1 and renders in its own window over everything. The bubble takes no space, so opening it does not push the controls beside it aside, it can sit above or to the left of the control, and no rounded box or scroll view can clip it. Without a trigger, the side prop has nothing to anchor to.

The position follows the web positionPopup(). side_offset is the gap from the chosen side, align_offset nudges along the alignment axis, and a positive value pushes inward on :end. The bubble flips to the opposite side when the requested side has no room and the opposite side does. It is then clamped into the window with 8 dp of edge padding plus the safe-area inset.

Leave the bubble room

The bubble takes no space in the page, so nothing in the layout makes way for it. An open bubble is drawn over whatever sits within reach of its trigger: the text above it, the next control below it or another tooltip's trigger. The bubble itself is placed correctly: it sits side_offset off its own trigger and is centered, started or ended by align. The page has to be spaced for it.

When several tooltips are open at once, leave about side_offset + 52 dp on the bubble's side of each trigger, or about side_offset + 96 dp for a short hint on the left or right. A tooltip that is open on its own needs only the room its text takes.

The arrow is drawn for the side you asked for. When the bubble flips because that side has no room, the node tree is already built, so the arrow does not turn with it and points away from the trigger. While arrow is on, keep a trigger that sits at a screen edge on the side that has room, and do not rely on the flip.

A hint, not a panel

A tooltip is dark, tight, borderless and one line of small text. It does not use the Popover's surface, which would make it read as a menu.

Taps and the trigger

The wrapped control's own tap goes in the tooltip's on_tap prop, as {:tap, tag}. A clickable child would take the hold before the tooltip saw it, because the innermost clickable wins the gesture on Android. One handler on the trigger carries both the tap and the hold.

A tap on the bubble dismisses it while close_on_tap is true. On Android, a tap outside the bubble also reports the tooltip closed.

With no children, the tooltip is the bare bubble, for a screen that places its own. offset_x and offset_y move it directly. A closed tooltip with no children contributes no node, so it takes no space.

With an id, the parts get these test tags: <id>-trigger, <id>-open and <id>-arrow-<side>.

Generate the tooltip component

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

bash
mix mishka.ui.gen.mob tooltip

The generator registers the tag, so you can use it in ~MOB markup. It also copies the shared Anchored and Event helpers. The examples on this page use the mishka_ prefix of the showcase app (--module-prefix mishka_), so the tag is <MishkaTooltip>. Without a prefix, the tag is <Tooltip>. The examples wrap <MishkaActionIcon> as the trigger, which you generate with mix mishka.ui.gen.mob action_icon.

Tooltip revealed by press and hold

A phone has no hover, so the tooltip uses the gesture that platforms already use to reveal more: press and hold. Hold again to put the hint away. on_open_change carries the state the tooltip wants next, so one clause opens and closes it. The control's own tap goes in on_tap on the tooltip, because a clickable child would take the hold before the tooltip saw it.

Tooltip revealed by press and hold

Hold to reveal card: three white icon buttons, copy, an arrow and a trash can, with no hint showing and a gray caption below
A dark tooltip bubble reading Copy to clipboard with an arrow pointing up at a copy icon button.

Left: the example as it loads. Right: after a long press. A long press reveals the hint on a touch screen.

elixir
<MishkaTooltip
  id="tip-copy"
  text="Copy to clipboard"
  open={@tip == :copy}
  side={:bottom}
  arrow={true}
  fill_width={false}
  on_open_change={{:hold, :copy}}
  on_tap={{:use, :copy}}
>
  <MishkaActionIcon icon="⧉" variant={:filled} />
</MishkaTooltip>

# on_open_change carries the state the tooltip wants NEXT, so one clause
# opens and closes, and a second hold on the same control puts it away.
def handle_info({:tap, {{:hold, which}, open?}}, socket) do
  {:noreply, Mob.Socket.assign(socket, :tip, if(open?, do: which, else: nil))}
end

# The control's own tap lives on the tooltip, not on the ActionIcon: a
# clickable child would swallow the hold before the tooltip ever saw it.
def handle_info({:tap, {:use, which}}, socket) do
  {:noreply, Mob.Socket.assign(socket, :used, which)}
end

Tooltip on every side with an arrow

side puts the bubble above, below or beside the trigger, and arrow={true} draws an arrow that turns to point back at the trigger. On Android the bubble floats over the page and takes no room, so it does not move the controls around it. Each trigger in this example is given space for its own bubble.

Tooltip on every side with an arrow

Every side, with an arrow card: dark hint bubbles reading side top, side bottom, side left and side right, each beside its own small icon button with its arrow pointing at it
heex
<MishkaTooltip
  id="side-left"
  text="On the left"
  open={true}
  side={:left}
  arrow={true}
>
  <MishkaActionIcon icon="●" variant={:filled} />
</MishkaTooltip>

Tooltip alignment and offsets

align places the bubble across the side: :start, :center or :end. side_offset is the gap to the trigger in dp, and align_offset nudges the bubble along the alignment axis. A positive align_offset pushes inward on :end. All three bubbles in this example are open at once, so each has room of its own above it.

Tooltip alignment and offsets

Align, and the two offsets card: dark hint bubbles reading start, center and end, each above its own icon button with room around it
heex
<MishkaTooltip
  id="align-end"
  text="Pinned to the end"
  open={true}
  align={:end}
  side_offset={16}
  align_offset={-12}
>
  <MishkaActionIcon icon="◆" variant={:filled} />
</MishkaTooltip>

Dismiss a tooltip by tapping the hint

The web closes a tooltip on blur, pointer leave or Escape. A phone has none of those, so tapping the bubble dismisses it. close_on_tap={false} keeps the bubble open when you tap it, and a second hold on the trigger closes it. The same clause serves both, because the bubble tap and the hold each report the state they want. The bubbles in this example open to the right with side={:right}, level with their triggers, so they do not cover the controls above or below.

Dismiss a tooltip by tapping the hint

Tap the hint to dismiss card: two white icon buttons, a close X and a pushpin, with no hint bubble showing, in the MishkaMob showcase app on Android
elixir
<MishkaTooltip
  id="dismiss-sticky"
  text="Only a second hold closes me"
  open={@sticky?}
  side={:right}
  close_on_tap={false}
  on_open_change={:sticky}
>
  <MishkaActionIcon icon="📌" variant={:filled} />
</MishkaTooltip>

# The same clause serves the bubble's tap and the trigger's hold: both
# report the state they want, so the screen only has to store it.
def handle_info({:tap, {:sticky, open?}}, socket) do
  {:noreply, Mob.Socket.assign(socket, :sticky?, open?)}
end

Disabled tooltip

disabled switches the hint off, and the control still works and reacts to taps. No hold handler is wired, and open cannot show the bubble.

Disabled tooltip

Disabled card: one white icon button with a no entry sign and no hint showing, above the gray text The hint stayed shut, as it must
elixir
<MishkaTooltip
  id="tip-off"
  text="You will never see this"
  open={@off?}
  disabled={true}
  on_open_change={:off}
>
  <MishkaActionIcon icon="🚫" variant={:filled} />
</MishkaTooltip>

# Wired, and still never called — disabled drops the handler rather
# than ignoring the event, so nothing arrives to ignore.
def handle_info({:tap, {:off, open?}}, socket) do
  {:noreply, Mob.Socket.assign(socket, :off?, open?)}
end

Tooltip colors, size and offset

The bubble is dark by default so it reads over any surface. background and color take a theme token or an ARGB integer, and text_size takes a size token. offset_x and offset_y shift the bubble without moving anything else.

Tooltip colors, size and offset

Colours, size and a static nudge card: dark, near black and violet hint bubbles reading Dark by default, Themed and A literal, when no token fits, each above its own icon button
heex
<MishkaTooltip
  id="style-token"
  text="Themed"
  open={true}
  background={:primary}
  color={:on_primary}
  text_size={:base}
  offset_x={12}
>
  <MishkaActionIcon icon="◐" variant={:filled} />
</MishkaTooltip>

Tooltip without a trigger

With no children you get the bare bubble, for a screen that places its own. When it is closed it contributes no node, so it takes no space in the layout.

Tooltip without a trigger

Without a trigger card: a dark hint bubble reading Saved to your library above an empty white bar, in the MishkaMob showcase app on Android
heex
<MishkaTooltip id="bare" text="Saved to your library" open={true} />

Props

PropTypeDefaultDescription
textstringnilThe hint.
openbooleanfalseWhether the hint is shown. The screen owns this value.
side:top, :bottom, :left or :right:topWhich side of the trigger the bubble takes. It flips when that side has no room.
align:start, :center or :end:centerWhere the bubble sits along the cross axis.
side_offsetnumber6Gap between the trigger and the bubble, in dp.
align_offsetnumber0Nudge along the alignment axis. A positive value pushes inward on :end.
arrowbooleanfalseDraw a triangle that points back at the trigger.
disabledbooleanfalseThe hint never opens, and no hold handler is wired.
on_open_changeevent tagNoneSent as {:tap, {tag, next_open?}}.
on_tapevent tagNoneThe wrapped control's own tap, sent as {:tap, tag}.
close_on_tapbooleantrueTapping the bubble dismisses it.
backgroundcolor token or ARGB integer0xFF111827Bubble fill. It is dark by default so it reads over any surface.
colorcolor token or ARGB integer0xFFFFFFFFHint color.
text_sizesize token:smHint size.
offset_x, offset_ynumbernilRaw nudge on the bubble, in dp, applied last.
fill_widthbooleanNoneNot read. The trigger always hugs the control it wraps, so passing it does nothing.
idstringNoneNative test tags: <id>-trigger, <id>-open and <id>-arrow-<side>.

Events

Your screen receives these messages in handle_info/2.

SourceMessageWhen
Hold on the trigger{:tap, {tag, next_open?}}The user presses and holds the control. next_open? is the opposite of the current state, so a second hold closes the hint. Not sent when disabled is true.
Tap on the bubble{:tap, {tag, false}}The user taps the bubble while close_on_tap is not false.
Outside tap{:tap, {tag, false}}On Android, the user taps outside the bubble.
on_tap{:tap, tag}The user taps the wrapped control.
elixir
<MishkaTooltip
  text="Copy to clipboard"
  open={@tip == :copy}
  side={:bottom}
  arrow={true}
  on_open_change={{:tip, :copy}}
>
  <MishkaActionIcon icon="⧉" />
</MishkaTooltip>

def handle_info({:tap, {{:tip, which}, open?}}, socket) do
  {:noreply, Mob.Socket.assign(socket, :tip, if(open?, do: which, else: nil))}
end

Differences from the web component

  • The children are the trigger and text is the hint. The web component does the reverse.
  • A long press replaces hover and focus as the way to open the hint.
  • delay, close_delay, hoverable and track_cursor_axis are not ported. They describe a pointer that does not exist on a phone.
  • group is not ported. It shares a hover delay.
  • trigger_label is not ported. Mob exposes no accessible name on a node, only a test tag.
  • close_on_escape becomes close_on_tap. A phone has no Escape key, and the one dismissal a finger can reach is the bubble itself.
  • The bubble floats on Android only. On iOS it stacks next to the trigger.