Skip to main content

logicaffeine_web/ui/components/
collapsible.rs

1//! A collapsible block: a titled section of a Studio panel that the reader opens and closes.
2//!
3//! Its heading is a button — the drawn chevron, then the title in the panel-title type — that
4//! says whether the block is expanded and names the region it controls. A closed block is its
5//! heading alone and opens wherever it is clicked; an open block closes from its heading, never
6//! from its content. Its content stays in the page while it is closed, hidden, so whatever state
7//! the content keeps survives a close.
8//!
9//! The owner keeps the open state, so the reader's choice outlives the block's content coming and
10//! going:
11//!
12//! ```no_run
13//! # use dioxus::prelude::*;
14//! use logicaffeine_web::ui::components::collapsible::Collapsible;
15//!
16//! # fn Example() -> Element {
17//! let open = use_signal(|| false);
18//! rsx! {
19//!     Collapsible { title: "Syntax Tree", open, p { "…" } }
20//! }
21//! # }
22//! ```
23
24use dioxus::prelude::*;
25use crate::ui::components::chevron::{Chevron, CHEVRON_STYLE};
26
27const COLLAPSIBLE_STYLE: &str = r#"
28/* A section of its panel set off by a hairline, as the panel's other parts are — not a card. */
29.collapsible {
30    border-top: 1px solid var(--studio-border, rgba(255, 255, 255, 0.08));
31}
32
33.collapsible:not(.open) {
34    cursor: pointer;
35}
36
37/* The heading is the block's whole row, the chevron at the panel's inset and the title beside it;
38   a closed block is this row alone. */
39.collapsible-heading {
40    display: flex;
41    align-items: center;
42    gap: 8px;
43    width: 100%;
44    min-height: 44px;
45    margin: 0;
46    padding: 0 var(--studio-inset, 20px);
47    border: none;
48    background: transparent;
49    font: inherit;
50    text-align: left;
51    cursor: pointer;
52}
53
54.collapsible-heading:hover {
55    background: rgba(255, 255, 255, 0.04);
56}
57
58.collapsible-heading:focus-visible {
59    outline: 2px solid var(--studio-accent, #667eea);
60    outline-offset: -2px;
61}
62
63/* The content holds the panel's inset on either side and below. */
64.collapsible-body {
65    padding: 0 var(--studio-inset, 20px) var(--studio-inset, 20px);
66}
67"#;
68
69/// The words of `title`, lowercased and joined by hyphens: "Symbol Dictionary" names its parts
70/// `symbol-dictionary-heading` and `symbol-dictionary-body`.
71fn slug(title: &str) -> String {
72    title
73        .split(|c: char| !c.is_alphanumeric())
74        .filter(|word| !word.is_empty())
75        .map(str::to_lowercase)
76        .collect::<Vec<_>>()
77        .join("-")
78}
79
80/// A titled block of a panel that the reader opens and closes.
81#[component]
82pub fn Collapsible(
83    /// What the block holds, named in the panel-title type.
84    #[props(into)]
85    title: String,
86    /// Whether the block is open. The owner keeps it.
87    open: Signal<bool>,
88    /// Names the heading (`{id}-heading`) and the region it controls (`{id}-body`); the title's
89    /// slug unless given, so two blocks with one title on a page need one each.
90    #[props(default, into)]
91    id: Option<String>,
92    children: Element,
93) -> Element {
94    let mut open = open;
95    let is_open = open();
96    let id = id.unwrap_or_else(|| slug(&title));
97
98    rsx! {
99        style { "{CHEVRON_STYLE}" }
100        style { "{COLLAPSIBLE_STYLE}" }
101        section {
102            class: if is_open { "collapsible open" } else { "collapsible" },
103            // Closed, the block opens wherever it is pressed — its hairline too.
104            onclick: move |_| {
105                if !open() {
106                    open.set(true);
107                }
108            },
109            button {
110                r#type: "button",
111                id: "{id}-heading",
112                class: "collapsible-heading",
113                aria_expanded: "{is_open}",
114                aria_controls: "{id}-body",
115                onclick: move |evt| {
116                    evt.stop_propagation();
117                    let now = open();
118                    open.set(!now);
119                },
120                Chevron { open: is_open }
121                span { class: "collapsible-title panel-title", "{title}" }
122            }
123            div {
124                id: "{id}-body",
125                class: "collapsible-body",
126                role: "region",
127                aria_labelledby: "{id}-heading",
128                hidden: !is_open,
129                {children}
130            }
131        }
132    }
133}
134
135#[cfg(test)]
136#[path = "../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/components/collapsible/tests.rs"]
137mod tests;