use blinc_animation::{AnimatedValue, SpringConfig};
use blinc_core::context_state::BlincContextState;
use blinc_core::{use_state_keyed, SignalId, State};
use blinc_layout::div::ElementTypeId;
use blinc_layout::element::{CursorStyle, RenderProps};
use blinc_layout::motion::{motion, SharedAnimatedValue};
use blinc_layout::prelude::*;
use blinc_layout::render_state::get_global_scheduler;
use blinc_layout::stateful::Stateful;
use blinc_layout::tree::{LayoutNodeId, LayoutTree};
use blinc_layout::InstanceKey;
use blinc_theme::{ColorToken, RadiusToken, ThemeState};
use std::cell::OnceCell;
use std::sync::{Arc, Mutex};
const CHEVRON_DOWN_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>"#;
const CHEVRON_UP_SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 15-6-6-6 6"/></svg>"#;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AccordionMode {
#[default]
Single,
Multi,
}
pub struct Accordion {
inner: Stateful<()>,
}
impl ElementBuilder for Accordion {
fn build(&self, tree: &mut LayoutTree) -> LayoutNodeId {
self.inner.build(tree)
}
fn render_props(&self) -> RenderProps {
self.inner.render_props()
}
fn children_builders(&self) -> &[Box<dyn ElementBuilder>] {
self.inner.children_builders()
}
fn element_type_id(&self) -> ElementTypeId {
self.inner.element_type_id()
}
fn layout_style(&self) -> Option<&taffy::Style> {
self.inner.layout_style()
}
fn visual_animation_config(
&self,
) -> Option<blinc_layout::visual_animation::VisualAnimationConfig> {
self.inner.visual_animation_config()
}
fn element_classes(&self) -> &[String] {
self.inner.element_classes()
}
fn element_id(&self) -> Option<&str> {
self.inner.element_id()
}
}
type ContentBuilderFn = Arc<dyn Fn() -> Div + Send + Sync>;
#[derive(Clone)]
struct AccordionItem {
key: String,
label: String,
content: ContentBuilderFn,
}
#[derive(Clone)]
struct AccordionItemState {
key: String,
is_open: State<bool>,
opacity_anim: SharedAnimatedValue,
}
pub struct AccordionBuilder {
instance_key: InstanceKey,
mode: AccordionMode,
spring_config: SpringConfig,
initial_open: Option<String>,
items: Vec<AccordionItem>,
classes: Vec<String>,
user_id: Option<String>,
built: OnceCell<Accordion>,
}
impl AccordionBuilder {
pub fn new() -> Self {
Self {
instance_key: InstanceKey::new("accordion"),
mode: AccordionMode::Single,
spring_config: SpringConfig::snappy(),
initial_open: None,
items: Vec::new(),
classes: Vec::new(),
user_id: None,
built: OnceCell::new(),
}
}
fn get_or_build(&self) -> &Accordion {
self.built.get_or_init(|| self.build_component())
}
pub fn multi_open(mut self) -> Self {
self.mode = AccordionMode::Multi;
self
}
pub fn default_open(mut self, key: impl Into<String>) -> Self {
self.initial_open = Some(key.into());
self
}
pub fn spring(mut self, config: SpringConfig) -> Self {
self.spring_config = config;
self
}
pub fn item<F>(mut self, key: impl Into<String>, label: impl Into<String>, content: F) -> Self
where
F: Fn() -> Div + Send + Sync + 'static,
{
self.items.push(AccordionItem {
key: self.instance_key.derive(&key.into()),
label: label.into(),
content: Arc::new(content),
});
self
}
pub fn class(mut self, name: impl Into<String>) -> Self {
self.classes.push(name.into());
self
}
pub fn id(mut self, id: &str) -> Self {
self.user_id = Some(id.to_string());
self
}
pub fn build_component(&self) -> Accordion {
let theme = ThemeState::get();
let scheduler = get_global_scheduler()
.expect("Animation scheduler not initialized - call this after app starts");
let mut all_signal_ids: Vec<SignalId> = Vec::new();
let mut items_with_state: Vec<(AccordionItem, AccordionItemState)> = Vec::new();
for item in &self.items {
let is_initially_open = self.initial_open.as_ref() == Some(&item.key);
let state_key = format!("{}_{}_open", self.instance_key.get(), item.key);
let is_open: State<bool> =
BlincContextState::get().use_state_keyed(&state_key, || is_initially_open);
all_signal_ids.push(is_open.signal_id());
let actual_is_open = is_open.get();
let actual_opacity = if actual_is_open { 1.0 } else { 0.0 };
let opacity_anim: SharedAnimatedValue = Arc::new(Mutex::new(AnimatedValue::new(
scheduler.clone(),
actual_opacity,
self.spring_config,
)));
let item_state = AccordionItemState {
key: item.key.clone(),
is_open,
opacity_anim,
};
items_with_state.push((item.clone(), item_state));
}
let text_primary = theme.color(ColorToken::TextPrimary);
let text_secondary = theme.color(ColorToken::TextSecondary);
let border_color = theme.color(ColorToken::Border);
let radius = theme.radius(RadiusToken::Lg);
let key_for_container = format!("{}_container", self.instance_key.get());
let container_state_handle = use_shared_state_with(&key_for_container, ());
let anim_key_for_container = key_for_container.clone();
let item_count = items_with_state.len();
let mode = self.mode;
let all_item_states: Vec<AccordionItemState> =
items_with_state.iter().map(|(_, s)| s.clone()).collect();
let accordion_stateful = Stateful::with_shared_state(container_state_handle)
.deps(&all_signal_ids)
.on_state(move |_state: &(), container: &mut Div| {
let mut content = div()
.class("cn-accordion")
.flex_col()
.w_full()
.flex_shrink()
.rounded(radius)
.shadow_md()
.bg(theme.color(ColorToken::SurfaceElevated))
.border(1.5, border_color)
.overflow_clip()
.animate_bounds(
blinc_layout::visual_animation::VisualAnimationConfig::height()
.with_key(&anim_key_for_container)
.clip_to_animated()
.snappy(),
);
for (index, (item, item_state)) in items_with_state.iter().enumerate() {
let is_open = item_state.is_open.clone();
let opacity_anim = item_state.opacity_anim.clone();
let item_key = item_state.key.clone();
let content_fn = item.content.clone();
let label = item.label.clone();
let is_open_for_click = is_open.clone();
let opacity_anim_for_click = opacity_anim.clone();
let all_states_for_click = all_item_states.clone();
let key_for_click = item_key.clone();
let section_is_open = is_open.get();
let chevron_svg = if section_is_open {
CHEVRON_UP_SVG
} else {
CHEVRON_DOWN_SVG
};
let mut trigger = div()
.class("cn-accordion-trigger")
.flex_row()
.w_full()
.justify_between()
.items_center()
.cursor(CursorStyle::Pointer)
.child(
text(&label)
.size(14.0)
.weight(blinc_layout::div::FontWeight::Medium)
.color(text_primary)
.pointer_events_none(),
)
.child(svg(chevron_svg).size(16.0, 16.0).color(text_secondary))
.on_click(move |_| {
let current = is_open_for_click.get();
let new_state = !current;
if mode == AccordionMode::Single && new_state {
for state in &all_states_for_click {
if state.key != key_for_click && state.is_open.get() {
state.is_open.set(false);
state.opacity_anim.lock().unwrap().set_target(0.0);
}
}
}
is_open_for_click.set(new_state);
let target_opacity = if new_state { 1.0 } else { 0.0 };
opacity_anim_for_click
.lock()
.unwrap()
.set_target(target_opacity);
});
let anim_key = format!("accordion-content-{}", item_key);
let collapsible_content = div()
.class("cn-accordion-content")
.flex_col()
.w_full()
.bg(theme.color(ColorToken::Surface))
.border_top(1.0, border_color)
.overflow_clip()
.animate_bounds(
blinc_layout::visual_animation::VisualAnimationConfig::height()
.with_key(&anim_key)
.clip_to_animated()
.snappy(),
)
.child(content_fn())
.when(section_is_open, |d| d.py(2.0).px(1.0))
.when(!section_is_open, |d| d.w_full().h(0.0).px(1.0));
let item_div_key = format!("accordion-item-{}", item_key);
let item_div = div()
.flex_col()
.w_full()
.animate_bounds(
blinc_layout::visual_animation::VisualAnimationConfig::position()
.with_key(&item_div_key)
.snappy(),
)
.child(trigger)
.child(collapsible_content);
content = content.child(item_div);
if index < item_count - 1 {
let separator_key = format!("accordion-sep-{}", item_key);
content = content.child(
div().w_full().h(1.0).bg(border_color).animate_bounds(
blinc_layout::visual_animation::VisualAnimationConfig::position()
.with_key(&separator_key)
.snappy(),
),
);
}
}
container.merge(content);
});
let mut inner = accordion_stateful;
for c in &self.classes {
inner = inner.class(c);
}
if let Some(ref id) = self.user_id {
inner = inner.id(id);
}
Accordion { inner }
}
}
impl Default for AccordionBuilder {
fn default() -> Self {
Self::new()
}
}
impl ElementBuilder for AccordionBuilder {
fn build(&self, tree: &mut LayoutTree) -> LayoutNodeId {
self.get_or_build().build(tree)
}
fn render_props(&self) -> RenderProps {
self.get_or_build().render_props()
}
fn children_builders(&self) -> &[Box<dyn ElementBuilder>] {
self.get_or_build().children_builders()
}
fn element_type_id(&self) -> ElementTypeId {
self.get_or_build().element_type_id()
}
fn layout_style(&self) -> Option<&taffy::Style> {
self.get_or_build().layout_style()
}
fn visual_animation_config(
&self,
) -> Option<blinc_layout::visual_animation::VisualAnimationConfig> {
self.get_or_build().visual_animation_config()
}
fn element_classes(&self) -> &[String] {
self.get_or_build().element_classes()
}
fn element_id(&self) -> Option<&str> {
self.get_or_build().element_id()
}
}
pub fn accordion() -> AccordionBuilder {
AccordionBuilder::new()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_accordion_mode_default() {
assert_eq!(AccordionMode::default(), AccordionMode::Single);
}
}