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