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}