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/0initial value. Default"". Through the element the value comes from:bindinstead.:placeholder-String.t/0hint 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 viaDrafter.get_widget_value/1. Default: none.:on_change-({String.t(), validation_result()} -> term())called on every keystroke, cursor move and selection change. Defaultnil. Through the element it is built by the framework, which returnsnilwhen neither:bindnor:on_changeis given. An exception it raises is swallowed.:on_submit- atom event name or({String.t(), validation_result()} -> term())called whenenteris pressed. Defaultnil. Setting it makesenterclear the field.:keep_focus-boolean/0, refocus the widget after:on_submitfires. Defaultfalse. Read byfrom_component_opts/2only.:validators- list ofDrafter.Validationvalidators run on blur. Defaultnil.:disabled-boolean/0; the field takes focus but ignores every other event. Defaultfalse.:readonly-boolean/0; same handling as:disabled. Defaultfalse.:password-boolean/0, render each character as•. Defaultfalse.:restrict- aRegex.t/0or a string pattern compiled withRegex.compile!/1; only matching characters may be typed. Defaultnil.:type-:text | :integer | :number. Default:text.:integerallows0-9and-,:numberalso allows., and both apply on top of:restrict.:select_on_focus-boolean/0, select the whole value on focus. Defaultfalse.:style-map/0of style overrides passed to the theme computation. Default%{}.:class- theme class atom or list of them, normalised byDrafter.Style.normalize_classes/1and reachingmount/1as:classes. Default[].:max_length-pos_integer/0cap on the number of characters. Defaultnil, no cap. Read bymount/1andupdate/2only; thetext_input/1element does not forward it.:width-pos_integer/0inner width the scroll offset works against. Default40when mounting directly. A:widthinoptsis 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 bymount/1with defaults0,0,nil,nil,false,falseandnil.
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 wordShift+←/Shift+→/Shift+Home/Shift+End— extend selectionCtrl+A— select allCtrl+C/Ctrl+X/Ctrl+V— copy, cut, pasteCtrl+U— delete from cursor to start of lineCtrl+K— delete from cursor to end of lineCtrl+W— delete word to the left of cursorBackspace/Delete— delete character or selectionEnter— call:on_submitand clear the field; bubbles when no:on_submitis setHome/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
@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() }
Functions
@spec component_tag() :: :text_input
The component tag this widget registers under.
iex> Drafter.Widget.TextInput.component_tag()
:text_input
@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}
@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
@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]$"
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
@spec render(t() | Drafter.Widget.props(), Drafter.Widget.rect()) :: [ Drafter.Draw.Strip.t() ]
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.
Callback implementation for Drafter.Widget.unmount/1.
@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"
@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"