Drafter.Widget.MaskedInput (drafter v0.3.1)

Copy Markdown View Source

A single-line text input that enforces a character-by-character format mask.

Unfilled positions are displayed as _ placeholders. The cursor advances automatically after a valid character is entered. The :on_change callback receives the unmasked raw value (user-entered characters only, without literal separators).

Mask format characters

  • # — accepts any printable character
  • 9 — accepts digits 09 only
  • a — accepts lowercase letters az only
  • A — accepts any letter (az or AZ)
  • Any other character — treated as a literal separator and displayed as-is

Component tag

Tag :masked_input, built by Drafter.App as {:masked_input, opts}:

masked_input(opts)

masked_input/1 requires a keyword list — there is no zero-argument form and no positional argument. from_component_opts/2 wraps :on_change and :on_submit with Drafter.Widget.Callback, so both may be given as atom event names.

Options

  • :mask - format mask string, e.g. "(999) 999-9999" for a US phone number. Default nil, which accepts no input at all and displays the raw value unchanged.
  • :value - initial raw value, unmasked characters only. Default "". Characters the mask rejects are dropped when it is applied.
  • :placeholder - hint text shown while the raw value is empty. Default "".
  • :on_change - atom event name or (String.t() -> term()) called with the raw value after every insertion or deletion. Default nil.
  • :on_submit - atom event name or (String.t() -> term()) called with the raw value when enter is pressed. Default nil.
  • :style - map/0 of style overrides passed to the theme computation. Default %{}.
  • :class - theme class atom or list of them, normalised by Drafter.Style.normalize_classes/1 and reaching mount/1 as :classes. Default [].
  • :focused - boolean/0 read by mount/1. Default false. Every editing key binding requires it.

update/2 accepts :mask, :value, :placeholder, :style, :classes, :app_module, :on_change and :on_submit, and never moves the cursor. Through the component tree update_props_from_mount/3 narrows that to :on_change and :on_submit, adding :mask and :placeholder only when they actually differ from the mounted state — so :value is mount-only and the text the user typed survives a re-render.

Key bindings

All of these require the widget to be focused; otherwise the event bubbles as {:noreply, state}.

  • left / right — move the cursor between editable positions
  • any character the mask position accepts — fills that position and advances the cursor
  • backspace — removes the character before the cursor and moves it back
  • delete — removes the character at the cursor
  • enter — calls :on_submit

Widget value

Drafter.get_widget_value/1 returns nil for this widget: the text lives under :raw_value and :value, neither of which it reads. Use get_unmasked_value/1 on the state from Drafter.get_widget_state/1.

Usage

masked_input(mask: "99/99/9999", placeholder: "DD/MM/YYYY", on_change: :date_changed)

Summary

Functions

The component tag this widget registers under.

Builds the props map for a {:masked_input, opts} element.

The characters the user entered, without the mask's literal separators.

Handles the field's own events, replacing the dispatch use Drafter.Widget would otherwise generate.

Builds the widget state from props.

The number of rows the element asks for: always 3. There is no :height override.

Draws the field into rect as a single strip.

Callback implementation for Drafter.Widget.unmount/1.

Replaces the state fields named in props, keeping the current value for any key that is absent.

Narrows a re-render to :on_change and :on_submit, adding :mask and :placeholder only when they differ from the mounted state.

Types

t()

@type t() :: %Drafter.Widget.MaskedInput{
  app_module: module() | nil,
  classes: [atom()],
  cursor_pos: non_neg_integer(),
  focused: boolean(),
  mask: String.t() | nil,
  on_change: (String.t() -> term()) | nil,
  on_submit: (String.t() -> term()) | nil,
  placeholder: String.t(),
  raw_value: String.t(),
  style: map(),
  value: String.t()
}

Functions

component_tag()

@spec component_tag() :: :masked_input

The component tag this widget registers under.

iex> Drafter.Widget.MaskedInput.component_tag()
:masked_input

focused(state)

from_component_opts(args, opts)

@spec from_component_opts(
  term(),
  keyword()
) :: Drafter.Widget.props()

Builds the props map for a {:masked_input, opts} element.

The positional argument is ignored. :on_change and :on_submit go through Drafter.Widget.Callback.wrap_1/1, so an atom becomes a closure that dispatches an app event. :class is normalised into :classes and :__app_module__ becomes :app_module.

iex> props = Drafter.Widget.MaskedInput.from_component_opts(nil, mask: "99/99")
iex> {props.mask, props.value, props.placeholder, props.on_change}
{"99/99", "", "", nil}

iex> props = Drafter.Widget.MaskedInput.from_component_opts(nil, on_submit: :saved)
iex> is_function(props.on_submit, 1)
true

get_unmasked_value(masked_input)

@spec get_unmasked_value(t()) :: String.t()

The characters the user entered, without the mask's literal separators.

Returns :raw_value, or "" when it is nil.

iex> Drafter.Widget.MaskedInput.mount(%{mask: "99/99", value: "1234"})
...> |> Drafter.Widget.MaskedInput.get_unmasked_value()
"1234"

iex> Drafter.Widget.MaskedInput.mount(%{mask: "99/99"})
...> |> Drafter.Widget.MaskedInput.get_unmasked_value()
""

handle_event(event, state)

@spec handle_event(term(), t() | Drafter.Widget.props()) ::
  {:ok, t()} | {:noreply, t()}

Handles the field's own events, replacing the dispatch use Drafter.Widget would otherwise generate.

A plain props map is passed through mount/1 first, so the returned state is always a t/0. Recognised events:

  • {:focus} / {:blur} - set or clear :focused
  • {:key, :left} / {:key, :right} - move :cursor_pos, clamped to 0..(editable_positions - 1)
  • {:key, :backspace} - delete before the cursor, or {:noreply, state} at position 0
  • {:key, :delete} - delete at the cursor
  • {:key, :enter} - call :on_submit and return the state unchanged
  • {:key, key} where key is a single printable character, and {:char, code} - insert when the mask position accepts it, otherwise {:noreply, state}

Every binding but focus and blur also requires :focused; without it, and for any unrecognised event, the result is {:noreply, state}.

iex> state = Drafter.Widget.MaskedInput.mount(%{mask: "99/99", focused: true})
iex> {:ok, typed} = Drafter.Widget.MaskedInput.handle_event({:char, ?5}, state)
iex> {typed.value, typed.raw_value, typed.cursor_pos}
{"5_/__", "5", 1}

iex> state = Drafter.Widget.MaskedInput.mount(%{mask: "99/99", focused: true})
iex> Drafter.Widget.MaskedInput.handle_event({:char, ?x}, state) |> elem(0)
:noreply

iex> state = Drafter.Widget.MaskedInput.mount(%{mask: "99/99", value: "12", focused: true})
iex> {:ok, moved} = Drafter.Widget.MaskedInput.handle_event({:key, :right}, state)
iex> {:ok, deleted} = Drafter.Widget.MaskedInput.handle_event({:key, :backspace}, moved)
iex> {deleted.raw_value, deleted.value, deleted.cursor_pos}
{"2", "2_/__", 0}

iex> state = Drafter.Widget.MaskedInput.mount(%{mask: "99/99"})
iex> Drafter.Widget.MaskedInput.handle_event({:char, ?5}, state) |> elem(0)
:noreply

mount(props)

@spec mount(Drafter.Widget.props()) :: t()

Builds the widget state from props.

:value is kept as :raw_value and also run through the mask to produce :value, with unfilled positions shown as _. :cursor_pos always starts at 0 and counts editable mask positions, not display columns.

iex> Drafter.Widget.MaskedInput.mount(%{mask: "99/99"}).value
"__/__"

iex> state = Drafter.Widget.MaskedInput.mount(%{mask: "99/99", value: "1234"})
iex> {state.value, state.raw_value, state.cursor_pos}
{"12/34", "1234", 0}

iex> Drafter.Widget.MaskedInput.mount(%{mask: "999", value: "1a2"}).value
"12_"

iex> state = Drafter.Widget.MaskedInput.mount(%{value: "anything"})
iex> {state.mask, state.value}
{nil, "anything"}

preferred_height(args, opts)

@spec preferred_height(
  term(),
  keyword()
) :: 3

The number of rows the element asks for: always 3. There is no :height override.

iex> Drafter.Widget.MaskedInput.preferred_height(nil, height: 1)
3

render(state, rect)

Draws the field into rect as a single strip.

state may be a plain props map, in which case it is passed through mount/1 first. The masked value is drawn once there is any raw input and the placeholder before that, either padded or truncated to rect.width. The cursor cell is drawn reversed only while the widget is focused and has input.

unmount(state)

Callback implementation for Drafter.Widget.unmount/1.

update(props, state)

@spec update(Drafter.Widget.props(), t()) :: t()

Replaces the state fields named in props, keeping the current value for any key that is absent.

:value sets the raw value, and :value is re-masked on every call. The mask used for that is the one already on the state, so a props that changes both :mask and :value formats the new value with the old mask until the next update. :cursor_pos and :focused are never touched here.

iex> state = Drafter.Widget.MaskedInput.mount(%{mask: "99/99"})
iex> updated = Drafter.Widget.MaskedInput.update(%{value: "1234"}, state)
iex> {updated.value, updated.raw_value}
{"12/34", "1234"}

iex> state = Drafter.Widget.MaskedInput.mount(%{mask: "99/99"})
iex> updated = Drafter.Widget.MaskedInput.update(%{mask: "999", value: "1234"}, state)
iex> {updated.mask, updated.value}
{"999", "12/34"}

update_props_from_mount(mount_props, existing_state, opts)

@spec update_props_from_mount(Drafter.Widget.props(), t(), keyword()) ::
  Drafter.Widget.props()

Narrows a re-render to :on_change and :on_submit, adding :mask and :placeholder only when they differ from the mounted state.

:value, :style and :classes are always dropped, so the text the user typed survives a re-render.

iex> props = Drafter.Widget.MaskedInput.from_component_opts(nil, mask: "99/99")
iex> state = Drafter.Widget.MaskedInput.mount(props)
iex> Drafter.Widget.MaskedInput.update_props_from_mount(props, state, []) |> Map.keys() |> Enum.sort()
[:on_change, :on_submit]

iex> props = Drafter.Widget.MaskedInput.from_component_opts(nil, mask: "999")
iex> state = Drafter.Widget.MaskedInput.mount(%{mask: "99/99"})
iex> Drafter.Widget.MaskedInput.update_props_from_mount(props, state, []).mask
"999"