Skip to main content

dioxus_ui_system/molecules/
collapsible.rs

1//! Collapsible molecule component
2//!
3//! A component that shows/hides content with smooth animation.
4//! Supports both controlled and uncontrolled modes.
5
6use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10/// Collapsible properties
11#[derive(Props, Clone, PartialEq)]
12pub struct CollapsibleProps {
13    /// Whether the collapsible is open (controlled mode)
14    #[props(default)]
15    pub open: Option<bool>,
16    /// Callback when open state changes
17    #[props(default)]
18    pub on_open_change: Option<EventHandler<bool>>,
19    /// The clickable header/trigger element
20    pub trigger: Element,
21    /// The collapsible content
22    pub children: Element,
23    /// Default open state for uncontrolled mode
24    #[props(default)]
25    pub default_open: bool,
26    /// Whether to show the default chevron icon
27    #[props(default = true)]
28    pub show_chevron: bool,
29    /// Custom chevron element (replaces default if provided)
30    #[props(default)]
31    pub chevron: Option<Element>,
32    /// Custom inline styles for the container
33    #[props(default)]
34    pub style: Option<String>,
35    /// Custom inline styles for the trigger
36    #[props(default)]
37    pub trigger_style: Option<String>,
38    /// Custom inline styles for the content
39    #[props(default)]
40    pub content_style: Option<String>,
41    /// Whether the collapsible is disabled
42    #[props(default)]
43    pub disabled: bool,
44    /// Transition duration in milliseconds
45    #[props(default = 200)]
46    pub transition_duration: u16,
47}
48
49/// Collapsible molecule component
50///
51/// # Example
52/// ```rust,ignore
53/// use dioxus_ui_system::molecules::{Collapsible, CollapsibleProps};
54///
55/// fn MyComponent() -> Element {
56///     rsx! {
57///         Collapsible {
58///             trigger: rsx! { "Click to expand" },
59///             children: rsx! { "This content is collapsible" },
60///         }
61///     }
62/// }
63/// ```
64#[component]
65pub fn Collapsible(props: CollapsibleProps) -> Element {
66    let _theme = use_theme();
67    let mut internal_open = use_signal(|| props.default_open);
68
69    // Determine if we're in controlled mode and get current open state
70    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    // Generate unique IDs for accessibility
79    let collapsible_id = use_memo(|| format!("collapsible-{}", generate_id()));
80    let content_id = use_memo(move || format!("{}-content", collapsible_id()));
81
82    // Handle toggle
83    let handle_toggle = move |_| {
84        if is_disabled {
85            return;
86        }
87
88        let new_state = !is_open;
89
90        // Update internal state if uncontrolled
91        if props.open.is_none() {
92            internal_open.set(new_state);
93        }
94
95        // Call callback if provided
96        if let Some(on_change) = &props.on_open_change {
97            on_change.call(new_state);
98        }
99    };
100
101    // Container style
102    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    // Trigger/header style
113    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    // Content wrapper style with animation
138    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    // Content inner style
155    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    // Chevron rotation
166    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            // Trigger button
178            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                // Trigger content wrapper
187                div {
188                    style: "flex: 1; display: flex; align-items: center;",
189                    {props.trigger}
190                }
191
192                // Chevron indicator
193                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            // Collapsible content
209            div {
210                style: "{content_wrapper_style}",
211                id: "{content_id}",
212                aria_hidden: "{!is_open}",
213
214                // Use a conditional render with animation support
215                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/// Default chevron icon component
227#[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
243/// Simple counter for generating unique IDs
244static ID_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
245
246/// Generate a unique ID
247fn generate_id() -> u64 {
248    ID_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
249}
250
251/// Simple collapsible variant with text trigger
252#[derive(Props, Clone, PartialEq)]
253pub struct SimpleCollapsibleProps {
254    /// Trigger text
255    pub trigger_text: String,
256    /// The collapsible content
257    pub children: Element,
258    /// Whether the collapsible is open (controlled mode)
259    #[props(default)]
260    pub open: Option<bool>,
261    /// Callback when open state changes
262    #[props(default)]
263    pub on_open_change: Option<EventHandler<bool>>,
264    /// Default open state for uncontrolled mode
265    #[props(default)]
266    pub default_open: bool,
267    /// Whether the collapsible is disabled
268    #[props(default)]
269    pub disabled: bool,
270}
271
272/// Simple collapsible with text trigger
273#[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/// Collapsible group for managing multiple collapsibles
288#[derive(Props, Clone, PartialEq)]
289pub struct CollapsibleGroupProps {
290    /// Child collapsibles or content
291    pub children: Element,
292    /// Allow multiple items to be open simultaneously
293    #[props(default)]
294    pub allow_multiple: bool,
295    /// Custom inline styles
296    #[props(default)]
297    pub style: Option<String>,
298    /// Gap between collapsible items
299    #[props(default)]
300    pub gap: Option<String>,
301}
302
303/// Group container for multiple collapsibles
304#[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        // Test that props can be created with defaults
325        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}