Skip to main content

dioxus_bootstrap_css/
form.rs

1use dioxus::prelude::*;
2
3use crate::types::{Color, 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    /// Safari's `autocorrect` hint (`on` / `off`). Not a `GlobalAttributes`
124    /// attribute, so it needs its own prop rather than riding `..attributes` —
125    /// the same reason `list` has one.
126    #[props(default)]
127    pub autocorrect: Option<String>,
128    /// Step granularity for numeric and date inputs. The typed sibling of
129    /// `min`/`max`, which are already props: a number field that constrains its
130    /// range but not its increment is only two-thirds typed.
131    #[props(default)]
132    pub step: Option<String>,
133    /// Which file types a `type="file"` input will accept.
134    #[props(default)]
135    pub accept: Option<String>,
136    /// Datalist id to bind for autocomplete (rendered as the input `list`
137    /// attribute). `list` is not a `GlobalAttributes` attribute, so it needs
138    /// its own typed prop rather than riding through `..attributes`.
139    #[props(default)]
140    pub list: Option<String>,
141    /// Input size.
142    #[props(default)]
143    pub size: Size,
144    /// Disabled state.
145    #[props(default)]
146    pub disabled: bool,
147    /// Readonly state.
148    #[props(default)]
149    pub readonly: bool,
150    /// Input event handler.
151    #[props(default)]
152    pub oninput: Option<EventHandler<FormEvent>>,
153    /// Change event handler.
154    #[props(default)]
155    pub onchange: Option<EventHandler<FormEvent>>,
156    /// Focus event handler.
157    #[props(default)]
158    pub onfocus: Option<EventHandler<FocusEvent>>,
159    /// Blur event handler.
160    #[props(default)]
161    pub onblur: Option<EventHandler<FocusEvent>>,
162    /// Key down event handler.
163    #[props(default)]
164    pub onkeydown: Option<EventHandler<KeyboardEvent>>,
165    /// Key up event handler.
166    #[props(default)]
167    pub onkeyup: Option<EventHandler<KeyboardEvent>>,
168    /// Additional CSS classes.
169    #[props(default)]
170    pub class: String,
171    /// Any additional HTML attributes.
172    #[props(extends = GlobalAttributes)]
173    attributes: Vec<Attribute>,
174}
175
176#[component]
177pub fn Input(props: InputProps) -> Element {
178    let size_class = match props.size {
179        Size::Md => String::new(),
180        s => format!(" form-control-{s}"),
181    };
182
183    let full_class = if props.class.is_empty() {
184        format!("form-control{size_class}")
185    } else {
186        format!("form-control{size_class} {}", props.class)
187    };
188
189    rsx! {
190        input {
191            class: "{full_class}",
192            r#type: "{props.r#type}",
193            value: if props.uncontrolled { None } else { Some(props.value.clone()) },
194            placeholder: "{props.placeholder}",
195            min: props.min.clone(),
196            max: props.max.clone(),
197            step: props.step.clone(),
198            accept: props.accept.clone(),
199            autocomplete: props.autocomplete.clone(),
200            autocorrect: props.autocorrect.clone(),
201            list: props.list.clone(),
202            disabled: props.disabled,
203            readonly: props.readonly,
204            oninput: move |evt| {
205                if let Some(handler) = &props.oninput {
206                    handler.call(evt);
207                }
208            },
209            onchange: move |evt| {
210                if let Some(handler) = &props.onchange {
211                    handler.call(evt);
212                }
213            },
214            onfocus: move |evt| {
215                if let Some(handler) = &props.onfocus {
216                    handler.call(evt);
217                }
218            },
219            onblur: move |evt| {
220                if let Some(handler) = &props.onblur {
221                    handler.call(evt);
222                }
223            },
224            onkeydown: move |evt| {
225                if let Some(handler) = &props.onkeydown {
226                    handler.call(evt);
227                }
228            },
229            onkeyup: move |evt| {
230                if let Some(handler) = &props.onkeyup {
231                    handler.call(evt);
232                }
233            },
234            ..props.attributes,
235        }
236    }
237}
238
239/// Bootstrap Select (dropdown) component.
240///
241/// # Bootstrap HTML → Dioxus
242///
243/// ```html
244/// <!-- Bootstrap HTML -->
245/// <select class="form-select">
246///   <option value="opt1">Option 1</option>
247///   <option value="opt2" selected>Option 2</option>
248/// </select>
249/// ```
250///
251/// ```rust,no_run
252/// # use dioxus::prelude::*;
253/// # use dioxus_bootstrap_css::prelude::*;
254/// # fn _doctest() -> Element {
255/// rsx! {
256///     Select { value: "opt2", onchange: move |evt| { /* handle */ },
257///         option { value: "opt1", "Option 1" }
258///         option { value: "opt2", "Option 2" }
259///     }
260/// }
261/// # }
262/// ```
263#[derive(Clone, PartialEq, Props)]
264pub struct SelectProps {
265    /// Current selected value.
266    #[props(default)]
267    pub value: String,
268    /// Select size.
269    #[props(default)]
270    pub size: Size,
271    /// Disabled state.
272    #[props(default)]
273    pub disabled: bool,
274    /// Change event handler.
275    #[props(default)]
276    pub onchange: Option<EventHandler<FormEvent>>,
277    /// Additional CSS classes.
278    #[props(default)]
279    pub class: String,
280    /// Any additional HTML attributes.
281    #[props(extends = GlobalAttributes)]
282    attributes: Vec<Attribute>,
283    /// Child elements (option elements).
284    pub children: Element,
285}
286
287#[component]
288pub fn Select(props: SelectProps) -> Element {
289    use wasm_bindgen::JsCast;
290
291    let size_class = match props.size {
292        Size::Md => String::new(),
293        s => format!(" form-select-{s}"),
294    };
295
296    let full_class = if props.class.is_empty() {
297        format!("form-select{size_class}")
298    } else {
299        format!("form-select{size_class} {}", props.class)
300    };
301
302    // A `<select>`'s selection is controlled by its `.value` property (or an
303    // `<option selected>`), NOT by a `value` content attribute — browsers ignore
304    // the latter on a select, so Dioxus's declarative `value` silently does
305    // nothing and the element shows its first option. Hold the mounted element
306    // and set `.value` imperatively on mount and whenever `value` changes, so the
307    // control reflects the value it is given.
308    let mut select_el = use_signal(|| None as Option<web_sys::HtmlSelectElement>);
309    let value = props.value.clone();
310    use_effect(use_reactive!(|value| {
311        if let Some(el) = select_el.peek().clone() {
312            el.set_value(&value);
313        }
314    }));
315
316    let mount_value = props.value.clone();
317    rsx! {
318        select {
319            class: "{full_class}",
320            disabled: props.disabled,
321            onmounted: move |evt: MountedEvent| {
322                if let Some(el) = evt
323                    .downcast::<web_sys::Element>()
324                    .and_then(|e| e.clone().dyn_into::<web_sys::HtmlSelectElement>().ok())
325                {
326                    el.set_value(&mount_value);
327                    select_el.set(Some(el));
328                }
329            },
330            onchange: move |evt| {
331                if let Some(handler) = &props.onchange {
332                    handler.call(evt);
333                }
334            },
335            ..props.attributes,
336            {props.children}
337        }
338    }
339}
340
341/// Bootstrap Textarea component.
342///
343/// # Bootstrap HTML → Dioxus
344///
345/// | HTML | Dioxus |
346/// |---|---|
347/// | `<textarea class="form-control" rows="5">` | `Textarea { rows: 5 }` |
348/// | `<textarea class="form-control form-control-sm">` | `Textarea { size: Size::Sm }` |
349/// | `<textarea class="form-control" placeholder="..." disabled>` | `Textarea { placeholder: "...", disabled: true }` |
350///
351/// ```rust,no_run
352/// # use dioxus::prelude::*;
353/// # use dioxus_bootstrap_css::prelude::*;
354/// # fn _doctest() -> Element {
355/// rsx! {
356///     Textarea { rows: 5, placeholder: "Enter description..." }
357/// }
358/// # }
359/// ```
360#[derive(Clone, PartialEq, Props)]
361pub struct TextareaProps {
362    /// Current value.
363    #[props(default)]
364    pub value: String,
365    /// When `true`, the `value` attribute is omitted so the field is
366    /// *uncontrolled*: the DOM keeps whatever value the user or an external
367    /// script writes, instead of Dioxus forcing it back to `value` on every
368    /// render. Use for a field another script streams into (e.g. a live
369    /// transcript box).
370    #[props(default)]
371    pub uncontrolled: bool,
372    /// Browser autocomplete hint.
373    #[props(default)]
374    pub autocomplete: Option<String>,
375    /// Safari's `autocorrect` hint (`on` / `off`) — the attribute a compose box
376    /// most often needs turned off, and not one `GlobalAttributes` carries.
377    #[props(default)]
378    pub autocorrect: Option<String>,
379    /// Number of visible rows.
380    #[props(default = 3)]
381    pub rows: u32,
382    /// Placeholder text.
383    #[props(default)]
384    pub placeholder: String,
385    /// Textarea size.
386    #[props(default)]
387    pub size: Size,
388    /// Disabled state.
389    #[props(default)]
390    pub disabled: bool,
391    /// Readonly state.
392    #[props(default)]
393    pub readonly: bool,
394    /// Input event handler.
395    #[props(default)]
396    pub oninput: Option<EventHandler<FormEvent>>,
397    /// Change event handler.
398    #[props(default)]
399    pub onchange: Option<EventHandler<FormEvent>>,
400    /// Focus event handler.
401    #[props(default)]
402    pub onfocus: Option<EventHandler<FocusEvent>>,
403    /// Blur event handler.
404    #[props(default)]
405    pub onblur: Option<EventHandler<FocusEvent>>,
406    /// Key down event handler.
407    #[props(default)]
408    pub onkeydown: Option<EventHandler<KeyboardEvent>>,
409    /// Key up event handler.
410    #[props(default)]
411    pub onkeyup: Option<EventHandler<KeyboardEvent>>,
412    /// Additional CSS classes.
413    #[props(default)]
414    pub class: String,
415    /// Any additional HTML attributes.
416    #[props(extends = GlobalAttributes)]
417    attributes: Vec<Attribute>,
418}
419
420#[component]
421pub fn Textarea(props: TextareaProps) -> Element {
422    let size_class = match props.size {
423        Size::Md => String::new(),
424        s => format!(" form-control-{s}"),
425    };
426    let full_class = if props.class.is_empty() {
427        format!("form-control{size_class}")
428    } else {
429        format!("form-control{size_class} {}", props.class)
430    };
431
432    rsx! {
433        textarea {
434            class: "{full_class}",
435            rows: "{props.rows}",
436            placeholder: "{props.placeholder}",
437            autocomplete: props.autocomplete.clone(),
438            autocorrect: props.autocorrect.clone(),
439            disabled: props.disabled,
440            readonly: props.readonly,
441            value: if props.uncontrolled { None } else { Some(props.value.clone()) },
442            oninput: move |evt| {
443                if let Some(handler) = &props.oninput {
444                    handler.call(evt);
445                }
446            },
447            onchange: move |evt| {
448                if let Some(handler) = &props.onchange {
449                    handler.call(evt);
450                }
451            },
452            onfocus: move |evt| {
453                if let Some(handler) = &props.onfocus {
454                    handler.call(evt);
455                }
456            },
457            onblur: move |evt| {
458                if let Some(handler) = &props.onblur {
459                    handler.call(evt);
460                }
461            },
462            onkeydown: move |evt| {
463                if let Some(handler) = &props.onkeydown {
464                    handler.call(evt);
465                }
466            },
467            onkeyup: move |evt| {
468                if let Some(handler) = &props.onkeyup {
469                    handler.call(evt);
470                }
471            },
472            ..props.attributes,
473        }
474    }
475}
476
477/// Bootstrap Checkbox component.
478///
479/// # Bootstrap HTML → Dioxus
480///
481/// ```html
482/// <!-- Bootstrap HTML -->
483/// <div class="form-check">
484///   <input class="form-check-input" type="checkbox" checked>
485///   <label class="form-check-label">Accept terms</label>
486/// </div>
487/// ```
488///
489/// ```rust,no_run
490/// # use dioxus::prelude::*;
491/// # use dioxus_bootstrap_css::prelude::*;
492/// # fn _doctest() -> Element {
493/// rsx! {
494///     Checkbox { checked: true, label: "Accept terms",
495///         onchange: move |evt| { /* handle */ },
496///     }
497/// }
498/// # }
499/// ```
500#[derive(Clone, PartialEq, Props)]
501pub struct CheckboxProps {
502    /// Whether the checkbox is checked.
503    #[props(default)]
504    pub checked: bool,
505    /// Optional id applied to the checkbox input.
506    #[props(default)]
507    pub input_id: Option<String>,
508    /// Label text.
509    #[props(default)]
510    pub label: String,
511    /// Disabled state.
512    #[props(default)]
513    pub disabled: bool,
514    /// Change event handler.
515    #[props(default)]
516    pub onchange: Option<EventHandler<FormEvent>>,
517    /// Click event handler for the checkbox input.
518    #[props(default)]
519    pub onclick: Option<EventHandler<MouseEvent>>,
520    /// Additional CSS classes for the wrapper.
521    #[props(default)]
522    pub class: String,
523    /// Any additional HTML attributes.
524    #[props(extends = GlobalAttributes)]
525    attributes: Vec<Attribute>,
526}
527
528#[component]
529pub fn Checkbox(props: CheckboxProps) -> Element {
530    let full_class = if props.class.is_empty() {
531        "form-check".to_string()
532    } else {
533        format!("form-check {}", props.class)
534    };
535    let label_for = props.input_id.clone().unwrap_or_default();
536
537    rsx! {
538            div { class: "{full_class}",
539                ..props.attributes,
540    input {
541    class: "form-check-input",
542    r#type: "checkbox",
543                    id: props.input_id.unwrap_or_default(),
544                    checked: props.checked,
545                    disabled: props.disabled,
546                    onclick: move |evt| {
547                        if let Some(handler) = &props.onclick {
548                            handler.call(evt);
549                        }
550                    },
551                    onchange: move |evt| {
552                        if let Some(handler) = &props.onchange {
553                            handler.call(evt);
554                        }
555                    },
556                }
557    if !props.label.is_empty() {
558    label { class: "form-check-label", r#for: "{label_for}", "{props.label}" }
559    }
560            }
561        }
562}
563
564/// Bootstrap Switch (toggle) component.
565///
566/// # Bootstrap HTML → Dioxus
567///
568/// ```html
569/// <!-- Bootstrap HTML -->
570/// <div class="form-check form-switch">
571///   <input class="form-check-input" type="checkbox" role="switch" checked>
572///   <label class="form-check-label">Enable notifications</label>
573/// </div>
574/// ```
575///
576/// ```rust,no_run
577/// # use dioxus::prelude::*;
578/// # use dioxus_bootstrap_css::prelude::*;
579/// # fn _doctest() -> Element {
580/// rsx! {
581///     Switch { checked: true, label: "Enable notifications",
582///         onchange: move |evt| { /* handle */ },
583///     }
584/// }
585/// # }
586/// ```
587#[derive(Clone, PartialEq, Props)]
588pub struct SwitchProps {
589    /// Whether the switch is on.
590    #[props(default)]
591    pub checked: bool,
592    /// Label text.
593    #[props(default)]
594    pub label: String,
595    /// Disabled state.
596    #[props(default)]
597    pub disabled: bool,
598    /// Change event handler.
599    #[props(default)]
600    pub onchange: Option<EventHandler<FormEvent>>,
601    /// Additional CSS classes for the wrapper.
602    #[props(default)]
603    pub class: String,
604    /// Any additional HTML attributes.
605    #[props(extends = GlobalAttributes)]
606    attributes: Vec<Attribute>,
607}
608
609#[component]
610pub fn Switch(props: SwitchProps) -> Element {
611    let full_class = if props.class.is_empty() {
612        "form-check form-switch".to_string()
613    } else {
614        format!("form-check form-switch {}", props.class)
615    };
616
617    rsx! {
618        div { class: "{full_class}",
619            ..props.attributes,
620            input {
621                class: "form-check-input",
622                r#type: "checkbox",
623                role: "switch",
624                checked: props.checked,
625                disabled: props.disabled,
626                onchange: move |evt| {
627                    if let Some(handler) = &props.onchange {
628                        handler.call(evt);
629                    }
630                },
631            }
632            if !props.label.is_empty() {
633                label { class: "form-check-label", "{props.label}" }
634            }
635        }
636    }
637}
638
639/// Bootstrap Range (slider) input.
640///
641/// # Bootstrap HTML → Dioxus
642///
643/// | HTML | Dioxus |
644/// |---|---|
645/// | `<input type="range" class="form-range" min="0" max="100">` | `Range { min: "0", max: "100" }` |
646/// | `<input type="range" class="form-range" step="5" disabled>` | `Range { step: "5".into(), disabled: true }` |
647///
648/// ```rust,no_run
649/// # use dioxus::prelude::*;
650/// # use dioxus_bootstrap_css::prelude::*;
651/// # fn _doctest() -> Element {
652/// rsx! {
653///     Range { value: "50", min: "0", max: "100" }
654/// }
655/// # }
656/// ```
657#[derive(Clone, PartialEq, Props)]
658pub struct RangeProps {
659    /// Current value.
660    #[props(default)]
661    pub value: String,
662    /// Minimum value.
663    #[props(default = "0".to_string())]
664    pub min: String,
665    /// Maximum value.
666    #[props(default = "100".to_string())]
667    pub max: String,
668    /// Step increment.
669    #[props(default)]
670    pub step: String,
671    /// Disabled state.
672    #[props(default)]
673    pub disabled: bool,
674    /// Input event handler.
675    #[props(default)]
676    pub oninput: Option<EventHandler<FormEvent>>,
677    /// Additional CSS classes.
678    #[props(default)]
679    pub class: String,
680    /// Any additional HTML attributes.
681    #[props(extends = GlobalAttributes)]
682    attributes: Vec<Attribute>,
683}
684
685#[component]
686pub fn Range(props: RangeProps) -> Element {
687    use wasm_bindgen::JsCast;
688
689    let full_class = if props.class.is_empty() {
690        "form-range".to_string()
691    } else {
692        format!("form-range {}", props.class)
693    };
694
695    // A range slider's thumb position is controlled by its `.value` DOM property,
696    // NOT by a `value` content attribute (the attribute only seeds the default).
697    // Dioxus's declarative `value` sets the attribute, so a server-reported value
698    // that differs from the default leaves the thumb at the default — the same
699    // property-vs-attribute gap the Select had. Hold the mounted element and set
700    // `.value` imperatively on mount and whenever `value` changes.
701    let mut range_el = use_signal(|| None as Option<web_sys::HtmlInputElement>);
702    let value = props.value.clone();
703    use_effect(use_reactive!(|value| {
704        if let Some(el) = range_el.peek().clone() {
705            el.set_value(&value);
706        }
707    }));
708
709    let mount_value = props.value.clone();
710    rsx! {
711        input {
712            class: "{full_class}",
713            r#type: "range",
714            min: "{props.min}",
715            max: "{props.max}",
716            step: if props.step.is_empty() { None } else { Some(props.step.clone()) },
717            disabled: props.disabled,
718            onmounted: move |evt: MountedEvent| {
719                if let Some(el) = evt
720                    .downcast::<web_sys::Element>()
721                    .and_then(|e| e.clone().dyn_into::<web_sys::HtmlInputElement>().ok())
722                {
723                    el.set_value(&mount_value);
724                    range_el.set(Some(el));
725                }
726            },
727            oninput: move |evt| {
728                if let Some(handler) = &props.oninput {
729                    handler.call(evt);
730                }
731            },
732            ..props.attributes,
733        }
734    }
735}
736
737/// Bootstrap Floating Label wrapper.
738///
739/// Wraps an Input or Textarea with a floating label that moves
740/// above the control when focused or filled.
741///
742/// # Bootstrap HTML → Dioxus
743///
744/// | HTML | Dioxus |
745/// |---|---|
746/// | `<div class="form-floating"><input class="form-control" placeholder="..."><label>Email</label></div>` | `FloatingLabel { label: "Email", Input { placeholder: "..." } }` |
747///
748/// ```rust,no_run
749/// # use dioxus::prelude::*;
750/// # use dioxus_bootstrap_css::prelude::*;
751/// # fn _doctest() -> Element {
752/// rsx! {
753///     FloatingLabel { label: "Email address",
754///         Input { r#type: "email", placeholder: "name@example.com" }
755///     }
756/// }
757/// # }
758/// ```
759#[derive(Clone, PartialEq, Props)]
760pub struct FloatingLabelProps {
761    /// Label text.
762    pub label: String,
763    /// Additional CSS classes.
764    #[props(default)]
765    pub class: String,
766    /// Any additional HTML attributes.
767    #[props(extends = GlobalAttributes)]
768    attributes: Vec<Attribute>,
769    /// Child element (Input or Textarea).
770    pub children: Element,
771}
772
773#[component]
774pub fn FloatingLabel(props: FloatingLabelProps) -> Element {
775    let full_class = if props.class.is_empty() {
776        "form-floating".to_string()
777    } else {
778        format!("form-floating {}", props.class)
779    };
780
781    rsx! {
782        div { class: "{full_class}",
783            ..props.attributes,
784            {props.children}
785            label { "{props.label}" }
786        }
787    }
788}
789
790/// Bootstrap form validation feedback text.
791///
792/// # Bootstrap HTML → Dioxus
793///
794/// | HTML | Dioxus |
795/// |---|---|
796/// | `<div class="valid-feedback">Looks good!</div>` | `FormFeedback { valid: true, "Looks good!" }` |
797/// | `<div class="invalid-feedback">Required.</div>` | `FormFeedback { "Required." }` |
798///
799/// ```rust,no_run
800/// # use dioxus::prelude::*;
801/// # use dioxus_bootstrap_css::prelude::*;
802/// # fn _doctest() -> Element {
803/// rsx! {
804///     Input { class: "is-valid".to_string(), value: "correct" }
805///     FormFeedback { valid: true, "Looks good!" }
806/// }
807/// # }
808/// ```
809#[derive(Clone, PartialEq, Props)]
810pub struct FormFeedbackProps {
811    /// True for valid feedback, false for invalid.
812    #[props(default)]
813    pub valid: bool,
814    /// Additional CSS classes.
815    #[props(default)]
816    pub class: String,
817    /// Any additional HTML attributes.
818    #[props(extends = GlobalAttributes)]
819    attributes: Vec<Attribute>,
820    /// Feedback text.
821    pub children: Element,
822}
823
824#[component]
825pub fn FormFeedback(props: FormFeedbackProps) -> Element {
826    let base = if props.valid {
827        "valid-feedback"
828    } else {
829        "invalid-feedback"
830    };
831    let full_class = if props.class.is_empty() {
832        base.to_string()
833    } else {
834        format!("{base} {}", props.class)
835    };
836
837    rsx! {
838        div { class: "{full_class}", ..props.attributes, {props.children} }
839    }
840}
841
842/// Bootstrap form text (help text below a control).
843///
844/// # Bootstrap HTML → Dioxus
845///
846/// | HTML | Dioxus |
847/// |---|---|
848/// | `<div class="form-text">Must be 8-20 characters.</div>` | `FormText { "Must be 8-20 characters." }` |
849///
850/// ```rust,no_run
851/// # use dioxus::prelude::*;
852/// # use dioxus_bootstrap_css::prelude::*;
853/// # fn _doctest() -> Element {
854/// rsx! {
855///     Input { r#type: "password" }
856///     FormText { "Must be 8-20 characters long." }
857/// }
858/// # }
859/// ```
860#[derive(Clone, PartialEq, Props)]
861pub struct FormTextProps {
862    /// Additional CSS classes.
863    #[props(default)]
864    pub class: String,
865    /// Any additional HTML attributes.
866    #[props(extends = GlobalAttributes)]
867    attributes: Vec<Attribute>,
868    /// Help text content.
869    pub children: Element,
870}
871
872#[component]
873pub fn FormText(props: FormTextProps) -> Element {
874    let full_class = if props.class.is_empty() {
875        "form-text".to_string()
876    } else {
877        format!("form-text {}", props.class)
878    };
879
880    rsx! {
881        div { class: "{full_class}", ..props.attributes, {props.children} }
882    }
883}
884
885/// Bootstrap Radio button component.
886///
887/// # Bootstrap HTML → Dioxus
888///
889/// ```html
890/// <!-- Bootstrap HTML -->
891/// <div class="form-check">
892///   <input class="form-check-input" type="radio" name="color" checked>
893///   <label class="form-check-label">Red</label>
894/// </div>
895/// <div class="form-check">
896///   <input class="form-check-input" type="radio" name="color">
897///   <label class="form-check-label">Blue</label>
898/// </div>
899/// ```
900///
901/// ```rust,no_run
902/// # use dioxus::prelude::*;
903/// # use dioxus_bootstrap_css::prelude::*;
904/// # fn _doctest() -> Element {
905/// rsx! {
906///     Radio { name: "color", label: "Red", checked: true }
907///     Radio { name: "color", label: "Blue" }
908/// }
909/// # }
910/// ```
911#[derive(Clone, PartialEq, Props)]
912pub struct RadioProps {
913    /// Radio group name.
914    pub name: String,
915    /// Whether the radio is checked.
916    #[props(default)]
917    pub checked: bool,
918    /// Label text.
919    #[props(default)]
920    pub label: String,
921    /// Disabled state.
922    #[props(default)]
923    pub disabled: bool,
924    /// Change event handler.
925    #[props(default)]
926    pub onchange: Option<EventHandler<FormEvent>>,
927    /// Additional CSS classes for the wrapper.
928    #[props(default)]
929    pub class: String,
930    /// Any additional HTML attributes.
931    #[props(extends = GlobalAttributes)]
932    attributes: Vec<Attribute>,
933}
934
935#[component]
936pub fn Radio(props: RadioProps) -> Element {
937    let full_class = if props.class.is_empty() {
938        "form-check".to_string()
939    } else {
940        format!("form-check {}", props.class)
941    };
942
943    rsx! {
944        div { class: "{full_class}",
945            ..props.attributes,
946            input {
947                class: "form-check-input",
948                r#type: "radio",
949                name: "{props.name}",
950                checked: props.checked,
951                disabled: props.disabled,
952                onchange: move |evt| {
953                    if let Some(handler) = &props.onchange {
954                        handler.call(evt);
955                    }
956                },
957            }
958            if !props.label.is_empty() {
959                label { class: "form-check-label", "{props.label}" }
960            }
961        }
962    }
963}
964
965/// The class string a `btn-check` toggle's `<label>` carries. Shared by
966/// [`CheckboxButton`] and [`RadioButton`] and extracted so it is assertable
967/// without rendering: the whole contract of a toggle button is that it emits
968/// the same button classes a real [`Button`](crate::button::Button) does, and a
969/// drift between the two is exactly what would go unnoticed.
970fn toggle_button_label_class(color: Color, outline: bool, size: Size, class: &str) -> String {
971    let style = if outline { "btn-outline" } else { "btn" };
972    let variant_class = format!(" {style}-{color}");
973
974    let size_class = match size {
975        Size::Md => String::new(),
976        s => format!(" btn-{s}"),
977    };
978
979    if class.is_empty() {
980        format!("btn{variant_class}{size_class}")
981    } else {
982        format!("btn{variant_class}{size_class} {class}")
983    }
984}
985
986/// Bootstrap checkbox toggle button (`btn-check`).
987///
988/// Bootstrap 5.3's "Checkbox toggle buttons": a visually hidden checkbox paired
989/// with a `<label class="btn …">` whose `for` targets it. The label is what the
990/// user sees and clicks; the checkbox holds the state and submits the value.
991/// This is a Bootstrap component in its own right, not a styled
992/// [`Checkbox`] — the markup, the classes and the CSS that drives them are
993/// different.
994///
995/// The `id` is required rather than optional: the `for`/`id` pair *is* the
996/// mechanism. A toggle whose ids do not match renders correctly and does
997/// nothing when clicked, which is the worst kind of broken.
998///
999/// # Bootstrap HTML → Dioxus
1000///
1001/// | HTML | Dioxus |
1002/// |---|---|
1003/// | `<input class="btn-check" type="checkbox" id="c1"><label class="btn btn-primary" for="c1">Mute</label>` | `CheckboxButton { id: "c1", label: "Mute" }` |
1004/// | `<label class="btn btn-outline-secondary btn-sm" …>` | `CheckboxButton { id: "c1", color: Color::Secondary, outline: true, size: Size::Sm, … }` |
1005///
1006/// ```rust,no_run
1007/// # use dioxus::prelude::*;
1008/// # use dioxus_bootstrap_css::prelude::*;
1009/// # fn _doctest() -> Element {
1010/// rsx! {
1011///     CheckboxButton { id: "mute", label: "Mute", checked: true }
1012///     CheckboxButton { id: "wide", label: "Wide", color: Color::Secondary, outline: true }
1013/// }
1014/// # }
1015/// ```
1016#[derive(Clone, PartialEq, Props)]
1017pub struct CheckboxButtonProps {
1018    /// The input's id and the label's `for` target. Required: the pair is what
1019    /// makes the label toggle the input.
1020    pub id: String,
1021    /// Optional `name`, for when several toggles submit under one form field.
1022    #[props(default)]
1023    pub name: Option<String>,
1024    /// The value submitted when checked.
1025    #[props(default)]
1026    pub value: Option<String>,
1027    /// Whether the toggle is on.
1028    #[props(default)]
1029    pub checked: bool,
1030    /// Disable the control.
1031    #[props(default)]
1032    pub disabled: bool,
1033    /// The visible text, rendered after any `children`.
1034    #[props(default)]
1035    pub label: String,
1036    /// Rich label content (a leading icon, say), rendered inside the label
1037    /// before `label`.
1038    #[props(default)]
1039    pub children: Element,
1040    /// Button colour variant.
1041    #[props(default)]
1042    pub color: Color,
1043    /// Use the outline style.
1044    #[props(default)]
1045    pub outline: bool,
1046    /// Button size.
1047    #[props(default)]
1048    pub size: Size,
1049    /// Bootstrap's own examples set `autocomplete="off"` so a browser does not
1050    /// restore a stale toggle state on reload. Left unset by default so the
1051    /// rendered attributes match the markup being ported rather than silently
1052    /// adding one.
1053    #[props(default)]
1054    pub autocomplete: Option<String>,
1055    /// Additional CSS classes, appended to the **label**'s button classes.
1056    #[props(default)]
1057    pub class: String,
1058    /// Change handler, fired on the input.
1059    #[props(default)]
1060    pub onchange: Option<EventHandler<FormEvent>>,
1061    /// Any additional HTML attributes, applied to the **input**.
1062    #[props(extends = GlobalAttributes)]
1063    attributes: Vec<Attribute>,
1064}
1065
1066#[component]
1067pub fn CheckboxButton(props: CheckboxButtonProps) -> Element {
1068    let label_class =
1069        toggle_button_label_class(props.color, props.outline, props.size, &props.class);
1070
1071    rsx! {
1072        input {
1073            class: "btn-check",
1074            r#type: "checkbox",
1075            name: props.name,
1076            id: "{props.id}",
1077            value: props.value,
1078            checked: props.checked,
1079            disabled: props.disabled,
1080            autocomplete: props.autocomplete,
1081            onchange: move |evt| {
1082                if let Some(handler) = &props.onchange {
1083                    handler.call(evt);
1084                }
1085            },
1086            ..props.attributes,
1087        }
1088        label { class: "{label_class}", r#for: "{props.id}", {props.children} "{props.label}" }
1089    }
1090}
1091
1092/// Bootstrap radio toggle button (`btn-check`).
1093///
1094/// Bootstrap 5.3's "Radio toggle buttons" — the radio sibling of
1095/// [`CheckboxButton`], and the markup behind a segmented button group: several
1096/// radios sharing one `name`, each with its own label, wrapped in a
1097/// [`ButtonGroup`](crate::button::ButtonGroup).
1098///
1099/// # Bootstrap HTML → Dioxus
1100///
1101/// | HTML | Dioxus |
1102/// |---|---|
1103/// | `<input class="btn-check" type="radio" name="view" id="r1"><label class="btn btn-primary" for="r1">List</label>` | `RadioButton { id: "r1", name: "view", label: "List" }` |
1104///
1105/// ```rust,no_run
1106/// # use dioxus::prelude::*;
1107/// # use dioxus_bootstrap_css::prelude::*;
1108/// # fn _doctest() -> Element {
1109/// rsx! {
1110///     ButtonGroup {
1111///         RadioButton { id: "v-list", name: "view", value: "list", label: "List", checked: true }
1112///         RadioButton { id: "v-grid", name: "view", value: "grid", label: "Grid" }
1113///     }
1114/// }
1115/// # }
1116/// ```
1117#[derive(Clone, PartialEq, Props)]
1118pub struct RadioButtonProps {
1119    /// The input's id and the label's `for` target. Required, as for
1120    /// [`CheckboxButton`].
1121    pub id: String,
1122    /// The radio group name. Radios sharing a `name` are mutually exclusive —
1123    /// which is the whole point of a radio, so this is where a segmented
1124    /// control is actually defined.
1125    #[props(default)]
1126    pub name: Option<String>,
1127    /// The value submitted when this option is selected.
1128    #[props(default)]
1129    pub value: Option<String>,
1130    /// Whether this option is selected.
1131    #[props(default)]
1132    pub checked: bool,
1133    /// Disable the control.
1134    #[props(default)]
1135    pub disabled: bool,
1136    /// The visible text, rendered after any `children`.
1137    #[props(default)]
1138    pub label: String,
1139    /// Rich label content, rendered inside the label before `label`.
1140    #[props(default)]
1141    pub children: Element,
1142    /// Button colour variant.
1143    #[props(default)]
1144    pub color: Color,
1145    /// Use the outline style.
1146    #[props(default)]
1147    pub outline: bool,
1148    /// Button size.
1149    #[props(default)]
1150    pub size: Size,
1151    /// See [`CheckboxButtonProps::autocomplete`].
1152    #[props(default)]
1153    pub autocomplete: Option<String>,
1154    /// Additional CSS classes, appended to the **label**'s button classes.
1155    #[props(default)]
1156    pub class: String,
1157    /// Change handler, fired on the input.
1158    #[props(default)]
1159    pub onchange: Option<EventHandler<FormEvent>>,
1160    /// Any additional HTML attributes, applied to the **input**.
1161    #[props(extends = GlobalAttributes)]
1162    attributes: Vec<Attribute>,
1163}
1164
1165#[component]
1166pub fn RadioButton(props: RadioButtonProps) -> Element {
1167    let label_class =
1168        toggle_button_label_class(props.color, props.outline, props.size, &props.class);
1169
1170    rsx! {
1171        input {
1172            class: "btn-check",
1173            r#type: "radio",
1174            name: props.name,
1175            id: "{props.id}",
1176            value: props.value,
1177            checked: props.checked,
1178            disabled: props.disabled,
1179            autocomplete: props.autocomplete,
1180            onchange: move |evt| {
1181                if let Some(handler) = &props.onchange {
1182                    handler.call(evt);
1183                }
1184            },
1185            ..props.attributes,
1186        }
1187        label { class: "{label_class}", r#for: "{props.id}", {props.children} "{props.label}" }
1188    }
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193    use super::*;
1194
1195    #[test]
1196    fn toggle_button_label_matches_a_plain_button() {
1197        // The contract: a toggle's label carries the same classes the equivalent
1198        // Button emits. If Button's composition changes and this does not, a
1199        // toggle and a button styled identically stop looking identical.
1200        assert_eq!(
1201            toggle_button_label_class(Color::Primary, false, Size::Md, ""),
1202            "btn btn-primary"
1203        );
1204    }
1205
1206    #[test]
1207    fn toggle_button_label_outline_and_size() {
1208        assert_eq!(
1209            toggle_button_label_class(Color::Secondary, true, Size::Sm, ""),
1210            "btn btn-outline-secondary btn-sm"
1211        );
1212    }
1213
1214    #[test]
1215    fn toggle_button_label_appends_extra_classes_last() {
1216        assert_eq!(
1217            toggle_button_label_class(Color::Danger, false, Size::Lg, "w-100"),
1218            "btn btn-danger btn-lg w-100"
1219        );
1220    }
1221
1222    #[test]
1223    fn toggle_button_medium_size_adds_no_class() {
1224        // Bootstrap has no `btn-md`; the default size is the absence of a class.
1225        assert!(!toggle_button_label_class(Color::Primary, false, Size::Md, "").contains("btn-md"));
1226    }
1227}