1use std::rc::Rc;
2
3use gpui::{
4 AnyElement, App, ClickEvent, Div, ElementId, InteractiveElement, Interactivity, IntoElement,
5 MouseButton, ParentElement, Refineable as _, RenderOnce, Role, SharedString,
6 StatefulInteractiveElement, StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _,
7 relative,
8};
9use smallvec::SmallVec;
10
11use crate::{StateStyle, StyledExt as _, TestSupportExt as _, a11y_disabled::A11yDisabledExt as _};
12
13type ClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
14
15#[derive(IntoElement)]
23pub struct Tab {
24 id: ElementId,
25 base: Div,
26 style: StyleRefinement,
27 semantic_styles: TabStyles,
28 selected: bool,
29 disabled: bool,
30 children: SmallVec<[AnyElement; 2]>,
31 on_click: Option<ClickHandler>,
32 accessibility_label: Option<SharedString>,
33 position_in_set: Option<usize>,
34 size_of_set: Option<usize>,
35}
36
37impl Tab {
38 pub fn new(id: impl Into<ElementId>) -> Self {
39 Self {
40 id: id.into(),
41 base: div(),
42 style: StyleRefinement::default(),
43 semantic_styles: TabStyles::default(),
44 selected: false,
45 disabled: false,
46 children: SmallVec::new(),
47 on_click: None,
48 accessibility_label: None,
49 position_in_set: None,
50 size_of_set: None,
51 }
52 }
53
54 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
56 self.id = id.into();
57 self
58 }
59
60 pub fn selected(mut self, selected: bool) -> Self {
61 self.selected = selected;
62 self
63 }
64
65 pub fn disabled(mut self, disabled: bool) -> Self {
66 self.disabled = disabled;
67 self
68 }
69
70 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
71 self.accessibility_label = Some(label.into());
72 self
73 }
74
75 pub fn set_position(mut self, position: usize, size: usize) -> Self {
78 self.position_in_set = Some(position);
79 self.size_of_set = Some(size);
80 self
81 }
82
83 pub fn on_click(
84 mut self,
85 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
86 ) -> Self {
87 self.on_click = Some(Rc::new(handler));
88 self
89 }
90
91 pub fn styles(mut self, build: impl FnOnce(TabStyles) -> TabStyles) -> Self {
92 self.semantic_styles = build(self.semantic_styles);
93 self
94 }
95
96 fn resolved_style(&self) -> StyleRefinement {
97 crate::state_style::resolve_style(
98 &self.style,
99 [
100 self.selected.then_some(&self.semantic_styles.selected),
101 self.disabled.then_some(&self.semantic_styles.disabled),
102 ]
103 .into_iter()
104 .flatten(),
105 )
106 }
107}
108
109#[derive(Default)]
110pub struct TabStyles {
111 selected: StyleRefinement,
112 disabled: StyleRefinement,
113}
114
115impl TabStyles {
116 pub fn selected(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
117 self.selected
118 .refine(&build(StateStyle::default()).into_refinement());
119 self
120 }
121
122 pub fn disabled(mut self, build: impl FnOnce(StateStyle) -> StateStyle) -> Self {
123 self.disabled
124 .refine(&build(StateStyle::default()).into_refinement());
125 self
126 }
127}
128
129impl Styled for Tab {
130 fn style(&mut self) -> &mut StyleRefinement {
131 &mut self.style
132 }
133}
134
135impl ParentElement for Tab {
136 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
137 self.children.extend(elements);
138 }
139}
140
141impl InteractiveElement for Tab {
142 fn interactivity(&mut self) -> &mut Interactivity {
143 self.base.interactivity()
144 }
145}
146
147impl StatefulInteractiveElement for Tab {}
148
149impl RenderOnce for Tab {
150 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
151 let disabled = self.disabled;
152 let style = self.resolved_style();
153
154 self.base
155 .id(self.id)
156 .test_support()
157 .role(Role::Tab)
158 .flex()
162 .items_center()
163 .justify_center()
164 .line_height(relative(1.))
165 .when_some(self.accessibility_label, |this, label| {
166 this.aria_label(label)
167 })
168 .aria_selected(self.selected)
169 .when_some(self.position_in_set, |this, position| {
170 this.aria_position_in_set(position)
171 })
172 .when_some(self.size_of_set, |this, size| this.aria_size_of_set(size))
173 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
174 .when_some(
175 (!disabled).then_some(self.on_click).flatten(),
176 |this, on_click| {
177 this.on_click(move |event, window, cx| on_click(event, window, cx))
178 },
179 )
180 .children(self.children)
181 .refine_style(&style)
182 .a11y_disabled(disabled)
183 }
184}
185
186#[derive(IntoElement)]
192pub struct Tabs {
193 base: gpui::Stateful<Div>,
194 style: StyleRefinement,
195 children: SmallVec<[AnyElement; 2]>,
196}
197
198impl Tabs {
199 pub fn new(id: impl Into<ElementId>) -> Self {
200 Self {
201 base: div().id(id),
202 style: StyleRefinement::default(),
203 children: SmallVec::new(),
204 }
205 }
206}
207
208impl Styled for Tabs {
209 fn style(&mut self) -> &mut StyleRefinement {
210 &mut self.style
211 }
212}
213
214impl ParentElement for Tabs {
215 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
216 self.children.extend(elements);
217 }
218}
219
220impl InteractiveElement for Tabs {
221 fn interactivity(&mut self) -> &mut Interactivity {
222 self.base.interactivity()
223 }
224}
225
226impl StatefulInteractiveElement for Tabs {}
227
228impl RenderOnce for Tabs {
229 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
230 self.base
231 .role(Role::TabList)
232 .children(self.children)
233 .refine_style(&self.style)
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use crate::ElementExt as _;
241 use std::{
242 cell::Cell,
243 rc::Rc,
244 sync::{Arc, Mutex},
245 };
246
247 use gpui::{
248 Context, Element as _, Modifiers, Render, Role, VisualTestContext, accesskit, canvas, hsla,
249 point, px,
250 };
251
252 struct TabHarness {
253 disabled: bool,
254 clicks: Rc<Cell<usize>>,
255 }
256
257 impl Render for TabHarness {
258 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
259 let clicks = self.clicks.clone();
260 Tab::new("tab")
261 .disabled(self.disabled)
262 .size(px(100.))
263 .on_click(move |_, _, _| clicks.set(clicks.get() + 1))
264 }
265 }
266
267 fn harness(
268 cx: &mut gpui::TestAppContext,
269 disabled: bool,
270 ) -> (&mut VisualTestContext, Rc<Cell<usize>>) {
271 let clicks = Rc::new(Cell::new(0));
272 let (_, cx) = cx.add_window_view({
273 let clicks = clicks.clone();
274 move |_, _| TabHarness { disabled, clicks }
275 });
276 cx.update(|window, cx| window.draw(cx).clear(cx));
277 (cx, clicks)
278 }
279
280 #[gpui::test]
281 fn pointer_activation_and_disabled_gating_match_tabs(cx: &mut gpui::TestAppContext) {
282 let (cx, clicks) = harness(cx, false);
283 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
284 assert_eq!(clicks.get(), 1);
285
286 let (cx, clicks) = harness(cx, true);
287 cx.simulate_click(point(px(10.), px(10.)), Modifiers::default());
288 assert_eq!(clicks.get(), 0);
289 }
290
291 #[gpui::test]
292 fn fixed_height_tab_centers_ordinary_child_geometry(cx: &mut gpui::TestAppContext) {
293 type Captured = Arc<
294 Mutex<(
295 Option<gpui::Bounds<gpui::Pixels>>,
296 Option<gpui::Bounds<gpui::Pixels>>,
297 )>,
298 >;
299
300 struct AlignmentProbe(Captured);
301
302 impl Render for AlignmentProbe {
303 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
304 let root_capture = self.0.clone();
305 let child_capture = self.0.clone();
306 Tab::new("alignment-tab")
307 .w(px(120.))
308 .h(px(40.))
309 .child(
310 div()
311 .w(px(48.))
312 .h(px(12.))
313 .on_prepaint(move |bounds, _, _| {
314 child_capture.lock().unwrap().1 = Some(bounds);
315 }),
316 )
317 .on_prepaint(move |bounds, _, _| {
318 root_capture.lock().unwrap().0 = Some(bounds);
319 })
320 }
321 }
322
323 let captured = Arc::new(Mutex::new((None, None)));
324 let (_, context) = cx.add_window_view({
325 let captured = captured.clone();
326 move |_, _| AlignmentProbe(captured)
327 });
328 context.update(|window, cx| window.draw(cx).clear(cx));
329
330 let (root, child) = *captured.lock().unwrap();
331 assert_eq!(
332 child.expect("child bounds").center(),
333 root.expect("tab bounds").center()
334 );
335 }
336
337 #[gpui::test]
338 fn exposes_tab_accessibility_state(cx: &mut gpui::TestAppContext) {
339 type Captured = Arc<Mutex<Option<(accesskit::Node, accesskit::Node)>>>;
340
341 struct Probe(Captured);
342
343 impl Render for Probe {
344 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
345 let captured = self.0.clone();
346 canvas(
347 move |_, window, cx| {
348 let mut info = |tab: Tab| {
349 let mut node = accesskit::Node::new(Role::Tab);
350 tab.render(window, cx)
351 .into_element()
352 .write_a11y_info(&mut node);
353 node
354 };
355 let selected = info(
356 Tab::new("selected")
357 .selected(true)
358 .accessibility_label("Account")
359 .on_click(|_, _, _| {}),
360 );
361 let disabled =
362 info(Tab::new("disabled").disabled(true).on_click(|_, _, _| {}));
363 *captured.lock().unwrap() = Some((selected, disabled));
364 },
365 |_, _, _, _| {},
366 )
367 }
368 }
369
370 let captured: Captured = Arc::new(Mutex::new(None));
371 let result = captured.clone();
372 let (_, cx) = cx.add_window_view(move |_, _| Probe(captured));
373 cx.update(|window, cx| window.draw(cx).clear(cx));
374 let (selected, disabled) = result.lock().unwrap().take().unwrap();
375
376 assert_eq!(selected.role(), Role::Tab);
377 assert_eq!(selected.label(), Some("Account"));
378 assert_eq!(selected.is_selected(), Some(true));
379 assert!(selected.supports_action(accesskit::Action::Click));
380 assert!(!disabled.supports_action(accesskit::Action::Click));
381 }
382
383 #[gpui::test]
384 fn semantic_styles_preserve_the_legacy_state_priority(_cx: &mut gpui::TestAppContext) {
385 let expected = hsla(0.3, 0.4, 0.5, 1.0);
386 let mut tab = Tab::new("tab")
387 .selected(true)
388 .disabled(true)
389 .styles(|styles| {
390 styles
391 .selected(|style| style.opacity(0.8))
392 .disabled(|style| style.opacity(0.5))
393 })
394 .opacity(0.9);
395
396 assert_eq!(tab.resolved_style().opacity, Some(0.5));
397 tab.style().background = Some(expected.into());
398 assert_eq!(tab.resolved_style().background, Some(expected.into()));
399 }
400
401 #[gpui::test]
402 fn tabs_exposes_tab_list_role(cx: &mut gpui::TestAppContext) {
403 type Captured = Arc<Mutex<Option<accesskit::Node>>>;
404 struct Probe(Captured);
405
406 impl Render for Probe {
407 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
408 let captured = self.0.clone();
409 canvas(
410 move |_, window, cx| {
411 let mut node = accesskit::Node::new(Role::TabList);
412 Tabs::new("tabs")
413 .render(window, cx)
414 .into_element()
415 .write_a11y_info(&mut node);
416 *captured.lock().unwrap() = Some(node);
417 },
418 |_, _, _, _| {},
419 )
420 }
421 }
422
423 let captured: Captured = Arc::new(Mutex::new(None));
424 let result = captured.clone();
425 let (_, cx) = cx.add_window_view(move |_, _| Probe(captured));
426 cx.update(|window, cx| window.draw(cx).clear(cx));
427 assert_eq!(result.lock().unwrap().take().unwrap().role(), Role::TabList);
428 }
429}