Skip to main content

form_rs/
leptos.rs

1// Copyright 2026 Open SASS Core Maintainers.
2//
3// Licensed under the MIT license
4// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
5// option. This file may not be copied, modified, or distributed
6// except according to those terms.
7
8#![doc = include_str!("../LEPTOS.md")]
9
10use crate::common::{
11    Color, EncType, LabelPlacement, Margin, Method, Size, Target, ValidationBehavior,
12    ValidationState, Variant, base_form_control_style, base_form_group_row_style,
13    base_form_group_style, base_form_style, base_helper_text_style, base_input_field_style,
14    base_label_style, field_disabled_style, field_error_style, field_valid_style,
15    helper_error_style, helper_valid_style, label_error_style, required_asterisk_style,
16};
17pub use input_rs::leptos::Input;
18use leptos::ev::{Event, FocusEvent, SubmitEvent};
19use leptos::prelude::*;
20
21/// Shared context propagated by [`Form`] to all descendant components.
22#[derive(Clone, Debug, PartialEq, Copy)]
23pub struct FormContext {
24    /// Active validation strategy for the entire form.
25    pub validation_behavior: ValidationBehavior,
26    /// `true` when the form is actively submitting.
27    pub is_submitting: bool,
28    /// Whether native browser validation is suppressed.
29    pub novalidate: bool,
30}
31
32/// Shared context provided by [`Control`] to its children.
33#[derive(Clone, Debug, PartialEq, Copy)]
34pub struct ControlContext {
35    /// Whether the field is in an error state.
36    pub error: bool,
37    /// Whether the field is disabled.
38    pub disabled: bool,
39    /// Whether the field is focused.
40    pub focused: bool,
41    /// Whether the field is required.
42    pub required: bool,
43    /// Whether the field has a non-empty value.
44    pub filled: bool,
45    /// Visual variant of the field.
46    pub variant: Variant,
47    /// Color theme of the field.
48    pub color: Color,
49    /// Size of the field.
50    pub size: Size,
51}
52
53/// A semantic `<form>` wrapper that provides validation context to descendant fields.
54///
55/// Propagates [`FormContext`] via Leptos context so that [`Control`],
56/// [`FormLabel`], and [`Helper`] can read validation mode and submission
57/// state without prop drilling.
58///
59/// # Accessibility
60///
61/// - Renders as a native `<form>` element (implicit `"form"` ARIA role).
62/// - Supply `aria_label` or `aria_labelledby` to create a named form landmark.
63///
64/// # Examples
65///
66/// ```rust
67/// use form_rs::leptos::{Form, Control, FormLabel};
68/// use leptos::prelude::*;
69/// use leptos::ev::SubmitEvent;
70///
71/// #[component]
72/// pub fn LoginForm() -> impl IntoView {
73///     view! {
74///         <Form aria_label="Login" on_submit=Callback::new(|e: SubmitEvent| e.prevent_default())>
75///             <Control input_id="email">
76///                 <FormLabel html_for="email">"Email"</FormLabel>
77///             </Control>
78///         </Form>
79///     }
80/// }
81/// ```
82#[allow(non_snake_case)]
83#[component]
84pub fn Form(
85    /// Form content: fields, buttons, and other interactive controls.
86    children: Children,
87    /// Additional CSS class names on the `<form>` element.
88    #[prop(default = "")]
89    class: &'static str,
90    /// Inline CSS on the `<form>` element.
91    #[prop(default = "")]
92    style: &'static str,
93    /// `id` attribute on the `<form>` element.
94    #[prop(default = "")]
95    id: &'static str,
96    /// URL that processes the form submission.
97    #[prop(default = "")]
98    action: &'static str,
99    /// HTTP method for form submission.
100    #[prop(default = Method::Get)]
101    method: Method,
102    /// MIME encoding type for submitted data.
103    #[prop(default = EncType::UrlEncoded)]
104    enc_type: EncType,
105    /// Where to display the form response.
106    #[prop(default = Target::Self_)]
107    target: Target,
108    /// When `true`, disables native browser validation.
109    #[prop(default = false)]
110    novalidate: bool,
111    /// Browser autocomplete hint.
112    #[prop(default = "on")]
113    autocomplete: &'static str,
114    /// Name of the form.
115    #[prop(default = "")]
116    name: &'static str,
117    /// Controls whether validation is native or ARIA-based.
118    #[prop(default = ValidationBehavior::Native)]
119    validation_behavior: ValidationBehavior,
120    /// Handler called when the form is submitted.
121    #[prop(optional)]
122    on_submit: Option<Callback<SubmitEvent>>,
123    /// Handler called when the form is reset.
124    #[prop(optional)]
125    on_reset: Option<Callback<Event>>,
126    /// Accessible label for screen readers.
127    #[prop(default = "")]
128    aria_label: &'static str,
129    /// ID of element that labels this form.
130    #[prop(default = "")]
131    aria_labelledby: &'static str,
132    /// `data-testid` for automated testing.
133    #[prop(default = "")]
134    data_testid: &'static str,
135) -> impl IntoView {
136    let is_submitting = RwSignal::new(false);
137    let should_novalidate = novalidate || validation_behavior == ValidationBehavior::Aria;
138
139    let ctx = FormContext {
140        validation_behavior,
141        is_submitting: is_submitting.get(),
142        novalidate: should_novalidate,
143    };
144    provide_context(ctx);
145
146    let full_style = format!("{} {}", base_form_style(), style);
147    let full_class = format!("form {}", class);
148
149    view! {
150        <form
151            id=id
152            class=full_class
153            style=full_style
154            action=action
155            method=method.as_str()
156            enctype=enc_type.as_str()
157            target=target.as_str()
158            novalidate=should_novalidate
159            autocomplete=autocomplete
160            name=name
161            on:submit=move |e| {
162                is_submitting.set(true);
163                if let Some(cb) = on_submit { cb.run(e); }
164                is_submitting.set(false);
165            }
166            on:reset=move |e: Event| {
167                if let Some(cb) = on_reset { cb.run(e); }
168            }
169            aria-label=aria_label
170            aria-labelledby=aria_labelledby
171            data-testid=data_testid
172        >
173            {children()}
174        </form>
175    }
176}
177
178/// A context provider that wraps a single form field with its label and helper text.
179///
180/// Provides [`ControlContext`] to [`FormLabel`] and [`Helper`]
181/// descendants so they reflect error, disabled, required, variant, and color
182/// states automatically.
183///
184/// # Examples
185///
186/// ```rust
187/// use form_rs::leptos::{Control, FormLabel, Helper};
188/// use form_rs::Variant;
189/// use leptos::prelude::*;
190///
191/// #[component]
192/// pub fn EmailField() -> impl IntoView {
193///     view! {
194///         <Control input_id="email" required=true variant=Variant::Outlined>
195///             <FormLabel html_for="email">"Email"</FormLabel>
196///             <Helper>"We'll never share your email."</Helper>
197///         </Control>
198///     }
199/// }
200/// ```
201#[allow(non_snake_case)]
202#[component]
203pub fn Control(
204    /// Field components: [`FormLabel`], input, [`Helper`].
205    children: Children,
206    /// Additional CSS class names on the wrapper `<div>`.
207    #[prop(default = "")]
208    class: &'static str,
209    /// Inline CSS on the wrapper `<div>`.
210    #[prop(default = "")]
211    style: &'static str,
212    /// `id` attribute on the wrapper `<div>`.
213    #[prop(default = "")]
214    id: &'static str,
215    /// The `id` of the inner `<input>` element.
216    #[allow(unused)]
217    #[prop(default = "")]
218    input_id: &'static str,
219    /// When `true`, renders all children in a disabled state.
220    #[prop(default = false)]
221    disabled: bool,
222    /// When `true`, renders the label and helper text in the error color.
223    #[prop(default = false)]
224    error: bool,
225    /// When `true`, applies the focused style.
226    #[prop(default = false)]
227    focused: bool,
228    /// When `true`, the component takes up full container width.
229    #[prop(default = false)]
230    full_width: bool,
231    /// When `true`, the label is hidden.
232    #[prop(default = false)]
233    hidden_label: bool,
234    /// Vertical spacing adjustment.
235    #[prop(default = Margin::None)]
236    margin: Margin,
237    /// Whether the field is required.
238    #[prop(default = false)]
239    required: bool,
240    /// Size of the component.
241    #[prop(default = Size::Medium)]
242    size: Size,
243    /// Visual style variant.
244    #[prop(default = Variant::Outlined)]
245    variant: Variant,
246    /// Color theme.
247    #[prop(default = Color::Primary)]
248    color: Color,
249    /// `data-testid` for automated testing.
250    #[prop(default = "")]
251    data_testid: &'static str,
252) -> impl IntoView {
253    let ctx = ControlContext {
254        error,
255        disabled,
256        focused,
257        required,
258        filled: false,
259        variant,
260        color,
261        size,
262    };
263    provide_context(ctx);
264
265    let mut cls_parts = vec![
266        "form-control".to_string(),
267        variant.to_class().to_string(),
268        size.to_class().to_string(),
269    ];
270    if !margin.to_class().is_empty() {
271        cls_parts.push(margin.to_class().to_string());
272    }
273    if full_width {
274        cls_parts.push("form-control--full-width".to_string());
275    }
276    if error {
277        cls_parts.push("form-control--error".to_string());
278    }
279    if disabled {
280        cls_parts.push("form-control--disabled".to_string());
281    }
282    if focused {
283        cls_parts.push("form-control--focused".to_string());
284    }
285    if hidden_label {
286        cls_parts.push("form-control--hidden-label".to_string());
287    }
288    cls_parts.push(class.to_string());
289
290    let mut sty_parts = vec![
291        base_form_control_style().to_string(),
292        margin.to_style().to_string(),
293    ];
294    if full_width {
295        sty_parts.push("width: 100%;".to_string());
296    }
297    if disabled {
298        sty_parts.push(field_disabled_style().to_string());
299    }
300    sty_parts.push(style.to_string());
301
302    let full_class = cls_parts.join(" ");
303    let full_style = sty_parts.join(" ");
304
305    view! {
306        <div
307            id=id
308            class=full_class
309            style=full_style
310            data-testid=data_testid
311        >
312            {children()}
313        </div>
314    }
315}
316
317/// Renders an accessible `<label>` linked to a form field.
318///
319/// When nested inside [`Control`], it automatically reads `error`,
320/// `disabled`, `focused`, `required`, and `color` from [`ControlContext`].
321///
322/// # Examples
323///
324/// ```rust
325/// use form_rs::leptos::FormLabel;
326/// use leptos::prelude::*;
327///
328/// #[component]
329/// pub fn MyLabel() -> impl IntoView {
330///     view! { <FormLabel html_for="username">"Username"</FormLabel> }
331/// }
332/// ```
333#[allow(non_snake_case)]
334#[component]
335pub fn FormLabel(
336    /// Label text or content.
337    children: Children,
338    /// Additional CSS class names on the `<label>`.
339    #[prop(default = "")]
340    class: &'static str,
341    /// Inline CSS on the `<label>`.
342    #[prop(default = "")]
343    style: &'static str,
344    /// `id` attribute on the `<label>`.
345    #[prop(default = "")]
346    id: &'static str,
347    /// The `id` of the labeled `<input>`.
348    #[prop(default = "")]
349    html_for: &'static str,
350    /// Color theme override.
351    #[prop(optional)]
352    color: Option<Color>,
353    /// When `true`, renders in disabled style.
354    #[prop(default = false)]
355    disabled: bool,
356    /// When `true`, renders in error color.
357    #[prop(default = false)]
358    error: bool,
359    /// When `true`, applies the filled modifier.
360    #[prop(default = false)]
361    filled: bool,
362    /// When `true`, applies the focused modifier.
363    #[prop(default = false)]
364    focused: bool,
365    /// When `true`, shows a required asterisk.
366    #[prop(default = false)]
367    required: bool,
368    /// `data-testid` for automated testing.
369    #[prop(default = "")]
370    data_testid: &'static str,
371) -> impl IntoView {
372    let ctx = use_context::<ControlContext>();
373
374    let is_error = error || ctx.is_some_and(|c| c.error);
375    let is_disabled = disabled || ctx.is_some_and(|c| c.disabled);
376    let is_focused = focused || ctx.is_some_and(|c| c.focused);
377    let is_required = required || ctx.is_some_and(|c| c.required);
378    let eff_color = color.or_else(|| ctx.map(|c| c.color)).unwrap_or_default();
379    let for_attr = if !html_for.is_empty() {
380        html_for.to_string()
381    } else {
382        String::new()
383    };
384
385    let color_style = if is_error {
386        label_error_style().to_string()
387    } else if is_focused {
388        eff_color.to_label_color()
389    } else {
390        String::new()
391    };
392
393    let mut cls_parts = vec!["form-label".to_string()];
394    if is_error {
395        cls_parts.push("form-label--error".to_string());
396    }
397    if is_disabled {
398        cls_parts.push("form-label--disabled".to_string());
399    }
400    if is_focused {
401        cls_parts.push("form-label--focused".to_string());
402    }
403    if filled {
404        cls_parts.push("form-label--filled".to_string());
405    }
406    cls_parts.push(class.to_string());
407
408    let full_style = format!("{} {} {}", base_label_style(), color_style, style);
409    let full_class = cls_parts.join(" ");
410
411    view! {
412        <label
413            id=id
414            class=full_class
415            style=full_style
416            for=for_attr
417            aria-disabled=if is_disabled { "true" } else { "false" }
418            data-testid=data_testid
419        >
420            {children()}
421            <Show when=move || is_required>
422                <span style=required_asterisk_style() aria-hidden="true">{"*"}</span>
423            </Show>
424        </label>
425    }
426}
427
428/// Renders accessible helper text beneath a form field.
429///
430/// When nested inside [`Control`], it inherits `error` and `disabled`
431/// from [`ControlContext`]. Error text uses `role="alert"` for immediate
432/// screen-reader announcement.
433///
434/// # Examples
435///
436/// ```rust
437/// use form_rs::leptos::Helper;
438/// use leptos::prelude::*;
439///
440/// #[component]
441/// pub fn PasswordHint() -> impl IntoView {
442///     view! {
443///         <Helper id="pw-hint">"Must be at least 8 characters."</Helper>
444///     }
445/// }
446/// ```
447#[allow(non_snake_case)]
448#[component]
449pub fn Helper(
450    /// Helper text content.
451    children: Children,
452    /// Additional CSS class names on the `<p>`.
453    #[prop(default = "")]
454    class: &'static str,
455    /// Inline CSS on the `<p>`.
456    #[prop(default = "")]
457    style: &'static str,
458    /// `id` attribute, use as `aria-describedby` on the input.
459    #[prop(default = "")]
460    id: &'static str,
461    /// When `true`, renders in disabled style.
462    #[prop(default = false)]
463    disabled: bool,
464    /// When `true`, renders in error color with `role="alert"`.
465    #[prop(default = false)]
466    error: bool,
467    /// When `true`, renders in success color.
468    #[prop(default = false)]
469    valid: bool,
470    /// When `true`, applies the filled modifier.
471    #[prop(default = false)]
472    filled: bool,
473    /// When `true`, applies the focused modifier.
474    #[prop(default = false)]
475    focused: bool,
476    /// Vertical spacing adjustment.
477    #[prop(default = Margin::None)]
478    margin: Margin,
479    /// `data-testid` for automated testing.
480    #[prop(default = "")]
481    data_testid: &'static str,
482) -> impl IntoView {
483    let ctx = use_context::<ControlContext>();
484
485    let is_error = error || ctx.is_some_and(|c| c.error);
486    let is_disabled = disabled || ctx.is_some_and(|c| c.disabled);
487
488    let color_style = if is_error {
489        helper_error_style()
490    } else if valid {
491        helper_valid_style()
492    } else {
493        ""
494    };
495
496    let mut cls_parts = vec!["form-helper-text".to_string()];
497    if is_error {
498        cls_parts.push("form-helper-text--error".to_string());
499    }
500    if valid {
501        cls_parts.push("form-helper-text--valid".to_string());
502    }
503    if is_disabled {
504        cls_parts.push("form-helper-text--disabled".to_string());
505    }
506    if focused {
507        cls_parts.push("form-helper-text--focused".to_string());
508    }
509    if filled {
510        cls_parts.push("form-helper-text--filled".to_string());
511    }
512    if !margin.to_class().is_empty() {
513        cls_parts.push(margin.to_class().to_string());
514    }
515    cls_parts.push(class.to_string());
516
517    let full_style = format!("{} {} {}", base_helper_text_style(), color_style, style);
518    let full_class = cls_parts.join(" ");
519
520    view! {
521        <p
522            id=id
523            class=full_class
524            style=full_style
525            role=if is_error { "alert" } else { "" }
526            aria-live=if is_error { "polite" } else { "" }
527            aria-disabled=if is_disabled { "true" } else { "false" }
528            data-testid=data_testid
529        >
530            {children()}
531        </p>
532    }
533}
534
535/// Groups checkboxes or switch controls with optional horizontal layout.
536///
537/// Renders as `<div role="group">` with `aria_label` or `aria_labelledby`
538/// to name the group for screen readers.
539///
540/// # Examples
541///
542/// ```rust
543/// use form_rs::leptos::Group;
544/// use leptos::prelude::*;
545///
546/// #[component]
547/// pub fn OptionsGroup() -> impl IntoView {
548///     view! {
549///         <Group aria_label="Notifications" row=true>
550///             <span>"Email"</span>
551///             <span>"SMS"</span>
552///         </Group>
553///     }
554/// }
555/// ```
556#[allow(non_snake_case)]
557#[component]
558pub fn Group(
559    /// Controls such as checkboxes or switches.
560    children: Children,
561    /// Additional CSS class names.
562    #[prop(default = "")]
563    class: &'static str,
564    /// Inline CSS.
565    #[prop(default = "")]
566    style: &'static str,
567    /// `id` attribute.
568    #[prop(default = "")]
569    id: &'static str,
570    /// When `true`, renders children horizontally.
571    #[prop(default = false)]
572    row: bool,
573    /// When `true`, applies the error modifier class.
574    #[prop(default = false)]
575    error: bool,
576    /// Accessible label for the group.
577    #[prop(default = "")]
578    aria_label: &'static str,
579    /// ID of element that labels the group.
580    #[prop(default = "")]
581    aria_labelledby: &'static str,
582    /// `data-testid` for automated testing.
583    #[prop(default = "")]
584    data_testid: &'static str,
585) -> impl IntoView {
586    let base_style = if row {
587        base_form_group_row_style()
588    } else {
589        base_form_group_style()
590    };
591
592    let mut cls_parts = vec!["form-group".to_string()];
593    if row {
594        cls_parts.push("form-group--row".to_string());
595    }
596    if error {
597        cls_parts.push("form-group--error".to_string());
598    }
599    cls_parts.push(class.to_string());
600
601    let full_style = format!("{} {}", base_style, style);
602    let full_class = cls_parts.join(" ");
603
604    view! {
605        <div
606            id=id
607            class=full_class
608            style=full_style
609            role="group"
610            aria-label=aria_label
611            aria-labelledby=aria_labelledby
612            data-testid=data_testid
613        >
614            {children()}
615        </div>
616    }
617}
618
619/// A label wrapper that pairs a control with its descriptive text.
620///
621/// Renders a `<label>` element containing both the control and its text,
622/// with configurable label placement (start, end, top, bottom).
623///
624/// # Examples
625///
626/// ```rust
627/// use form_rs::leptos::ControlLabel;
628/// use form_rs::LabelPlacement;
629/// use leptos::prelude::*;
630///
631/// #[component]
632/// pub fn TermsCheckbox() -> impl IntoView {
633///     view! {
634///         <ControlLabel
635///             control=view! { <input type="checkbox" /> }.into_any()
636///             label=view! { <span>"I agree to the terms"</span> }.into_any()
637///         />
638///     }
639/// }
640/// ```
641#[allow(non_snake_case)]
642#[component]
643pub fn ControlLabel(
644    /// The control element.
645    control: AnyView,
646    /// The label text or content.
647    #[prop(optional)]
648    label: Option<AnyView>,
649    /// Additional CSS class names.
650    #[prop(default = "")]
651    class: &'static str,
652    /// Inline CSS.
653    #[prop(default = "")]
654    style: &'static str,
655    /// `id` attribute.
656    #[prop(default = "")]
657    id: &'static str,
658    /// Whether the control appears checked.
659    #[prop(default = false)]
660    checked: bool,
661    /// When `true`, all elements within are visually disabled.
662    #[prop(default = false)]
663    disabled: bool,
664    /// Position of the label text relative to the control.
665    #[prop(default = LabelPlacement::End)]
666    label_placement: LabelPlacement,
667    /// Whether the label indicates a required field.
668    #[prop(default = false)]
669    required: bool,
670    /// The value associated with this labeled control.
671    #[prop(default = "")]
672    value: &'static str,
673    /// `data-testid` for automated testing.
674    #[prop(default = "")]
675    data_testid: &'static str,
676) -> impl IntoView {
677    let flex_style = label_placement.to_flex_direction();
678    let placement_class = label_placement.to_class();
679
680    let mut cls_parts = vec![
681        "form-control-label".to_string(),
682        placement_class.to_string(),
683    ];
684    if disabled {
685        cls_parts.push("form-control-label--disabled".to_string());
686    }
687    cls_parts.push(class.to_string());
688
689    let base_style = format!(
690        "display: inline-flex; {} gap: 8px; align-items: center; cursor: {}; user-select: none; {}",
691        flex_style,
692        if disabled { "not-allowed" } else { "pointer" },
693        style
694    );
695
696    let full_class = cls_parts.join(" ");
697
698    let _ = checked;
699    let _ = value;
700
701    view! {
702        <label
703            id=id
704            class=full_class
705            style=base_style
706            aria-disabled=if disabled { "true" } else { "false" }
707            aria-required=if required { "true" } else { "false" }
708            data-testid=data_testid
709        >
710            {control}
711            <span
712                class="form-control-label__label"
713                style="font-size: 14px; color: #d4d4d8; line-height: 1.4;"
714            >
715                {label}
716                <Show when=move || required>
717                    <span style=required_asterisk_style() aria-hidden="true">{"*"}</span>
718                </Show>
719            </span>
720        </label>
721    }
722}
723
724/// A convenience composition of [`Control`], [`FormLabel`], `Input`, and
725/// [`Helper`] into a single validated form field for Leptos.
726///
727/// Uses `input-rs` for the underlying `<input>` element with HTML5 validation.
728///
729/// # Examples
730///
731/// ```rust
732/// use form_rs::leptos::Field;
733/// use leptos::prelude::*;
734///
735/// #[component]
736/// pub fn EmailInput() -> impl IntoView {
737///     let handle = RwSignal::new(String::new());
738///     let valid = RwSignal::new(true);
739///     view! {
740///         <Field
741///             id="email"
742///             name="email"
743///             r#type="email"
744///             label="Email address"
745///             placeholder="ferris@opensass.org"
746///             required=true
747///             handle=handle
748///             valid_handle=valid
749///             validate_function=|v: String| !v.is_empty() && v.contains('@')
750///         />
751///     }
752/// }
753/// ```
754#[allow(non_snake_case)]
755#[component]
756pub fn Field(
757    /// The unique `id` for the `<input>` element.
758    id: &'static str,
759    /// The `name` attribute for the `<input>`.
760    #[prop(default = "")]
761    name: &'static str,
762    /// The input type.
763    #[prop(default = "text")]
764    r#type: &'static str,
765    /// Label text displayed above the input.
766    #[prop(default = "")]
767    label: &'static str,
768    /// Placeholder text.
769    #[prop(default = "")]
770    placeholder: &'static str,
771    /// Helper text below the input.
772    #[prop(default = "")]
773    helper_text: &'static str,
774    /// Validation state.
775    #[prop(default = ValidationState::None)]
776    validation_state: ValidationState,
777    /// Whether the field is required.
778    #[prop(default = false)]
779    required: bool,
780    /// Whether the field is disabled.
781    #[prop(default = false)]
782    disabled: bool,
783    /// Whether the field takes the full container width.
784    #[prop(default = true)]
785    full_width: bool,
786    /// Visual variant.
787    #[prop(default = Variant::Outlined)]
788    variant: Variant,
789    /// Color theme.
790    #[prop(default = Color::Primary)]
791    color: Color,
792    /// Size.
793    #[prop(default = Size::Medium)]
794    size: Size,
795    /// HTML5 `pattern` for native regex validation.
796    #[prop(default = ".*")]
797    pattern: &'static str,
798    /// Max character count.
799    #[prop(optional)]
800    maxlength: Option<usize>,
801    /// Min character count.
802    #[prop(optional)]
803    minlength: Option<usize>,
804    /// Outer `<div>` CSS class.
805    #[prop(default = "")]
806    class: &'static str,
807    /// Outer `<div>` inline CSS.
808    #[prop(default = "")]
809    style: &'static str,
810    /// Controlled value signal.
811    handle: RwSignal<String>,
812    /// Validity signal.
813    valid_handle: RwSignal<bool>,
814    /// Validation function.
815    validate_function: fn(String) -> bool,
816    /// `data-testid` for automated testing.
817    #[prop(default = "")]
818    data_testid: &'static str,
819) -> impl IntoView {
820    let focused = RwSignal::new(false);
821
822    let vs1 = validation_state.clone();
823    let is_error =
824        Memo::new(move |_| vs1.is_invalid() || (!valid_handle.get() && !handle.get().is_empty()));
825
826    let vs2 = validation_state.clone();
827    let is_valid = Memo::new(move |_| {
828        matches!(vs2, ValidationState::Valid) || (valid_handle.get() && !handle.get().is_empty())
829    });
830
831    let error_msg = validation_state.error_message().map(|s| s.to_string());
832    let helper_id = format!("{}-helper", id);
833
834    let input_style = move || {
835        let focus_ring = if focused.get() && !is_error.get() {
836            color.to_focus_ring()
837        } else {
838            String::new()
839        };
840        let error_ring = if is_error.get() {
841            field_error_style().to_string()
842        } else {
843            String::new()
844        };
845        let valid_ring = if is_valid.get() && !is_error.get() {
846            field_valid_style().to_string()
847        } else {
848            String::new()
849        };
850        format!(
851            "{} {} {} {} {} {} transition: all 0.2s ease;",
852            base_input_field_style(),
853            variant.to_field_style(),
854            size.to_input_style(),
855            focus_ring,
856            error_ring,
857            valid_ring
858        )
859    };
860
861    let has_helper = !helper_text.is_empty() || error_msg.is_some();
862    let input_style_static: &'static str = Box::leak(input_style().into_boxed_str());
863    let helper_id_static: &'static str = Box::leak(helper_id.clone().into_boxed_str());
864    let (handle_read, handle_write) = handle.split();
865    let (valid_read, valid_write) = valid_handle.split();
866
867    view! {
868        <Control
869            id=""
870            input_id=id
871            error=is_error.get()
872            disabled=disabled
873            focused=focused.get()
874            full_width=full_width
875            required=required
876            variant=variant
877            color=color
878            size=size
879            class=class
880            style=style
881            data_testid=data_testid
882        >
883            <Show when=move || !label.is_empty()>
884                <FormLabel
885                    html_for=id
886                    error=is_error.get()
887                    focused=focused.get()
888                    required=required
889                    disabled=disabled
890                >
891                    {label}
892                </FormLabel>
893            </Show>
894            <Input
895                r#type=r#type
896                id=id
897                name=name
898                placeholder=placeholder
899                handle=(handle_read, handle_write)
900                valid_handle=(valid_read, valid_write)
901                validate_function=validate_function
902                required=required
903                disabled=disabled
904                pattern=pattern
905                maxlength=maxlength
906                minlength=minlength
907                input_style=input_style_static
908                aria_describedby=helper_id_static
909                aria_required=if required { "true" } else { "false" }
910                aria_invalid=if is_error.get() { "true" } else { "false" }
911                otp_mode=true
912                on_focus=Callback::new(move |_: FocusEvent| focused.set(true))
913                on_blur=Callback::new(move |_: FocusEvent| focused.set(false))
914            />
915            <Show when=move || has_helper>
916                {
917                    let content = if let Some(ref msg) = error_msg {
918                        msg.clone()
919                    } else if is_error.get() {
920                        "Invalid value.".to_string()
921                    } else {
922                        helper_text.to_string()
923                    };
924                    view! {
925                        <Helper
926                            id=helper_id_static
927                            error=is_error.get()
928                            valid=is_valid.get() && !is_error.get()
929                        >
930                            {content}
931                        </Helper>
932                    }
933                }
934            </Show>
935        </Control>
936    }
937}
938
939// Copyright 2026 Open SASS Core Maintainers.
940//
941// Licensed under the MIT license
942// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
943// option. This file may not be copied, modified, or distributed
944// except according to those terms.