logicaffeine_web/ui/components/
collapsible.rs1use 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
69fn 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#[component]
82pub fn Collapsible(
83 #[props(into)]
85 title: String,
86 open: Signal<bool>,
88 #[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 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;