Skip to main content

form_rs/
yew.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!("../YEW.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::yew::Input;
18use yew::prelude::*;
19
20/// Shared context propagated by [`Form`] to all descendant components.
21///
22/// Consumed by [`Control`], [`FormLabel`], and [`Helper`] to
23/// inherit validation behavior and submission state automatically.
24#[derive(Clone, Debug, PartialEq)]
25pub struct FormContext {
26    /// Active validation strategy for the entire form.
27    pub validation_behavior: ValidationBehavior,
28    /// `true` when the form is actively being submitted.
29    pub is_submitting: bool,
30    /// Whether the form has `novalidate` set.
31    pub novalidate: bool,
32}
33
34/// Shared context provided by [`Control`] to its children.
35///
36/// Consumed by [`FormLabel`] and [`Helper`] so they can reflect the
37/// parent control's state without prop drilling.
38#[derive(Clone, Debug, PartialEq)]
39pub struct ControlContext {
40    /// Whether the field is in an error state.
41    pub error: bool,
42    /// Whether the field is disabled.
43    pub disabled: bool,
44    /// Whether the field is focused.
45    pub focused: bool,
46    /// Whether the field is required.
47    pub required: bool,
48    /// Whether the field has a non-empty value.
49    pub filled: bool,
50    /// Visual variant of the field.
51    pub variant: Variant,
52    /// Color theme of the field.
53    pub color: Color,
54    /// Size of the field.
55    pub size: Size,
56    /// ID of the underlying `<input>` element, used to link `<label>`.
57    pub input_id: String,
58}
59
60/// Props for the [`Form`] Yew component.
61#[derive(Properties, PartialEq, Clone)]
62pub struct FormProps {
63    /// Form content: fields, buttons, and other interactive controls.
64    #[prop_or_default]
65    pub children: Children,
66
67    /// Additional CSS class names on the `<form>` element.
68    #[prop_or_default]
69    pub class: &'static str,
70
71    /// Inline CSS on the `<form>` element.
72    #[prop_or_default]
73    pub style: &'static str,
74
75    /// `id` attribute on the `<form>` element.
76    #[prop_or_default]
77    pub id: &'static str,
78
79    /// URL that processes the form submission.
80    #[prop_or_default]
81    pub action: &'static str,
82
83    /// HTTP method for form submission.
84    #[prop_or_default]
85    pub method: Method,
86
87    /// MIME encoding type for submitted data.
88    #[prop_or_default]
89    pub enc_type: EncType,
90
91    /// Where to display the form response.
92    #[prop_or_default]
93    pub target: Target,
94
95    /// When `true`, disables native browser validation on submit.
96    /// Has no effect when `validation_behavior` is `Aria`.
97    #[prop_or_default]
98    pub novalidate: bool,
99
100    /// Browser autocomplete hint for the form.
101    #[prop_or("on")]
102    pub autocomplete: &'static str,
103
104    /// Name of the form; must be unique in the `forms` collection.
105    #[prop_or_default]
106    pub name: &'static str,
107
108    /// Controls whether validation is native (blocks submit) or ARIA (realtime).
109    #[prop_or_default]
110    pub validation_behavior: ValidationBehavior,
111
112    /// Handler called when the form is submitted.
113    #[prop_or_default]
114    pub on_submit: Callback<SubmitEvent>,
115
116    /// Handler called when the form is reset.
117    #[prop_or_default]
118    pub on_reset: Callback<Event>,
119
120    /// Handler called when any field fails native validation.
121    #[prop_or_default]
122    pub on_invalid: Callback<Event>,
123
124    /// Accessible label for screen readers (creates a form landmark).
125    #[prop_or_default]
126    pub aria_label: &'static str,
127
128    /// ID of an element that labels this form.
129    #[prop_or_default]
130    pub aria_labelledby: &'static str,
131
132    /// `data-testid` for automated testing.
133    #[prop_or_default]
134    pub data_testid: &'static str,
135}
136
137/// A semantic `<form>` wrapper that provides validation context to descendant fields.
138///
139/// Propagates [`FormContext`] via Yew context so children such as [`Control`],
140/// [`FormLabel`], and [`Helper`] can reflect the form's validation mode
141/// and submission state without additional prop drilling.
142///
143/// # Accessibility
144///
145/// - Renders as a native `<form>` element, which implies a `"form"` ARIA role.
146/// - Supply `aria_label` **or** `aria_labelledby` to create a named form landmark.
147/// - Native constraint validation errors are auto-announced by assistive technology.
148///
149/// # Examples
150///
151/// ```rust
152/// use form_rs::yew::{Form, Control, FormLabel};
153/// use yew::prelude::*;
154///
155/// #[function_component(LoginForm)]
156/// pub fn login_form() -> Html {
157///     let on_submit = Callback::from(|e: SubmitEvent| {
158///         e.prevent_default();
159///     });
160///     html! {
161///         <Form on_submit={on_submit} aria_label="Login">
162///             <Control id="email">
163///                 <FormLabel html_for="email">{"Email"}</FormLabel>
164///             </Control>
165///         </Form>
166///     }
167/// }
168/// ```
169#[function_component(Form)]
170pub fn form(props: &FormProps) -> Html {
171    let is_submitting = use_state(|| false);
172
173    let ctx = FormContext {
174        validation_behavior: props.validation_behavior,
175        is_submitting: *is_submitting,
176        novalidate: props.novalidate || props.validation_behavior == ValidationBehavior::Aria,
177    };
178
179    let on_submit = {
180        let user_cb = props.on_submit.clone();
181        let is_submitting = is_submitting.clone();
182        Callback::from(move |e: SubmitEvent| {
183            is_submitting.set(true);
184            user_cb.emit(e);
185            is_submitting.set(false);
186        })
187    };
188
189    let on_reset = props.on_reset.clone();
190    let on_invalid = props.on_invalid.clone();
191
192    let novalidate = props.novalidate || props.validation_behavior == ValidationBehavior::Aria;
193    let full_style = format!("{} {}", base_form_style(), props.style);
194
195    html! {
196        <ContextProvider<FormContext> context={ctx}>
197            <form
198                id={props.id}
199                class={format!("form {}", props.class)}
200                style={full_style}
201                action={props.action}
202                method={props.method.as_str()}
203                enctype={props.enc_type.as_str()}
204                target={props.target.as_str()}
205                novalidate={novalidate}
206                autocomplete={props.autocomplete}
207                name={props.name}
208                onsubmit={on_submit}
209                onreset={Callback::from(move |e: Event| on_reset.emit(e))}
210                oninvalid={Callback::from(move |e: Event| on_invalid.emit(e))}
211                aria-label={props.aria_label}
212                aria-labelledby={props.aria_labelledby}
213                data-testid={props.data_testid}
214            >
215                { for props.children.iter() }
216            </form>
217        </ContextProvider<FormContext>>
218    }
219}
220
221/// Props for the [`Control`] Yew component.
222#[derive(Properties, PartialEq, Clone)]
223pub struct ControlProps {
224    /// Field components: [`FormLabel`], input, [`Helper`].
225    #[prop_or_default]
226    pub children: Children,
227
228    /// Additional CSS class names on the wrapper `<div>`.
229    #[prop_or_default]
230    pub class: &'static str,
231
232    /// Inline CSS on the wrapper `<div>`.
233    #[prop_or_default]
234    pub style: &'static str,
235
236    /// `id` attribute on the wrapper `<div>`.
237    #[prop_or_default]
238    pub id: &'static str,
239
240    /// The `id` of the inner `<input>` element; links `<label>` via `for`.
241    #[prop_or_default]
242    pub input_id: &'static str,
243
244    /// When `true`, renders the label, input, and helper text in a disabled state.
245    #[prop_or_default]
246    pub disabled: bool,
247
248    /// When `true`, renders the label and helper text in the error color.
249    #[prop_or_default]
250    pub error: bool,
251
252    /// When `true`, applies the focused style. Use for controlled focus.
253    #[prop_or_default]
254    pub focused: bool,
255
256    /// When `true`, the component takes up the full width of its container.
257    #[prop_or_default]
258    pub full_width: bool,
259
260    /// When `true`, the label is hidden (useful with `aria-label` on the input).
261    #[prop_or_default]
262    pub hidden_label: bool,
263
264    /// Vertical spacing adjustment.
265    #[prop_or_default]
266    pub margin: Margin,
267
268    /// Whether the field is required.
269    #[prop_or_default]
270    pub required: bool,
271
272    /// Size of the component.
273    #[prop_or_default]
274    pub size: Size,
275
276    /// Visual style variant.
277    #[prop_or_default]
278    pub variant: Variant,
279
280    /// Color theme applied to the label and focused/active field.
281    #[prop_or_default]
282    pub color: Color,
283
284    /// `data-testid` for automated testing.
285    #[prop_or_default]
286    pub data_testid: &'static str,
287}
288
289/// A context provider that wraps a single form field with its label and helper text.
290///
291/// Provides [`ControlContext`] so that [`FormLabel`] and [`Helper`]
292/// descendants can automatically reflect the `error`, `disabled`, `required`,
293/// `variant`, and `color` states without additional prop drilling.
294///
295/// # Accessibility
296///
297/// - The wrapper `<div>` uses `role="group"` when `hidden_label` is `true`.
298/// - Cascades `aria-disabled` semantics to all children via CSS opacity.
299///
300/// # Examples
301///
302/// ```rust
303/// use form_rs::yew::{Control, FormLabel, Helper};
304/// use form_rs::Variant;
305/// use yew::prelude::*;
306///
307/// #[function_component(EmailField)]
308/// pub fn email_field() -> Html {
309///     html! {
310///         <Control input_id="email" required=true variant={Variant::Outlined}>
311///             <FormLabel html_for="email">{"Email"}</FormLabel>
312///             <Helper>{"We'll never share your email."}</Helper>
313///         </Control>
314///     }
315/// }
316/// ```
317#[function_component(Control)]
318pub fn form_control(props: &ControlProps) -> Html {
319    let ctx = ControlContext {
320        error: props.error,
321        disabled: props.disabled,
322        focused: props.focused,
323        required: props.required,
324        filled: false,
325        variant: props.variant,
326        color: props.color,
327        size: props.size,
328        input_id: props.input_id.to_string(),
329    };
330
331    let mut class_parts = vec!["form-control".to_string()];
332    class_parts.push(props.variant.to_class().to_string());
333    class_parts.push(props.size.to_class().to_string());
334    if !props.margin.to_class().is_empty() {
335        class_parts.push(props.margin.to_class().to_string());
336    }
337    if props.full_width {
338        class_parts.push("form-control--full-width".to_string());
339    }
340    if props.error {
341        class_parts.push("form-control--error".to_string());
342    }
343    if props.disabled {
344        class_parts.push("form-control--disabled".to_string());
345    }
346    if props.focused {
347        class_parts.push("form-control--focused".to_string());
348    }
349    if props.hidden_label {
350        class_parts.push("form-control--hidden-label".to_string());
351    }
352    class_parts.push(props.class.to_string());
353
354    let mut style_parts = vec![base_form_control_style().to_string()];
355    style_parts.push(props.margin.to_style().to_string());
356    if props.full_width {
357        style_parts.push("width: 100%;".to_string());
358    }
359    if props.disabled {
360        style_parts.push(field_disabled_style().to_string());
361    }
362    style_parts.push(props.style.to_string());
363
364    html! {
365        <ContextProvider<ControlContext> context={ctx}>
366            <div
367                id={props.id}
368                class={class_parts.join(" ")}
369                style={style_parts.join(" ")}
370                data-testid={props.data_testid}
371            >
372                { for props.children.iter() }
373            </div>
374        </ContextProvider<ControlContext>>
375    }
376}
377
378/// Props for the [`FormLabel`] Yew component.
379#[derive(Properties, PartialEq, Clone)]
380pub struct FormLabelProps {
381    /// Label text or any content.
382    #[prop_or_default]
383    pub children: Children,
384
385    /// Additional CSS class names on the `<label>`.
386    #[prop_or_default]
387    pub class: &'static str,
388
389    /// Inline CSS on the `<label>`.
390    #[prop_or_default]
391    pub style: &'static str,
392
393    /// `id` attribute on the `<label>`.
394    #[prop_or_default]
395    pub id: &'static str,
396
397    /// The `id` of the labeled `<input>`. Overrides the value from [`ControlContext`].
398    #[prop_or_default]
399    pub html_for: &'static str,
400
401    /// Color theme override. Defaults to the [`ControlContext`] color.
402    #[prop_or_default]
403    pub color: Option<Color>,
404
405    /// When `true`, renders in a disabled style.
406    #[prop_or_default]
407    pub disabled: bool,
408
409    /// When `true`, renders in the error color.
410    #[prop_or_default]
411    pub error: bool,
412
413    /// When `true`, applies the filled modifier class.
414    #[prop_or_default]
415    pub filled: bool,
416
417    /// When `true`, applies the focused modifier class.
418    #[prop_or_default]
419    pub focused: bool,
420
421    /// When `true`, shows a required asterisk after the label text.
422    #[prop_or_default]
423    pub required: bool,
424
425    /// `data-testid` for automated testing.
426    #[prop_or_default]
427    pub data_testid: &'static str,
428}
429
430/// Renders an accessible `<label>` linked to a form field.
431///
432/// When nested inside [`Control`], it automatically reads `error`,
433/// `disabled`, `focused`, `required`, and `color` from [`ControlContext`].
434///
435/// # Accessibility
436///
437/// - Always links to its input via `for` / [`FormLabelProps::html_for`].
438/// - Shows a visual required indicator (`*`) when `required` is `true`.
439/// - Reflects error, focused, and disabled states via class names and color.
440///
441/// # Examples
442///
443/// ```rust
444/// use form_rs::yew::FormLabel;
445/// use yew::prelude::*;
446///
447/// #[function_component(MyLabel)]
448/// pub fn my_label() -> Html {
449///     html! { <FormLabel html_for="username">{"Username"}</FormLabel> }
450/// }
451/// ```
452#[function_component(FormLabel)]
453pub fn form_label(props: &FormLabelProps) -> Html {
454    let ctx = use_context::<ControlContext>();
455
456    let error = props.error || ctx.as_ref().is_some_and(|c| c.error);
457    let disabled = props.disabled || ctx.as_ref().is_some_and(|c| c.disabled);
458    let focused = props.focused || ctx.as_ref().is_some_and(|c| c.focused);
459    let required = props.required || ctx.as_ref().is_some_and(|c| c.required);
460    let color = props
461        .color
462        .or_else(|| ctx.as_ref().map(|c| c.color))
463        .unwrap_or_default();
464    let linked_id = if !props.html_for.is_empty() {
465        props.html_for.to_string()
466    } else {
467        ctx.as_ref().map(|c| c.input_id.clone()).unwrap_or_default()
468    };
469
470    let color_style = if error {
471        label_error_style().to_string()
472    } else if focused {
473        color.to_label_color()
474    } else {
475        String::new()
476    };
477
478    let mut class_parts = vec!["form-label".to_string()];
479    if error {
480        class_parts.push("form-label--error".to_string());
481    }
482    if disabled {
483        class_parts.push("form-label--disabled".to_string());
484    }
485    if focused {
486        class_parts.push("form-label--focused".to_string());
487    }
488    if props.filled {
489        class_parts.push("form-label--filled".to_string());
490    }
491    class_parts.push(props.class.to_string());
492
493    let full_style = format!("{} {} {}", base_label_style(), color_style, props.style);
494
495    html! {
496        <label
497            id={props.id}
498            class={class_parts.join(" ")}
499            style={full_style}
500            for={linked_id}
501            aria-disabled={if disabled { "true" } else { "false" }}
502            data-testid={props.data_testid}
503        >
504            { for props.children.iter() }
505            if required {
506                <span style={required_asterisk_style()} aria-hidden="true">{ "*" }</span>
507            }
508        </label>
509    }
510}
511
512/// Props for the [`Helper`] Yew component.
513#[derive(Properties, PartialEq, Clone)]
514pub struct HelperProps {
515    /// Helper text content or any views.
516    #[prop_or_default]
517    pub children: Children,
518
519    /// Additional CSS class names on the `<p>`.
520    #[prop_or_default]
521    pub class: &'static str,
522
523    /// Inline CSS on the `<p>`.
524    #[prop_or_default]
525    pub style: &'static str,
526
527    /// `id` attribute on the `<p>`. Use this as `aria-describedby` on the input.
528    #[prop_or_default]
529    pub id: &'static str,
530
531    /// When `true`, renders in a disabled style.
532    #[prop_or_default]
533    pub disabled: bool,
534
535    /// When `true`, renders in the error color.
536    #[prop_or_default]
537    pub error: bool,
538
539    /// When `true`, renders in a valid/success color.
540    #[prop_or_default]
541    pub valid: bool,
542
543    /// When `true`, applies the filled modifier class.
544    #[prop_or_default]
545    pub filled: bool,
546
547    /// When `true`, applies the focused modifier class.
548    #[prop_or_default]
549    pub focused: bool,
550
551    /// Vertical spacing adjustment.
552    #[prop_or_default]
553    pub margin: Margin,
554
555    /// Whether the parent field is required (affects ARIA live region).
556    #[prop_or_default]
557    pub required: bool,
558
559    /// `data-testid` for automated testing.
560    #[prop_or_default]
561    pub data_testid: &'static str,
562}
563
564/// Renders accessible helper text beneath a form field.
565///
566/// When nested inside [`Control`], it inherits `error`, `disabled`,
567/// `focused`, and `filled` states from [`ControlContext`]. Error text is
568/// announced to screen readers via `role="alert"`.
569///
570/// # Accessibility
571///
572/// - `role="alert"` is added automatically when `error` is `true`.
573/// - Assign its `id` to the input's `aria-describedby` attribute.
574///
575/// # Examples
576///
577/// ```rust
578/// use form_rs::yew::Helper;
579/// use yew::prelude::*;
580///
581/// #[function_component(PasswordHint)]
582/// pub fn password_hint() -> Html {
583///     html! {
584///         <Helper id="pw-hint">
585///             {"Must be at least 8 characters."}
586///         </Helper>
587///     }
588/// }
589/// ```
590#[function_component(Helper)]
591pub fn form_helper_text(props: &HelperProps) -> Html {
592    let ctx = use_context::<ControlContext>();
593
594    let error = props.error || ctx.as_ref().is_some_and(|c| c.error);
595    let disabled = props.disabled || ctx.as_ref().is_some_and(|c| c.disabled);
596
597    let color_style = if error {
598        helper_error_style()
599    } else if props.valid {
600        helper_valid_style()
601    } else {
602        ""
603    };
604
605    let mut class_parts = vec!["form-helper-text".to_string()];
606    if error {
607        class_parts.push("form-helper-text--error".to_string());
608    }
609    if props.valid {
610        class_parts.push("form-helper-text--valid".to_string());
611    }
612    if disabled {
613        class_parts.push("form-helper-text--disabled".to_string());
614    }
615    if props.focused {
616        class_parts.push("form-helper-text--focused".to_string());
617    }
618    if props.filled {
619        class_parts.push("form-helper-text--filled".to_string());
620    }
621    if !props.margin.to_class().is_empty() {
622        class_parts.push(props.margin.to_class().to_string());
623    }
624    class_parts.push(props.class.to_string());
625
626    let full_style = format!(
627        "{} {} {}",
628        base_helper_text_style(),
629        color_style,
630        props.style
631    );
632
633    html! {
634        <p
635            id={props.id}
636            class={class_parts.join(" ")}
637            style={full_style}
638            role={if error { "alert" } else { "" }}
639            aria-live={if error { "polite" } else { "" }}
640            aria-disabled={if disabled { "true" } else { "false" }}
641            data-testid={props.data_testid}
642        >
643            { for props.children.iter() }
644        </p>
645    }
646}
647
648/// Props for the [`Group`] Yew component.
649#[derive(Properties, PartialEq, Clone)]
650pub struct GroupProps {
651    /// Controls such as checkboxes or switches.
652    #[prop_or_default]
653    pub children: Children,
654
655    /// Additional CSS class names on the wrapper `<div>`.
656    #[prop_or_default]
657    pub class: &'static str,
658
659    /// Inline CSS on the wrapper `<div>`.
660    #[prop_or_default]
661    pub style: &'static str,
662
663    /// `id` attribute on the `<div>`.
664    #[prop_or_default]
665    pub id: &'static str,
666
667    /// When `true`, renders children horizontally in a row.
668    #[prop_or_default]
669    pub row: bool,
670
671    /// When `true`, applies the error modifier class.
672    #[prop_or_default]
673    pub error: bool,
674
675    /// Accessible label for the group (`aria-label`).
676    #[prop_or_default]
677    pub aria_label: &'static str,
678
679    /// ID of the element that labels this group (`aria-labelledby`).
680    #[prop_or_default]
681    pub aria_labelledby: &'static str,
682
683    /// `data-testid` for automated testing.
684    #[prop_or_default]
685    pub data_testid: &'static str,
686}
687
688/// Wraps groups of controls such as checkboxes and switches.
689///
690/// Provides a compact column layout by default; set `row=true` for a
691/// horizontal row. For radio groups, prefer `<RadioGroup>` instead.
692///
693/// # Accessibility
694///
695/// - Renders as `<div role="group">` with support for `aria_label` and
696///   `aria_labelledby` to name the group for screen readers.
697///
698/// # Examples
699///
700/// ```rust
701/// use form_rs::yew::Group;
702/// use yew::prelude::*;
703///
704/// #[function_component(OptionsGroup)]
705/// pub fn options_group() -> Html {
706///     html! {
707///         <Group aria_label="Notifications" row=true>
708///             <span>{"Email"}</span>
709///             <span>{"SMS"}</span>
710///         </Group>
711///     }
712/// }
713/// ```
714#[function_component(Group)]
715pub fn form_group(props: &GroupProps) -> Html {
716    let base_style = if props.row {
717        base_form_group_row_style()
718    } else {
719        base_form_group_style()
720    };
721
722    let mut class_parts = vec!["form-group".to_string()];
723    if props.row {
724        class_parts.push("form-group--row".to_string());
725    }
726    if props.error {
727        class_parts.push("form-group--error".to_string());
728    }
729    class_parts.push(props.class.to_string());
730
731    let full_style = format!("{} {}", base_style, props.style);
732
733    html! {
734        <div
735            id={props.id}
736            class={class_parts.join(" ")}
737            style={full_style}
738            role="group"
739            aria-label={props.aria_label}
740            aria-labelledby={props.aria_labelledby}
741            data-testid={props.data_testid}
742        >
743            { for props.children.iter() }
744        </div>
745    }
746}
747
748/// Props for the [`ControlLabel`] Yew component.
749#[derive(Properties, PartialEq, Clone)]
750pub struct ControlLabelProps {
751    /// The control element, e.g. a checkbox or switch `<input>`.
752    pub control: Html,
753
754    /// The label text or content shown next to the control.
755    #[prop_or_default]
756    pub label: Html,
757
758    /// Additional CSS class names on the wrapper `<label>`.
759    #[prop_or_default]
760    pub class: &'static str,
761
762    /// Inline CSS on the wrapper `<label>`.
763    #[prop_or_default]
764    pub style: &'static str,
765
766    /// `id` attribute on the wrapper `<label>`.
767    #[prop_or_default]
768    pub id: &'static str,
769
770    /// Whether the control appears checked (for display only; wire your own state).
771    #[prop_or_default]
772    pub checked: bool,
773
774    /// When `true`, all interactive elements within are visually disabled.
775    #[prop_or_default]
776    pub disabled: bool,
777
778    /// Position of the label text relative to the control element.
779    #[prop_or_default]
780    pub label_placement: LabelPlacement,
781
782    /// Whether the label indicates a required field.
783    #[prop_or_default]
784    pub required: bool,
785
786    /// The `value` associated with this labeled control.
787    #[prop_or_default]
788    pub value: &'static str,
789
790    /// `data-testid` for automated testing.
791    #[prop_or_default]
792    pub data_testid: &'static str,
793}
794
795/// A drop-in label wrapper for Radio, Switch, and Checkbox controls.
796///
797/// Renders a `<label>` element that wraps a control (`<input type="checkbox">`,
798/// etc.) alongside descriptive label text. The `label_placement` prop controls
799/// the visual order of the control and its label.
800///
801/// # Accessibility
802///
803/// - The wrapping `<label>` element natively associates its text with the
804///   contained control, so no explicit `for` attribute is needed.
805/// - `aria-disabled` is set when `disabled` is `true`.
806/// - `aria-required` is set when `required` is `true`.
807///
808/// # Examples
809///
810/// ```rust
811/// use form_rs::yew::ControlLabel;
812/// use form_rs::LabelPlacement;
813/// use yew::prelude::*;
814///
815/// #[function_component(TermsCheckbox)]
816/// pub fn terms_checkbox() -> Html {
817///     html! {
818///         <ControlLabel
819///             control={html! { <input type="checkbox" /> }}
820///             label={html! { <span>{"I agree to the terms"}</span> }}
821///             label_placement={LabelPlacement::End}
822///         />
823///     }
824/// }
825/// ```
826#[function_component(ControlLabel)]
827pub fn form_control_label(props: &ControlLabelProps) -> Html {
828    let flex_style = props.label_placement.to_flex_direction();
829    let placement_class = props.label_placement.to_class();
830
831    let mut class_parts = vec!["form-control-label".to_string()];
832    class_parts.push(placement_class.to_string());
833    if props.disabled {
834        class_parts.push("form-control-label--disabled".to_string());
835    }
836    class_parts.push(props.class.to_string());
837
838    let base_style = format!(
839        "display: inline-flex; {} gap: 8px; align-items: center; cursor: {}; user-select: none;",
840        flex_style,
841        if props.disabled {
842            "not-allowed"
843        } else {
844            "pointer"
845        }
846    );
847    let full_style = format!("{} {}", base_style, props.style);
848
849    html! {
850        <label
851            id={props.id}
852            class={class_parts.join(" ")}
853            style={full_style}
854            aria-disabled={if props.disabled { "true" } else { "false" }}
855            aria-required={if props.required { "true" } else { "false" }}
856            data-testid={props.data_testid}
857        >
858            { props.control.clone() }
859            <span
860                class="form-control-label__label"
861                style="font-size: 14px; color: #d4d4d8; line-height: 1.4;"
862            >
863                { props.label.clone() }
864                if props.required {
865                    <span style={required_asterisk_style()} aria-hidden="true">{ "*" }</span>
866                }
867            </span>
868        </label>
869    }
870}
871
872/// Props for the [`Field`] Yew component, a convenience wrapper for a
873/// labeled, validated input built on top of `input-rs`.
874#[derive(Properties, PartialEq, Clone)]
875pub struct FieldProps {
876    /// The unique `id` for the `<input>` element.
877    pub id: &'static str,
878
879    /// The `name` attribute for the `<input>`.
880    #[prop_or_default]
881    pub name: &'static str,
882
883    /// The input type, e.g. `"text"`, `"email"`, `"password"`.
884    #[prop_or("text")]
885    pub r#type: &'static str,
886
887    /// Label text displayed above the input.
888    #[prop_or_default]
889    pub label: &'static str,
890
891    /// Placeholder text shown inside the empty input.
892    #[prop_or_default]
893    pub placeholder: &'static str,
894
895    /// Helper text displayed below the input.
896    #[prop_or_default]
897    pub helper_text: &'static str,
898
899    /// Validation state: `None`, `Valid`, or `Invalid(message)`.
900    #[prop_or_default]
901    pub validation_state: ValidationState,
902
903    /// Whether the field is required.
904    #[prop_or_default]
905    pub required: bool,
906
907    /// Whether the field is disabled.
908    #[prop_or_default]
909    pub disabled: bool,
910
911    /// Whether the field takes the full container width.
912    #[prop_or(true)]
913    pub full_width: bool,
914
915    /// Visual variant of the field border.
916    #[prop_or_default]
917    pub variant: Variant,
918
919    /// Color theme for focus/label highlights.
920    #[prop_or_default]
921    pub color: Color,
922
923    /// Size of the field.
924    #[prop_or_default]
925    pub size: Size,
926
927    /// HTML5 `pattern` attribute for native regex validation.
928    #[prop_or(".*")]
929    pub pattern: &'static str,
930
931    /// Maximum number of characters allowed.
932    #[prop_or_default]
933    pub maxlength: Option<usize>,
934
935    /// Minimum number of characters required.
936    #[prop_or_default]
937    pub minlength: Option<usize>,
938
939    /// Additional CSS class on the outer `<div>`.
940    #[prop_or_default]
941    pub class: &'static str,
942
943    /// Inline CSS on the outer `<div>`.
944    #[prop_or_default]
945    pub style: &'static str,
946
947    /// Controlled value signal.
948    pub handle: UseStateHandle<String>,
949
950    /// Validity signal.
951    pub valid_handle: UseStateHandle<bool>,
952
953    /// Validation function called with the current value.
954    pub validate_function: Callback<String, bool>,
955
956    /// `data-testid` for automated testing.
957    #[prop_or_default]
958    pub data_testid: &'static str,
959}
960
961/// A convenience composition of [`Control`], [`FormLabel`], [`Input`], and
962/// [`Helper`] into a single, fully validated form field.
963///
964/// This component removes boilerplate for the common pattern of a labeled input
965/// with validation state and helper text. It wraps `input-rs` for the actual
966/// `<input>` element, inheriting all its HTML5 validation attributes.
967///
968/// # Accessibility
969///
970/// - The `<label>` is explicitly linked to the `<input>` via `for`/`id`.
971/// - Helper text is linked via `aria-describedby`.
972/// - Error messages have `role="alert"` for immediate screen-reader announcement.
973///
974/// # Examples
975///
976/// ```rust
977/// use form_rs::yew::Field;
978/// use yew::prelude::*;
979///
980/// #[function_component(EmailInput)]
981/// pub fn email_input() -> Html {
982///     let handle = use_state(String::new);
983///     let valid = use_state(|| true);
984///     html! {
985///         <Field
986///             id="email"
987///             name="email"
988///             r#type="email"
989///             label="Email address"
990///             placeholder="ferris@opensass.org"
991///             helper_text="We'll never share your email."
992///             required=true
993///             handle={handle}
994///             valid_handle={valid}
995///             validate_function={|v: String| !v.is_empty() && v.contains('@')}
996///         />
997///     }
998/// }
999/// ```
1000#[function_component(Field)]
1001pub fn form_field(props: &FieldProps) -> Html {
1002    let is_error = props.validation_state.is_invalid()
1003        || (!(*props.valid_handle) && !(*props.handle).is_empty());
1004    let is_valid = matches!(props.validation_state, ValidationState::Valid)
1005        || ((*props.valid_handle) && !(*props.handle).is_empty());
1006
1007    let error_msg = props
1008        .validation_state
1009        .error_message()
1010        .map(|s| s.to_string());
1011
1012    let helper_id: &'static str = Box::leak(format!("{}-helper", props.id).into_boxed_str());
1013    let focused = use_state(|| false);
1014
1015    let on_focus = {
1016        let focused = focused.clone();
1017        Callback::from(move |_| focused.set(true))
1018    };
1019    let on_blur = {
1020        let focused = focused.clone();
1021        Callback::from(move |_| focused.set(false))
1022    };
1023
1024    let field_border = props.variant.to_field_style();
1025    let input_size = props.size.to_input_style();
1026    let focus_ring = if *focused && !is_error {
1027        props.color.to_focus_ring()
1028    } else {
1029        String::new()
1030    };
1031    let error_ring = if is_error {
1032        field_error_style().to_string()
1033    } else {
1034        String::new()
1035    };
1036    let valid_ring = if is_valid && !is_error {
1037        field_valid_style().to_string()
1038    } else {
1039        String::new()
1040    };
1041
1042    let input_style = format!(
1043        "{} {} {} {} {} {} transition: all 0.2s ease;",
1044        base_input_field_style(),
1045        field_border,
1046        input_size,
1047        focus_ring,
1048        error_ring,
1049        valid_ring
1050    );
1051
1052    let input_style_static: &'static str = Box::leak(input_style.into_boxed_str());
1053
1054    let helper_content = if let Some(ref msg) = error_msg {
1055        msg.clone()
1056    } else if is_error {
1057        "Invalid value.".to_string()
1058    } else if !props.helper_text.is_empty() {
1059        props.helper_text.to_string()
1060    } else {
1061        String::new()
1062    };
1063
1064    let node_ref = use_node_ref();
1065    let validate_cb = props.validate_function.clone();
1066
1067    html! {
1068        <Control
1069            input_id={props.id}
1070            error={is_error}
1071            disabled={props.disabled}
1072            focused={*focused}
1073            full_width={props.full_width}
1074            required={props.required}
1075            variant={props.variant}
1076            color={props.color}
1077            size={props.size}
1078            class={props.class}
1079            style={props.style}
1080            data_testid={props.data_testid}
1081        >
1082            if !props.label.is_empty() {
1083                <FormLabel
1084                    html_for={props.id}
1085                    error={is_error}
1086                    focused={*focused}
1087                    required={props.required}
1088                    disabled={props.disabled}
1089                >
1090                    { html!{props.label} }
1091                </FormLabel>
1092            }
1093            <Input
1094                r#type={props.r#type}
1095                id={props.id}
1096                name={props.name}
1097                placeholder={props.placeholder}
1098                r#ref={node_ref}
1099                handle={props.handle.clone()}
1100                valid_handle={props.valid_handle.clone()}
1101                validate_function={validate_cb}
1102                required={props.required}
1103                disabled={props.disabled}
1104                pattern={props.pattern}
1105                maxlength={props.maxlength}
1106                minlength={props.minlength}
1107                input_style={input_style_static}
1108                aria_describedby={helper_id}
1109                aria_required={if props.required { "true" } else { "false" }}
1110                aria_invalid={if is_error { "true" } else { "false" }}
1111                otp_mode=true
1112                on_focus={on_focus}
1113                on_blur={on_blur}
1114            />
1115            if !helper_content.is_empty() {
1116                <Helper id={helper_id} error={is_error} valid={is_valid && !is_error}>
1117                    { html!{helper_content} }
1118                </Helper>
1119            }
1120        </Control>
1121    }
1122}
1123
1124// Copyright 2026 Open SASS Core Maintainers.
1125//
1126// Licensed under the MIT license
1127// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
1128// option. This file may not be copied, modified, or distributed
1129// except according to those terms.