adui_dioxus/components/
collapse.rs

1use crate::components::config_provider::{ComponentSize, use_config};
2use crate::components::icon::{Icon, IconKind};
3use crate::foundation::{
4    ClassListExt, CollapseClassNames, CollapseSemantic, CollapseStyles, StyleStringExt,
5};
6use crate::theme::use_theme;
7use dioxus::prelude::*;
8
9/// Function type for custom expand icon rendering.
10/// Takes (panel_props, is_active) and returns Element.
11pub type ExpandIconRenderFn = fn(&CollapsePanel, bool) -> Element;
12
13/// Collapsible trigger type.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum CollapsibleType {
16    /// Trigger by clicking header.
17    Header,
18    /// Trigger by clicking icon only.
19    Icon,
20    /// Disabled, cannot be triggered.
21    Disabled,
22}
23
24/// Size variants for Collapse.
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
26pub enum CollapseSize {
27    Small,
28    #[default]
29    Middle,
30    Large,
31}
32
33impl CollapseSize {
34    fn from_global(size: ComponentSize) -> Self {
35        match size {
36            ComponentSize::Small => CollapseSize::Small,
37            ComponentSize::Large => CollapseSize::Large,
38            ComponentSize::Middle => CollapseSize::Middle,
39        }
40    }
41
42    fn as_class(&self) -> &'static str {
43        match self {
44            CollapseSize::Small => "adui-collapse-sm",
45            CollapseSize::Middle => "adui-collapse-md",
46            CollapseSize::Large => "adui-collapse-lg",
47        }
48    }
49}
50
51/// Placement of the expand icon.
52#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
53pub enum ExpandIconPlacement {
54    #[default]
55    Start,
56    End,
57}
58
59impl ExpandIconPlacement {
60    fn as_class(&self) -> &'static str {
61        match self {
62            ExpandIconPlacement::Start => "adui-collapse-icon-start",
63            ExpandIconPlacement::End => "adui-collapse-icon-end",
64        }
65    }
66}
67
68/// Data model for a single collapse panel.
69#[derive(Clone, PartialEq)]
70pub struct CollapsePanel {
71    pub key: String,
72    pub header: Element,
73    pub content: Element,
74    pub disabled: bool,
75    pub show_arrow: bool,
76    pub collapsible: Option<CollapsibleType>,
77    pub extra: Option<Element>,
78}
79
80impl CollapsePanel {
81    pub fn new(key: impl Into<String>, header: Element, content: Element) -> Self {
82        Self {
83            key: key.into(),
84            header,
85            content,
86            disabled: false,
87            show_arrow: true,
88            collapsible: None,
89            extra: None,
90        }
91    }
92
93    pub fn disabled(mut self, disabled: bool) -> Self {
94        self.disabled = disabled;
95        self
96    }
97
98    pub fn show_arrow(mut self, show: bool) -> Self {
99        self.show_arrow = show;
100        self
101    }
102
103    pub fn collapsible(mut self, collapsible: CollapsibleType) -> Self {
104        self.collapsible = Some(collapsible);
105        self
106    }
107
108    pub fn extra(mut self, extra: Element) -> Self {
109        self.extra = Some(extra);
110        self
111    }
112}
113
114// Function pointer only used for props equality in diffing.
115#[allow(unpredictable_function_pointer_comparisons)]
116/// Props for the Collapse component.
117#[derive(Props, Clone, PartialEq)]
118pub struct CollapseProps {
119    /// Panel items to display.
120    pub items: Vec<CollapsePanel>,
121    /// Controlled active keys (expanded panels).
122    #[props(optional)]
123    pub active_key: Option<Vec<String>>,
124    /// Default active keys for uncontrolled mode.
125    #[props(optional)]
126    pub default_active_key: Option<Vec<String>>,
127    /// Called when active keys change.
128    #[props(optional)]
129    pub on_change: Option<EventHandler<Vec<String>>>,
130    /// Accordion mode (only one panel can be expanded at a time).
131    #[props(default)]
132    pub accordion: bool,
133    /// Whether to show border.
134    #[props(default = true)]
135    pub bordered: bool,
136    /// Ghost mode (transparent background).
137    #[props(default)]
138    pub ghost: bool,
139    /// Size variant.
140    #[props(optional)]
141    pub size: Option<CollapseSize>,
142    /// Expand icon placement.
143    #[props(default)]
144    pub expand_icon_placement: ExpandIconPlacement,
145    /// Default collapsible type for all panels.
146    #[props(optional)]
147    pub collapsible: Option<CollapsibleType>,
148    /// Whether to destroy inactive panel content.
149    #[props(default = true)]
150    pub destroy_on_hidden: bool,
151    /// Custom expand icon render function.
152    #[props(optional)]
153    pub expand_icon: Option<ExpandIconRenderFn>,
154    /// Extra class name.
155    #[props(optional)]
156    pub class: Option<String>,
157    /// Inline style.
158    #[props(optional)]
159    pub style: Option<String>,
160    /// Semantic class names.
161    #[props(optional)]
162    pub class_names: Option<CollapseClassNames>,
163    /// Semantic styles.
164    #[props(optional)]
165    pub styles: Option<CollapseStyles>,
166}
167
168/// Ant Design flavored Collapse component.
169#[component]
170pub fn Collapse(props: CollapseProps) -> Element {
171    let CollapseProps {
172        items,
173        active_key,
174        default_active_key,
175        on_change,
176        accordion,
177        bordered,
178        ghost,
179        size,
180        expand_icon_placement,
181        collapsible,
182        destroy_on_hidden,
183        expand_icon,
184        class,
185        style,
186        class_names,
187        styles,
188    } = props;
189
190    let config = use_config();
191    let theme = use_theme();
192    let tokens = theme.tokens();
193
194    // Resolve size
195    let resolved_size = if let Some(s) = size {
196        s
197    } else {
198        CollapseSize::from_global(config.size)
199    };
200
201    // Initialize active keys for uncontrolled mode
202    let initial_keys = default_active_key.unwrap_or_default();
203    let active_keys_internal: Signal<Vec<String>> = use_signal(|| initial_keys);
204
205    let is_controlled = active_key.is_some();
206    let current_active_keys = if is_controlled {
207        active_key.clone().unwrap_or_default()
208    } else {
209        active_keys_internal.read().clone()
210    };
211
212    // Build root classes
213    let mut class_list = vec!["adui-collapse".to_string()];
214    class_list.push(resolved_size.as_class().to_string());
215    class_list.push(expand_icon_placement.as_class().to_string());
216    if !bordered {
217        class_list.push("adui-collapse-borderless".into());
218    }
219    if ghost {
220        class_list.push("adui-collapse-ghost".into());
221    }
222    class_list.push_semantic(&class_names, CollapseSemantic::Root);
223    if let Some(extra) = class {
224        class_list.push(extra);
225    }
226    let class_attr = class_list
227        .into_iter()
228        .filter(|s| !s.is_empty())
229        .collect::<Vec<_>>()
230        .join(" ");
231
232    let mut style_attr = format!("border-color:{};", tokens.color_border);
233    style_attr.push_str(&style.unwrap_or_default());
234    style_attr.append_semantic(&styles, CollapseSemantic::Root);
235
236    let on_change_cb = on_change;
237
238    rsx! {
239        div {
240            class: "{class_attr}",
241            style: "{style_attr}",
242            role: "group",
243            {items.iter().map(|panel| {
244                let key = panel.key.clone();
245                let is_active = current_active_keys.contains(&key);
246                let panel_disabled = panel.disabled;
247                let panel_collapsible = panel.collapsible.or(collapsible).unwrap_or(CollapsibleType::Header);
248                let show_arrow = panel.show_arrow;
249                let header = panel.header.clone();
250                let content = panel.content.clone();
251                let extra = panel.extra.clone();
252
253                let is_icon_only = matches!(panel_collapsible, CollapsibleType::Icon);
254                let is_disabled = panel_disabled || matches!(panel_collapsible, CollapsibleType::Disabled);
255
256                let mut panel_class = vec!["adui-collapse-item".to_string()];
257                if is_active {
258                    panel_class.push("adui-collapse-item-active".into());
259                }
260                if is_disabled {
261                    panel_class.push("adui-collapse-item-disabled".into());
262                }
263                let panel_class_attr = panel_class.join(" ");
264
265                let active_keys_for_toggle = active_keys_internal;
266                let on_change_for_toggle = on_change_cb;
267                let key_for_toggle = key.clone();
268                let active_key_for_toggle = active_key.clone();
269
270                rsx! {
271                    div {
272                        key: "{key}",
273                        class: "{panel_class_attr}",
274                        div {
275                            class: "adui-collapse-header",
276                            role: "button",
277                            tabindex: if is_disabled { "-1" } else { "0" },
278                            "aria-expanded": "{is_active}",
279                            "aria-disabled": "{is_disabled}",
280                            onclick: move |_| {
281                                if is_disabled || is_icon_only {
282                                    return;
283                                }
284
285                                if !is_controlled {
286                                    let mut keys = active_keys_for_toggle;
287                                    let current = keys.read().clone();
288                                    let new_keys = if accordion {
289                                        if current.contains(&key_for_toggle) {
290                                            vec![]
291                                        } else {
292                                            vec![key_for_toggle.clone()]
293                                        }
294                                    } else {
295                                        if current.contains(&key_for_toggle) {
296                                            current.into_iter().filter(|k| k != &key_for_toggle).collect()
297                                        } else {
298                                            let mut new = current;
299                                            new.push(key_for_toggle.clone());
300                                            new
301                                        }
302                                    };
303                                    keys.set(new_keys.clone());
304                                    if let Some(cb) = on_change_for_toggle {
305                                        cb.call(new_keys);
306                                    }
307                                } else {
308                                    if let Some(cb) = on_change_for_toggle {
309                                        let current = active_key_for_toggle.clone().unwrap_or_default();
310                                        let new_keys = if accordion {
311                                            if current.contains(&key_for_toggle) {
312                                                vec![]
313                                            } else {
314                                                vec![key_for_toggle.clone()]
315                                            }
316                                        } else {
317                                            if current.contains(&key_for_toggle) {
318                                                current.into_iter().filter(|k| k != &key_for_toggle).collect()
319                                            } else {
320                                                let mut new = current;
321                                                new.push(key_for_toggle.clone());
322                                                new
323                                            }
324                                        };
325                                        cb.call(new_keys);
326                                    }
327                                }
328                            },
329                            {show_arrow.then(|| {
330                                let active_keys_for_icon = active_keys_internal;
331                                let on_change_for_icon = on_change_cb;
332                                let key_for_icon = key.clone();
333                                let active_key_for_icon = active_key.clone();
334
335                                // Use custom expand_icon if provided
336                                let icon_element = if let Some(render_fn) = expand_icon {
337                                    render_fn(panel, is_active)
338                                } else {
339                                    rsx! {
340                                        Icon {
341                                            kind: IconKind::ArrowRight,
342                                            rotate: if is_active { Some(90.0) } else { None },
343                                            aria_label: if is_active { "collapse" } else { "expand" },
344                                        }
345                                    }
346                                };
347
348                                rsx! {
349                                    span {
350                                        class: "adui-collapse-expand-icon",
351                                        onclick: move |_| {
352                                            if is_disabled || !is_icon_only {
353                                                return;
354                                            }
355
356                                            if !is_controlled {
357                                                let mut keys = active_keys_for_icon;
358                                                let current = keys.read().clone();
359                                                let new_keys = if accordion {
360                                                    if current.contains(&key_for_icon) {
361                                                        vec![]
362                                                    } else {
363                                                        vec![key_for_icon.clone()]
364                                                    }
365                                                } else {
366                                                    if current.contains(&key_for_icon) {
367                                                        current.into_iter().filter(|k| k != &key_for_icon).collect()
368                                                    } else {
369                                                        let mut new = current;
370                                                        new.push(key_for_icon.clone());
371                                                        new
372                                                    }
373                                                };
374                                                keys.set(new_keys.clone());
375                                                if let Some(cb) = on_change_for_icon {
376                                                    cb.call(new_keys);
377                                                }
378                                            } else {
379                                                if let Some(cb) = on_change_for_icon {
380                                                    let current = active_key_for_icon.clone().unwrap_or_default();
381                                                    let new_keys = if accordion {
382                                                        if current.contains(&key_for_icon) {
383                                                            vec![]
384                                                        } else {
385                                                            vec![key_for_icon.clone()]
386                                                        }
387                                                    } else {
388                                                        if current.contains(&key_for_icon) {
389                                                            current.into_iter().filter(|k| k != &key_for_icon).collect()
390                                                        } else {
391                                                            let mut new = current;
392                                                            new.push(key_for_icon.clone());
393                                                            new
394                                                        }
395                                                    };
396                                                    cb.call(new_keys);
397                                                }
398                                            }
399                                        },
400                                        {icon_element}
401                                    }
402                                }
403                            })},
404                            span { class: "adui-collapse-header-text",
405                                {header}
406                            },
407                            {extra.map(|e| rsx! {
408                                span { class: "adui-collapse-extra",
409                                    {e}
410                                }
411                            })},
412                        },
413                        // Content rendering based on destroy_on_hidden
414                        if destroy_on_hidden {
415                            {is_active.then(|| rsx! {
416                                div {
417                                    class: "adui-collapse-content",
418                                    role: "region",
419                                    div { class: "adui-collapse-content-box",
420                                        {content}
421                                    }
422                                }
423                            })}
424                        } else {
425                            div {
426                                class: if is_active { "adui-collapse-content" } else { "adui-collapse-content adui-collapse-content-hidden" },
427                                role: "region",
428                                hidden: !is_active,
429                                div { class: "adui-collapse-content-box",
430                                    {content}
431                                }
432                            }
433                        },
434                    }
435                }
436            })}
437        }
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn collapse_size_class_mapping_is_stable() {
447        assert_eq!(CollapseSize::Small.as_class(), "adui-collapse-sm");
448        assert_eq!(CollapseSize::Middle.as_class(), "adui-collapse-md");
449        assert_eq!(CollapseSize::Large.as_class(), "adui-collapse-lg");
450    }
451
452    #[test]
453    fn collapse_size_from_global() {
454        assert_eq!(
455            CollapseSize::from_global(ComponentSize::Small),
456            CollapseSize::Small
457        );
458        assert_eq!(
459            CollapseSize::from_global(ComponentSize::Middle),
460            CollapseSize::Middle
461        );
462        assert_eq!(
463            CollapseSize::from_global(ComponentSize::Large),
464            CollapseSize::Large
465        );
466    }
467
468    #[test]
469    fn collapse_size_default() {
470        assert_eq!(CollapseSize::default(), CollapseSize::Middle);
471    }
472
473    #[test]
474    fn collapse_size_variants() {
475        assert_ne!(CollapseSize::Small, CollapseSize::Middle);
476        assert_ne!(CollapseSize::Middle, CollapseSize::Large);
477        assert_ne!(CollapseSize::Small, CollapseSize::Large);
478    }
479
480    #[test]
481    fn expand_icon_placement_class_mapping_is_stable() {
482        assert_eq!(
483            ExpandIconPlacement::Start.as_class(),
484            "adui-collapse-icon-start"
485        );
486        assert_eq!(
487            ExpandIconPlacement::End.as_class(),
488            "adui-collapse-icon-end"
489        );
490    }
491
492    #[test]
493    fn expand_icon_placement_default() {
494        assert_eq!(ExpandIconPlacement::default(), ExpandIconPlacement::Start);
495    }
496
497    #[test]
498    fn expand_icon_placement_variants() {
499        assert_ne!(ExpandIconPlacement::Start, ExpandIconPlacement::End);
500    }
501
502    #[test]
503    fn collapsible_type_variants() {
504        assert_eq!(CollapsibleType::Header, CollapsibleType::Header);
505        assert_eq!(CollapsibleType::Icon, CollapsibleType::Icon);
506        assert_eq!(CollapsibleType::Disabled, CollapsibleType::Disabled);
507        assert_ne!(CollapsibleType::Header, CollapsibleType::Icon);
508        assert_ne!(CollapsibleType::Header, CollapsibleType::Disabled);
509        assert_ne!(CollapsibleType::Icon, CollapsibleType::Disabled);
510    }
511
512    #[test]
513    fn collapsible_type_debug() {
514        let debug_str = format!("{:?}", CollapsibleType::Header);
515        assert!(debug_str.contains("Header"));
516
517        let debug_str2 = format!("{:?}", CollapsibleType::Icon);
518        assert!(debug_str2.contains("Icon"));
519
520        let debug_str3 = format!("{:?}", CollapsibleType::Disabled);
521        assert!(debug_str3.contains("Disabled"));
522    }
523
524    #[test]
525    fn collapsible_type_clone_and_copy() {
526        let t1 = CollapsibleType::Header;
527        let t2 = t1; // Copy
528        assert_eq!(t1, t2);
529    }
530
531    #[test]
532    fn collapse_panel_builder_methods_exist() {
533        // Test that builder methods exist and can be called
534        // Note: CollapsePanel::new requires Element, so we can't create a full instance
535        // But we can verify the builder pattern methods exist
536        let _disabled_method_exists = CollapsePanel::disabled;
537        let _show_arrow_method_exists = CollapsePanel::show_arrow;
538        let _collapsible_method_exists = CollapsePanel::collapsible;
539        let _extra_method_exists = CollapsePanel::extra;
540        // Methods exist
541        assert!(true);
542    }
543
544    #[test]
545    fn collapse_size_all_variants_have_classes() {
546        let variants = [
547            CollapseSize::Small,
548            CollapseSize::Middle,
549            CollapseSize::Large,
550        ];
551        for variant in variants.iter() {
552            let class = variant.as_class();
553            assert!(!class.is_empty());
554            assert!(class.starts_with("adui-collapse-"));
555        }
556    }
557}