Skip to main content

gpui_base/
accordion.rs

1use std::rc::Rc;
2
3use gpui::{
4    AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, Interactivity, IntoElement,
5    ParentElement, RenderOnce, Role, StatefulInteractiveElement, StyleRefinement, Styled, Window,
6    div, prelude::FluentBuilder as _,
7};
8use smallvec::SmallVec;
9
10use crate::StyledExt as _;
11
12type ChangeHandler = Rc<dyn Fn(bool, &ClickEvent, &mut Window, &mut App)>;
13
14/// An unstyled accordion root for application-owned items.
15#[derive(IntoElement)]
16pub struct Accordion {
17    base: gpui::Stateful<Div>,
18    style: StyleRefinement,
19    children: SmallVec<[AnyElement; 2]>,
20}
21
22impl Accordion {
23    pub fn new(id: impl Into<ElementId>) -> Self {
24        Self {
25            base: div().id(id),
26            style: StyleRefinement::default(),
27            children: SmallVec::new(),
28        }
29    }
30}
31
32impl Styled for Accordion {
33    fn style(&mut self) -> &mut StyleRefinement {
34        &mut self.style
35    }
36}
37
38impl ParentElement for Accordion {
39    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
40        self.children.extend(elements);
41    }
42}
43
44impl InteractiveElement for Accordion {
45    fn interactivity(&mut self) -> &mut Interactivity {
46        self.base.interactivity()
47    }
48}
49
50impl StatefulInteractiveElement for Accordion {}
51
52impl RenderOnce for Accordion {
53    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
54        self.base
55            .role(Role::Group)
56            .children(self.children)
57            .refine_style(&self.style)
58    }
59}
60
61/// An unstyled accordion item connecting one trigger with its panel content.
62#[derive(IntoElement)]
63pub struct AccordionItem {
64    base: Div,
65    style: StyleRefinement,
66    open: bool,
67    disabled: bool,
68    header: Option<AccordionHeader>,
69    panel: Option<AccordionPanel>,
70    children: SmallVec<[AnyElement; 2]>,
71}
72
73impl AccordionItem {
74    pub fn new() -> Self {
75        Self {
76            base: div(),
77            style: StyleRefinement::default(),
78            open: false,
79            disabled: false,
80            header: None,
81            panel: None,
82            children: SmallVec::new(),
83        }
84    }
85    pub fn open(mut self, open: bool) -> Self {
86        self.open = open;
87        self
88    }
89
90    pub fn disabled(mut self, disabled: bool) -> Self {
91        self.disabled = disabled;
92        self
93    }
94
95    pub fn header(mut self, header: AccordionHeader) -> Self {
96        self.header = Some(header);
97        self
98    }
99
100    pub fn panel(mut self, panel: AccordionPanel) -> Self {
101        self.panel = Some(panel);
102        self
103    }
104}
105
106impl Styled for AccordionItem {
107    fn style(&mut self) -> &mut StyleRefinement {
108        &mut self.style
109    }
110}
111
112impl ParentElement for AccordionItem {
113    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
114        self.children.extend(elements);
115    }
116}
117
118impl RenderOnce for AccordionItem {
119    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
120        self.base
121            .when_some(self.header, |this, header| {
122                this.child(header.open(self.open).disabled(self.disabled))
123            })
124            .when_some(self.panel, |this, panel| this.child(panel.open(self.open)))
125            .children(self.children)
126            .refine_style(&self.style)
127    }
128}
129
130/// An unstyled heading that owns the trigger for one accordion item.
131#[derive(IntoElement)]
132pub struct AccordionHeader {
133    id: Option<ElementId>,
134    style: StyleRefinement,
135    level: usize,
136    trigger: AccordionTrigger,
137    children: SmallVec<[AnyElement; 1]>,
138}
139
140impl AccordionHeader {
141    pub fn new(trigger: AccordionTrigger) -> Self {
142        Self {
143            id: None,
144            style: StyleRefinement::default(),
145            level: 3,
146            trigger,
147            children: SmallVec::new(),
148        }
149    }
150    pub fn level(mut self, level: usize) -> Self {
151        self.level = level;
152        self
153    }
154
155    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
156        self.id = Some(id.into());
157        self
158    }
159    fn open(mut self, open: bool) -> Self {
160        self.trigger = self.trigger.open(open);
161        self
162    }
163
164    fn disabled(mut self, disabled: bool) -> Self {
165        self.trigger = self.trigger.disabled(disabled);
166        self
167    }
168}
169
170impl Styled for AccordionHeader {
171    fn style(&mut self) -> &mut StyleRefinement {
172        &mut self.style
173    }
174}
175
176impl ParentElement for AccordionHeader {
177    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
178        self.children.extend(elements);
179    }
180}
181
182impl RenderOnce for AccordionHeader {
183    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
184        let content = div()
185            .child(self.trigger)
186            .children(self.children)
187            .refine_style(&self.style);
188        match self.id {
189            Some(id) => content
190                .id(id)
191                .role(Role::Heading)
192                .aria_level(self.level)
193                .into_any_element(),
194            None => content.into_any_element(),
195        }
196    }
197}
198
199/// An unstyled accordion panel with controlled mounting.
200#[derive(IntoElement)]
201pub struct AccordionPanel {
202    id: Option<ElementId>,
203    style: StyleRefinement,
204    open: bool,
205    keep_mounted: bool,
206    children: SmallVec<[AnyElement; 2]>,
207}
208
209impl AccordionPanel {
210    pub fn new() -> Self {
211        Self {
212            id: None,
213            style: StyleRefinement::default(),
214            open: false,
215            keep_mounted: false,
216            children: SmallVec::new(),
217        }
218    }
219
220    pub fn open(mut self, open: bool) -> Self {
221        self.open = open;
222        self
223    }
224
225    pub fn keep_mounted(mut self, keep_mounted: bool) -> Self {
226        self.keep_mounted = keep_mounted;
227        self
228    }
229
230    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
231        self.id = Some(id.into());
232        self
233    }
234}
235
236impl Styled for AccordionPanel {
237    fn style(&mut self) -> &mut StyleRefinement {
238        &mut self.style
239    }
240}
241
242impl ParentElement for AccordionPanel {
243    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
244        self.children.extend(elements);
245    }
246}
247
248impl RenderOnce for AccordionPanel {
249    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
250        if !self.open && !self.keep_mounted {
251            return gpui::Empty.into_any_element();
252        }
253
254        let content = div().children(self.children).refine_style(&self.style);
255        match self.id {
256            Some(id) => content.id(id).role(Role::Region).into_any_element(),
257            None => content.into_any_element(),
258        }
259    }
260}
261
262/// An unstyled accordion trigger with controlled expanded state.
263///
264/// The application owns the title, disclosure icon, content, layout, and
265/// animation. Activating the trigger requests the opposite of `open`.
266#[derive(IntoElement)]
267pub struct AccordionTrigger {
268    base: gpui::Stateful<Div>,
269    style: StyleRefinement,
270    children: SmallVec<[AnyElement; 2]>,
271    open: bool,
272    disabled: bool,
273    on_change: Option<ChangeHandler>,
274}
275
276impl AccordionTrigger {
277    pub fn new(id: impl Into<ElementId>) -> Self {
278        Self {
279            base: div().id(id),
280            style: StyleRefinement::default(),
281            children: SmallVec::new(),
282            open: false,
283            disabled: false,
284            on_change: None,
285        }
286    }
287
288    pub fn open(mut self, open: bool) -> Self {
289        self.open = open;
290        self
291    }
292
293    pub fn disabled(mut self, disabled: bool) -> Self {
294        self.disabled = disabled;
295        self
296    }
297
298    /// Handles a requested change to the controlled expanded state.
299    pub fn on_change(
300        mut self,
301        handler: impl Fn(bool, &ClickEvent, &mut Window, &mut App) + 'static,
302    ) -> Self {
303        self.on_change = Some(Rc::new(handler));
304        self
305    }
306}
307
308impl Styled for AccordionTrigger {
309    fn style(&mut self) -> &mut StyleRefinement {
310        &mut self.style
311    }
312}
313
314impl ParentElement for AccordionTrigger {
315    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
316        self.children.extend(elements);
317    }
318}
319
320impl InteractiveElement for AccordionTrigger {
321    fn interactivity(&mut self) -> &mut Interactivity {
322        self.base.interactivity()
323    }
324}
325
326impl StatefulInteractiveElement for AccordionTrigger {}
327
328impl RenderOnce for AccordionTrigger {
329    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
330        let next_open = !self.open;
331
332        self.base
333            .role(Role::Button)
334            .aria_expanded(self.open)
335            .when_some(
336                (!self.disabled).then_some(self.on_change).flatten(),
337                move |this, on_change| {
338                    this.on_click(move |event, window, cx| on_change(next_open, event, window, cx))
339                },
340            )
341            .children(self.children)
342            .refine_style(&self.style)
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use std::{cell::RefCell, rc::Rc};
350
351    use gpui::{
352        Context, Element as _, Modifiers, Render, Role, VisualTestContext, accesskit, canvas,
353        point, px,
354    };
355
356    #[gpui::test]
357    fn trigger_projects_expanded_accessibility_state(cx: &mut gpui::TestAppContext) {
358        let requested = Rc::new(RefCell::new(Vec::new()));
359        let captured = Rc::new(RefCell::new(None));
360
361        struct Probe {
362            requested: Rc<RefCell<Vec<bool>>>,
363            captured: Rc<RefCell<Option<accesskit::Node>>>,
364        }
365
366        impl Render for Probe {
367            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
368                let requested = self.requested.clone();
369                let captured = self.captured.clone();
370                canvas(
371                    move |_, window, cx| {
372                        let mut node = accesskit::Node::new(Role::Button);
373                        AccordionTrigger::new("trigger")
374                            .open(true)
375                            .on_change(move |open, _, _, _| requested.borrow_mut().push(open))
376                            .render(window, cx)
377                            .into_element()
378                            .write_a11y_info(&mut node);
379                        *captured.borrow_mut() = Some(node);
380                    },
381                    |_, _, _, _| {},
382                )
383            }
384        }
385
386        let (_, window) = cx.add_window_view({
387            let requested = requested.clone();
388            let captured = captured.clone();
389            move |_, _| Probe {
390                requested,
391                captured,
392            }
393        });
394        window.update(|window, cx| window.draw(cx).clear(cx));
395
396        let node = captured.borrow_mut().take().unwrap();
397        assert_eq!(node.role(), Role::Button);
398        assert_eq!(node.is_expanded(), Some(true));
399
400        assert!(requested.borrow().is_empty());
401    }
402
403    struct TriggerHarness {
404        open: bool,
405        disabled: bool,
406        requested: Rc<RefCell<Vec<bool>>>,
407    }
408
409    impl Render for TriggerHarness {
410        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
411            let requested = self.requested.clone();
412            AccordionTrigger::new("trigger")
413                .open(self.open)
414                .disabled(self.disabled)
415                .size(px(100.))
416                .on_change(move |open, _, _, _| requested.borrow_mut().push(open))
417        }
418    }
419
420    fn harness(
421        cx: &mut gpui::TestAppContext,
422        open: bool,
423        disabled: bool,
424    ) -> (&mut VisualTestContext, Rc<RefCell<Vec<bool>>>) {
425        let requested = Rc::new(RefCell::new(Vec::new()));
426        let (_, cx) = cx.add_window_view({
427            let requested = requested.clone();
428            move |_, _| TriggerHarness {
429                open,
430                disabled,
431                requested,
432            }
433        });
434        cx.update(|window, cx| window.draw(cx).clear(cx));
435        (cx, requested)
436    }
437
438    #[gpui::test]
439    fn pointer_requests_next_controlled_state_and_respects_disabled(cx: &mut gpui::TestAppContext) {
440        let (cx, requested) = harness(cx, false, false);
441        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
442        assert_eq!(&*requested.borrow(), &[true]);
443
444        let (cx, requested) = harness(cx, true, true);
445        cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
446        assert!(requested.borrow().is_empty());
447    }
448
449    #[gpui::test]
450    fn header_and_panel_project_structural_roles(cx: &mut gpui::TestAppContext) {
451        let captured = Rc::new(RefCell::new(Vec::new()));
452
453        struct Probe(Rc<RefCell<Vec<accesskit::Node>>>);
454
455        impl Render for Probe {
456            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
457                let captured = self.0.clone();
458                canvas(
459                    move |_, window, cx| {
460                        let mut header_node = accesskit::Node::new(Role::Heading);
461                        AccordionHeader::new(AccordionTrigger::new("trigger"))
462                            .id("header")
463                            .level(2)
464                            .render(window, cx)
465                            .into_element()
466                            .write_a11y_info(&mut header_node);
467
468                        let mut panel_node = accesskit::Node::new(Role::Region);
469                        AccordionPanel::new()
470                            .id("panel")
471                            .open(true)
472                            .render(window, cx)
473                            .into_element()
474                            .write_a11y_info(&mut panel_node);
475                        *captured.borrow_mut() = vec![header_node, panel_node];
476                    },
477                    |_, _, _, _| {},
478                )
479            }
480        }
481
482        let (_, view) = cx.add_window_view({
483            let captured = captured.clone();
484            move |_, _| Probe(captured)
485        });
486        view.update(|window, cx| window.draw(cx).clear(cx));
487        let nodes = captured.borrow();
488        assert_eq!(nodes[0].role(), Role::Heading);
489        assert_eq!(nodes[1].role(), Role::Region);
490    }
491}