Skip to main content

herogpui_components/
disclosure.rs

1//! Disclosure & DisclosureGroup — HeroUI v3 `Disclosure`.
2//!
3//! A single collapsible section: a trigger and a `p-2` body, which is all v3's
4//! `disclosure.css` gives it -- not an `Accordion` with one item, which is what
5//! this used to render. `DisclosureGroup` keeps the accordion, whose group
6//! behaviour (single or multiple expansion) it needs.
7
8use std::collections::HashSet;
9
10use gpui::{
11    px, AnyElement, App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce,
12    SharedString, Styled, Window,
13};
14use herogpui_core::element_id;
15use herogpui_theme::ActiveTheme;
16
17use crate::a11y::{self, A11y as _};
18
19/// The values HeroUI passes to a Disclosure children render function.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21#[non_exhaustive]
22pub struct DisclosureRenderState {
23    /// Whether the disclosure is expanded.
24    pub is_expanded: bool,
25    /// Whether the disclosure is disabled.
26    pub is_disabled: bool,
27}
28
29type DisclosureContent = std::sync::Arc<dyn Fn(DisclosureRenderState) -> AnyElement + 'static>;
30
31/// Single Disclosure — like an accordion with one item.
32#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
33#[derive(IntoElement)]
34pub struct Disclosure {
35    id: ElementId,
36    title: SharedString,
37    is_expanded: Option<bool>,
38    default_expanded: bool,
39    is_disabled: bool,
40    children: Vec<AnyElement>,
41    content: Option<DisclosureContent>,
42    on_toggle: Option<std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
43    /// The `sx` slot, refined over the root style at the end of render.
44    sx: Option<Box<gpui::StyleRefinement>>,
45}
46
47impl Disclosure {
48    /// `onExpandedChange` — reports the expansion the press moves to.
49    pub fn on_expanded_change(
50        mut self,
51        handler: impl Fn(&bool, &mut Window, &mut App) + 'static,
52    ) -> Self {
53        self.on_toggle = Some(std::sync::Arc::new(handler));
54        self
55    }
56
57    /// Creates a disclosure with the given id and title.
58    pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
59        Self {
60            id: id.into(),
61            title: title.into(),
62            is_expanded: None,
63            default_expanded: false,
64            is_disabled: false,
65            children: Vec::new(),
66            content: None,
67            on_toggle: None,
68            sx: None,
69        }
70    }
71
72    /// Sets whether the disclosure is expanded.
73    pub fn is_expanded(mut self, v: bool) -> Self {
74        self.is_expanded = Some(v);
75        self
76    }
77
78    /// `defaultExpanded` — seeds the disclosure's own expansion state.
79    pub fn default_expanded(mut self, v: bool) -> Self {
80        self.default_expanded = v;
81        self
82    }
83
84    /// Sets whether the disclosure is disabled.
85    pub fn is_disabled(mut self, v: bool) -> Self {
86        self.is_disabled = v;
87        self
88    }
89
90    /// `children` as a render function — replaces static body children and is
91    /// handed the current expanded and disabled state on every render.
92    pub fn content(
93        mut self,
94        render: impl Fn(DisclosureRenderState) -> AnyElement + 'static,
95    ) -> Self {
96        self.content = Some(std::sync::Arc::new(render));
97        self
98    }
99
100    /// The one slot for caller-owned low-level styling: GPUI's styling methods
101    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
102    /// applied to the disclosure's root element after every value the trigger
103    /// variant and the active theme chose, so they win.
104    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
105        crate::util::refine_sx(&mut self.sx, style);
106        self
107    }
108}
109
110impl ParentElement for Disclosure {
111    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
112        self.children.extend(elements);
113    }
114}
115
116impl RenderOnce for Disclosure {
117    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
118        // A v3 Disclosure is not a small Accordion: `.disclosure` is `relative`,
119        // its trigger is whatever the caller passes (`<Button slot="trigger">`
120        // in v3's own examples) with a `.disclosure__indicator` chevron, and
121        // `.disclosure__body` is `p-2`. Rendering it as a one-item accordion
122        // gave it a card, a 16px trigger row and a separator that v3's sheet
123        // has no rule for.
124        let (expanded, expanded_own) = crate::util::controlled(
125            window,
126            cx,
127            element_id::scoped(&self.id, "expanded"),
128            self.is_expanded,
129            self.default_expanded,
130        );
131        let cb = self.on_toggle.clone();
132        let indicator_id = element_id::scoped(&self.id, "indicator");
133        let children = match self.content {
134            Some(render) => vec![render(DisclosureRenderState {
135                is_expanded: expanded,
136                is_disabled: self.is_disabled,
137            })],
138            None => self.children,
139        };
140        // `.disclosure__trigger` is `inline-block` with the focus ring on it;
141        // v3 passes a Button, which is what this builds.
142        let trigger = crate::button::Button::new(element_id::scoped(&self.id, "trigger"))
143        .variant(if expanded {
144            herogpui_core::Variant::Secondary
145        } else {
146            herogpui_core::Variant::Tertiary
147        })
148        .label(self.title.clone())
149        .is_disabled(self.is_disabled)
150        // `.disclosure__indicator` is `ms-auto size-4` and rotates one
151        // down-chevron through 180 degrees over 250ms when the panel opens.
152        .child(crate::anim::rotating_indicator(
153            &indicator_id,
154            expanded,
155            gpui::svg()
156                .size(px(16.))
157                .path(crate::icons::CHEVRON_DOWN)
158                .flex_shrink_0()
159                .text_color(cx.colors().muted),
160            window,
161            cx,
162        ))
163        .on_press(move |_, w, cx| {
164            if let Some(held) = &expanded_own {
165                held.update(cx, |expanded, cx| {
166                    *expanded = !*expanded;
167                    cx.notify();
168                });
169            }
170            if let Some(f) = &cb {
171                f(&!expanded, w, cx);
172            }
173        });
174
175        let mut el = gpui::div()
176            .id(self.id.clone())
177            .relative()
178            .flex()
179            .flex_col()
180            .child(trigger);
181        // v3 transitions measured height and opacity. The shared collapsible
182        // helper retains the body for the 200ms closing phase and measures its
183        // natural height before driving the clipped outer panel.
184        let panel_id = element_id::scoped(&self.id, "panel");
185        let panel = gpui::div()
186            // `DisclosureBody` is an RAC `DisclosurePanel`
187            // (`disclosure/disclosure.js`), whose role is `group` and whose
188            // accessible name is the trigger (`Disclosure.mjs`:
189            // `role: role = 'group'`; `useDisclosure.js`:
190            // `'aria-labelledby': triggerId`). The trigger's own
191            // `aria-expanded` is carried by the caller-visible Button.
192            .id(panel_id.clone())
193            .a11y_named(a11y::Role::Group, &a11y::Name::labelled(self.title.clone()));
194        let body = gpui::div()
195            // `.disclosure__body` is `p-2`.
196            .p(px(8.))
197            .flex()
198            .flex_col()
199            .gap(px(6.))
200            .children(children)
201            .into_any_element();
202        if let Some(panel) =
203            crate::anim::collapsible_panel(&panel_id, expanded, panel, body, window, cx)
204        {
205            el = el.child(panel);
206        }
207        crate::util::apply_sx(el, &self.sx).into_any_element()
208    }
209}
210
211/// Group of Disclosures — mirrors Accordion but with `Disclosure` naming.
212#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
213#[derive(IntoElement)]
214pub struct DisclosureGroup {
215    id: ElementId,
216    items: Vec<(SharedString, SharedString, Vec<AnyElement>)>,
217    expanded: Option<HashSet<SharedString>>,
218    default_expanded: HashSet<SharedString>,
219    allows_multiple_expanded: bool,
220    is_disabled: bool,
221    on_expanded_change:
222        Option<std::sync::Arc<dyn Fn(&HashSet<SharedString>, &mut Window, &mut App) + 'static>>,
223    /// The `sx` slot, refined over the root style at the end of render.
224    sx: Option<Box<gpui::StyleRefinement>>,
225}
226
227impl DisclosureGroup {
228    /// Creates an empty group with the given id.
229    pub fn new(id: impl Into<ElementId>) -> Self {
230        Self {
231            id: id.into(),
232            items: Vec::new(),
233            expanded: None,
234            default_expanded: HashSet::new(),
235            allows_multiple_expanded: false,
236            is_disabled: false,
237            on_expanded_change: None,
238            sx: None,
239        }
240    }
241
242    /// Adds an item with the given key, title and content.
243    pub fn item(
244        mut self,
245        key: impl Into<SharedString>,
246        title: impl Into<SharedString>,
247        content: impl IntoElement,
248    ) -> Self {
249        self.items
250            .push((key.into(), title.into(), vec![content.into_any_element()]));
251        self
252    }
253
254    /// `expandedKeys` — the caller-owned expanded set.
255    pub fn expanded_keys(
256        mut self,
257        keys: impl IntoIterator<Item = impl Into<SharedString>>,
258    ) -> Self {
259        self.expanded = Some(keys.into_iter().map(Into::into).collect());
260        self
261    }
262
263    /// `defaultExpandedKeys` — seeds the group's own expanded set.
264    pub fn default_expanded_keys(
265        mut self,
266        keys: impl IntoIterator<Item = impl Into<SharedString>>,
267    ) -> Self {
268        self.default_expanded = keys.into_iter().map(Into::into).collect();
269        self
270    }
271
272    /// `allowsMultipleExpanded` — otherwise opening one item closes the rest.
273    pub fn allows_multiple_expanded(mut self, v: bool) -> Self {
274        self.allows_multiple_expanded = v;
275        self
276    }
277
278    /// `isDisabled` — disables every disclosure in the group.
279    pub fn is_disabled(mut self, v: bool) -> Self {
280        self.is_disabled = v;
281        self
282    }
283
284    /// `onExpandedChange` — reports the complete next expanded set.
285    pub fn on_expanded_change(
286        mut self,
287        f: impl Fn(&HashSet<SharedString>, &mut Window, &mut App) + 'static,
288    ) -> Self {
289        self.on_expanded_change = Some(std::sync::Arc::new(f));
290        self
291    }
292
293    /// The one slot for caller-owned low-level styling: GPUI's styling methods
294    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
295    /// applied to the group's root element after every value the expansion
296    /// state and the active theme chose, so they win.
297    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
298        crate::util::refine_sx(&mut self.sx, style);
299        self
300    }
301}
302
303impl RenderOnce for DisclosureGroup {
304    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
305        let (mut expanded, expanded_own) = crate::util::controlled(
306            window,
307            cx,
308            element_id::scoped(&self.id, "expanded"),
309            self.expanded,
310            self.default_expanded,
311        );
312        if !self.allows_multiple_expanded && expanded_own.is_some() && expanded.len() > 1 {
313            let retained = self
314                .items
315                .iter()
316                .find_map(|(key, _, _)| expanded.contains(key).then(|| key.clone()));
317            let next = retained.into_iter().collect::<HashSet<_>>();
318            if let Some(held) = &expanded_own {
319                let held_next = next.clone();
320                held.update(cx, |expanded, cx| {
321                    *expanded = held_next;
322                    cx.notify();
323                });
324            }
325            if let Some(callback) = self.on_expanded_change.clone() {
326                let reported = next.clone();
327                window.defer(cx, move |window, cx| callback(&reported, window, cx));
328            }
329            expanded = next;
330        }
331        // `.disclosure-group` is `w-full` and nothing else: v3's group *is* a
332        // column of `Disclosure`s, one of which may be open, so this renders
333        // them rather than an `Accordion` -- which would give every row the
334        // card, the padded trigger and the separator the sheet has no rule for.
335        let mut el = gpui::div().id(self.id.clone()).w_full().flex().flex_col();
336        for (key, title, children) in self.items {
337            let is_expanded = expanded.contains(&key);
338            let mut disclosure = Disclosure::new(key.clone(), title)
339                .is_expanded(is_expanded)
340                .is_disabled(self.is_disabled)
341                .children(children);
342            if expanded_own.is_some() || self.on_expanded_change.is_some() {
343                let current = expanded.clone();
344                let own = expanded_own.clone();
345                let cb = self.on_expanded_change.clone();
346                let allows_multiple = self.allows_multiple_expanded;
347                disclosure = disclosure.on_expanded_change(move |_next, window, cx| {
348                    let next = crate::accordion::next_expanded(&current, &key, allows_multiple);
349                    if let Some(held) = &own {
350                        let held_next = next.clone();
351                        held.update(cx, |expanded, cx| {
352                            *expanded = held_next;
353                            cx.notify();
354                        });
355                    }
356                    if let Some(f) = &cb {
357                        f(&next, window, cx);
358                    }
359                });
360            }
361            el = el.child(disclosure);
362        }
363        crate::util::apply_sx(el, &self.sx).into_any_element()
364    }
365}
366
367crate::util::impl_component_styled!(Disclosure, DisclosureGroup);