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    let size_class = match props.size {
274        Size::Md => String::new(),
275        s => format!(" form-select-{s}"),
276    };
277
278    let full_class = if props.class.is_empty() {
279        format!("form-select{size_class}")
280    } else {
281        format!("form-select{size_class} {}", props.class)
282    };
283
284    rsx! {
285        select {
286            class: "{full_class}",
287            value: "{props.value}",
288            disabled: props.disabled,
289            onchange: move |evt| {
290                if let Some(handler) = &props.onchange {
291                    handler.call(evt);
292                }
293            },
294            ..props.attributes,
295            {props.children}
296        }
297    }
298}
299
300/// Bootstrap Textarea component.
301///
302/// # Bootstrap HTML → Dioxus
303///
304/// | HTML | Dioxus |
305/// |---|---|
306/// | `<textarea class="form-control" rows="5">` | `Textarea { rows: 5 }` |
307/// | `<textarea class="form-control form-control-sm">` | `Textarea { size: Size::Sm }` |
308/// | `<textarea class="form-control" placeholder="..." disabled>` | `Textarea { placeholder: "...", disabled: true }` |
309///
310/// ```rust,no_run
311/// # use dioxus::prelude::*;
312/// # use dioxus_bootstrap_css::prelude::*;
313/// # fn _doctest() -> Element {
314/// rsx! {
315///     Textarea { rows: 5, placeholder: "Enter description..." }
316/// }
317/// # }
318/// ```
319#[derive(Clone, PartialEq, Props)]
320pub struct TextareaProps {
321    /// Current value.
322    #[props(default)]
323    pub value: String,
324    /// When `true`, the `value` attribute is omitted so the field is
325    /// *uncontrolled*: the DOM keeps whatever value the user or an external
326    /// script writes, instead of Dioxus forcing it back to `value` on every
327    /// render. Use for a field another script streams into (e.g. a live
328    /// transcript box).
329    #[props(default)]
330    pub uncontrolled: bool,
331    /// Number of visible rows.
332    #[props(default = 3)]
333    pub rows: u32,
334    /// Placeholder text.
335    #[props(default)]
336    pub placeholder: String,
337    /// Textarea size.
338    #[props(default)]
339    pub size: Size,
340    /// Disabled state.
341    #[props(default)]
342    pub disabled: bool,
343    /// Readonly state.
344    #[props(default)]
345    pub readonly: bool,
346    /// Input event handler.
347    #[props(default)]
348    pub oninput: Option<EventHandler<FormEvent>>,
349    /// Change event handler.
350    #[props(default)]
351    pub onchange: Option<EventHandler<FormEvent>>,
352    /// Focus event handler.
353    #[props(default)]
354    pub onfocus: Option<EventHandler<FocusEvent>>,
355    /// Blur event handler.
356    #[props(default)]
357    pub onblur: Option<EventHandler<FocusEvent>>,
358    /// Key down event handler.
359    #[props(default)]
360    pub onkeydown: Option<EventHandler<KeyboardEvent>>,
361    /// Key up event handler.
362    #[props(default)]
363    pub onkeyup: Option<EventHandler<KeyboardEvent>>,
364    /// Additional CSS classes.
365    #[props(default)]
366    pub class: String,
367    /// Any additional HTML attributes.
368    #[props(extends = GlobalAttributes)]
369    attributes: Vec<Attribute>,
370}
371
372#[component]
373pub fn Textarea(props: TextareaProps) -> Element {
374    let size_class = match props.size {
375        Size::Md => String::new(),
376        s => format!(" form-control-{s}"),
377    };
378    let full_class = if props.class.is_empty() {
379        format!("form-control{size_class}")
380    } else {
381        format!("form-control{size_class} {}", props.class)
382    };
383
384    rsx! {
385        textarea {
386            class: "{full_class}",
387            rows: "{props.rows}",
388            placeholder: "{props.placeholder}",
389            disabled: props.disabled,
390            readonly: props.readonly,
391            value: if props.uncontrolled { None } else { Some(props.value.clone()) },
392            oninput: move |evt| {
393                if let Some(handler) = &props.oninput {
394                    handler.call(evt);
395                }
396            },
397            onchange: move |evt| {
398                if let Some(handler) = &props.onchange {
399                    handler.call(evt);
400                }
401            },
402            onfocus: move |evt| {
403                if let Some(handler) = &props.onfocus {
404                    handler.call(evt);
405                }
406            },
407            onblur: move |evt| {
408                if let Some(handler) = &props.onblur {
409                    handler.call(evt);
410                }
411            },
412            onkeydown: move |evt| {
413                if let Some(handler) = &props.onkeydown {
414                    handler.call(evt);
415                }
416            },
417            onkeyup: move |evt| {
418                if let Some(handler) = &props.onkeyup {
419                    handler.call(evt);
420                }
421            },
422            ..props.attributes,
423        }
424    }
425}
426
427/// Bootstrap Checkbox component.
428///
429/// # Bootstrap HTML → Dioxus
430///
431/// ```html
432/// <!-- Bootstrap HTML -->
433/// <div class="form-check">
434///   <input class="form-check-input" type="checkbox" checked>
435///   <label class="form-check-label">Accept terms</label>
436/// </div>
437/// ```
438///
439/// ```rust,no_run
440/// # use dioxus::prelude::*;
441/// # use dioxus_bootstrap_css::prelude::*;
442/// # fn _doctest() -> Element {
443/// rsx! {
444///     Checkbox { checked: true, label: "Accept terms",
445///         onchange: move |evt| { /* handle */ },
446///     }
447/// }
448/// # }
449/// ```
450#[derive(Clone, PartialEq, Props)]
451pub struct CheckboxProps {
452    /// Whether the checkbox is checked.
453    #[props(default)]
454    pub checked: bool,
455    /// Optional id applied to the checkbox input.
456    #[props(default)]
457    pub input_id: Option<String>,
458    /// Label text.
459    #[props(default)]
460    pub label: String,
461    /// Disabled state.
462    #[props(default)]
463    pub disabled: bool,
464    /// Change event handler.
465    #[props(default)]
466    pub onchange: Option<EventHandler<FormEvent>>,
467    /// Click event handler for the checkbox input.
468    #[props(default)]
469    pub onclick: Option<EventHandler<MouseEvent>>,
470    /// Additional CSS classes for the wrapper.
471    #[props(default)]
472    pub class: String,
473    /// Any additional HTML attributes.
474    #[props(extends = GlobalAttributes)]
475    attributes: Vec<Attribute>,
476}
477
478#[component]
479pub fn Checkbox(props: CheckboxProps) -> Element {
480    let full_class = if props.class.is_empty() {
481        "form-check".to_string()
482    } else {
483        format!("form-check {}", props.class)
484    };
485    let label_for = props.input_id.clone().unwrap_or_default();
486
487    rsx! {
488            div { class: "{full_class}",
489                ..props.attributes,
490    input {
491    class: "form-check-input",
492    r#type: "checkbox",
493                    id: props.input_id.unwrap_or_default(),
494                    checked: props.checked,
495                    disabled: props.disabled,
496                    onclick: move |evt| {
497                        if let Some(handler) = &props.onclick {
498                            handler.call(evt);
499                        }
500                    },
501                    onchange: move |evt| {
502                        if let Some(handler) = &props.onchange {
503                            handler.call(evt);
504                        }
505                    },
506                }
507    if !props.label.is_empty() {
508    label { class: "form-check-label", r#for: "{label_for}", "{props.label}" }
509    }
510            }
511        }
512}
513
514/// Bootstrap Switch (toggle) component.
515///
516/// # Bootstrap HTML → Dioxus
517///
518/// ```html
519/// <!-- Bootstrap HTML -->
520/// <div class="form-check form-switch">
521///   <input class="form-check-input" type="checkbox" role="switch" checked>
522///   <label class="form-check-label">Enable notifications</label>
523/// </div>
524/// ```
525///
526/// ```rust,no_run
527/// # use dioxus::prelude::*;
528/// # use dioxus_bootstrap_css::prelude::*;
529/// # fn _doctest() -> Element {
530/// rsx! {
531///     Switch { checked: true, label: "Enable notifications",
532///         onchange: move |evt| { /* handle */ },
533///     }
534/// }
535/// # }
536/// ```
537#[derive(Clone, PartialEq, Props)]
538pub struct SwitchProps {
539    /// Whether the switch is on.
540    #[props(default)]
541    pub checked: bool,
542    /// Label text.
543    #[props(default)]
544    pub label: String,
545    /// Disabled state.
546    #[props(default)]
547    pub disabled: bool,
548    /// Change event handler.
549    #[props(default)]
550    pub onchange: Option<EventHandler<FormEvent>>,
551    /// Additional CSS classes for the wrapper.
552    #[props(default)]
553    pub class: String,
554    /// Any additional HTML attributes.
555    #[props(extends = GlobalAttributes)]
556    attributes: Vec<Attribute>,
557}
558
559#[component]
560pub fn Switch(props: SwitchProps) -> Element {
561    let full_class = if props.class.is_empty() {
562        "form-check form-switch".to_string()
563    } else {
564        format!("form-check form-switch {}", props.class)
565    };
566
567    rsx! {
568        div { class: "{full_class}",
569            ..props.attributes,
570            input {
571                class: "form-check-input",
572                r#type: "checkbox",
573                role: "switch",
574                checked: props.checked,
575                disabled: props.disabled,
576                onchange: move |evt| {
577                    if let Some(handler) = &props.onchange {
578                        handler.call(evt);
579                    }
580                },
581            }
582            if !props.label.is_empty() {
583                label { class: "form-check-label", "{props.label}" }
584            }
585        }
586    }
587}
588
589/// Bootstrap Range (slider) input.
590///
591/// # Bootstrap HTML → Dioxus
592///
593/// | HTML | Dioxus |
594/// |---|---|
595/// | `<input type="range" class="form-range" min="0" max="100">` | `Range { min: "0", max: "100" }` |
596/// | `<input type="range" class="form-range" step="5" disabled>` | `Range { step: "5".into(), disabled: true }` |
597///
598/// ```rust,no_run
599/// # use dioxus::prelude::*;
600/// # use dioxus_bootstrap_css::prelude::*;
601/// # fn _doctest() -> Element {
602/// rsx! {
603///     Range { value: "50", min: "0", max: "100" }
604/// }
605/// # }
606/// ```
607#[derive(Clone, PartialEq, Props)]
608pub struct RangeProps {
609    /// Current value.
610    #[props(default)]
611    pub value: String,
612    /// Minimum value.
613    #[props(default = "0".to_string())]
614    pub min: String,
615    /// Maximum value.
616    #[props(default = "100".to_string())]
617    pub max: String,
618    /// Step increment.
619    #[props(default)]
620    pub step: String,
621    /// Disabled state.
622    #[props(default)]
623    pub disabled: bool,
624    /// Input event handler.
625    #[props(default)]
626    pub oninput: Option<EventHandler<FormEvent>>,
627    /// Additional CSS classes.
628    #[props(default)]
629    pub class: String,
630    /// Any additional HTML attributes.
631    #[props(extends = GlobalAttributes)]
632    attributes: Vec<Attribute>,
633}
634
635#[component]
636pub fn Range(props: RangeProps) -> Element {
637    let full_class = if props.class.is_empty() {
638        "form-range".to_string()
639    } else {
640        format!("form-range {}", props.class)
641    };
642
643    rsx! {
644        input {
645            class: "{full_class}",
646            r#type: "range",
647            value: "{props.value}",
648            min: "{props.min}",
649            max: "{props.max}",
650            step: if props.step.is_empty() { None } else { Some(props.step.clone()) },
651            disabled: props.disabled,
652            oninput: move |evt| {
653                if let Some(handler) = &props.oninput {
654                    handler.call(evt);
655                }
656            },
657            ..props.attributes,
658        }
659    }
660}
661
662/// Bootstrap Floating Label wrapper.
663///
664/// Wraps an Input or Textarea with a floating label that moves
665/// above the control when focused or filled.
666///
667/// # Bootstrap HTML → Dioxus
668///
669/// | HTML | Dioxus |
670/// |---|---|
671/// | `<div class="form-floating"><input class="form-control" placeholder="..."><label>Email</label></div>` | `FloatingLabel { label: "Email", Input { placeholder: "..." } }` |
672///
673/// ```rust,no_run
674/// # use dioxus::prelude::*;
675/// # use dioxus_bootstrap_css::prelude::*;
676/// # fn _doctest() -> Element {
677/// rsx! {
678///     FloatingLabel { label: "Email address",
679///         Input { r#type: "email", placeholder: "name@example.com" }
680///     }
681/// }
682/// # }
683/// ```
684#[derive(Clone, PartialEq, Props)]
685pub struct FloatingLabelProps {
686    /// Label text.
687    pub label: String,
688    /// Additional CSS classes.
689    #[props(default)]
690    pub class: String,
691    /// Any additional HTML attributes.
692    #[props(extends = GlobalAttributes)]
693    attributes: Vec<Attribute>,
694    /// Child element (Input or Textarea).
695    pub children: Element,
696}
697
698#[component]
699pub fn FloatingLabel(props: FloatingLabelProps) -> Element {
700    let full_class = if props.class.is_empty() {
701        "form-floating".to_string()
702    } else {
703        format!("form-floating {}", props.class)
704    };
705
706    rsx! {
707        div { class: "{full_class}",
708            ..props.attributes,
709            {props.children}
710            label { "{props.label}" }
711        }
712    }
713}
714
715/// Bootstrap form validation feedback text.
716///
717/// # Bootstrap HTML → Dioxus
718///
719/// | HTML | Dioxus |
720/// |---|---|
721/// | `<div class="valid-feedback">Looks good!</div>` | `FormFeedback { valid: true, "Looks good!" }` |
722/// | `<div class="invalid-feedback">Required.</div>` | `FormFeedback { "Required." }` |
723///
724/// ```rust,no_run
725/// # use dioxus::prelude::*;
726/// # use dioxus_bootstrap_css::prelude::*;
727/// # fn _doctest() -> Element {
728/// rsx! {
729///     Input { class: "is-valid".to_string(), value: "correct" }
730///     FormFeedback { valid: true, "Looks good!" }
731/// }
732/// # }
733/// ```
734#[derive(Clone, PartialEq, Props)]
735pub struct FormFeedbackProps {
736    /// True for valid feedback, false for invalid.
737    #[props(default)]
738    pub valid: bool,
739    /// Additional CSS classes.
740    #[props(default)]
741    pub class: String,
742    /// Any additional HTML attributes.
743    #[props(extends = GlobalAttributes)]
744    attributes: Vec<Attribute>,
745    /// Feedback text.
746    pub children: Element,
747}
748
749#[component]
750pub fn FormFeedback(props: FormFeedbackProps) -> Element {
751    let base = if props.valid {
752        "valid-feedback"
753    } else {
754        "invalid-feedback"
755    };
756    let full_class = if props.class.is_empty() {
757        base.to_string()
758    } else {
759        format!("{base} {}", props.class)
760    };
761
762    rsx! {
763        div { class: "{full_class}", ..props.attributes, {props.children} }
764    }
765}
766
767/// Bootstrap form text (help text below a control).
768///
769/// # Bootstrap HTML → Dioxus
770///
771/// | HTML | Dioxus |
772/// |---|---|
773/// | `<div class="form-text">Must be 8-20 characters.</div>` | `FormText { "Must be 8-20 characters." }` |
774///
775/// ```rust,no_run
776/// # use dioxus::prelude::*;
777/// # use dioxus_bootstrap_css::prelude::*;
778/// # fn _doctest() -> Element {
779/// rsx! {
780///     Input { r#type: "password" }
781///     FormText { "Must be 8-20 characters long." }
782/// }
783/// # }
784/// ```
785#[derive(Clone, PartialEq, Props)]
786pub struct FormTextProps {
787    /// Additional CSS classes.
788    #[props(default)]
789    pub class: String,
790    /// Any additional HTML attributes.
791    #[props(extends = GlobalAttributes)]
792    attributes: Vec<Attribute>,
793    /// Help text content.
794    pub children: Element,
795}
796
797#[component]
798pub fn FormText(props: FormTextProps) -> Element {
799    let full_class = if props.class.is_empty() {
800        "form-text".to_string()
801    } else {
802        format!("form-text {}", props.class)
803    };
804
805    rsx! {
806        div { class: "{full_class}", ..props.attributes, {props.children} }
807    }
808}
809
810/// Bootstrap Radio button component.
811///
812/// # Bootstrap HTML → Dioxus
813///
814/// ```html
815/// <!-- Bootstrap HTML -->
816/// <div class="form-check">
817///   <input class="form-check-input" type="radio" name="color" checked>
818///   <label class="form-check-label">Red</label>
819/// </div>
820/// <div class="form-check">
821///   <input class="form-check-input" type="radio" name="color">
822///   <label class="form-check-label">Blue</label>
823/// </div>
824/// ```
825///
826/// ```rust,no_run
827/// # use dioxus::prelude::*;
828/// # use dioxus_bootstrap_css::prelude::*;
829/// # fn _doctest() -> Element {
830/// rsx! {
831///     Radio { name: "color", label: "Red", checked: true }
832///     Radio { name: "color", label: "Blue" }
833/// }
834/// # }
835/// ```
836#[derive(Clone, PartialEq, Props)]
837pub struct RadioProps {
838    /// Radio group name.
839    pub name: String,
840    /// Whether the radio is checked.
841    #[props(default)]
842    pub checked: bool,
843    /// Label text.
844    #[props(default)]
845    pub label: String,
846    /// Disabled state.
847    #[props(default)]
848    pub disabled: bool,
849    /// Change event handler.
850    #[props(default)]
851    pub onchange: Option<EventHandler<FormEvent>>,
852    /// Additional CSS classes for the wrapper.
853    #[props(default)]
854    pub class: String,
855    /// Any additional HTML attributes.
856    #[props(extends = GlobalAttributes)]
857    attributes: Vec<Attribute>,
858}
859
860#[component]
861pub fn Radio(props: RadioProps) -> Element {
862    let full_class = if props.class.is_empty() {
863        "form-check".to_string()
864    } else {
865        format!("form-check {}", props.class)
866    };
867
868    rsx! {
869        div { class: "{full_class}",
870            ..props.attributes,
871            input {
872                class: "form-check-input",
873                r#type: "radio",
874                name: "{props.name}",
875                checked: props.checked,
876                disabled: props.disabled,
877                onchange: move |evt| {
878                    if let Some(handler) = &props.onchange {
879                        handler.call(evt);
880                    }
881                },
882            }
883            if !props.label.is_empty() {
884                label { class: "form-check-label", "{props.label}" }
885            }
886        }
887    }
888}