Skip to main content

dioxus_bootstrap_css/
form.rs

1use dioxus::prelude::*;
2
3use crate::types::Size;
4
5/// Bootstrap FormGroup — label + control wrapper.
6///
7/// # Bootstrap HTML → Dioxus
8///
9/// ```html
10/// <!-- Bootstrap HTML -->
11/// <div class="mb-3">
12///   <label class="form-label">Email</label>
13///   <input type="email" class="form-control" placeholder="you@example.com">
14/// </div>
15/// ```
16///
17/// ```rust,no_run
18/// # use dioxus::prelude::*;
19/// # use dioxus_bootstrap_css::prelude::*;
20/// # fn _doctest() -> Element {
21/// rsx! {
22///     FormGroup { label: "Email",
23///         Input { r#type: "email", placeholder: "you@example.com" }
24///     }
25/// }
26/// # }
27/// ```
28#[derive(Clone, PartialEq, Props)]
29pub struct FormGroupProps {
30    /// Label text.
31    #[props(default)]
32    pub label: String,
33    /// Additional CSS classes for the wrapper div.
34    #[props(default)]
35    pub class: String,
36    /// Any additional HTML attributes.
37    #[props(extends = GlobalAttributes)]
38    attributes: Vec<Attribute>,
39    /// Child elements (form control).
40    pub children: Element,
41}
42
43#[component]
44pub fn FormGroup(props: FormGroupProps) -> Element {
45    let full_class = if props.class.is_empty() {
46        "mb-3".to_string()
47    } else {
48        format!("mb-3 {}", props.class)
49    };
50
51    rsx! {
52        div { class: "{full_class}",
53            ..props.attributes,
54            if !props.label.is_empty() {
55                label { class: "form-label", "{props.label}" }
56            }
57            {props.children}
58        }
59    }
60}
61
62/// Bootstrap Input component.
63///
64/// # Bootstrap HTML → Dioxus
65///
66/// | HTML | Dioxus |
67/// |---|---|
68/// | `<input class="form-control" type="text">` | `Input { r#type: "text" }` |
69/// | `<input class="form-control form-control-sm" type="email">` | `Input { r#type: "email", size: Size::Sm }` |
70/// | `<input class="form-control" disabled>` | `Input { disabled: true }` |
71/// | `<input class="form-control" list="opts">` | `Input { list: "opts" }` |
72///
73/// Bind a `<datalist>` for autocomplete with `list`, and observe focus with
74/// `onfocus` / `onblur`:
75///
76/// ```rust,no_run
77/// # use dioxus::prelude::*;
78/// # use dioxus_bootstrap_css::prelude::*;
79/// # fn _doctest() -> Element {
80/// rsx! {
81///     Input { r#type: "text", value: "hello", placeholder: "Enter text" }
82///     Input { r#type: "email", size: Size::Sm, oninput: move |evt| { /* handle */ } }
83///     Input { r#type: "password", disabled: true }
84///     Input {
85///         list: "icon-options",
86///         onfocus: move |_| { /* open suggestions */ },
87///         onblur: move |_| { /* close suggestions */ },
88///     }
89///     datalist { id: "icon-options",
90///         option { value: "star" }
91///         option { value: "heart" }
92///     }
93/// }
94/// # }
95/// ```
96#[derive(Clone, PartialEq, Props)]
97pub struct InputProps {
98    /// Input type (text, email, password, number, etc.).
99    #[props(default = "text".to_string())]
100    pub r#type: String,
101    /// Current value.
102    #[props(default)]
103    pub value: String,
104    /// When `true`, the `value` attribute is omitted so the field is
105    /// *uncontrolled*: the DOM keeps whatever value the user or an external
106    /// script writes, instead of Dioxus forcing it back to `value` on every
107    /// render. Use for a field another script streams into (e.g. a live
108    /// transcript box).
109    #[props(default)]
110    pub uncontrolled: bool,
111    /// Placeholder text.
112    #[props(default)]
113    pub placeholder: String,
114    /// Minimum value for numeric/date inputs.
115    #[props(default)]
116    pub min: Option<String>,
117    /// Maximum value for numeric/date inputs.
118    #[props(default)]
119    pub max: Option<String>,
120    /// Browser autocomplete hint.
121    #[props(default)]
122    pub autocomplete: Option<String>,
123    /// Datalist id to bind for autocomplete (rendered as the input `list`
124    /// attribute). `list` is not a `GlobalAttributes` attribute, so it needs
125    /// its own typed prop rather than riding through `..attributes`.
126    #[props(default)]
127    pub list: Option<String>,
128    /// Input size.
129    #[props(default)]
130    pub size: Size,
131    /// Disabled state.
132    #[props(default)]
133    pub disabled: bool,
134    /// Readonly state.
135    #[props(default)]
136    pub readonly: bool,
137    /// Input event handler.
138    #[props(default)]
139    pub oninput: Option<EventHandler<FormEvent>>,
140    /// Change event handler.
141    #[props(default)]
142    pub onchange: Option<EventHandler<FormEvent>>,
143    /// Focus event handler.
144    #[props(default)]
145    pub onfocus: Option<EventHandler<FocusEvent>>,
146    /// Blur event handler.
147    #[props(default)]
148    pub onblur: Option<EventHandler<FocusEvent>>,
149    /// Key down event handler.
150    #[props(default)]
151    pub onkeydown: Option<EventHandler<KeyboardEvent>>,
152    /// Key up event handler.
153    #[props(default)]
154    pub onkeyup: Option<EventHandler<KeyboardEvent>>,
155    /// Additional CSS classes.
156    #[props(default)]
157    pub class: String,
158    /// Any additional HTML attributes.
159    #[props(extends = GlobalAttributes)]
160    attributes: Vec<Attribute>,
161}
162
163#[component]
164pub fn Input(props: InputProps) -> Element {
165    let size_class = match props.size {
166        Size::Md => String::new(),
167        s => format!(" form-control-{s}"),
168    };
169
170    let full_class = if props.class.is_empty() {
171        format!("form-control{size_class}")
172    } else {
173        format!("form-control{size_class} {}", props.class)
174    };
175
176    rsx! {
177        input {
178            class: "{full_class}",
179            r#type: "{props.r#type}",
180            value: if props.uncontrolled { None } else { Some(props.value.clone()) },
181            placeholder: "{props.placeholder}",
182            min: props.min.clone(),
183            max: props.max.clone(),
184            autocomplete: props.autocomplete.clone(),
185            list: props.list.clone(),
186            disabled: props.disabled,
187            readonly: props.readonly,
188            oninput: move |evt| {
189                if let Some(handler) = &props.oninput {
190                    handler.call(evt);
191                }
192            },
193            onchange: move |evt| {
194                if let Some(handler) = &props.onchange {
195                    handler.call(evt);
196                }
197            },
198            onfocus: move |evt| {
199                if let Some(handler) = &props.onfocus {
200                    handler.call(evt);
201                }
202            },
203            onblur: move |evt| {
204                if let Some(handler) = &props.onblur {
205                    handler.call(evt);
206                }
207            },
208            onkeydown: move |evt| {
209                if let Some(handler) = &props.onkeydown {
210                    handler.call(evt);
211                }
212            },
213            onkeyup: move |evt| {
214                if let Some(handler) = &props.onkeyup {
215                    handler.call(evt);
216                }
217            },
218            ..props.attributes,
219        }
220    }
221}
222
223/// Bootstrap Select (dropdown) component.
224///
225/// # Bootstrap HTML → Dioxus
226///
227/// ```html
228/// <!-- Bootstrap HTML -->
229/// <select class="form-select">
230///   <option value="opt1">Option 1</option>
231///   <option value="opt2" selected>Option 2</option>
232/// </select>
233/// ```
234///
235/// ```rust,no_run
236/// # use dioxus::prelude::*;
237/// # use dioxus_bootstrap_css::prelude::*;
238/// # fn _doctest() -> Element {
239/// rsx! {
240///     Select { value: "opt2", onchange: move |evt| { /* handle */ },
241///         option { value: "opt1", "Option 1" }
242///         option { value: "opt2", "Option 2" }
243///     }
244/// }
245/// # }
246/// ```
247#[derive(Clone, PartialEq, Props)]
248pub struct SelectProps {
249    /// Current selected value.
250    #[props(default)]
251    pub value: String,
252    /// Select size.
253    #[props(default)]
254    pub size: Size,
255    /// Disabled state.
256    #[props(default)]
257    pub disabled: bool,
258    /// Change event handler.
259    #[props(default)]
260    pub onchange: Option<EventHandler<FormEvent>>,
261    /// Additional CSS classes.
262    #[props(default)]
263    pub class: String,
264    /// Any additional HTML attributes.
265    #[props(extends = GlobalAttributes)]
266    attributes: Vec<Attribute>,
267    /// Child elements (option elements).
268    pub children: Element,
269}
270
271#[component]
272pub fn Select(props: SelectProps) -> Element {
273    use wasm_bindgen::JsCast;
274
275    let size_class = match props.size {
276        Size::Md => String::new(),
277        s => format!(" form-select-{s}"),
278    };
279
280    let full_class = if props.class.is_empty() {
281        format!("form-select{size_class}")
282    } else {
283        format!("form-select{size_class} {}", props.class)
284    };
285
286    // A `<select>`'s selection is controlled by its `.value` property (or an
287    // `<option selected>`), NOT by a `value` content attribute — browsers ignore
288    // the latter on a select, so Dioxus's declarative `value` silently does
289    // nothing and the element shows its first option. Hold the mounted element
290    // and set `.value` imperatively on mount and whenever `value` changes, so the
291    // control reflects the value it is given.
292    let mut select_el = use_signal(|| None as Option<web_sys::HtmlSelectElement>);
293    let value = props.value.clone();
294    use_effect(use_reactive!(|value| {
295        if let Some(el) = select_el.peek().clone() {
296            el.set_value(&value);
297        }
298    }));
299
300    let mount_value = props.value.clone();
301    rsx! {
302        select {
303            class: "{full_class}",
304            disabled: props.disabled,
305            onmounted: move |evt: MountedEvent| {
306                if let Some(el) = evt
307                    .downcast::<web_sys::Element>()
308                    .and_then(|e| e.clone().dyn_into::<web_sys::HtmlSelectElement>().ok())
309                {
310                    el.set_value(&mount_value);
311                    select_el.set(Some(el));
312                }
313            },
314            onchange: move |evt| {
315                if let Some(handler) = &props.onchange {
316                    handler.call(evt);
317                }
318            },
319            ..props.attributes,
320            {props.children}
321        }
322    }
323}
324
325/// Bootstrap Textarea component.
326///
327/// # Bootstrap HTML → Dioxus
328///
329/// | HTML | Dioxus |
330/// |---|---|
331/// | `<textarea class="form-control" rows="5">` | `Textarea { rows: 5 }` |
332/// | `<textarea class="form-control form-control-sm">` | `Textarea { size: Size::Sm }` |
333/// | `<textarea class="form-control" placeholder="..." disabled>` | `Textarea { placeholder: "...", disabled: true }` |
334///
335/// ```rust,no_run
336/// # use dioxus::prelude::*;
337/// # use dioxus_bootstrap_css::prelude::*;
338/// # fn _doctest() -> Element {
339/// rsx! {
340///     Textarea { rows: 5, placeholder: "Enter description..." }
341/// }
342/// # }
343/// ```
344#[derive(Clone, PartialEq, Props)]
345pub struct TextareaProps {
346    /// Current value.
347    #[props(default)]
348    pub value: String,
349    /// When `true`, the `value` attribute is omitted so the field is
350    /// *uncontrolled*: the DOM keeps whatever value the user or an external
351    /// script writes, instead of Dioxus forcing it back to `value` on every
352    /// render. Use for a field another script streams into (e.g. a live
353    /// transcript box).
354    #[props(default)]
355    pub uncontrolled: bool,
356    /// Number of visible rows.
357    #[props(default = 3)]
358    pub rows: u32,
359    /// Placeholder text.
360    #[props(default)]
361    pub placeholder: String,
362    /// Textarea size.
363    #[props(default)]
364    pub size: Size,
365    /// Disabled state.
366    #[props(default)]
367    pub disabled: bool,
368    /// Readonly state.
369    #[props(default)]
370    pub readonly: bool,
371    /// Input event handler.
372    #[props(default)]
373    pub oninput: Option<EventHandler<FormEvent>>,
374    /// Change event handler.
375    #[props(default)]
376    pub onchange: Option<EventHandler<FormEvent>>,
377    /// Focus event handler.
378    #[props(default)]
379    pub onfocus: Option<EventHandler<FocusEvent>>,
380    /// Blur event handler.
381    #[props(default)]
382    pub onblur: Option<EventHandler<FocusEvent>>,
383    /// Key down event handler.
384    #[props(default)]
385    pub onkeydown: Option<EventHandler<KeyboardEvent>>,
386    /// Key up event handler.
387    #[props(default)]
388    pub onkeyup: Option<EventHandler<KeyboardEvent>>,
389    /// Additional CSS classes.
390    #[props(default)]
391    pub class: String,
392    /// Any additional HTML attributes.
393    #[props(extends = GlobalAttributes)]
394    attributes: Vec<Attribute>,
395}
396
397#[component]
398pub fn Textarea(props: TextareaProps) -> Element {
399    let size_class = match props.size {
400        Size::Md => String::new(),
401        s => format!(" form-control-{s}"),
402    };
403    let full_class = if props.class.is_empty() {
404        format!("form-control{size_class}")
405    } else {
406        format!("form-control{size_class} {}", props.class)
407    };
408
409    rsx! {
410        textarea {
411            class: "{full_class}",
412            rows: "{props.rows}",
413            placeholder: "{props.placeholder}",
414            disabled: props.disabled,
415            readonly: props.readonly,
416            value: if props.uncontrolled { None } else { Some(props.value.clone()) },
417            oninput: move |evt| {
418                if let Some(handler) = &props.oninput {
419                    handler.call(evt);
420                }
421            },
422            onchange: move |evt| {
423                if let Some(handler) = &props.onchange {
424                    handler.call(evt);
425                }
426            },
427            onfocus: move |evt| {
428                if let Some(handler) = &props.onfocus {
429                    handler.call(evt);
430                }
431            },
432            onblur: move |evt| {
433                if let Some(handler) = &props.onblur {
434                    handler.call(evt);
435                }
436            },
437            onkeydown: move |evt| {
438                if let Some(handler) = &props.onkeydown {
439                    handler.call(evt);
440                }
441            },
442            onkeyup: move |evt| {
443                if let Some(handler) = &props.onkeyup {
444                    handler.call(evt);
445                }
446            },
447            ..props.attributes,
448        }
449    }
450}
451
452/// Bootstrap Checkbox component.
453///
454/// # Bootstrap HTML → Dioxus
455///
456/// ```html
457/// <!-- Bootstrap HTML -->
458/// <div class="form-check">
459///   <input class="form-check-input" type="checkbox" checked>
460///   <label class="form-check-label">Accept terms</label>
461/// </div>
462/// ```
463///
464/// ```rust,no_run
465/// # use dioxus::prelude::*;
466/// # use dioxus_bootstrap_css::prelude::*;
467/// # fn _doctest() -> Element {
468/// rsx! {
469///     Checkbox { checked: true, label: "Accept terms",
470///         onchange: move |evt| { /* handle */ },
471///     }
472/// }
473/// # }
474/// ```
475#[derive(Clone, PartialEq, Props)]
476pub struct CheckboxProps {
477    /// Whether the checkbox is checked.
478    #[props(default)]
479    pub checked: bool,
480    /// Optional id applied to the checkbox input.
481    #[props(default)]
482    pub input_id: Option<String>,
483    /// Label text.
484    #[props(default)]
485    pub label: String,
486    /// Disabled state.
487    #[props(default)]
488    pub disabled: bool,
489    /// Change event handler.
490    #[props(default)]
491    pub onchange: Option<EventHandler<FormEvent>>,
492    /// Click event handler for the checkbox input.
493    #[props(default)]
494    pub onclick: Option<EventHandler<MouseEvent>>,
495    /// Additional CSS classes for the wrapper.
496    #[props(default)]
497    pub class: String,
498    /// Any additional HTML attributes.
499    #[props(extends = GlobalAttributes)]
500    attributes: Vec<Attribute>,
501}
502
503#[component]
504pub fn Checkbox(props: CheckboxProps) -> Element {
505    let full_class = if props.class.is_empty() {
506        "form-check".to_string()
507    } else {
508        format!("form-check {}", props.class)
509    };
510    let label_for = props.input_id.clone().unwrap_or_default();
511
512    rsx! {
513            div { class: "{full_class}",
514                ..props.attributes,
515    input {
516    class: "form-check-input",
517    r#type: "checkbox",
518                    id: props.input_id.unwrap_or_default(),
519                    checked: props.checked,
520                    disabled: props.disabled,
521                    onclick: move |evt| {
522                        if let Some(handler) = &props.onclick {
523                            handler.call(evt);
524                        }
525                    },
526                    onchange: move |evt| {
527                        if let Some(handler) = &props.onchange {
528                            handler.call(evt);
529                        }
530                    },
531                }
532    if !props.label.is_empty() {
533    label { class: "form-check-label", r#for: "{label_for}", "{props.label}" }
534    }
535            }
536        }
537}
538
539/// Bootstrap Switch (toggle) component.
540///
541/// # Bootstrap HTML → Dioxus
542///
543/// ```html
544/// <!-- Bootstrap HTML -->
545/// <div class="form-check form-switch">
546///   <input class="form-check-input" type="checkbox" role="switch" checked>
547///   <label class="form-check-label">Enable notifications</label>
548/// </div>
549/// ```
550///
551/// ```rust,no_run
552/// # use dioxus::prelude::*;
553/// # use dioxus_bootstrap_css::prelude::*;
554/// # fn _doctest() -> Element {
555/// rsx! {
556///     Switch { checked: true, label: "Enable notifications",
557///         onchange: move |evt| { /* handle */ },
558///     }
559/// }
560/// # }
561/// ```
562#[derive(Clone, PartialEq, Props)]
563pub struct SwitchProps {
564    /// Whether the switch is on.
565    #[props(default)]
566    pub checked: bool,
567    /// Label text.
568    #[props(default)]
569    pub label: String,
570    /// Disabled state.
571    #[props(default)]
572    pub disabled: bool,
573    /// Change event handler.
574    #[props(default)]
575    pub onchange: Option<EventHandler<FormEvent>>,
576    /// Additional CSS classes for the wrapper.
577    #[props(default)]
578    pub class: String,
579    /// Any additional HTML attributes.
580    #[props(extends = GlobalAttributes)]
581    attributes: Vec<Attribute>,
582}
583
584#[component]
585pub fn Switch(props: SwitchProps) -> Element {
586    let full_class = if props.class.is_empty() {
587        "form-check form-switch".to_string()
588    } else {
589        format!("form-check form-switch {}", props.class)
590    };
591
592    rsx! {
593        div { class: "{full_class}",
594            ..props.attributes,
595            input {
596                class: "form-check-input",
597                r#type: "checkbox",
598                role: "switch",
599                checked: props.checked,
600                disabled: props.disabled,
601                onchange: move |evt| {
602                    if let Some(handler) = &props.onchange {
603                        handler.call(evt);
604                    }
605                },
606            }
607            if !props.label.is_empty() {
608                label { class: "form-check-label", "{props.label}" }
609            }
610        }
611    }
612}
613
614/// Bootstrap Range (slider) input.
615///
616/// # Bootstrap HTML → Dioxus
617///
618/// | HTML | Dioxus |
619/// |---|---|
620/// | `<input type="range" class="form-range" min="0" max="100">` | `Range { min: "0", max: "100" }` |
621/// | `<input type="range" class="form-range" step="5" disabled>` | `Range { step: "5".into(), disabled: true }` |
622///
623/// ```rust,no_run
624/// # use dioxus::prelude::*;
625/// # use dioxus_bootstrap_css::prelude::*;
626/// # fn _doctest() -> Element {
627/// rsx! {
628///     Range { value: "50", min: "0", max: "100" }
629/// }
630/// # }
631/// ```
632#[derive(Clone, PartialEq, Props)]
633pub struct RangeProps {
634    /// Current value.
635    #[props(default)]
636    pub value: String,
637    /// Minimum value.
638    #[props(default = "0".to_string())]
639    pub min: String,
640    /// Maximum value.
641    #[props(default = "100".to_string())]
642    pub max: String,
643    /// Step increment.
644    #[props(default)]
645    pub step: String,
646    /// Disabled state.
647    #[props(default)]
648    pub disabled: bool,
649    /// Input event handler.
650    #[props(default)]
651    pub oninput: Option<EventHandler<FormEvent>>,
652    /// Additional CSS classes.
653    #[props(default)]
654    pub class: String,
655    /// Any additional HTML attributes.
656    #[props(extends = GlobalAttributes)]
657    attributes: Vec<Attribute>,
658}
659
660#[component]
661pub fn Range(props: RangeProps) -> Element {
662    use wasm_bindgen::JsCast;
663
664    let full_class = if props.class.is_empty() {
665        "form-range".to_string()
666    } else {
667        format!("form-range {}", props.class)
668    };
669
670    // A range slider's thumb position is controlled by its `.value` DOM property,
671    // NOT by a `value` content attribute (the attribute only seeds the default).
672    // Dioxus's declarative `value` sets the attribute, so a server-reported value
673    // that differs from the default leaves the thumb at the default — the same
674    // property-vs-attribute gap the Select had. Hold the mounted element and set
675    // `.value` imperatively on mount and whenever `value` changes.
676    let mut range_el = use_signal(|| None as Option<web_sys::HtmlInputElement>);
677    let value = props.value.clone();
678    use_effect(use_reactive!(|value| {
679        if let Some(el) = range_el.peek().clone() {
680            el.set_value(&value);
681        }
682    }));
683
684    let mount_value = props.value.clone();
685    rsx! {
686        input {
687            class: "{full_class}",
688            r#type: "range",
689            min: "{props.min}",
690            max: "{props.max}",
691            step: if props.step.is_empty() { None } else { Some(props.step.clone()) },
692            disabled: props.disabled,
693            onmounted: move |evt: MountedEvent| {
694                if let Some(el) = evt
695                    .downcast::<web_sys::Element>()
696                    .and_then(|e| e.clone().dyn_into::<web_sys::HtmlInputElement>().ok())
697                {
698                    el.set_value(&mount_value);
699                    range_el.set(Some(el));
700                }
701            },
702            oninput: move |evt| {
703                if let Some(handler) = &props.oninput {
704                    handler.call(evt);
705                }
706            },
707            ..props.attributes,
708        }
709    }
710}
711
712/// Bootstrap Floating Label wrapper.
713///
714/// Wraps an Input or Textarea with a floating label that moves
715/// above the control when focused or filled.
716///
717/// # Bootstrap HTML → Dioxus
718///
719/// | HTML | Dioxus |
720/// |---|---|
721/// | `<div class="form-floating"><input class="form-control" placeholder="..."><label>Email</label></div>` | `FloatingLabel { label: "Email", Input { placeholder: "..." } }` |
722///
723/// ```rust,no_run
724/// # use dioxus::prelude::*;
725/// # use dioxus_bootstrap_css::prelude::*;
726/// # fn _doctest() -> Element {
727/// rsx! {
728///     FloatingLabel { label: "Email address",
729///         Input { r#type: "email", placeholder: "name@example.com" }
730///     }
731/// }
732/// # }
733/// ```
734#[derive(Clone, PartialEq, Props)]
735pub struct FloatingLabelProps {
736    /// Label text.
737    pub label: String,
738    /// Additional CSS classes.
739    #[props(default)]
740    pub class: String,
741    /// Any additional HTML attributes.
742    #[props(extends = GlobalAttributes)]
743    attributes: Vec<Attribute>,
744    /// Child element (Input or Textarea).
745    pub children: Element,
746}
747
748#[component]
749pub fn FloatingLabel(props: FloatingLabelProps) -> Element {
750    let full_class = if props.class.is_empty() {
751        "form-floating".to_string()
752    } else {
753        format!("form-floating {}", props.class)
754    };
755
756    rsx! {
757        div { class: "{full_class}",
758            ..props.attributes,
759            {props.children}
760            label { "{props.label}" }
761        }
762    }
763}
764
765/// Bootstrap form validation feedback text.
766///
767/// # Bootstrap HTML → Dioxus
768///
769/// | HTML | Dioxus |
770/// |---|---|
771/// | `<div class="valid-feedback">Looks good!</div>` | `FormFeedback { valid: true, "Looks good!" }` |
772/// | `<div class="invalid-feedback">Required.</div>` | `FormFeedback { "Required." }` |
773///
774/// ```rust,no_run
775/// # use dioxus::prelude::*;
776/// # use dioxus_bootstrap_css::prelude::*;
777/// # fn _doctest() -> Element {
778/// rsx! {
779///     Input { class: "is-valid".to_string(), value: "correct" }
780///     FormFeedback { valid: true, "Looks good!" }
781/// }
782/// # }
783/// ```
784#[derive(Clone, PartialEq, Props)]
785pub struct FormFeedbackProps {
786    /// True for valid feedback, false for invalid.
787    #[props(default)]
788    pub valid: bool,
789    /// Additional CSS classes.
790    #[props(default)]
791    pub class: String,
792    /// Any additional HTML attributes.
793    #[props(extends = GlobalAttributes)]
794    attributes: Vec<Attribute>,
795    /// Feedback text.
796    pub children: Element,
797}
798
799#[component]
800pub fn FormFeedback(props: FormFeedbackProps) -> Element {
801    let base = if props.valid {
802        "valid-feedback"
803    } else {
804        "invalid-feedback"
805    };
806    let full_class = if props.class.is_empty() {
807        base.to_string()
808    } else {
809        format!("{base} {}", props.class)
810    };
811
812    rsx! {
813        div { class: "{full_class}", ..props.attributes, {props.children} }
814    }
815}
816
817/// Bootstrap form text (help text below a control).
818///
819/// # Bootstrap HTML → Dioxus
820///
821/// | HTML | Dioxus |
822/// |---|---|
823/// | `<div class="form-text">Must be 8-20 characters.</div>` | `FormText { "Must be 8-20 characters." }` |
824///
825/// ```rust,no_run
826/// # use dioxus::prelude::*;
827/// # use dioxus_bootstrap_css::prelude::*;
828/// # fn _doctest() -> Element {
829/// rsx! {
830///     Input { r#type: "password" }
831///     FormText { "Must be 8-20 characters long." }
832/// }
833/// # }
834/// ```
835#[derive(Clone, PartialEq, Props)]
836pub struct FormTextProps {
837    /// Additional CSS classes.
838    #[props(default)]
839    pub class: String,
840    /// Any additional HTML attributes.
841    #[props(extends = GlobalAttributes)]
842    attributes: Vec<Attribute>,
843    /// Help text content.
844    pub children: Element,
845}
846
847#[component]
848pub fn FormText(props: FormTextProps) -> Element {
849    let full_class = if props.class.is_empty() {
850        "form-text".to_string()
851    } else {
852        format!("form-text {}", props.class)
853    };
854
855    rsx! {
856        div { class: "{full_class}", ..props.attributes, {props.children} }
857    }
858}
859
860/// Bootstrap Radio button component.
861///
862/// # Bootstrap HTML → Dioxus
863///
864/// ```html
865/// <!-- Bootstrap HTML -->
866/// <div class="form-check">
867///   <input class="form-check-input" type="radio" name="color" checked>
868///   <label class="form-check-label">Red</label>
869/// </div>
870/// <div class="form-check">
871///   <input class="form-check-input" type="radio" name="color">
872///   <label class="form-check-label">Blue</label>
873/// </div>
874/// ```
875///
876/// ```rust,no_run
877/// # use dioxus::prelude::*;
878/// # use dioxus_bootstrap_css::prelude::*;
879/// # fn _doctest() -> Element {
880/// rsx! {
881///     Radio { name: "color", label: "Red", checked: true }
882///     Radio { name: "color", label: "Blue" }
883/// }
884/// # }
885/// ```
886#[derive(Clone, PartialEq, Props)]
887pub struct RadioProps {
888    /// Radio group name.
889    pub name: String,
890    /// Whether the radio is checked.
891    #[props(default)]
892    pub checked: bool,
893    /// Label text.
894    #[props(default)]
895    pub label: String,
896    /// Disabled state.
897    #[props(default)]
898    pub disabled: bool,
899    /// Change event handler.
900    #[props(default)]
901    pub onchange: Option<EventHandler<FormEvent>>,
902    /// Additional CSS classes for the wrapper.
903    #[props(default)]
904    pub class: String,
905    /// Any additional HTML attributes.
906    #[props(extends = GlobalAttributes)]
907    attributes: Vec<Attribute>,
908}
909
910#[component]
911pub fn Radio(props: RadioProps) -> Element {
912    let full_class = if props.class.is_empty() {
913        "form-check".to_string()
914    } else {
915        format!("form-check {}", props.class)
916    };
917
918    rsx! {
919        div { class: "{full_class}",
920            ..props.attributes,
921            input {
922                class: "form-check-input",
923                r#type: "radio",
924                name: "{props.name}",
925                checked: props.checked,
926                disabled: props.disabled,
927                onchange: move |evt| {
928                    if let Some(handler) = &props.onchange {
929                        handler.call(evt);
930                    }
931                },
932            }
933            if !props.label.is_empty() {
934                label { class: "form-check-label", "{props.label}" }
935            }
936        }
937    }
938}