dioxus_tw_components/components/molecules/accordion/
props.rs

1use crate::{attributes::*, components::atoms::icon::*};
2use dioxus::prelude::*;
3use dioxus_core::AttributeValue;
4use dioxus_tw_components_macro::UiComp;
5
6struct AccordionState {
7    multi_open: bool,
8    active_items: Vec<String>,
9}
10
11impl AccordionState {
12    fn new(multi_open: bool) -> Self {
13        Self {
14            multi_open,
15            active_items: Vec::with_capacity(1),
16        }
17    }
18
19    fn add_id(&mut self, id: String) {
20        self.active_items.push(id);
21    }
22
23    fn remove_id(&mut self, id: String) {
24        self.active_items.retain(|x| x != &id);
25    }
26
27    fn set_id(&mut self, id: String) {
28        self.active_items.clear();
29        self.active_items.push(id);
30    }
31
32    fn is_active(&self, id: &str) -> bool {
33        self.active_items.contains(&id.to_string())
34    }
35
36    fn is_active_to_attr_value(&self, id: String) -> AttributeValue {
37        match self.active_items.contains(&id) {
38            true => AttributeValue::Text("active".to_string()),
39            false => AttributeValue::Text("inactive".to_string()),
40        }
41    }
42}
43
44#[derive(Clone, PartialEq, Props, UiComp)]
45pub struct AccordionProps {
46    #[props(extends = div, extends = GlobalAttributes)]
47    attributes: Vec<Attribute>,
48
49    /// Control if multiple items can be open at the same time
50    #[props(default = false)]
51    multi_open: bool,
52
53    children: Element,
54}
55
56impl std::default::Default for AccordionProps {
57    fn default() -> Self {
58        Self {
59            attributes: Vec::<Attribute>::default(),
60            multi_open: false,
61            children: rsx! {},
62        }
63    }
64}
65
66/// The Accordion component divides the content into collapsible items \
67/// Usage:
68/// ```ignore
69/// rsx!{Accordion {
70///     AccordionItem {
71///         AccordionTrigger { id: "acc-1", "Trigger 1" }
72///         AccordionContent { id: "acc-1", "Content 1" }
73///     }
74///     AccordionItem {
75///         AccordionTrigger { id: "acc-2", "Trigger 2" }
76///         AccordionContent { id: "acc-2", "Content 2" }
77///     }
78/// }}
79/// ```
80#[component]
81pub fn Accordion(mut props: AccordionProps) -> Element {
82    use_context_provider(|| Signal::new(AccordionState::new(props.multi_open)));
83
84    props.update_class_attribute();
85
86    rsx! {
87        div { ..props.attributes,{props.children} }
88    }
89}
90
91#[derive(Clone, PartialEq, Props, UiComp)]
92pub struct AccordionItemProps {
93    #[props(extends = div, extends = GlobalAttributes)]
94    attributes: Vec<Attribute>,
95
96    children: Element,
97}
98
99impl std::default::Default for AccordionItemProps {
100    fn default() -> Self {
101        Self {
102            attributes: Vec::<Attribute>::default(),
103            children: rsx! {},
104        }
105    }
106}
107
108/// Wrapper for the [AccordionTrigger] and [AccordionContent] components
109#[component]
110pub fn AccordionItem(mut props: AccordionItemProps) -> Element {
111    props.update_class_attribute();
112
113    rsx! {
114        div { ..props.attributes,{props.children} }
115    }
116}
117
118#[derive(Clone, PartialEq, Props, UiComp)]
119pub struct AccordionTriggerProps {
120    #[props(extends = button, extends = GlobalAttributes)]
121    attributes: Vec<Attribute>,
122
123    #[props(optional)]
124    id: ReadOnlySignal<String>,
125
126    /// Determine if the accordion item is open by default
127    #[props(optional, into)]
128    is_open: ReadOnlySignal<bool>,
129
130    #[props(optional, default)]
131    onclick: EventHandler<MouseEvent>,
132
133    /// Decoration element that is displayed next to the trigger text, by default a chevron
134    #[props(optional, default = default_trigger_decoration())]
135    trigger_decoration: Element,
136
137    children: Element,
138}
139
140impl std::default::Default for AccordionTriggerProps {
141    fn default() -> Self {
142        Self {
143            attributes: Vec::<Attribute>::default(),
144            id: ReadOnlySignal::<String>::default(),
145            is_open: ReadOnlySignal::<bool>::default(),
146            onclick: EventHandler::<MouseEvent>::default(),
147            trigger_decoration: default_trigger_decoration(),
148            children: rsx! {},
149        }
150    }
151}
152
153/// The clickable element that toggles the visibility of the [AccordionContent] component
154#[component]
155pub fn AccordionTrigger(mut props: AccordionTriggerProps) -> Element {
156    props.update_class_attribute();
157
158    let mut state = use_context::<Signal<AccordionState>>();
159
160    use_effect(move || {
161        if *props.is_open.read() {
162            if !state.peek().multi_open {
163                state.write().set_id(props.id.read().clone());
164            } else {
165                state.write().add_id(props.id.read().clone());
166            }
167        } else if state.peek().is_active(&props.id.read()) {
168            state.write().remove_id(props.id.read().clone());
169        }
170    });
171
172    let button_closure = move |event: Event<MouseData>| {
173        // If the current item is active, remove it from the list, effectively closing it
174        if state.read().is_active(&props.id.read()) {
175            state.write().remove_id(props.id.read().clone());
176        } else {
177            // If the current item is not active
178            // set it as the only active item if multi_open is false
179            // or add it to the list of active items if multi_open is true
180            if !state.read().multi_open {
181                state.write().set_id(props.id.read().clone());
182            } else {
183                state.write().add_id(props.id.read().clone());
184            }
185        }
186        props.onclick.call(event)
187    };
188
189    rsx! {
190        button {
191            "data-state": state.read().is_active_to_attr_value(props.id.read().to_string()),
192            onclick: button_closure,
193            ..props.attributes,
194            {props.children}
195            {props.trigger_decoration}
196        }
197    }
198}
199
200fn default_trigger_decoration() -> Element {
201    rsx! {
202        Icon {
203            class: "transition-transform transform duration-300 group-data-[state=active]:-rotate-180",
204            icon: Icons::ExpandMore,
205        }
206    }
207}
208
209#[derive(Clone, PartialEq, Props, UiComp)]
210pub struct AccordionContentProps {
211    #[props(extends = div, extends = GlobalAttributes)]
212    attributes: Vec<Attribute>,
213
214    #[props(optional)]
215    id: ReadOnlySignal<String>,
216
217    #[props(optional)]
218    height: ReadOnlySignal<String>,
219
220    #[props(default)]
221    pub animation: ReadOnlySignal<Animation>,
222
223    children: Element,
224}
225
226impl std::default::Default for AccordionContentProps {
227    fn default() -> Self {
228        Self {
229            attributes: Vec::<Attribute>::default(),
230            id: ReadOnlySignal::<String>::default(),
231            height: ReadOnlySignal::<String>::default(),
232            animation: ReadOnlySignal::<Animation>::default(),
233            children: rsx! {},
234        }
235    }
236}
237
238/// Collapsible element that is toggled by the [AccordionTrigger] component
239#[component]
240pub fn AccordionContent(mut props: AccordionContentProps) -> Element {
241    // This is the height of the element when visible, we need to calcul it before rendering it to have a smooth transition
242    let mut elem_height = use_signal(|| "".to_string());
243
244    props.update_class_attribute();
245
246    let state = use_context::<Signal<AccordionState>>();
247
248    let final_height = match state.read().is_active(&props.id.read()) {
249        true => {
250            if props.height.read().is_empty() {
251                elem_height()
252            } else {
253                props.height.read().clone()
254            }
255        }
256        false => "0".to_string(),
257    };
258
259    rsx! {
260        div {
261            onmounted: move |element| async move {
262                if !props.height.read().is_empty() {
263                    return;
264                }
265                if props.animation == Animation::None {
266                    elem_height.set("auto".to_string());
267                    return;
268                }
269                elem_height
270                    .set(
271                        match element.data().get_scroll_size().await {
272                            Ok(size) => format!("{}px", size.height),
273                            Err(e) => {
274                                log::error!(
275                                    "AccordionContent: Failed to get element height(id probably not set): setting it to auto: {e:?}",
276                                );
277                                "auto".to_string()
278                            }
279                        },
280                    );
281            },
282            "data-state": state.read().is_active_to_attr_value(props.id.read().to_string()),
283            id: props.id,
284            height: final_height,
285            ..props.attributes,
286            {props.children}
287        }
288    }
289}