OTP field

v0.0.10

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

The Mishka Chelekom Mob OTP Field is a segmented one-time-code input for iOS and Android. It is the native port of the headless OTP Field and runs on Mob.

Overview of the Mob OTP Field component for iOS and Android

The component draws the slots as boxes and stacks one invisible TextField over them. The field's background, text and border are transparent, so tapping the boxes opens the keyboard and typing fills the slots from left to right. One field serves all the slots, so paste and backspace behave as they do in any text field.

Mob has no focus control, so the active slot comes from the length of the value. That is the slot that focus would pick on the web. A caret needs focus, though. The field reports on_focus and on_blur, so keep a focused flag on the screen and pass it back. Without it no caret is drawn, and a tapped field looks the same as an untapped one.

The screen owns the value. The invisible field has no max_length, so a pasted 123-456 arrives whole. MishkaOtpField.sanitize/2 drops the characters that validation_type does not allow and truncates the rest to length, which turns that paste into 123456. MishkaOtpField.complete?/2 returns true when the code fills every slot, so the screen can decide when to submit.

validation_type also picks the keyboard. :numeric, the default, opens the number keyboard. :alpha, :alphanumeric and :none open the text keyboard.

Generate the OTP field component

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

bash
mix mishka.ui.gen.mob otp_field

The generator registers the <OtpField> tag so you can use it in ~MOB markup. It also copies the shared Event support module, unless you pass --no-kit. The examples on this page use the mishka_ prefix of the showcase app (--module-prefix mishka_), so the tag is <MishkaOtpField>. Without a prefix the tag is <OtpField>.

Six-digit OTP field

Render <MishkaOtpField> with a value, a length and an on_change tag. The default is six numeric slots. In handle_info/2, pass the raw text through MishkaOtpField.sanitize/2 and assign the result. The generator registers the tag with Mob.Composite.register/2, as the first comment in the snippet shows. The last comment shows the function form, otp_field/1, which gives the same output.

Six-digit OTP field

A 6-digit code card with six empty one-time-code boxes, the first focused, and the line 0 of 6 digits below, in the MishkaMob showcase app on Android
elixir
# Registered once at boot, then it is a tag like any other:
#   Mob.Composite.register(:mishka_otp_field, {MishkaOtpField, :expand})

def render(assigns) do
  ~MOB"""
  <Column fill_width={true}>
    <MishkaOtpField value={@code} length={6} on_change={:code} />
  </Column>
  """
end

def handle_info({:change, :code, raw}, socket) do
  {:noreply, assign(socket, :code, MishkaOtpField.sanitize(raw, length: 6))}
end

# The function form is still there and gives identical output, for
# anything a tag cannot express:
#
#   import MishkaMob.Components.MishkaOtpField, only: [otp_field: 1]
#   {otp_field(value: @code, length: 6, on_change: :code)}

Masked OTP field with fewer slots

Set mask={true} to draw • in a filled slot instead of the character, and length to change the number of slots. This example uses four.

Masked OTP field with fewer slots

Masked and shorter card with a row of four empty one-time-code boxes, the first outlined as focused, in the MishkaMob showcase app on Android
elixir
~MOB"""
<Column fill_width={true}>
  <MishkaOtpField value={@code} length={4} mask={true} on_change={:code} />
</Column>
"""

Grouped OTP field with a separator

group splits the slots and separator draws a string between the groups. An integer divides evenly: group={3} over six slots reads as 123-456. A list divides unevenly, so group={[2, 4]} gives a reference such as Ab-5563, two slots and then four. Set both props, because one without the other does nothing. The last slot never gets a separator. This example also sets validation_type={:alphanumeric} to allow letters.

Grouped OTP field with a separator

Grouped, with a separator card with six empty code boxes split by a hyphen into groups of two and four, in the MishkaMob showcase app on Android
elixir
~MOB"""
<Column fill_width={true}>
  <MishkaOtpField value={@ref} length={6} group={[2, 4]} separator="-"
                  validation_type={:alphanumeric} on_change={:ref} />
</Column>
"""

# an even split needs only a number:
#   <MishkaOtpField value={@code} length={6} group={3} separator="-" />

Props

Props of the <MishkaOtpField> tag.

PropTypeDefaultDescription
valuestring""The code so far.
lengthinteger6Number of slots.
validation_type:numeric, :alpha, :alphanumeric or :none:numericWhat sanitize/2 keeps, and which keyboard opens.
maskbooleanfalseDraws • instead of the character.
groupinteger or listnil3 splits evenly (123-456). [2, 4] splits unevenly (Ab-5563).
separatorstringnilDrawn between groups. It needs group, and group needs it.
focusedbooleanfalseDraws the caret in the active slot. Track it with on_focus and on_blur.
on_changeevent tagNoneSends {:change, tag, text} on every keystroke. Sanitize the text in the handler.
on_focus / on_blurevent tagsNoneSend {:focus, tag} and {:blur, tag}, not taps.
disabledbooleanfalseMutes the slots, disables the native field and unwires its events.
colorcolor token or ARGB integer:primaryBorder of the filled and active slots, and the caret.
slot_widthnumber44Width of one slot.
idstringnilSets a native test tag on the input, for end-to-end tests.
sanitize/2, complete?/2 and boundaries/1helpersNoneFilter and truncate, check that the code is full, and find where the separators land.

Events

Your screen receives these messages in handle_info/2:

  • on_change sends {:change, tag, text} with the raw text.
  • on_focus and on_blur send {:focus, tag} and {:blur, tag}. These are not taps.

To draw the caret, keep a focused? flag on the screen and update it from the focus and blur messages.

elixir
<MishkaOtpField
  value={@code}
  focused={@focused?}
  on_change={:code}
  on_focus={:code_focus}
  on_blur={:code_blur}
/>

def handle_info({:change, :code, raw}, socket) do
  code = MishkaOtpField.sanitize(raw, length: 6)
  {:noreply, assign(socket, :code, code)}
end

def handle_info({:focus, :code_focus}, socket), do: {:noreply, assign(socket, :focused?, true)}
def handle_info({:blur, :code_blur}, socket), do: {:noreply, assign(socket, :focused?, false)}

Differences from the web component

  • The web renders one <input> per slot and moves focus between them. Mob draws the slots and puts one invisible field over them.
  • group accepts a list as well as an integer. The web divides evenly only.
  • Not ported: name (form plumbing), auto_complete and input_mode (browser hints), transform and the *_class attributes.
  • auto_submit is not ported. The screen decides, using complete?/2.
  • id is kept as a native test tag, which is how an end-to-end test finds the invisible input.