Skip to main content

gpui_base/
accordion.rs

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