Packages
Multi-surface application runtime for Elixir. One TEA module renders to terminal, browser (LiveView), SSH, and MCP (agents). 30+ widgets, flexbox + CSS grid, AI agent runtime, distributed swarm with CRDTs, time-travel debugging, session recording, sandboxed REPL, and agentic commerce.
Current section
Files
Jump to
Current section
Files
lib/raxol/ui/components/text_input.ex
defmodule Raxol.UI.Components.TextInput do
@moduledoc """
A text input component with editing capabilities.
This module provides a text input field that allows users to enter
and edit text with cursor positioning, selection, and validation.
## Example
```elixir
alias Raxol.UI.Components.TextInput
TextInput.new(
value: model.name,
placeholder: "Enter your name",
on_change: fn value -> {:update_name, value} end
)
```
"""
alias Raxol.Style
@type t :: map()
@doc """
Creates a new text input with the given options.
## Options
* `:value` - Current input value
* `:placeholder` - Placeholder text when empty
* `:on_change` - Function to call when value changes
* `:password` - Whether to mask input as password
* `:max_length` - Maximum input length
* `:style` - Custom style for the input
* `:disabled` - Whether the input is disabled
* `:validation` - Validation function
* `:invalid_message` - Message to show when validation fails
## Returns
A text input component that can be used in a Raxol view.
## Example
```elixir
TextInput.new(
value: model.email,
placeholder: "example@example.com",
validation: &String.contains?(&1, "@"),
invalid_message: "Must be a valid email"
)
```
"""
def new(opts \\ []) do
# Extract options with defaults
value = Keyword.get(opts, :value, "")
placeholder = Keyword.get(opts, :placeholder, "")
on_change = Keyword.get(opts, :on_change)
password = Keyword.get(opts, :password, false)
max_length = Keyword.get(opts, :max_length)
input_style = Keyword.get(opts, :style, :default)
disabled = Keyword.get(opts, :disabled, false)
validation = Keyword.get(opts, :validation)
invalid_message = Keyword.get(opts, :invalid_message, "Invalid input")
# Validate if needed
valid = validate_value(validation, value)
# Create the input with merged styles
%{
type: :component,
component_type: :text_input,
value: value,
placeholder: placeholder,
on_change: on_change,
password: password,
max_length: max_length,
disabled: disabled,
validation: validation,
valid: valid,
invalid_message: invalid_message,
cursor_pos: String.length(value),
selection: nil,
style: get_input_style(input_style, disabled, valid),
focus_key: Keyword.get(opts, :focus_key, generate_focus_key())
}
end
@doc """
Renders the text input as a basic element.
This is typically called by the renderer, not directly by users.
"""
def render(input) do
# The style is pre-calculated in new/1
# display_text = get_display_text(input)
# Style.render(input.style, %{\n # type: :text_input,\n # attrs: %{\n # value: input.value,\n # display_text: display_text,\n # placeholder: input.placeholder,\n # password: input.password,\n # cursor_pos: input.cursor_pos,\n # selection: input.selection,\n # disabled: input.disabled,\n # valid: input.valid,\n # invalid_message: if(!input.valid, do: input.invalid_message, else: nil),\n # focus_key: input.focus_key\n # }\n # })
input.style
end
@doc """
Updates the text input based on keyboard input.
## Parameters
* `input` - The text input component
* `key_event` - Keyboard event as `{meta, key}`
## Returns
Updated text input component.
## Example
```elixir
updated_input = TextInput.handle_key_event(input, {:none, ?a})
```
"""
def handle_key_event(%{disabled: true} = input, {_meta, key})
when key in 32..126//1 do
input
end
def handle_key_event(input, {_meta, key} = _key_event)
when key in 32..126//1 do
process_character_input(input, key)
end
def handle_key_event(%{disabled: true} = input, {:none, :backspace}),
do: input
def handle_key_event(%{value: ""} = input, {:none, :backspace}), do: input
def handle_key_event(%{cursor_pos: 0} = input, {:none, :backspace}), do: input
def handle_key_event(input, {:none, :backspace}) do
process_backspace(input)
end
def handle_key_event(%{disabled: true} = input, {:none, :delete}), do: input
def handle_key_event(%{value: ""} = input, {:none, :delete}), do: input
def handle_key_event(input, {:none, :delete}) do
handle_delete_key(input)
end
def handle_key_event(%{disabled: true} = input, {:none, :arrow_left}),
do: input
def handle_key_event(%{cursor_pos: 0} = input, {:none, :arrow_left}),
do: input
def handle_key_event(input, {:none, :arrow_left}) do
%{input | cursor_pos: input.cursor_pos - 1, selection: nil}
end
def handle_key_event(%{disabled: true} = input, {:none, :arrow_right}),
do: input
def handle_key_event(input, {:none, :arrow_right}) do
handle_arrow_right(input)
end
def handle_key_event(%{disabled: true} = input, {:none, :home}), do: input
def handle_key_event(input, {:none, :home}) do
%{input | cursor_pos: 0, selection: nil}
end
def handle_key_event(%{disabled: true} = input, {:none, :end}), do: input
def handle_key_event(input, {:none, :end}) do
%{input | cursor_pos: String.length(input.value), selection: nil}
end
def handle_key_event(%{disabled: true} = input, {:shift, :arrow_left}),
do: input
def handle_key_event(%{cursor_pos: 0} = input, {:shift, :arrow_left}),
do: input
def handle_key_event(input, {:shift, :arrow_left}) do
extend_selection_left(input)
end
def handle_key_event(%{disabled: true} = input, {:shift, :arrow_right}),
do: input
def handle_key_event(input, {:shift, :arrow_right}) do
extend_selection_right(input)
end
def handle_key_event(input, _key_event) do
# Ignore other key events
input
end
# Private functions
defp validate_value(nil, _value), do: true
defp validate_value(validation, value), do: validation.(value)
defp process_character_input(input, key) do
char = <<key::utf8>>
input
|> check_max_length(char)
|> insert_character(char)
|> validate_input()
end
defp check_max_length(%{max_length: nil} = input, _char), do: input
defp check_max_length(%{max_length: max_len, value: value} = _input, _char)
when byte_size(value) >= max_len,
do: :max_length_reached
defp check_max_length(input, _char), do: input
defp insert_character(:max_length_reached, _char), do: :max_length_reached
defp insert_character(input, char) do
{before_cursor, after_cursor} =
String.split_at(input.value, input.cursor_pos)
new_value = before_cursor <> char <> after_cursor
new_cursor_pos = input.cursor_pos + 1
%{
input
| value: new_value,
cursor_pos: new_cursor_pos,
selection: nil
}
end
defp process_backspace(input) do
{before_cursor, after_cursor} =
String.split_at(input.value, input.cursor_pos)
before_cursor = trim_last_character(before_cursor)
new_value = before_cursor <> after_cursor
new_cursor_pos = input.cursor_pos - 1
%{
input
| value: new_value,
cursor_pos: new_cursor_pos,
selection: nil
}
|> validate_input()
end
defp trim_last_character(""), do: ""
defp trim_last_character(str) do
{_, trimmed} = String.split_at(str, -1)
trimmed
end
defp handle_delete_key(input) when input.cursor_pos >= byte_size(input.value),
do: input
defp handle_delete_key(input) do
{before_cursor, after_cursor} =
String.split_at(input.value, input.cursor_pos)
{_, after_cursor} = String.split_at(after_cursor, 1)
new_value = before_cursor <> after_cursor
%{input | value: new_value, selection: nil}
|> validate_input()
end
defp handle_arrow_right(input)
when input.cursor_pos >= byte_size(input.value),
do: input
defp handle_arrow_right(input) do
%{input | cursor_pos: input.cursor_pos + 1, selection: nil}
end
defp extend_selection_left(input) do
new_cursor_pos = input.cursor_pos - 1
new_selection =
compute_selection(
input.selection,
new_cursor_pos,
input.cursor_pos,
:left
)
%{input | cursor_pos: new_cursor_pos, selection: new_selection}
end
defp extend_selection_right(input)
when input.cursor_pos >= byte_size(input.value),
do: input
defp extend_selection_right(input) do
new_cursor_pos = input.cursor_pos + 1
new_selection =
compute_selection(
input.selection,
input.cursor_pos,
new_cursor_pos,
:right
)
%{input | cursor_pos: new_cursor_pos, selection: new_selection}
end
defp compute_selection(nil, start_pos, end_pos, :left),
do: {start_pos, end_pos}
defp compute_selection(nil, start_pos, end_pos, :right),
do: {start_pos, end_pos}
defp compute_selection({anchor, _}, new_pos, _, _), do: {anchor, new_pos}
# defp get_display_text(%{value: "", placeholder: placeholder}) do
# placeholder
# end
#
# defp get_display_text(%{value: value, password: true}) do
# String.duplicate("*", String.length(value))
# end
#
# defp get_display_text(%{value: value}) do
# value
# end
defp get_input_style(style_atom, disabled, valid) when is_atom(style_atom) do
base_style =
Style.new(
padding: [0, 1],
width: :fill,
border: :single
)
# Define state-based styles
state_style = determine_state_style(disabled, valid)
# Merge base and state styles
Style.merge(base_style, state_style)
end
defp get_input_style(custom_style, disabled, valid)
when is_map(custom_style) do
# Define a base style if needed, or assume custom_style provides all defaults
base_style =
Style.new(
padding: [0, 1],
width: :fill,
border: :single
)
# Merge base with the custom style provided
merged_base = Style.merge(base_style, custom_style)
# Define state-based overrides
state_override_style = determine_override_style(disabled, valid)
# Merge the combined base/custom style with state overrides
Style.merge(merged_base, state_override_style)
end
defp determine_state_style(true, _valid),
do: Style.new(%{color: :gray, border_color: :dark_gray})
defp determine_state_style(false, false),
do: Style.new(%{border_color: :red})
defp determine_state_style(false, true),
do: Style.new(%{border_color: :gray})
defp determine_override_style(true, _valid),
do: Style.new(%{color: :gray, border_color: :dark_gray})
defp determine_override_style(false, false),
do: Style.new(%{border_color: :red})
defp determine_override_style(false, true),
do: Style.new(%{})
defp validate_input(%{validation: nil} = input) do
%{input | valid: true}
end
defp validate_input(%{validation: validation, value: value} = input) do
valid = validation.(value)
%{input | valid: valid}
end
defp validate_input(:max_length_reached), do: :max_length_reached
defp process_input_with_validation(input, text, nil) do
combined_value = input.value <> text
new_value = apply_max_length(combined_value, input.max_length)
%{
input
| value: new_value,
cursor_pos: String.length(new_value)
}
|> validate_input()
end
defp process_input_with_validation(input, text, validator) do
combined_text = input.value <> text
case validator.(combined_text) do
true ->
new_value = apply_max_length(combined_text, input.max_length)
%{
input
| value: new_value,
cursor_pos: String.length(new_value)
}
|> validate_input()
false ->
handle_invalid_input(input, text, validator)
end
end
defp handle_invalid_input(input, text, validator) do
filtered_text = filter_text_by_validator(input.value, text, validator)
new_value = apply_max_length(filtered_text, input.max_length)
%{
input
| value: new_value,
cursor_pos: String.length(new_value)
}
end
defp filter_text_by_validator(current_value, text, validator) do
case validator.("123") do
true ->
# Numeric validator - filter only digits
current_value <> String.replace(text, ~r/[^\d]/, "")
_ ->
# Other validators - reject entirely
current_value
end
end
defp apply_max_length(text, nil), do: text
defp apply_max_length(text, max_length) do
String.slice(text, 0, max_length)
end
defp generate_focus_key do
:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower)
end
# defp maybe_update_cursor_position(_state) do
# # Implementation of maybe_update_cursor_position/1
# end
@doc """
Handles text input by updating the value.
This is a simplified API for property tests that directly updates the value.
"""
@spec handle_input(map(), String.t()) :: map()
def handle_input(%{disabled: true} = input, _text), do: input
def handle_input(input, text) do
validator = Map.get(input, :validator) || Map.get(input, :validation)
process_input_with_validation(input, text, validator)
end
@doc """
Handles cursor positioning.
Ensures cursor stays within valid bounds.
"""
@spec handle_cursor(map(), integer()) :: map()
def handle_cursor(%{disabled: true} = input, _pos), do: input
def handle_cursor(input, pos) do
max_pos = String.length(input.value)
new_pos = pos |> max(0) |> min(max_pos)
%{input | cursor_pos: new_pos}
end
end