Input
Displays a form input field for collecting user text input.
Default
A basic text input with default settings.
Sizes
Inputs are available in small, default, and large sizes.
Small
Default
Large
Types
Inputs support various HTML input types for different kinds of data.
Password
Number
Disabled
A disabled input prevents user interaction.
With Placeholder
Inputs can display placeholder text as a hint to the user.
FreeInput
Text inputs and form fields.
Install with the Proa CLI
$ proa ui add inputInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →recipe.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Input recipe - type-safe styling system for Input component
//!
//! Provides size variants and base styling for the Input component.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Base styles for the input
mod base {
use super::*;
pub const INPUT_BASE: &str = tw_join!(
// Layout
"flex w-full",
// Typography
"text-base md:text-sm",
// Colors
"bg-transparent",
Input source
Browse and copy the reviewed source included with this Free component.
controlled.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! rsjs-controlled input pilot.
use proa_core::{DataLoader, WebContext, WebRenderSync, WriteBuf, WriteError};
use rsjs::Handler;
use super::{InputRecipeProps, InputSize, INPUT_RECIPE};
/// Minimal controlled text input for proving rsjs component composition.
///
/// This intentionally stays narrower than [`super::Input`]: it proves the
/// `value` + `on_change` composition contract without defining how the static
/// input's registration, validation, and reactive-attribute APIs interact with
/// parent-owned controlled state.
///
/// TODO: converge this with [`super::Input`] once those controlled semantics
/// are specified; RSJS v2 already supports server-only dynamic attributes.
pub struct ControlledInput {
pub value: String,
pub label: &'static str,
pub size: InputSize,
pub on_change: Handler<String>,
}
impl ControlledInput {
pub fn new(value: String, label: &'static str, on_change: Handler<String>) -> Self {
Self {
value,
label,
size: InputSize::Default,
on_change,
}
}
}
#[rsjs::rsjs(component, client)]
impl<B: WriteBuf, L: DataLoader> WebRenderSync<L, B> for ControlledInput {
fn render(self, cx: &mut WebContext<L, B>) -> Result<(), WriteError> {
let value = rsjs::signal(self.value);
let style = INPUT_RECIPE.resolve(InputRecipeProps {
size: Some(self.size),
});
let size = style.size().as_str();
let update_on_input = rsjs::event_handler(|event: rsjs::InputEvent<rsjs::elem::Input>| {
self.on_change.call(event.current_value());
});
proa_macros::html_sync! {
<label
data_controlled_input
data_slot="controlled-input"
class={INPUT_RECIPE.controlled_root}
>
<span
data_slot="controlled-input-label"
class={INPUT_RECIPE.controlled_label}
>
{self.label}
</span>
<input
type="text"
data_input
data_slot="input"
data_size={size}
class={style}
value={value.get()}
on_input={update_on_input()}
/>
</label>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn controlled_input_renders() {
let html = proa_core::render_to_string(ControlledInput {
value: "Ada".to_string(),
label: "Name",
size: InputSize::Default,
on_change: rsjs::noop_handler(),
});
assert!(html.contains("data-controlled-input"));
assert!(html.contains("data-slot=\"controlled-input\""));
assert!(html.contains("data-slot=\"controlled-input-label\""));
assert!(html.contains("Name"));
assert!(html.contains("value=\"Ada\""));
assert!(html.contains("rounded-md"));
}
#[test]
fn controlled_input_uses_resolved_recipe_size() {
let html = proa_core::render_to_string(ControlledInput {
value: String::new(),
label: "Name",
size: InputSize::Lg,
on_change: rsjs::noop_handler(),
});
assert!(html.contains(r#"data-size="lg""#));
assert!(html.contains(InputSize::Lg.classes()));
}
}
input.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Input component implementation
use super::recipe::{InputRecipeProps, InputSize, InputType, INPUT_RECIPE};
use crate::free_web::{binding_to_js_attr, JsonAttr};
#[cfg(test)]
use proa_core::ctx::Ctx;
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_ir::{FieldRegistration, ReactiveAttr};
use proa_macros::html_sync;
/// Type-safe Input component.
///
/// A form input field with support for various input types, sizes, and states.
/// Follows the shadcn/ui input design with Tailwind CSS styling.
///
/// # Example
///
/// ```ignore
/// use proa_ui::{Input, InputType, InputSize};
/// use proa_macros::html_sync;
///
/// // Basic usage
/// html_sync! {
/// <div>
/// {Input {
/// input_type: InputType::Email,
/// placeholder: Some("[email protected]"),
/// size: Some(InputSize::Default),
/// ..Input::new()
/// }}
/// </div>
/// }.render(cx);
///
/// // With form registration (React Hook Form style)
/// let form = SignUpForm::form();
/// html_sync! {
/// <div>
/// {Input {
/// registration: Some(form.register_email()),
/// input_type: InputType::Email,
/// placeholder: Some("[email protected]"),
/// aria_invalid: form.field_email().has_error(),
/// ..Input::new()
/// }}
/// </div>
/// }.render(cx);
/// ```
pub struct Input {
pub size: Option<InputSize>,
pub input_type: InputType,
pub name: Option<&'static str>,
pub value: Option<&'static str>,
pub placeholder: Option<&'static str>,
pub disabled: ReactiveAttr<bool>,
pub required: bool,
pub readonly: bool,
pub autofocus: bool,
pub autocomplete: Option<&'static str>,
pub min: Option<&'static str>,
pub max: Option<&'static str>,
pub minlength: Option<&'static str>,
pub maxlength: Option<&'static str>,
pub pattern: Option<&'static str>,
pub step: Option<&'static str>,
pub aria_label: Option<&'static str>,
pub aria_describedby: Option<&'static str>,
pub aria_invalid: ReactiveAttr<bool>,
/// Field registration data (from form.register_*)
pub registration: Option<FieldRegistration>,
pub class: Option<&'static str>,
pub id: Option<&'static str>,
}
impl Input {
/// Create a new input with default settings
pub const fn new() -> Self {
Self {
size: None,
input_type: InputType::Text,
name: None,
value: None,
placeholder: None,
disabled: ReactiveAttr::static_value(false),
required: false,
readonly: false,
autofocus: false,
autocomplete: None,
min: None,
max: None,
minlength: None,
maxlength: None,
pattern: None,
step: None,
aria_label: None,
aria_describedby: None,
aria_invalid: ReactiveAttr::static_value(false),
registration: None,
class: None,
id: None,
}
}
}
impl Default for Input {
fn default() -> Self {
Self::new()
}
}
impl<B: WriteBuf, L: ::proa_core::DataLoader> WebRenderSync<L, B> for Input {
#[inline(always)]
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = INPUT_RECIPE.resolve(InputRecipeProps { size: self.size });
let is_disabled = self.disabled.default;
let is_aria_invalid = self.aria_invalid.default;
let disabled_binding_string = binding_to_js_attr(&self.disabled.binding);
let aria_invalid_binding_string = binding_to_js_attr(&self.aria_invalid.binding);
let disabled_binding = disabled_binding_string.as_deref();
let aria_invalid_binding = aria_invalid_binding_string.as_deref();
let registration = self.registration.as_ref();
let rules_attr = JsonAttr(registration.map(|reg| reg.rules.as_slice()));
let data_field = registration.map(|reg| reg.data_field);
let name_attr = registration.map(|reg| reg.name).or(self.name);
let validate_on = registration.map(|reg| match reg.validate_on {
proa_ir::ValidationTrigger::Blur => "blur",
proa_ir::ValidationTrigger::Input => "input",
proa_ir::ValidationTrigger::Change => "change",
proa_ir::ValidationTrigger::Submit => "submit",
});
html_sync! {
<input
type={self.input_type.as_str()}
class={proa_macros::text!("{} {}", style, self.class)}
id={self.id}
name={name_attr}
value={self.value}
placeholder={self.placeholder}
disabled={is_disabled}
required={self.required}
readonly={self.readonly}
autofocus={self.autofocus}
autocomplete={self.autocomplete}
min={self.min}
max={self.max}
minlength={self.minlength}
maxlength={self.maxlength}
pattern={self.pattern}
step={self.step}
aria-label={self.aria_label}
aria-describedby={self.aria_describedby}
aria-invalid={is_aria_invalid}
data-input
data-slot="input"
data-size={style.size().as_str()}
data-field={data_field}
data-rules={rules_attr}
data-validate-on={validate_on}
data-bind-disabled={disabled_binding}
data-bind-aria-invalid={aria_invalid_binding}
/>
}
.render(cx)
}
}
/// Convert a Binding to a JavaScript expression string
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn input_renders_with_defaults() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Input::new().render(&mut cx).unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("<input"));
assert!(html.contains("type=\"text\""));
assert!(html.contains("class=\""));
assert!(html.contains("h-9"));
assert!(html.contains("data-slot=\"input\""));
assert!(html.contains("data-size=\"default\""));
}
#[test]
fn input_renders_email_type() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Input {
input_type: InputType::Email,
placeholder: Some("[email protected]"),
..Input::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("type=\"email\""));
assert!(html.contains("placeholder=\"[email protected]\""));
}
#[test]
fn input_applies_sizes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Input {
size: Some(InputSize::Lg),
..Input::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("h-10"));
}
#[test]
fn input_handles_disabled_state() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Input {
disabled: true.into(),
..Input::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains(" disabled"));
}
#[test]
fn input_handles_required_state() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Input {
required: true,
..Input::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains(" required"));
}
#[test]
fn input_renders_with_validation_attrs() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Input {
input_type: InputType::Number,
min: Some("0"),
max: Some("100"),
step: Some("5"),
..Input::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("type=\"number\""));
assert!(html.contains("min=\"0\""));
assert!(html.contains("max=\"100\""));
assert!(html.contains("step=\"5\""));
}
#[test]
fn input_renders_aria_attributes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Input {
aria_label: Some("Email address"),
aria_describedby: Some("email-help"),
aria_invalid: true.into(),
..Input::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("aria-label=\"Email address\""));
assert!(html.contains("aria-describedby=\"email-help\""));
assert!(html.contains("aria-invalid"));
}
#[test]
fn input_renders_with_custom_class() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Input {
class: Some("custom-input"),
..Input::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("custom-input"));
}
#[test]
fn input_omits_none_attributes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Input::new().render(&mut cx).unwrap();
let html = String::from_utf8(out).unwrap();
assert!(!html.contains("placeholder="));
assert!(!html.contains("name="));
assert!(!html.contains("value="));
assert!(!html.contains("min="));
assert!(!html.contains("max="));
}
#[test]
fn input_size_is_reasonable() {
assert!(std::mem::size_of::<Input>() < 512);
const _INPUT: Input = Input::new();
}
#[test]
fn input_with_reactive_disabled() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
let disabled = !ReactiveAttr::form_state(false, "isValid");
Input {
disabled,
..Input::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("disabled"));
assert!(html.contains("data-bind-disabled"));
assert!(html.contains("formState.isValid"));
}
#[test]
fn input_with_reactive_aria_invalid() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
let aria_invalid = ReactiveAttr::field_state(false, "email", "hasError");
Input {
name: Some("email"),
aria_invalid,
..Input::new()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-bind-aria-invalid"));
assert!(html.contains("fields.email.hasError"));
}
}
md.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
use super::Input;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
use proa_macros::md_sync;
/// Input renders its placeholder text in brackets — inputs are interactive,
/// so markdown shows a text indicator.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf> MdRenderSync<Loader, B> for Input {
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
if let Some(placeholder) = self.placeholder {
md_sync! { "[" { placeholder } "]" }.render_md(cx)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn input_renders_placeholder() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Input {
placeholder: Some("Enter your email"),
..Input::new()
}
.render_md(&mut cx)
.unwrap();
assert_eq!(String::from_utf8(buf).unwrap(), "[Enter your email]");
}
#[test]
fn input_no_placeholder_is_empty() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
Input::new().render_md(&mut cx).unwrap();
assert!(String::from_utf8(buf).unwrap().is_empty());
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
#![allow(clippy::module_inception)]
//! Input component - form text input field
//!
//! The Input component provides a styled text input with support for
//! various input types, sizes, and validation states.
#[cfg(feature = "rsjs")]
mod controlled;
mod input;
mod md;
mod recipe;
// Re-export input types
#[cfg(feature = "rsjs")]
pub use controlled::ControlledInput;
pub use input::Input;
// Re-export recipe types
pub use recipe::{
input_classes, input_recipe, InputRecipe, InputRecipeProps, InputSize, InputStyle, InputType,
INPUT_RECIPE,
};
recipe.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND MIT
//! Input recipe - type-safe styling system for Input component
//!
//! Provides size variants and base styling for the Input component.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Base styles for the input
mod base {
use super::*;
pub const INPUT_BASE: &str = tw_join!(
// Layout
"flex w-full",
// Typography
"text-base md:text-sm",
// Colors
"bg-transparent",
"text-foreground",
"placeholder:text-muted-foreground",
// Border (shadcn input style)
"border border-input",
"rounded-md",
// Shadow
"shadow-xs",
// Focus visible (shadcn style)
"focus-visible:outline-hidden",
"focus-visible:border-ring",
"focus-visible:ring-ring/50",
"focus-visible:ring-[3px]",
// Disabled
"disabled:cursor-not-allowed",
"disabled:opacity-50",
// File input styling
"file:border-0",
"file:bg-transparent",
"file:text-sm",
"file:font-medium",
"file:text-foreground",
// Selection
"selection:bg-primary",
"selection:text-primary-foreground",
// Aria invalid (error state)
"aria-invalid:border-destructive",
"aria-invalid:ring-destructive/20",
"dark:aria-invalid:ring-destructive/40"
);
}
/// Size variants for the input
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, proa_macros::ProaVariant)]
#[variant(name = "size")]
pub enum InputSize {
Sm,
#[default]
Default,
Lg,
}
impl InputSize {
/// Get the Tailwind classes for this size
pub const fn classes(self) -> &'static str {
match self {
InputSize::Sm => "h-8 px-2.5 py-1.5 text-xs",
InputSize::Default => "h-9 px-3 py-1",
InputSize::Lg => "h-10 px-4 py-2 text-base",
}
}
pub const fn as_str(self) -> &'static str {
match self {
Self::Sm => "sm",
Self::Default => "default",
Self::Lg => "lg",
}
}
}
/// Input type variants (HTML input types)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum InputType {
#[default]
Text,
Email,
Password,
Number,
Tel,
Url,
Search,
Date,
Time,
DatetimeLocal,
File,
Hidden,
}
impl InputType {
/// Get the HTML type attribute value
pub const fn as_str(self) -> &'static str {
match self {
InputType::Text => "text",
InputType::Email => "email",
InputType::Password => "password",
InputType::Number => "number",
InputType::Tel => "tel",
InputType::Url => "url",
InputType::Search => "search",
InputType::Date => "date",
InputType::Time => "time",
InputType::DatetimeLocal => "datetime-local",
InputType::File => "file",
InputType::Hidden => "hidden",
}
}
}
/// Optional inputs accepted by [`InputRecipe::resolve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct InputRecipeProps {
pub size: Option<InputSize>,
}
/// Fully typed input recipe with compile-time defaults.
#[derive(Debug, Clone, Copy)]
pub struct InputRecipe {
base: &'static str,
/// Wrapper used by the rsjs-controlled input surface.
pub controlled_root: &'static str,
/// Label text used by the rsjs-controlled input surface.
pub controlled_label: &'static str,
size_default: InputSize,
}
pub const INPUT_CONTROLLED_ROOT: &str = "grid gap-1.5";
pub const INPUT_CONTROLLED_LABEL: &str = "text-sm font-medium";
/// Global input recipe instance
pub const INPUT_RECIPE: InputRecipe = InputRecipe {
base: base::INPUT_BASE,
controlled_root: INPUT_CONTROLLED_ROOT,
controlled_label: INPUT_CONTROLLED_LABEL,
size_default: InputSize::Default,
};
impl InputRecipe {
pub const fn resolve(&'static self, props: InputRecipeProps) -> InputStyle {
let size = match props.size {
Some(size) => size,
None => self.size_default,
};
InputStyle { recipe: self, size }
}
}
/// Fully resolved, streamable Input style.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct InputStyle {
recipe: &'static InputRecipe,
size: InputSize,
}
impl InputStyle {
pub const fn size(self) -> InputSize {
self.size
}
}
impl AttrValue for InputStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.recipe.base.as_bytes())?;
cx.out.extend_static(b" ")?;
cx.out.extend_static(self.size.classes().as_bytes())
}
}
/// Get the global input recipe
pub const fn input_recipe() -> &'static InputRecipe {
&INPUT_RECIPE
}
/// Compatibility resolver for callers of the former helper.
pub const fn input_classes(size: InputSize) -> InputStyle {
INPUT_RECIPE.resolve(InputRecipeProps { size: Some(size) })
}
impl crate::variant_spec::ComponentSpec for InputRecipe {
const NAME: &'static str = "Input";
fn base_classes(&self) -> &'static str {
self.base
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
vec![crate::variant_spec::variant_metadata::<InputSize>()]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn input_recipe_generates_default() {
let style = input_recipe().resolve(InputRecipeProps::default());
assert!(input_recipe().base.contains("flex"));
assert!(input_recipe().base.contains("rounded-md"));
assert!(input_recipe().controlled_root.contains("grid"));
assert!(input_recipe().controlled_label.contains("font-medium"));
assert_eq!(style.size(), InputSize::Default);
assert!(style.size().classes().contains("h-9"));
}
#[test]
fn input_recipe_supports_sizes() {
for (size, expected) in [(InputSize::Sm, "h-8"), (InputSize::Lg, "h-10")] {
let style = input_recipe().resolve(InputRecipeProps { size: Some(size) });
assert_eq!(style.size(), size);
assert!(style.size().classes().contains(expected));
}
}
#[test]
fn input_type_as_str() {
assert_eq!(InputType::Text.as_str(), "text");
assert_eq!(InputType::Email.as_str(), "email");
assert_eq!(InputType::Password.as_str(), "password");
assert_eq!(InputType::DatetimeLocal.as_str(), "datetime-local");
}
#[test]
fn recipe_is_fully_const() {
const RECIPE: &InputRecipe = &INPUT_RECIPE;
const STYLE: InputStyle = RECIPE.resolve(InputRecipeProps {
size: Some(InputSize::Lg),
});
assert_eq!(STYLE.size(), InputSize::Lg);
}
}