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()));
}
}