Combobox

v0.0.10

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

The Mishka Chelekom Mob Combobox is a text field that filters a list of options as you type and lets you pick one or several, on iOS and Android. It is the native port of the headless Combobox and runs on Mob.

Overview of the Mob Combobox component for iOS and Android

Combobox draws one bordered control, as the web does. It holds the chosen options as removable chips (in multiple mode), the query field, and two optional buttons: a ✕ that clears the query (clear) and a ▾ that opens and closes the list (trigger). The query field shares the last chip row when there is room. While open is true, the list appears in flow below the control.

The component filters the options by query itself. The default mode is :contains, and :starts_with is the other. Matching ignores case and accents, so typing cafe finds Café. This matters more on a phone, where accents are hard to type. Groups that lose all their options disappear with their heading. MishkaCombobox.filter/3 and fold/1 are public, so a handler can use the same matching.

Choosing from the filtered list works like Select. Combobox reads the same <MishkaSelectOption> children and builds options with the same option/3. Your handler can use MishkaSelect.toggle/3 to compute the next value.

The screen owns query, value and open, and the events arrive in handle_info/2. Three events open the list. Tapping the field sends on_press on every tap, so use it to open the list. Typing sends on_query, and the ▾ sends on_toggle. Do not use on_focus to reopen the list. It fires only when the field gains focus, and closing the list does not take the caret away, so a second tap reports nothing.

The list renders in flow, so nothing covers the page to catch a tap outside the control. To close the list on an outside tap, put an on_tap on the container around the combobox. A child's handler consumes its own tap, so the control and its options keep working and only the surrounding space closes the list.

Mob cannot measure text, so chips wrap by an estimate of 34 characters per row. Set wrap_chars to change it.

Generate the combobox component

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

bash
mix mishka.ui.gen.mob combobox

The generator registers the component's tag, so you can use it in ~MOB markup. It also generates the Menu, Pill, Select and Tags Input components, which it builds on. The examples on this page use the mishka_ prefix of the showcase app (--module-prefix mishka_), so the tag is <MishkaCombobox>. Without a prefix the tag is <Combobox>.

Filter and choose in a combobox

Type to narrow the list. Matching ignores case and accents. The ✕ clears the query, and the ▾ opens and closes the list. In the showcase, tapping the field opens the list and tapping outside the control closes it. The snippet passes open={true}, so the list shows.

Filter and choose in a combobox

Card titled Filter and choose, with a Combobox showing the placeholder Search countries, a clear X and a down arrow, the list closed, above the text Nothing chosen
A combobox with a search field and an open list of seven countries from Iran to Türkiye.

Left: the example as it loads. Right: after a tap. The caret opens and closes the list.

elixir
<MishkaCombobox
  query={@query}
  value={@value}
  open={true}
  clear={true}
  on_query={:query}
  on_select={:pick}
  on_clear={:clear}
>
  <MishkaSelectOption id={:uk} label="United Kingdom" />
  <MishkaSelectOption id={:ir} label="Iran" />
</MishkaCombobox>

MishkaCombobox.filter(pairs, query)   # "cafe" finds "Café"

Multiple combobox

Chosen options appear as removable chips inside the control, as on the web. The ▾ opens the list, and picking an option keeps the list open so you can choose another.

Multiple combobox

Card titled Multiple, with a Combobox holding United Kingdom and Germany chips, each with an X, above the placeholder Pick several and a down arrow
A combobox with chips for United Kingdom and Germany and an open country list with those two ticked.

Left: the example as it loads. Right: after a tap. Chips share the line with the field.

heex
<MishkaCombobox multiple={true} value={@values} on_select={:pick}>
  <MishkaSelectOption id={:uk} label="United Kingdom" />
  <MishkaSelectOption id={:ir} label="Iran" />
</MishkaCombobox>

Grouped, creatable combobox with a disabled option

Headings split the options into runs. Durian is out of stock, so it has disabled={true} and cannot be picked. With creatable on, the list ends with a create row, for example Create "Pomelo", when the query matches no option exactly. The component only offers that row. Adding the new option to your list is the screen's job, and the screen already holds the query.

The row arrives through on_select with the id :__create__. The handler below matches {:tap, {:pick, :__create__}}, so the combobox needs on_select={:pick}.

Grouped, creatable combobox with a disabled option

Card titled Grouped, creatable, with a disabled item, with a Combobox holding Apple and Carrot chips above the placeholder Add items, a clear X and a down arrow
A combobox with Apple and Carrot chips and an open grouped list with FRUIT and VEGETABLE headings and a greyed Durian.

Left: the example as it loads. Right: after a tap. Headings per run, and a disabled item.

elixir
<MishkaCombobox multiple={true} creatable={true} on_create={:create}>
  <MishkaSelectOption id={:apple} label="Apple" group="FRUIT" />
  <MishkaSelectOption id={:durian} label="Durian (out of stock)" group="FRUIT" disabled={true} />
  <MishkaSelectOption id={:carrot} label="Carrot" group="VEGETABLE" />
</MishkaCombobox>

# The component only OFFERS; adding to the list is the screen's job, and
# the screen already holds the query.
def handle_info({:tap, {:pick, :__create__}}, socket), do: create(socket)

Combobox with no matches

An empty result says so. Instead of collapsing to nothing, the list shows one muted row with the empty_text.

Combobox with no matches

Card titled No matches, with a Combobox with zzz typed and, below it, an open list holding one muted row, No country matches that.
heex
<MishkaCombobox query="zzz" open={true} empty_text="Nothing found">
  <MishkaSelectOption id={:uk} label="United Kingdom" />
  <MishkaSelectOption id={:ir} label="Iran" />
</MishkaCombobox>

Combobox with prefix matching

filter={:starts_with} keeps only the options whose label begins with the query. Typing u keeps United Kingdom and drops the rest. The default, :contains, matches anywhere in the label.

Combobox with prefix matching

Card titled starts_with, with a Combobox with u typed and an open list showing only United Kingdom, in the MishkaMob showcase app on Android
heex
<MishkaCombobox filter={:starts_with} query="u" open={true}>
  <MishkaSelectOption id={:uk} label="United Kingdom" />
  <MishkaSelectOption id={:ir} label="Iran" />
</MishkaCombobox>

Helper functions

FunctionDescription
filter(options, query, opts)Filters {id, label} pairs by the query. Pass mode: :starts_with for prefix matching. An empty query matches everything.
fold(text)Lowercases text and strips accents, so Café and cafe compare equal.
option(id, label, opts)Builds one option node. It is MishkaSelect.option/3.

Props

PropTypeDefaultDescription
valueoption id, list or nilnilThe current choice. A list in multiple mode.
querystring""The text field's contents.
openbooleanfalseWhether the list is shown. The screen owns it.
multiplebooleanfalseAllow several choices. Chosen options show as removable chips.
filter:contains or :starts_with:containsMatch mode.
placeholderstring"Search…"Field placeholder.
clearbooleanfalseShows a ✕ button that clears the query.
empty_textstring"No matches"Shown when nothing matches.
disabledbooleanfalseMutes the control and unwires it.
on_queryevent tag (atom)NoneSends {:change, tag, text} as you type.
on_selectevent tag (atom)NoneSends {:tap, {tag, option_id}} for a picked option.
on_clearevent tag (atom)NoneSends {:tap, tag} from the ✕.
triggerbooleanfalseShows a ▾ button that opens and closes the list.
on_toggleevent tag (atom)NoneSends {:tap, tag} from the ▾.
creatablebooleanfalseOffers a Create row for a query that matches no option exactly.
on_removeevent tag (atom)NoneSends {:tap, {tag, id}} from a chip's ✕.
on_pressevent tag (atom)NoneSends {:tap, tag} on every tap of the field.
on_focusevent tag (atom)NoneSends {:focus, tag} when the field gains focus.
on_blurevent tag (atom)NoneSends {:blur, tag} when the field loses focus.
trigger_iconstring or {closed, open}"▾" and "▴"The glyph of the trigger button.
clear_iconstring"✕"The glyph of the clear button.
create_labelstring"Create"Prefix on the create row.
wrap_charsnumber34Characters per chip row. Chips wrap by this estimate.
idstringnilTest tags for the input, chips, options and buttons.

Options

Combobox uses the same <MishkaSelectOption> tag and option/3 function as Select. An option takes id, label, disabled and group.

Events

Your screen receives these messages in handle_info/2:

  • on_query sends {:change, tag, text} as you type.
  • on_select sends {:tap, {tag, option_id}}. The create row reports the id :__create__.
  • on_clear, on_toggle and on_press send {:tap, tag}.
  • on_remove sends {:tap, {tag, id}} from a chip's ✕.
  • on_focus sends {:focus, tag} and on_blur sends {:blur, tag}.

With on_query={:query}, on_select={:pick} and on_clear={:clear}, the screen handles the common events like this. Typing also opens the list, so someone who types before the list appears does not have to tap again.

elixir
def handle_info({:change, :query, text}, socket) do
  {:noreply, socket |> Mob.Socket.assign(:query, text) |> Mob.Socket.assign(:open?, true)}
end

def handle_info({:tap, {:pick, id}}, socket) do
  {value, _close?} = MishkaSelect.toggle(socket.assigns.value, id, false)
  {:noreply, Mob.Socket.assign(socket, :value, value)}
end

def handle_info({:tap, :clear}, socket) do
  {:noreply, Mob.Socket.assign(socket, :query, "")}
end

To close the list when the user taps outside the control, wrap it in a container with an on_tap and handle that tag.

heex
<Column fill_width={true} on_tap={{self(), :close}}>
  <MishkaCombobox query={@query} open={@open?} on_press={:open} on_select={:pick}>
    ...
  </MishkaCombobox>
</Column>
elixir
def handle_info({:tap, :open}, socket), do: {:noreply, Mob.Socket.assign(socket, :open?, true)}
def handle_info({:tap, :close}, socket), do: {:noreply, Mob.Socket.assign(socket, :open?, false)}

Differences from the web component

  • The list renders in flow below the control. The component does not close it on an outside click, so the screen does it with an on_tap on a container.
  • name and form are not ported, because they are form plumbing.
  • auto_highlight is not ported, because there is no focus ring to highlight.
  • The *_class attributes are not ported. Mob draws native views, so there is no CSS.