dioxus_bootstrap_css/list_group.rs
1use dioxus::prelude::*;
2
3use crate::types::Color;
4
5/// Bootstrap ListGroup component.
6///
7/// # Bootstrap HTML → Dioxus
8///
9/// ```html
10/// <!-- Bootstrap HTML -->
11/// <ul class="list-group list-group-flush">
12/// <li class="list-group-item active">Active</li>
13/// <li class="list-group-item">Normal</li>
14/// <li class="list-group-item list-group-item-danger">Danger</li>
15/// <li class="list-group-item disabled">Disabled</li>
16/// </ul>
17/// ```
18///
19/// ```rust,no_run
20/// # use dioxus::prelude::*;
21/// # use dioxus_bootstrap_css::prelude::*;
22/// # fn _doctest() -> Element {
23/// # let handler = move |_: MouseEvent| {};
24/// rsx! {
25/// ListGroup { flush: true,
26/// ListGroupItem { active: true, "Active" }
27/// ListGroupItem { "Normal" }
28/// ListGroupItem { color: Color::Danger, "Danger" }
29/// ListGroupItem { disabled: true, "Disabled" }
30/// }
31/// // Clickable list group
32/// ListGroup {
33/// ListGroupItem { onclick: handler, "Click me" }
34/// }
35/// // Numbered list
36/// ListGroup { numbered: true,
37/// ListGroupItem { "First" }
38/// ListGroupItem { "Second" }
39/// }
40/// }
41/// # }
42/// ```
43#[derive(Clone, PartialEq, Props)]
44pub struct ListGroupProps {
45 /// Remove borders and rounded corners for use inside cards.
46 #[props(default)]
47 pub flush: bool,
48 /// Use numbered list style.
49 #[props(default)]
50 pub numbered: bool,
51 /// Container element. Empty (the default) renders `<ul>`, or `<ol>` when
52 /// `numbered`; `"div"` renders Bootstrap's generic `<div class="list-group">`
53 /// form, which is what a list of links or buttons requires — `<a>` and
54 /// `<button>` are not valid children of `<ul>`.
55 #[props(default)]
56 pub tag: String,
57 /// Additional CSS classes.
58 #[props(default)]
59 pub class: String,
60 /// Any additional HTML attributes.
61 #[props(extends = GlobalAttributes)]
62 attributes: Vec<Attribute>,
63 /// Child elements (ListGroupItem components).
64 pub children: Element,
65}
66
67#[component]
68pub fn ListGroup(props: ListGroupProps) -> Element {
69 let mut classes = vec!["list-group".to_string()];
70 if props.flush {
71 classes.push("list-group-flush".to_string());
72 }
73 if props.numbered {
74 classes.push("list-group-numbered".to_string());
75 }
76 if !props.class.is_empty() {
77 classes.push(props.class.clone());
78 }
79 let full_class = classes.join(" ");
80
81 // `div` first: the generic form is the one that can hold `<a>`/`<button>`
82 // children, so it must win over the numbered `<ol>` rather than be
83 // unreachable behind it.
84 if props.tag == "div" {
85 rsx! {
86 div { class: "{full_class}", ..props.attributes, {props.children} }
87 }
88 } else if props.numbered {
89 rsx! {
90 ol { class: "{full_class}", ..props.attributes, {props.children} }
91 }
92 } else {
93 rsx! {
94 ul { class: "{full_class}", ..props.attributes, {props.children} }
95 }
96 }
97}
98
99/// Bootstrap ListGroupItem component.
100#[derive(Clone, PartialEq, Props)]
101pub struct ListGroupItemProps {
102 /// Active state.
103 #[props(default)]
104 pub active: bool,
105 /// Disabled state.
106 #[props(default)]
107 pub disabled: bool,
108 /// Item color variant.
109 #[props(default)]
110 pub color: Option<Color>,
111 /// Click event handler. With the default `tag`, this renders the item as a
112 /// `<button>` — Bootstrap's actionable form.
113 #[props(default)]
114 pub onclick: Option<EventHandler<MouseEvent>>,
115 /// Item element. Empty (the default) renders `<li>`, or `<button>` when
116 /// `onclick` is set; `"div"` renders `<div class="list-group-item">`, the
117 /// generic form.
118 ///
119 /// `"div"` is honoured **even with `onclick`**. A `<button>` may not contain
120 /// interactive descendants, so a clickable row that holds its own input or
121 /// buttons cannot be one — the browser's tag-soup recovery reparents them and
122 /// the controls stop working. Such a row is an ordinary element carrying an
123 /// ordinary handler, which is what the escape hatches are for; it still gets
124 /// `list-group-item-action`, because that class is what Bootstrap defines for
125 /// the look, and the caller has said this row is actionable by handing it a
126 /// handler.
127 #[props(default)]
128 pub tag: String,
129 /// Additional CSS classes.
130 #[props(default)]
131 pub class: String,
132 /// Any additional HTML attributes.
133 #[props(extends = GlobalAttributes)]
134 attributes: Vec<Attribute>,
135 /// Child elements.
136 pub children: Element,
137}
138
139/// Which element a list-group item renders as, given its `tag` and whether a
140/// click handler was supplied. `tag` wins: see the note on the prop.
141fn list_group_item_element(tag: &str, has_onclick: bool) -> &'static str {
142 if tag == "div" {
143 "div"
144 } else if has_onclick {
145 "button"
146 } else {
147 "li"
148 }
149}
150
151#[component]
152pub fn ListGroupItem(props: ListGroupItemProps) -> Element {
153 let mut classes = vec!["list-group-item".to_string()];
154 if props.active {
155 classes.push("active".to_string());
156 }
157 if props.disabled {
158 classes.push("disabled".to_string());
159 }
160 if let Some(ref c) = props.color {
161 classes.push(format!("list-group-item-{c}"));
162 }
163 if props.onclick.is_some() {
164 classes.push("list-group-item-action".to_string());
165 }
166 if !props.class.is_empty() {
167 classes.push(props.class.clone());
168 }
169 let full_class = classes.join(" ");
170
171 // The element is chosen by one function, which the tests exercise directly —
172 // `tag` wins over `onclick`, for the reason on the prop.
173 match list_group_item_element(&props.tag, props.onclick.is_some()) {
174 "div" => {
175 let handler = props.onclick;
176 rsx! {
177 div {
178 class: "{full_class}",
179 onclick: move |evt| {
180 if let Some(handler) = &handler {
181 handler.call(evt);
182 }
183 },
184 ..props.attributes,
185 {props.children}
186 }
187 }
188 }
189 "button" => {
190 let handler = props.onclick.expect("button form implies a handler");
191 rsx! {
192 button {
193 class: "{full_class}",
194 r#type: "button",
195 disabled: props.disabled,
196 onclick: move |evt| handler.call(evt),
197 ..props.attributes,
198 {props.children}
199 }
200 }
201 }
202 _ => rsx! {
203 li { class: "{full_class}", ..props.attributes, {props.children} }
204 },
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
213 fn item_defaults_to_li() {
214 assert_eq!(list_group_item_element("", false), "li");
215 }
216
217 #[test]
218 fn a_handler_alone_makes_it_a_button() {
219 // Bootstrap's actionable item is an `<a>` or a `<button>`, and with no
220 // element named this is the one to pick.
221 assert_eq!(list_group_item_element("", true), "button");
222 }
223
224 #[test]
225 fn an_explicit_div_wins_over_the_handler() {
226 // The whole point of the prop. A `<button>` may not contain interactive
227 // descendants, so a clickable row holding its own input or buttons has to
228 // stay a `<div>` — if the handler silently overrode `tag` here, the markup
229 // would be invalid and the nested controls would stop working, while every
230 // class-level check still passed.
231 assert_eq!(list_group_item_element("div", true), "div");
232 assert_eq!(list_group_item_element("div", false), "div");
233 }
234}