nightshade-api 0.51.0

Procedural high level API for the nightshade game engine
Documentation
//! Collapsible content components: a standalone [`Disclosure`] and an [`Accordion`] whose
//! items reveal one section at a time.

use leptos::prelude::*;

/// A collapsible section with a clickable `title` header that shows or hides its
/// `children`. Open state is controlled by the optional `open` signal, otherwise it starts
/// from `default_open`.
#[component]
pub fn Disclosure(
    #[prop(into)] title: String,
    #[prop(optional)] open: Option<RwSignal<bool>>,
    #[prop(default = true)] default_open: bool,
    children: ChildrenFn,
) -> impl IntoView {
    let open = open.unwrap_or_else(|| RwSignal::new(default_open));
    let children = StoredValue::new(children);
    view! {
        <div class="nightshade-disclosure">
            <button
                class="nightshade-disclosure-header"
                aria-expanded=move || open.get().to_string()
                on:click=move |_| open.update(|value| *value = !*value)
            >
                <span class="nightshade-disclosure-caret" class:open=move || open.get()>
                    "\u{25b8}"
                </span>
                <span class="nightshade-disclosure-title">{title}</span>
            </button>
            <Show when=move || open.get() fallback=|| ()>
                <div class="nightshade-disclosure-body">{children.with_value(|render| render())}</div>
            </Show>
        </div>
    }
}

#[derive(Clone, Copy)]
struct AccordionContext(RwSignal<Option<String>>);

/// A container for [`AccordionItem`] children that keeps at most one item open at a time,
/// optionally starting with the item whose id matches `default_open`.
#[component]
pub fn Accordion(
    #[prop(into, optional)] default_open: Option<String>,
    children: Children,
) -> impl IntoView {
    let active = RwSignal::new(default_open);
    provide_context(AccordionContext(active));
    view! { <div class="nightshade-accordion">{children()}</div> }
}

/// A single collapsible section within an [`Accordion`], identified by `id` and headed by
/// `title`. Clicking its header opens it and closes any sibling that was open.
#[component]
pub fn AccordionItem(
    #[prop(into)] id: String,
    #[prop(into)] title: String,
    children: ChildrenFn,
) -> impl IntoView {
    let context = use_context::<AccordionContext>().map(|context| context.0);
    let id = StoredValue::new(id);
    let children = StoredValue::new(children);
    let is_open = move || {
        context.is_some_and(|active| active.get().as_deref() == Some(id.get_value().as_str()))
    };
    let toggle = move |_| {
        if let Some(active) = context {
            let key = id.get_value();
            active.update(|current| {
                *current = if current.as_deref() == Some(key.as_str()) {
                    None
                } else {
                    Some(key)
                };
            });
        }
    };
    view! {
        <div class="nightshade-disclosure">
            <button
                class="nightshade-disclosure-header"
                aria-expanded=move || is_open().to_string()
                on:click=toggle
            >
                <span class="nightshade-disclosure-caret" class:open=is_open>
                    "\u{25b8}"
                </span>
                <span class="nightshade-disclosure-title">{title}</span>
            </button>
            <Show when=is_open fallback=|| ()>
                <div class="nightshade-disclosure-body">{children.with_value(|render| render())}</div>
            </Show>
        </div>
    }
}