Skip to main content

nightshade_api/web/
disclosure.rs

1//! Collapsible content components: a standalone [`Disclosure`] and an [`Accordion`] whose
2//! items reveal one section at a time.
3
4use leptos::prelude::*;
5
6/// A collapsible section with a clickable `title` header that shows or hides its
7/// `children`. Open state is controlled by the optional `open` signal, otherwise it starts
8/// from `default_open`.
9#[component]
10pub fn Disclosure(
11    #[prop(into)] title: String,
12    #[prop(optional)] open: Option<RwSignal<bool>>,
13    #[prop(default = true)] default_open: bool,
14    children: ChildrenFn,
15) -> impl IntoView {
16    let open = open.unwrap_or_else(|| RwSignal::new(default_open));
17    let children = StoredValue::new(children);
18    view! {
19        <div class="nightshade-disclosure">
20            <button
21                class="nightshade-disclosure-header"
22                aria-expanded=move || open.get().to_string()
23                on:click=move |_| open.update(|value| *value = !*value)
24            >
25                <span class="nightshade-disclosure-caret" class:open=move || open.get()>
26                    "\u{25b8}"
27                </span>
28                <span class="nightshade-disclosure-title">{title}</span>
29            </button>
30            <Show when=move || open.get() fallback=|| ()>
31                <div class="nightshade-disclosure-body">{children.with_value(|render| render())}</div>
32            </Show>
33        </div>
34    }
35}
36
37#[derive(Clone, Copy)]
38struct AccordionContext(RwSignal<Option<String>>);
39
40/// A container for [`AccordionItem`] children that keeps at most one item open at a time,
41/// optionally starting with the item whose id matches `default_open`.
42#[component]
43pub fn Accordion(
44    #[prop(into, optional)] default_open: Option<String>,
45    children: Children,
46) -> impl IntoView {
47    let active = RwSignal::new(default_open);
48    provide_context(AccordionContext(active));
49    view! { <div class="nightshade-accordion">{children()}</div> }
50}
51
52/// A single collapsible section within an [`Accordion`], identified by `id` and headed by
53/// `title`. Clicking its header opens it and closes any sibling that was open.
54#[component]
55pub fn AccordionItem(
56    #[prop(into)] id: String,
57    #[prop(into)] title: String,
58    children: ChildrenFn,
59) -> impl IntoView {
60    let context = use_context::<AccordionContext>().map(|context| context.0);
61    let id = StoredValue::new(id);
62    let children = StoredValue::new(children);
63    let is_open = move || {
64        context.is_some_and(|active| active.get().as_deref() == Some(id.get_value().as_str()))
65    };
66    let toggle = move |_| {
67        if let Some(active) = context {
68            let key = id.get_value();
69            active.update(|current| {
70                *current = if current.as_deref() == Some(key.as_str()) {
71                    None
72                } else {
73                    Some(key)
74                };
75            });
76        }
77    };
78    view! {
79        <div class="nightshade-disclosure">
80            <button
81                class="nightshade-disclosure-header"
82                aria-expanded=move || is_open().to_string()
83                on:click=toggle
84            >
85                <span class="nightshade-disclosure-caret" class:open=is_open>
86                    "\u{25b8}"
87                </span>
88                <span class="nightshade-disclosure-title">{title}</span>
89            </button>
90            <Show when=is_open fallback=|| ()>
91                <div class="nightshade-disclosure-body">{children.with_value(|render| render())}</div>
92            </Show>
93        </div>
94    }
95}