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#[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#[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#[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#[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#[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 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}