dioxus_ui_system/molecules/
collapsible.rs1use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10#[derive(Props, Clone, PartialEq)]
12pub struct CollapsibleProps {
13 #[props(default)]
15 pub open: Option<bool>,
16 #[props(default)]
18 pub on_open_change: Option<EventHandler<bool>>,
19 pub trigger: Element,
21 pub children: Element,
23 #[props(default)]
25 pub default_open: bool,
26 #[props(default = true)]
28 pub show_chevron: bool,
29 #[props(default)]
31 pub chevron: Option<Element>,
32 #[props(default)]
34 pub style: Option<String>,
35 #[props(default)]
37 pub trigger_style: Option<String>,
38 #[props(default)]
40 pub content_style: Option<String>,
41 #[props(default)]
43 pub disabled: bool,
44 #[props(default = 200)]
46 pub transition_duration: u16,
47}
48
49#[component]
65pub fn Collapsible(props: CollapsibleProps) -> Element {
66 let _theme = use_theme();
67 let mut internal_open = use_signal(|| props.default_open);
68
69 let is_controlled = props.open.is_some();
71 let is_open = if is_controlled {
72 props.open.unwrap_or(false)
73 } else {
74 internal_open()
75 };
76 let is_disabled = props.disabled;
77
78 let collapsible_id = use_memo(|| format!("collapsible-{}", generate_id()));
80 let content_id = use_memo(move || format!("{}-content", collapsible_id()));
81
82 let handle_toggle = move |_| {
84 if is_disabled {
85 return;
86 }
87
88 let new_state = !is_open;
89
90 if props.open.is_none() {
92 internal_open.set(new_state);
93 }
94
95 if let Some(on_change) = &props.on_open_change {
97 on_change.call(new_state);
98 }
99 };
100
101 let container_style = use_style(|t| {
103 Style::new()
104 .w_full()
105 .border(1, &t.colors.border)
106 .rounded(&t.radius, "md")
107 .bg(&t.colors.background)
108 .overflow_hidden()
109 .build()
110 });
111
112 let trigger_base_style = use_style(move |t| {
114 Style::new()
115 .w_full()
116 .flex()
117 .items_center()
118 .justify_between()
119 .px(&t.spacing, "lg")
120 .py(&t.spacing, "md")
121 .bg_transparent()
122 .border(0, &t.colors.border)
123 .outline("none")
124 .cursor(if is_disabled {
125 "not-allowed"
126 } else {
127 "pointer"
128 })
129 .text_color(&t.colors.foreground)
130 .text(&t.typography, "base")
131 .font_weight(500)
132 .transition("all 150ms ease")
133 .opacity(if is_disabled { 0.5 } else { 1.0 })
134 .build()
135 });
136
137 let duration = props.transition_duration;
139 let content_wrapper_style = use_style(move |_t| {
140 let transition_str = format!("height {}ms ease, opacity {}ms ease", duration, duration);
141 let base = Style::new()
142 .w_full()
143 .overflow_hidden()
144 .transition(&transition_str);
145
146 if is_open {
147 base.opacity(1.0)
148 } else {
149 base.opacity(0.0)
150 }
151 .build()
152 });
153
154 let content_inner_style = use_style(|t| {
156 Style::new()
157 .px(&t.spacing, "lg")
158 .pb(&t.spacing, "md")
159 .text(&t.typography, "sm")
160 .text_color(&t.colors.muted_foreground)
161 .line_height(1.6)
162 .build()
163 });
164
165 let chevron_rotation = if is_open {
167 "rotate(180deg)"
168 } else {
169 "rotate(0deg)"
170 };
171
172 rsx! {
173 div {
174 style: "{container_style} {props.style.clone().unwrap_or_default()}",
175 id: "{collapsible_id}",
176
177 button {
179 style: "{trigger_base_style} {props.trigger_style.clone().unwrap_or_default()}",
180 type: "button",
181 aria_expanded: "{is_open}",
182 aria_controls: "{content_id}",
183 disabled: is_disabled,
184 onclick: handle_toggle,
185
186 div {
188 style: "flex: 1; display: flex; align-items: center;",
189 {props.trigger}
190 }
191
192 if props.show_chevron {
194 if let Some(custom_chevron) = props.chevron {
195 span {
196 style: "transform: {chevron_rotation}; transition: transform {props.transition_duration}ms ease; flex-shrink: 0; margin-left: 8px;",
197 {custom_chevron}
198 }
199 } else {
200 span {
201 style: "transform: {chevron_rotation}; transition: transform {props.transition_duration}ms ease; flex-shrink: 0; margin-left: 8px;",
202 CollapsibleChevron {}
203 }
204 }
205 }
206 }
207
208 div {
210 style: "{content_wrapper_style}",
211 id: "{content_id}",
212 aria_hidden: "{!is_open}",
213
214 if is_open {
216 div {
217 style: "{content_inner_style} {props.content_style.clone().unwrap_or_default()}",
218 {props.children}
219 }
220 }
221 }
222 }
223 }
224}
225
226#[component]
228fn CollapsibleChevron() -> Element {
229 rsx! {
230 svg {
231 view_box: "0 0 24 24",
232 fill: "none",
233 stroke: "currentColor",
234 stroke_width: "2",
235 stroke_linecap: "round",
236 stroke_linejoin: "round",
237 style: "width: 16px; height: 16px; display: block;",
238 polyline { points: "6 9 12 15 18 9" }
239 }
240 }
241}
242
243static ID_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
245
246fn generate_id() -> u64 {
248 ID_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
249}
250
251#[derive(Props, Clone, PartialEq)]
253pub struct SimpleCollapsibleProps {
254 pub trigger_text: String,
256 pub children: Element,
258 #[props(default)]
260 pub open: Option<bool>,
261 #[props(default)]
263 pub on_open_change: Option<EventHandler<bool>>,
264 #[props(default)]
266 pub default_open: bool,
267 #[props(default)]
269 pub disabled: bool,
270}
271
272#[component]
274pub fn SimpleCollapsible(props: SimpleCollapsibleProps) -> Element {
275 rsx! {
276 Collapsible {
277 open: props.open,
278 on_open_change: props.on_open_change,
279 default_open: props.default_open,
280 disabled: props.disabled,
281 trigger: rsx! { "{props.trigger_text}" },
282 {props.children}
283 }
284 }
285}
286
287#[derive(Props, Clone, PartialEq)]
289pub struct CollapsibleGroupProps {
290 pub children: Element,
292 #[props(default)]
294 pub allow_multiple: bool,
295 #[props(default)]
297 pub style: Option<String>,
298 #[props(default)]
300 pub gap: Option<String>,
301}
302
303#[component]
305pub fn CollapsibleGroup(props: CollapsibleGroupProps) -> Element {
306 let _theme = use_theme();
307
308 let gap_style = props.gap.clone().unwrap_or_else(|| "8px".to_string());
309
310 rsx! {
311 div {
312 style: "display: flex; flex-direction: column; gap: {gap_style}; {props.style.clone().unwrap_or_default()}",
313 {props.children}
314 }
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn test_collapsible_props_creation() {
324 let _props = CollapsibleProps {
326 open: Some(true),
327 on_open_change: None,
328 trigger: rsx! { "Test" },
329 children: rsx! { "Content" },
330 default_open: false,
331 show_chevron: true,
332 chevron: None,
333 style: None,
334 trigger_style: None,
335 content_style: None,
336 disabled: false,
337 transition_duration: 200,
338 };
339 }
340
341 #[test]
342 fn test_simple_collapsible_props() {
343 let _props = SimpleCollapsibleProps {
344 trigger_text: "Click me".to_string(),
345 children: rsx! { "Content" },
346 open: None,
347 on_open_change: None,
348 default_open: false,
349 disabled: false,
350 };
351 }
352
353 #[test]
354 fn test_unique_id_generation() {
355 let id1 = generate_id();
356 let id2 = generate_id();
357 assert_ne!(id1, id2);
358 }
359}