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
9pub type ExpandIconRenderFn = fn(&CollapsePanel, bool) -> Element;
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum CollapsibleType {
16 Header,
18 Icon,
20 Disabled,
22}
23
24#[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#[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#[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#[allow(unpredictable_function_pointer_comparisons)]
116#[derive(Props, Clone, PartialEq)]
118pub struct CollapseProps {
119 pub items: Vec<CollapsePanel>,
121 #[props(optional)]
123 pub active_key: Option<Vec<String>>,
124 #[props(optional)]
126 pub default_active_key: Option<Vec<String>>,
127 #[props(optional)]
129 pub on_change: Option<EventHandler<Vec<String>>>,
130 #[props(default)]
132 pub accordion: bool,
133 #[props(default = true)]
135 pub bordered: bool,
136 #[props(default)]
138 pub ghost: bool,
139 #[props(optional)]
141 pub size: Option<CollapseSize>,
142 #[props(default)]
144 pub expand_icon_placement: ExpandIconPlacement,
145 #[props(optional)]
147 pub collapsible: Option<CollapsibleType>,
148 #[props(default = true)]
150 pub destroy_on_hidden: bool,
151 #[props(optional)]
153 pub expand_icon: Option<ExpandIconRenderFn>,
154 #[props(optional)]
156 pub class: Option<String>,
157 #[props(optional)]
159 pub style: Option<String>,
160 #[props(optional)]
162 pub class_names: Option<CollapseClassNames>,
163 #[props(optional)]
165 pub styles: Option<CollapseStyles>,
166}
167
168#[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 let resolved_size = if let Some(s) = size {
196 s
197 } else {
198 CollapseSize::from_global(config.size)
199 };
200
201 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 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 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 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; assert_eq!(t1, t2);
529 }
530
531 #[test]
532 fn collapse_panel_builder_methods_exist() {
533 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 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}