Skip to main content

azul_layout/widgets/
accordion.rs

1//! Accordion / expander widget — one or more collapsible titled sections. Each
2//! section is a clickable header row plus a body that shows or hides. Combines
3//! the expand/collapse state of [`crate::widgets::tree_view::TreeView`] with a
4//! flat list of sections (each carrying an arbitrary content [`Dom`]).
5//!
6//! Sections toggle independently (any number may be open at once). Clicking a
7//! header flips that section's `is_open` flag in a per-header [`RefAny`] (the
8//! self-contained per-row data pattern of `tree_view`), invokes the optional
9//! user `on_toggle(section_index)`, and shows/hides the section body by setting
10//! `display: block | none` on it via `set_css_property` (mirroring tree_view /
11//! check_box live restyling).
12//!
13//! TODO2: the header is a plain styled clickable bar with no animated disclosure
14//! chevron — a glyph cannot be re-textured via `set_css_property` without a
15//! relayout, so an indicator that flips on toggle is deferred. The `display`
16//! toggle itself follows the proven live-restyle pattern but the `display:none`
17//! relayout is not GUI-verified in this build.
18//!
19//! Key types: [`Accordion`], [`AccordionSection`], [`AccordionOnToggle`].
20
21use std::vec::Vec;
22
23use azul_core::{
24    callbacks::{CoreCallback, CoreCallbackData, Update},
25    dom::{
26        Dom, DomVec, EventFilter, HoverEventFilter, IdOrClass, IdOrClass::Class, IdOrClassVec,
27        TabIndex,
28    },
29    refany::{OptionRefAny, RefAny},
30};
31use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
32use azul_css::{
33    impl_option, impl_vec, impl_vec_clone, impl_vec_debug, impl_vec_mut, impl_vec_partialeq,
34    props::{
35        basic::{color::ColorU, font::{StyleFontFamily, StyleFontFamilyVec}, StyleFontSize},
36        layout::{LayoutDisplay, LayoutFlexDirection, LayoutFlexGrow, LayoutOverflow, LayoutAlignItems, LayoutPaddingTop, LayoutPaddingBottom, LayoutPaddingLeft, LayoutPaddingRight},
37        property::{CssProperty, *},
38        style::{StyleBackgroundContent, StyleBackgroundContentVec, StyleTextColor, LayoutBorderTopWidth, LayoutBorderBottomWidth, LayoutBorderLeftWidth, LayoutBorderRightWidth, StyleBorderTopStyle, BorderStyle, StyleBorderBottomStyle, StyleBorderLeftStyle, StyleBorderRightStyle, StyleBorderTopColor, StyleBorderBottomColor, StyleBorderLeftColor, StyleBorderRightColor, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleCursor, StyleUserSelect, StyleTextAlign},
39    },
40    impl_option_inner, AzString,
41};
42
43use crate::callbacks::{Callback, CallbackInfo};
44
45static ACCORDION_CLASS: &[IdOrClass] =
46    &[Class(AzString::from_const_str("__azul-native-accordion"))];
47static ACCORDION_SECTION_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
48    "__azul-native-accordion-section",
49))];
50static ACCORDION_HEADER_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
51    "__azul-native-accordion-header",
52))];
53static ACCORDION_TITLE_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
54    "__azul-native-accordion-title",
55))];
56static ACCORDION_BODY_CLASS: &[IdOrClass] =
57    &[Class(AzString::from_const_str("__azul-native-accordion-body"))];
58
59const SYSTEM_UI_STR: AzString = AzString::from_const_str("system:ui");
60const SYSTEM_UI_FAMILIES: &[StyleFontFamily] = &[StyleFontFamily::System(SYSTEM_UI_STR)];
61const SYSTEM_UI_FAMILY: StyleFontFamilyVec =
62    StyleFontFamilyVec::from_const_slice(SYSTEM_UI_FAMILIES);
63
64/// Callback invoked when a section header is clicked. The `usize` is the
65/// zero-based index of the toggled section.
66pub type AccordionOnToggleCallbackType = extern "C" fn(RefAny, CallbackInfo, usize) -> Update;
67impl_widget_callback!(
68    AccordionOnToggle,
69    OptionAccordionOnToggle,
70    AccordionOnToggleCallback,
71    AccordionOnToggleCallbackType
72);
73
74azul_core::impl_managed_callback! {
75    wrapper:        AccordionOnToggleCallback,
76    info_ty:        CallbackInfo,
77    return_ty:      Update,
78    default_ret:    Update::DoNothing,
79    invoker_static: ACCORDION_ON_TOGGLE_INVOKER,
80    invoker_ty:     AzAccordionOnToggleCallbackInvoker,
81    thunk_fn:       az_accordion_on_toggle_callback_thunk,
82    setter_fn:      AzApp_setAccordionOnToggleCallbackInvoker,
83    from_handle_fn: AzAccordionOnToggleCallback_createFromHostHandle,
84    extra_args:     [ section_index: usize ],
85}
86
87// ---- colours ----
88const BORDER_COLOR: ColorU = ColorU { r: 222, g: 226, b: 230, a: 255 }; // #dee2e6
89const HEADER_BG: ColorU = ColorU { r: 248, g: 249, b: 250, a: 255 }; // #f8f9fa
90const TEXT_COLOR: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 }; // #212529
91
92const HEADER_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(HEADER_BG)];
93const HEADER_BG_VEC: StyleBackgroundContentVec =
94    StyleBackgroundContentVec::from_const_slice(HEADER_BG_ITEMS);
95
96/// One collapsible section: a header title and an arbitrary content body.
97#[derive(Debug, Clone, PartialEq, Eq)]
98#[repr(C)]
99pub struct AccordionSection {
100    /// The header text shown for this section.
101    pub title: AzString,
102    /// The body content revealed when the section is open.
103    pub content: Dom,
104    /// Whether this section starts open (body visible).
105    pub is_open: bool,
106}
107
108impl AccordionSection {
109    /// Creates a new collapsed section with the given title and content.
110    pub fn new<S: Into<AzString>>(title: S, content: Dom) -> Self {
111        Self {
112            title: title.into(),
113            content,
114            is_open: false,
115        }
116    }
117
118    /// Builder method: sets the initial open state.
119    #[must_use] pub const fn with_open(mut self, open: bool) -> Self {
120        self.is_open = open;
121        self
122    }
123}
124
125impl_option!(AccordionSection, OptionAccordionSection, copy = false, [Debug, Clone, PartialEq, Eq]);
126impl_vec!(
127    AccordionSection,
128    AccordionSectionVec,
129    AccordionSectionVecDestructor,
130    AccordionSectionVecDestructorType,
131    AccordionSectionVecSlice,
132    OptionAccordionSection
133);
134impl_vec_clone!(AccordionSection, AccordionSectionVec, AccordionSectionVecDestructor);
135impl_vec_debug!(AccordionSection, AccordionSectionVec);
136impl_vec_partialeq!(AccordionSection, AccordionSectionVec);
137impl_vec_mut!(AccordionSection, AccordionSectionVec);
138
139/// A vertical stack of collapsible titled sections.
140#[derive(Debug, Clone, PartialEq)]
141#[repr(C)]
142pub struct Accordion {
143    /// The sections, in display order.
144    pub sections: AccordionSectionVec,
145    /// Optional callback fired when any section header is toggled.
146    pub on_toggle: OptionAccordionOnToggle,
147}
148
149// ---- styles ----
150
151static ACCORDION_CONTAINER_STYLE: &[CssPropertyWithConditions] = &[
152    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
153    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
154        LayoutFlexDirection::Column,
155    )),
156    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
157    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(14))),
158    CssPropertyWithConditions::simple(CssProperty::const_font_family(SYSTEM_UI_FAMILY)),
159    CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
160        inner: TEXT_COLOR,
161    })),
162    // border: 1px solid #dee2e6
163    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
164        LayoutBorderTopWidth::const_px(1),
165    )),
166    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
167        LayoutBorderBottomWidth::const_px(1),
168    )),
169    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
170        LayoutBorderLeftWidth::const_px(1),
171    )),
172    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
173        LayoutBorderRightWidth::const_px(1),
174    )),
175    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
176        inner: BorderStyle::Solid,
177    })),
178    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
179        StyleBorderBottomStyle {
180            inner: BorderStyle::Solid,
181        },
182    )),
183    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
184        inner: BorderStyle::Solid,
185    })),
186    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
187        StyleBorderRightStyle {
188            inner: BorderStyle::Solid,
189        },
190    )),
191    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
192        inner: BORDER_COLOR,
193    })),
194    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
195        StyleBorderBottomColor {
196            inner: BORDER_COLOR,
197        },
198    )),
199    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
200        inner: BORDER_COLOR,
201    })),
202    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
203        StyleBorderRightColor {
204            inner: BORDER_COLOR,
205        },
206    )),
207    // rounded corners, clipping the per-section separators
208    CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
209        StyleBorderTopLeftRadius::const_px(6),
210    )),
211    CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
212        StyleBorderTopRightRadius::const_px(6),
213    )),
214    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
215        StyleBorderBottomLeftRadius::const_px(6),
216    )),
217    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
218        StyleBorderBottomRightRadius::const_px(6),
219    )),
220    CssPropertyWithConditions::simple(CssProperty::const_overflow_x(LayoutOverflow::Hidden)),
221    CssPropertyWithConditions::simple(CssProperty::const_overflow_y(LayoutOverflow::Hidden)),
222];
223
224static ACCORDION_SECTION_STYLE: &[CssPropertyWithConditions] = &[
225    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
226    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
227        LayoutFlexDirection::Column,
228    )),
229    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
230    // a thin separator between stacked sections
231    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
232        LayoutBorderBottomWidth::const_px(1),
233    )),
234    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
235        StyleBorderBottomStyle {
236            inner: BorderStyle::Solid,
237        },
238    )),
239    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
240        StyleBorderBottomColor {
241            inner: BORDER_COLOR,
242        },
243    )),
244];
245
246static ACCORDION_HEADER_STYLE: &[CssPropertyWithConditions] = &[
247    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
248    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
249    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
250    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
251    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
252        10,
253    ))),
254    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
255        LayoutPaddingBottom::const_px(10),
256    )),
257    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
258        12,
259    ))),
260    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
261        LayoutPaddingRight::const_px(12),
262    )),
263    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
264    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
265    CssPropertyWithConditions::simple(CssProperty::const_background_content(HEADER_BG_VEC)),
266];
267
268static ACCORDION_TITLE_STYLE: &[CssPropertyWithConditions] = &[
269    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(1))),
270    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Left)),
271];
272
273/// Body style when the section is OPEN: a padded block with a top separator.
274static ACCORDION_BODY_STYLE_OPEN: &[CssPropertyWithConditions] = &[
275    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
276    CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
277        12,
278    ))),
279    CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
280        LayoutPaddingBottom::const_px(12),
281    )),
282    CssPropertyWithConditions::simple(CssProperty::const_padding_left(LayoutPaddingLeft::const_px(
283        12,
284    ))),
285    CssPropertyWithConditions::simple(CssProperty::const_padding_right(
286        LayoutPaddingRight::const_px(12),
287    )),
288];
289
290/// Body style when the section is CLOSED: not laid out at all.
291static ACCORDION_BODY_STYLE_CLOSED: &[CssPropertyWithConditions] = &[
292    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::None)),
293];
294
295impl Accordion {
296    /// Creates a new accordion from the given sections, with no toggle callback.
297    #[must_use] pub fn new(sections: AccordionSectionVec) -> Self {
298        Self {
299            sections,
300            on_toggle: None.into(),
301        }
302    }
303
304    /// Creates an empty accordion.
305    #[must_use] pub fn create() -> Self {
306        Self::new(AccordionSectionVec::from_const_slice(&[]))
307    }
308
309    /// Sets the callback invoked when any section header is toggled.
310    pub fn set_on_toggle<C: Into<AccordionOnToggleCallback>>(&mut self, data: RefAny, callback: C) {
311        self.on_toggle = Some(AccordionOnToggle {
312            callback: callback.into(),
313            refany: data,
314        })
315        .into();
316    }
317
318    /// Builder method: sets the toggle callback.
319    #[must_use] pub fn with_on_toggle<C: Into<AccordionOnToggleCallback>>(
320        mut self,
321        data: RefAny,
322        callback: C,
323    ) -> Self {
324        self.set_on_toggle(data, callback);
325        self
326    }
327
328    /// Replaces `self` with an empty default accordion and returns the original.
329    #[must_use] pub fn swap_with_default(&mut self) -> Self {
330        let mut s = Self::create();
331        core::mem::swap(&mut s, self);
332        s
333    }
334
335    /// Renders the accordion into a [`Dom`] subtree.
336    #[must_use] pub fn dom(self) -> Dom {
337        let on_toggle = self.on_toggle;
338        let sections = self.sections;
339
340        let mut section_doms: Vec<Dom> = Vec::with_capacity(sections.as_ref().len());
341
342        for (index, section) in sections.as_ref().iter().enumerate() {
343            let title = Dom::create_text(section.title.clone())
344                .with_ids_and_classes(IdOrClassVec::from_const_slice(ACCORDION_TITLE_CLASS))
345                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
346                    ACCORDION_TITLE_STYLE,
347                ));
348
349            // Per-header self-contained click data (mirrors tree_view's NodeClickData).
350            let header_data = HeaderClickData {
351                index,
352                is_open: section.is_open,
353                on_toggle: clone_option_on_toggle(&on_toggle),
354            };
355
356            let header = Dom::create_div()
357                .with_ids_and_classes(IdOrClassVec::from_const_slice(ACCORDION_HEADER_CLASS))
358                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
359                    ACCORDION_HEADER_STYLE,
360                ))
361                .with_tab_index(TabIndex::Auto)
362                .with_callbacks(
363                    alloc::vec![CoreCallbackData {
364                        event: EventFilter::Hover(HoverEventFilter::MouseUp),
365                        callback: CoreCallback {
366                            cb: on_accordion_header_click as usize,
367                            ctx: OptionRefAny::None,
368                        },
369                        refany: RefAny::new(header_data),
370                    }]
371                    .into(),
372                )
373                .with_children(DomVec::from_vec(alloc::vec![title]));
374
375            let body_style = if section.is_open {
376                ACCORDION_BODY_STYLE_OPEN
377            } else {
378                ACCORDION_BODY_STYLE_CLOSED
379            };
380            let body = Dom::create_div()
381                .with_ids_and_classes(IdOrClassVec::from_const_slice(ACCORDION_BODY_CLASS))
382                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(body_style))
383                .with_children(DomVec::from_vec(alloc::vec![section.content.clone()]));
384
385            section_doms.push(
386                Dom::create_div()
387                    .with_ids_and_classes(IdOrClassVec::from_const_slice(ACCORDION_SECTION_CLASS))
388                    .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
389                        ACCORDION_SECTION_STYLE,
390                    ))
391                    .with_children(DomVec::from_vec(alloc::vec![header, body])),
392            );
393        }
394
395        Dom::create_div()
396            .with_ids_and_classes(IdOrClassVec::from_const_slice(ACCORDION_CLASS))
397            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
398                ACCORDION_CONTAINER_STYLE,
399            ))
400            .with_children(DomVec::from_vec(section_doms))
401    }
402}
403
404impl Default for Accordion {
405    fn default() -> Self {
406        Self::create()
407    }
408}
409
410/// Clones an `OptionAccordionOnToggle` (the callback wrapper is not `Copy`).
411fn clone_option_on_toggle(opt: &OptionAccordionOnToggle) -> OptionAccordionOnToggle {
412    match opt.as_ref() {
413        Some(AccordionOnToggle { callback, refany }) => Some(AccordionOnToggle {
414            callback: callback.clone(),
415            refany: refany.clone(),
416        })
417        .into(),
418        None => None.into(),
419    }
420}
421
422/// Per-header callback payload (kept internal, like `tree_view::NodeClickData`).
423struct HeaderClickData {
424    index: usize,
425    is_open: bool,
426    on_toggle: OptionAccordionOnToggle,
427}
428
429/// Header click handler. The hit node is the header (the callback-bearing node,
430/// per `currentTarget` semantics — see `radio_group`); its next sibling is the
431/// body. Flips this section's `is_open`, invokes the optional user callback with
432/// the section index, then shows/hides the body via `display`.
433extern "C" fn on_accordion_header_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
434    let header = info.get_hit_node();
435    let Some(body) = info.get_next_sibling(header) else {
436        return Update::DoNothing;
437    };
438
439    let (now_open, result) = {
440        let Some(mut hd) = data.downcast_mut::<HeaderClickData>() else {
441            return Update::DoNothing;
442        };
443        hd.is_open = !hd.is_open;
444        let now_open = hd.is_open;
445        let index = hd.index;
446        let result = match hd.on_toggle.as_mut() {
447            Some(AccordionOnToggle { callback, refany }) => {
448                (callback.cb)(refany.clone(), info, index)
449            }
450            None => Update::DoNothing,
451        };
452        (now_open, result)
453    };
454
455    let display = if now_open {
456        LayoutDisplay::Block
457    } else {
458        LayoutDisplay::None
459    };
460    info.set_css_property(body, CssProperty::const_display(display));
461
462    result
463}
464
465impl From<Accordion> for Dom {
466    fn from(a: Accordion) -> Self {
467        a.dom()
468    }
469}