Drafter.Widget.TextInput (drafter v0.3.1)

Copy Markdown View Source

A single-line text input widget with cursor navigation, text selection, and clipboard support.

Renders inside a bordered box and shows a blinking-style cursor block when focused. Placeholder text is displayed when the field is empty and unfocused. Validation errors appear below the input border in red when the field has been touched (blurred at least once).

Component tag

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

text_input(opts)

There is no positional argument. The value goes through the binding layer: passing bind: :some_key seeds the text from that app-state key and writes every keystroke back to it. :width is always the allocated rect width less two columns for the border.

Options

  • :text - String.t/0 initial value. Default "". Through the element the value comes from :bind instead.
  • :placeholder - String.t/0 hint shown while the field is empty and unfocused. Default "".
  • :bind - app state key atom for two-way binding; the key is written on every keystroke. Default: none.
  • :id - atom identifier for programmatic access via Drafter.get_widget_value/1. Default: none.
  • :on_change - ({String.t(), validation_result()} -> term()) called on every keystroke, cursor move and selection change. Default nil. Through the element it is built by the framework, which returns nil when neither :bind nor :on_change is given. An exception it raises is swallowed.
  • :on_submit - atom event name or ({String.t(), validation_result()} -> term()) called when enter is pressed. Default nil. Setting it makes enter clear the field.
  • :keep_focus - boolean/0, refocus the widget after :on_submit fires. Default false. Read by from_component_opts/2 only.
  • :validators - list of Drafter.Validation validators run on blur. Default nil.
  • :disabled - boolean/0; the field takes focus but ignores every other event. Default false.
  • :readonly - boolean/0; same handling as :disabled. Default false.
  • :password - boolean/0, render each character as . Default false.
  • :restrict - a Regex.t/0 or a string pattern compiled with Regex.compile!/1; only matching characters may be typed. Default nil.
  • :type - :text | :integer | :number. Default :text. :integer allows 0-9 and -, :number also allows ., and both apply on top of :restrict.

  • :select_on_focus - boolean/0, select the whole value on focus. Default false.
  • :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 [].
  • :max_length - pos_integer/0 cap on the number of characters. Default nil, no cap. Read by mount/1 and update/2 only; the text_input/1 element does not forward it.
  • :width - pos_integer/0 inner width the scroll offset works against. Default 40 when mounting directly. A :width in opts is ignored by the element, which always uses the allocated rect width less the two border columns.
  • :cursor_position, :scroll_offset, :selection_start, :selection_end, :focused, :touched, :error - read by mount/1 with defaults 0, 0, nil, nil, false, false and nil.

update/2 accepts every key above except :cursor_position and :scroll_offset, and ignores :text entirely while the field is focused so typing is never overwritten by a re-render. Through the component tree update_props_from_mount/3 always passes :on_change, :on_submit, :classes, :validators, :disabled, :readonly, :password, :restrict, :type and :select_on_focus; :width and :placeholder only when they changed, and :text only when opts carries :bind or :value and the text differs.

Widget value

Drafter.get_widget_value/1 returns the current text, and Drafter.set_widget_value/2 replaces it.

Key bindings

  • Arrow keys — move cursor one character left/right
  • Ctrl+← / Ctrl+→ — jump by word
  • Shift+← / Shift+→ / Shift+Home / Shift+End — extend selection
  • Ctrl+A — select all
  • Ctrl+C / Ctrl+X / Ctrl+V — copy, cut, paste
  • Ctrl+U — delete from cursor to start of line
  • Ctrl+K — delete from cursor to end of line
  • Ctrl+W — delete word to the left of cursor
  • Backspace / Delete — delete character or selection
  • Enter — call :on_submit and clear the field; bubbles when no :on_submit is set
  • Home / End — move cursor to start/end of text

A :disabled or :readonly field accepts {:focus}, {:blur} and :activate and returns {:noreply, state} for everything else.

Usage

text_input(placeholder: "Email address", on_submit: :login, validators: [:required, :email])

Summary

Functions

The component tag this widget registers under.

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

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, the two border rows plus the content row. There is no :height override, and a validation error row is not accounted for.

Draws the bordered field into rect, always returning exactly rect.height strips.

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 the props that may safely change after mount.

Types

t()

@type t() :: %Drafter.Widget.TextInput{
  app_module: module() | nil,
  classes: [atom()],
  cursor_position: non_neg_integer(),
  disabled: boolean(),
  error: String.t() | nil,
  focused: boolean(),
  max_length: pos_integer() | nil,
  on_change: ({String.t(), validation_result()} -> term()) | nil,
  on_submit: ({String.t(), validation_result()} -> term()) | nil,
  password: boolean(),
  placeholder: String.t(),
  readonly: boolean(),
  restrict: Regex.t() | nil,
  scroll_offset: non_neg_integer(),
  select_on_focus: boolean(),
  selection_end: non_neg_integer() | nil,
  selection_start: non_neg_integer() | nil,
  style: map(),
  text: String.t(),
  touched: boolean(),
  type: :text | :integer | :number,
  validators: [Drafter.Validation.validator()] | nil,
  width: pos_integer()
}

validation_result()

@type validation_result() :: {:ok, String.t()} | {:error, [String.t()]}

Functions

component_tag()

@spec component_tag() :: :text_input

The component tag this widget registers under.

iex> Drafter.Widget.TextInput.component_tag()
:text_input

focused(state)

from_component_opts(args, opts)

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

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

The positional argument is ignored. :text is the bound value for that key, so bind: :key seeds it from opts[:__app_state__] and plain value: is used otherwise, defaulting to "". :width is the width of opts[:__rect__] less two border columns, with the rect defaulting to %{width: 2}. :on_change is the binding's writer and :on_submit is wrapped so that it dispatches the text and, when :keep_focus is set, sends {:focus_widget, id} back to the session. :max_length, :style and any :width in opts are not forwarded.

iex> props = Drafter.Widget.TextInput.from_component_opts(nil, placeholder: "Email")
iex> {props.text, props.placeholder, props.width, props.type, props.on_change}
{"", "Email", 0, :text, nil}

iex> opts = [bind: :query, __app_state__: %{query: "abc"}, __rect__: %{width: 22}]
iex> props = Drafter.Widget.TextInput.from_component_opts(nil, opts)
iex> {props.text, props.width, is_function(props.on_change, 1)}
{"abc", 20, true}

handle_event(event, state)

@spec handle_event(term(), t()) ::
  {:ok, t()} | {:ok, t(), list()} | {:bubble, t()} | {:noreply, t()}

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

Routing goes in three stages. A :disabled or :readonly field only handles {:focus}, {:blur} and :activate, and returns {:noreply, state} for everything else. An unfocused field additionally handles a mouse up, :validate and :clear_error. A focused field handles the full set of key bindings listed in the module doc, {:char, code}, {:bracketed_paste, text}, mouse up and drag.

Most handled events return {:ok, new_state}; enter with an :on_submit returns {:ok, cleared_state, actions}; ctrl with an unhandled key and enter without an :on_submit return {:bubble, state}; a keystroke the restriction rejects, a backspace at position zero and a delete at the end return {:noreply, state}.

iex> state = Drafter.Widget.TextInput.mount(%{focused: true})
iex> {:ok, typed} = Drafter.Widget.TextInput.handle_event({:char, ?a}, state)
iex> {typed.text, typed.cursor_position}
{"a", 1}

iex> state = Drafter.Widget.TextInput.mount(%{focused: true, type: :integer})
iex> Drafter.Widget.TextInput.handle_event({:char, ?a}, state) |> elem(0)
:noreply

iex> state = Drafter.Widget.TextInput.mount(%{text: "hi", focused: true, cursor_position: 2})
iex> {:ok, deleted} = Drafter.Widget.TextInput.handle_event({:key, :backspace}, state)
iex> {deleted.text, deleted.cursor_position}
{"h", 1}

iex> state = Drafter.Widget.TextInput.mount(%{text: "hi", disabled: true})
iex> Drafter.Widget.TextInput.handle_event({:char, ?a}, state) |> elem(0)
:noreply

iex> state = Drafter.Widget.TextInput.mount(%{text: "hi", focused: true})
iex> Drafter.Widget.TextInput.handle_event({:key, :enter}, state) |> elem(0)
:bubble

mount(props)

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

Builds the widget state from props.

Every option listed in the module doc is read here with the default stated there. :restrict is compiled to a Regex.t/0 when it is given as a string.

iex> state = Drafter.Widget.TextInput.mount(%{})
iex> {state.text, state.cursor_position, state.width, state.type, state.focused}
{"", 0, 40, :text, false}

iex> state = Drafter.Widget.TextInput.mount(%{text: "hi", placeholder: "name"})
iex> {state.text, state.placeholder, state.max_length, state.error}
{"hi", "name", nil, nil}

iex> Drafter.Widget.TextInput.mount(%{restrict: "^[a-z]$"}).restrict |> Regex.source()
"^[a-z]$"

preferred_height(args, opts)

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

The number of rows the element asks for: always 3, the two border rows plus the content row. There is no :height override, and a validation error row is not accounted for.

iex> Drafter.Widget.TextInput.preferred_height(nil, height: 10)
3

render(state, rect)

Draws the bordered field into rect, always returning exactly rect.height strips.

state may be a plain props map, in which case it is passed through mount/1 first. The first three strips are the top border, the content row and the bottom border; a non-nil :error adds a fourth in red. Shorter output is padded with blank rows and longer output is truncated, so a rect under three rows high loses the bottom of the box. The content is min(state.width, rect.width - 2) columns wide, drawn from :scroll_offset, with the cursor block shown only while focused.

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.

:text is ignored entirely while the field is focused, so a re-render never overwrites what is being typed. Unfocused, it accepts either a string or a {text, validation_result} tuple and falls back to the current text for anything else. :cursor_position and :scroll_offset are never set here, so a shorter new text can leave the cursor past its end until the next keystroke.

iex> state = Drafter.Widget.TextInput.mount(%{text: "old"})
iex> Drafter.Widget.TextInput.update(%{text: "new"}, state).text
"new"

iex> state = Drafter.Widget.TextInput.mount(%{text: "typing", focused: true})
iex> Drafter.Widget.TextInput.update(%{text: "clobber"}, state).text
"typing"

iex> state = Drafter.Widget.TextInput.mount(%{text: "old"})
iex> Drafter.Widget.TextInput.update(%{text: {"tupled", {:ok, "tupled"}}}, state).text
"tupled"

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 the props that may safely change after mount.

Always passes :on_change, :on_submit, :classes, :validators, :disabled, :readonly, :password, :restrict, :type and :select_on_focus. Adds :width and :placeholder only when they differ from the mounted state, and :text only when opts carries :bind or :value and the text differs — so an unbound field keeps whatever the user typed.

iex> props = Drafter.Widget.TextInput.from_component_opts(nil, placeholder: "Email")
iex> state = Drafter.Widget.TextInput.mount(props)
iex> Drafter.Widget.TextInput.update_props_from_mount(props, state, []) |> Map.has_key?(:text)
false

iex> opts = [bind: :query, __app_state__: %{query: "abc"}]
iex> props = Drafter.Widget.TextInput.from_component_opts(nil, opts)
iex> state = Drafter.Widget.TextInput.mount(%{text: "old", width: 0, placeholder: ""})
iex> Drafter.Widget.TextInput.update_props_from_mount(props, state, opts).text
"abc"