aetna-core 0.3.3

Aetna — backend-agnostic UI library core
Documentation
//! Accordion / collapsible anatomy.
//!
//! This is a controlled widget: the app owns which item is open and
//! folds routed trigger events through [`apply_event`], matching the
//! same pattern as tabs, select, switch, and radio.

use std::panic::Location;

use crate::anim::Timing;
use crate::cursor::Cursor;
use crate::event::{UiEvent, UiEventKind};
use crate::metrics::MetricsRole;
use crate::style::StyleProfile;
use crate::tokens;
use crate::tree::*;
use crate::widgets::separator::separator;
use crate::widgets::text::text;
use crate::{IntoIconSource, icon};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum AccordionAction<'a> {
    Toggle(&'a str),
}

pub fn accordion_item_key(key: &str, value: &impl std::fmt::Display) -> String {
    format!("{key}:accordion:{value}")
}

pub fn classify_event<'a>(event: &'a UiEvent, key: &str) -> Option<AccordionAction<'a>> {
    if !matches!(event.kind, UiEventKind::Click | UiEventKind::Activate) {
        return None;
    }
    let routed = event.route()?;
    let rest = routed.strip_prefix(key)?.strip_prefix(':')?;
    let value = rest.strip_prefix("accordion:")?;
    Some(AccordionAction::Toggle(value))
}

pub fn apply_event<V>(
    open: &mut Option<V>,
    event: &UiEvent,
    key: &str,
    parse: impl FnOnce(&str) -> Option<V>,
) -> bool
where
    V: std::fmt::Display + PartialEq,
{
    let Some(AccordionAction::Toggle(raw)) = classify_event(event, key) else {
        return false;
    };
    let Some(next) = parse(raw) else {
        return true;
    };
    if open.as_ref() == Some(&next) {
        *open = None;
    } else {
        *open = Some(next);
    }
    true
}

#[track_caller]
pub fn accordion<I, E>(children: I) -> El
where
    I: IntoIterator<Item = E>,
    E: Into<El>,
{
    column(children)
        .at_loc(Location::caller())
        .width(Size::Fill(1.0))
        .height(Size::Hug)
        .padding(Sides::x(tokens::RING_WIDTH))
        .gap(0.0)
}

#[track_caller]
pub fn accordion_item<I, E>(
    key: &str,
    value: impl std::fmt::Display,
    label: impl Into<String>,
    open: bool,
    children: I,
) -> El
where
    I: IntoIterator<Item = E>,
    E: Into<El>,
{
    let mut body = vec![accordion_trigger(key, value, label, open)];
    if open {
        body.push(accordion_content(children));
    }
    column(body)
        .at_loc(Location::caller())
        .width(Size::Fill(1.0))
        .height(Size::Hug)
        .gap(0.0)
}

#[track_caller]
pub fn accordion_trigger(
    key: &str,
    value: impl std::fmt::Display,
    label: impl Into<String>,
    open: bool,
) -> El {
    let chevron = if open {
        "chevron-down"
    } else {
        "chevron-right"
    };
    row([
        text(label)
            .label()
            .font_weight(FontWeight::Medium)
            .ellipsis()
            .width(Size::Fill(1.0)),
        icon(chevron)
            .icon_size(tokens::ICON_XS)
            .color(tokens::MUTED_FOREGROUND),
    ])
    .at_loc(Location::caller())
    .key(accordion_item_key(key, &value))
    .style_profile(StyleProfile::Solid)
    .metrics_role(MetricsRole::ListItem)
    .focusable()
    .cursor(Cursor::Pointer)
    .fill(tokens::CARD)
    .default_radius(tokens::RADIUS_SM)
    .default_gap(tokens::SPACE_2)
    .default_padding(Sides::xy(tokens::SPACE_3, 0.0))
    .default_height(Size::Fixed(40.0))
    .paint_overflow(Sides::all(tokens::RING_WIDTH))
    .hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
    .axis(Axis::Row)
    .align(Align::Center)
    .width(Size::Fill(1.0))
    .animate(Timing::SPRING_QUICK)
}

#[track_caller]
pub fn accordion_trigger_with_icon(
    key: &str,
    value: impl std::fmt::Display,
    source: impl IntoIconSource,
    label: impl Into<String>,
    open: bool,
) -> El {
    let chevron = if open {
        "chevron-down"
    } else {
        "chevron-right"
    };
    row([
        icon(source)
            .icon_size(tokens::ICON_SM)
            .color(tokens::MUTED_FOREGROUND),
        text(label)
            .label()
            .font_weight(FontWeight::Medium)
            .ellipsis()
            .width(Size::Fill(1.0)),
        icon(chevron)
            .icon_size(tokens::ICON_XS)
            .color(tokens::MUTED_FOREGROUND),
    ])
    .at_loc(Location::caller())
    .key(accordion_item_key(key, &value))
    .style_profile(StyleProfile::Solid)
    .metrics_role(MetricsRole::ListItem)
    .focusable()
    .cursor(Cursor::Pointer)
    .fill(tokens::CARD)
    .default_radius(tokens::RADIUS_SM)
    .default_gap(tokens::SPACE_2)
    .default_padding(Sides::xy(tokens::SPACE_3, 0.0))
    .default_height(Size::Fixed(40.0))
    .paint_overflow(Sides::all(tokens::RING_WIDTH))
    .hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
    .axis(Axis::Row)
    .align(Align::Center)
    .width(Size::Fill(1.0))
    .animate(Timing::SPRING_QUICK)
}

#[track_caller]
pub fn accordion_content<I, E>(children: I) -> El
where
    I: IntoIterator<Item = E>,
    E: Into<El>,
{
    column(children)
        .at_loc(Location::caller())
        .width(Size::Fill(1.0))
        .height(Size::Hug)
        .padding(Sides {
            left: tokens::SPACE_2,
            right: tokens::SPACE_2,
            top: 0.0,
            bottom: tokens::SPACE_3,
        })
        .gap(tokens::SPACE_2)
}

#[track_caller]
pub fn accordion_separator() -> El {
    separator()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::event::{KeyModifiers, UiEvent};

    fn click_event(key: &str) -> UiEvent {
        UiEvent {
            path: None,
            kind: UiEventKind::Click,
            key: Some(key.to_string()),
            target: None,
            pointer: None,
            key_press: None,
            text: None,
            selection: None,
            modifiers: KeyModifiers::default(),
            click_count: 1,
        }
    }

    #[test]
    fn accordion_trigger_routes_by_stable_key_and_centers_row() {
        let trigger = accordion_trigger("settings", "security", "Security", false);

        assert_eq!(trigger.key.as_deref(), Some("settings:accordion:security"));
        assert_eq!(trigger.metrics_role, Some(MetricsRole::ListItem));
        assert_eq!(trigger.align, Align::Center);
        assert!(trigger.focusable);
        assert_eq!(trigger.paint_overflow, Sides::all(tokens::RING_WIDTH));
        assert_eq!(
            trigger.children[1].icon,
            Some(crate::IconSource::Builtin(IconName::ChevronRight))
        );
    }

    #[test]
    fn accordion_item_includes_content_only_when_open() {
        let closed = accordion_item("settings", "security", "Security", false, [text("Body")]);
        let open = accordion_item("settings", "security", "Security", true, [text("Body")]);

        assert_eq!(closed.children.len(), 1);
        assert_eq!(open.children.len(), 2);
        assert_eq!(open.children[1].padding.bottom, tokens::SPACE_3);
    }

    #[test]
    fn apply_event_toggles_single_open_value() {
        let mut open = Some("security".to_string());

        assert!(apply_event(
            &mut open,
            &click_event("settings:accordion:security"),
            "settings",
            |raw| Some(raw.to_string()),
        ));
        assert_eq!(open, None);

        assert!(apply_event(
            &mut open,
            &click_event("settings:accordion:billing"),
            "settings",
            |raw| Some(raw.to_string()),
        ));
        assert_eq!(open, Some("billing".to_string()));
    }
}