Skip to main content

gpui_base/
toolbar.rs

1use gpui::{
2    AnyElement, App, Axis, Div, ElementId, FocusHandle, InteractiveElement, Interactivity,
3    IntoElement, KeyDownEvent, ParentElement, RenderOnce, Role, SharedString, Stateful,
4    StatefulInteractiveElement, StyleRefinement, Styled, Window, accesskit, div,
5    prelude::FluentBuilder as _,
6};
7use smallvec::SmallVec;
8
9use crate::{StyledExt as _, TestSupportExt as _};
10
11/// An unstyled container that groups a set of controls and owns roving
12/// keyboard focus among them.
13///
14/// This is the behavior primitive behind a styled toolbar. The container
15/// exposes `Toolbar` semantics to assistive technology (`Role::Toolbar` with
16/// an orientation) and moves focus between its focusable descendants with the
17/// arrow keys, so applications do not have to reimplement the roving-focus
18/// contract per toolbar. It works with any focusable children — buttons,
19/// menu triggers, inputs — because traversal walks the rendered tab stops
20/// and constrains them to this subtree, mirroring how `Root` constrains
21/// focus-trap cycling.
22///
23/// Keyboard contract:
24///
25/// - Left and Right move focus to the previous or next focusable descendant,
26///   wrapping around at either end (the same default as Base UI's `loopFocus`).
27///   A vertical toolbar ([`Toolbar::axis`]) uses Up and Down instead and
28///   reports a vertical orientation.
29/// - When `disabled`, the arrow keys do nothing. Hosted controls must be
30///   disabled by their owner; the flag only suppresses the toolbar's own
31///   navigation.
32///
33/// The container is not itself a tab stop, so ordinary `Tab` traversal enters
34/// and leaves the toolbar through its items, matching the ARIA toolbar
35/// pattern.
36///
37/// An input hosted inside the toolbar keeps its own arrow-key behavior: text
38/// inputs consume the arrow keys for caret movement before the toolbar sees
39/// them. Place inputs at the trailing end of a horizontal toolbar, as the
40/// Base UI Toolbar recommends.
41#[derive(IntoElement)]
42pub struct Toolbar {
43    id: ElementId,
44    base: crate::ObservedElement<Stateful<Div>>,
45    style: StyleRefinement,
46    disabled: bool,
47    axis: Axis,
48    children: SmallVec<[AnyElement; 4]>,
49}
50
51impl Toolbar {
52    pub fn new(id: impl Into<ElementId>) -> Self {
53        let id = id.into();
54        Self {
55            base: div().id(id.clone()).test_support(),
56            style: StyleRefinement::default(),
57            disabled: false,
58            axis: Axis::Horizontal,
59            children: SmallVec::new(),
60            id,
61        }
62    }
63
64    /// Disables the toolbar's own keyboard navigation. Hosted controls are
65    /// not automatically disabled.
66    pub fn disabled(mut self, disabled: bool) -> Self {
67        self.disabled = disabled;
68        self
69    }
70
71    /// The direction the items run: horizontal (Left/Right, the default) or
72    /// vertical (Up/Down). Assistive technology hears the same orientation.
73    pub fn axis(mut self, axis: Axis) -> Self {
74        self.axis = axis;
75        self
76    }
77}
78
79fn step_focus(window: &mut Window, cx: &mut App, forward: bool) {
80    if forward {
81        window.focus_next(cx);
82    } else {
83        window.focus_prev(cx);
84    }
85}
86
87/// Move focus to the next (or previous) focusable element inside `container`.
88///
89/// Walk the window's tab order in the asked direction, passing over stops
90/// outside the container, and land on the first stop inside it. The tab order
91/// wraps around the window, so past the container's last stop the walk comes
92/// back in at its first however many stops the rest of the window holds, and a
93/// stop that a tab index places between two of the container's own is passed
94/// over like any other. GPUI's tab order can cycle short of the whole window,
95/// so the walk ends at the first repeated stop and focus goes back where it
96/// started: a container with no other reachable stop keeps focus where it is.
97fn move_focus(container: &FocusHandle, forward: bool, window: &mut Window, cx: &mut App) {
98    let Some(start) = window.focused(cx) else {
99        return;
100    };
101
102    let mut visited = vec![start.clone()];
103    loop {
104        step_focus(window, cx, forward);
105        let Some(focused) = window.focused(cx) else {
106            break;
107        };
108        if visited.contains(&focused) {
109            break;
110        }
111        if container.contains_focused(window, cx) {
112            return;
113        }
114        visited.push(focused);
115    }
116    window.focus(&start, cx);
117}
118
119fn handle_key_down(
120    disabled: bool,
121    axis: Axis,
122    container: &FocusHandle,
123    event: &KeyDownEvent,
124    window: &mut Window,
125    cx: &mut App,
126) {
127    if disabled {
128        return;
129    }
130
131    let forward = match (axis, event.keystroke.key.as_str()) {
132        (Axis::Horizontal, "left") | (Axis::Vertical, "up") => Some(false),
133        (Axis::Horizontal, "right") | (Axis::Vertical, "down") => Some(true),
134        _ => None,
135    };
136
137    if let Some(forward) = forward {
138        move_focus(container, forward, window, cx);
139        cx.stop_propagation();
140    }
141}
142
143impl Styled for Toolbar {
144    fn style(&mut self) -> &mut StyleRefinement {
145        &mut self.style
146    }
147}
148
149impl ParentElement for Toolbar {
150    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
151        self.children.extend(elements);
152    }
153}
154
155impl InteractiveElement for Toolbar {
156    fn interactivity(&mut self) -> &mut Interactivity {
157        self.base.interactivity()
158    }
159}
160
161impl StatefulInteractiveElement for Toolbar {}
162
163impl RenderOnce for Toolbar {
164    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
165        let Self {
166            base,
167            style,
168            disabled,
169            axis,
170            children,
171            ..
172        } = self;
173
174        // The handle must survive across frames so containment checks and the
175        // key handler refer to the same dispatch-tree node; button.rs uses
176        // the same keyed-state pattern for its own focus handle.
177        let focus_handle = window
178            .use_keyed_state(self.id.clone(), cx, |_, cx| {
179                cx.focus_handle().tab_stop(false)
180            })
181            .read(cx)
182            .clone();
183        let key_handler = {
184            let focus_handle = focus_handle.clone();
185            move |event: &KeyDownEvent, window: &mut Window, cx: &mut App| {
186                handle_key_down(disabled, axis, &focus_handle, event, window, cx);
187            }
188        };
189
190        base.track_focus(&focus_handle)
191            .role(Role::Toolbar)
192            .aria_orientation(match axis {
193                Axis::Horizontal => accesskit::Orientation::Horizontal,
194                Axis::Vertical => accesskit::Orientation::Vertical,
195            })
196            .on_key_down(key_handler)
197            // The handle exists for containment checks only. Without this, a
198            // press on blank space or non-focusable content would focus the
199            // toolbar itself and take focus away from the rest of the window.
200            // Focusable items handle the press first and keep their focus.
201            .on_any_mouse_down(|_, window, _| window.prevent_default())
202            .children(children)
203            .refine_style(&style)
204    }
205}
206
207/// A semantic subgroup of items within a [`Toolbar`].
208///
209/// The group carries no behavior or styling of its own: the surrounding
210/// toolbar's roving arrow-key focus traverses its items exactly like the
211/// toolbar's direct children, because containment follows the element tree.
212/// Its value is structure — assistive technology announces the group and its
213/// accessible name, so a run of related controls reads as one unit ("Undo",
214/// "Redo" inside a "History" group).
215///
216/// Unlike Base UI's `Toolbar.Group`, the group cannot disable its children.
217/// That API propagates through React context into Base UI's own button
218/// primitives; GPUI composition offers no equivalent for arbitrary children,
219/// and the platform a11y layer exposes no disabled state for a container
220/// node. Disabling the hosted controls is the group owner's job.
221#[derive(IntoElement)]
222pub struct ToolbarGroup {
223    base: Stateful<Div>,
224    style: StyleRefinement,
225    label: Option<SharedString>,
226    children: SmallVec<[AnyElement; 4]>,
227}
228
229impl ToolbarGroup {
230    pub fn new(id: impl Into<ElementId>) -> Self {
231        Self {
232            base: div().id(id.into()),
233            style: StyleRefinement::default(),
234            label: None,
235            children: SmallVec::new(),
236        }
237    }
238
239    /// Sets the accessible name announced for the group, e.g. "History".
240    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
241        self.label = Some(label.into());
242        self
243    }
244}
245
246impl Styled for ToolbarGroup {
247    fn style(&mut self) -> &mut StyleRefinement {
248        &mut self.style
249    }
250}
251
252impl ParentElement for ToolbarGroup {
253    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
254        self.children.extend(elements);
255    }
256}
257
258impl InteractiveElement for ToolbarGroup {
259    fn interactivity(&mut self) -> &mut Interactivity {
260        self.base.interactivity()
261    }
262}
263
264impl StatefulInteractiveElement for ToolbarGroup {}
265
266impl RenderOnce for ToolbarGroup {
267    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
268        // A group is one inline segment of the bar, so its children flow
269        // along the row and center on the bar's cross axis; the same neutral
270        // geometry `Tab` applies. Spacing between items is the caller's
271        // (matching the bar's own gap).
272        self.base
273            .flex()
274            .items_center()
275            .role(Role::Group)
276            .when_some(self.label, |this, label| this.aria_label(label))
277            .children(self.children)
278            .refine_style(&self.style)
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_toolbar_builder() {
288        let toolbar = Toolbar::new("toolbar").disabled(true).child(div());
289
290        assert!(toolbar.disabled);
291        assert_eq!(toolbar.children.len(), 1);
292    }
293
294    #[test]
295    fn test_toolbar_defaults() {
296        let toolbar = Toolbar::new("toolbar");
297
298        assert!(!toolbar.disabled);
299        assert_eq!(toolbar.axis, Axis::Horizontal);
300        assert!(toolbar.children.is_empty());
301        assert_eq!(
302            Toolbar::new("toolbar").axis(Axis::Vertical).axis,
303            Axis::Vertical
304        );
305    }
306
307    #[test]
308    fn test_toolbar_group_builder() {
309        let group = ToolbarGroup::new("history-group")
310            .label("History")
311            .child(div())
312            .child(div());
313
314        assert_eq!(group.label.as_deref(), Some("History"));
315        assert_eq!(group.children.len(), 2);
316    }
317
318    #[cfg(test)]
319    mod behavior {
320        use super::*;
321        use gpui::{
322            Context, Element as _, FocusHandle, Modifiers, Render, TestAppContext,
323            VisualTestContext, canvas, point, px,
324        };
325        use std::sync::{Arc, Mutex};
326
327        struct NavHarness {
328            items: [FocusHandle; 3],
329        }
330
331        impl Render for NavHarness {
332            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
333                let [first, second, third] = &self.items;
334                Toolbar::new("nav-toolbar")
335                    .child(div().id("first").size(px(20.)).track_focus(first))
336                    .child(div().id("second").size(px(20.)).track_focus(second))
337                    .child(div().id("third").size(px(20.)).track_focus(third))
338            }
339        }
340
341        fn harness(cx: &mut TestAppContext) -> ([FocusHandle; 3], &mut VisualTestContext) {
342            let (state, cx) = cx.add_window_view(move |window, cx| {
343                let items = [
344                    cx.focus_handle().tab_stop(true),
345                    cx.focus_handle().tab_stop(true),
346                    cx.focus_handle().tab_stop(true),
347                ];
348                items[0].focus(window, cx);
349                NavHarness { items }
350            });
351            let items = state.read_with(cx, |harness, _| harness.items.clone());
352            cx.update(|window, cx| {
353                window.draw(cx).clear(cx);
354            });
355            (items, cx)
356        }
357
358        fn assert_focused(cx: &mut VisualTestContext, expected: &FocusHandle, label: &str) {
359            cx.update(|window, _| {
360                assert!(
361                    expected.is_focused(window),
362                    "expected {label} to be focused"
363                );
364            });
365        }
366
367        #[gpui::test]
368        fn arrow_keys_rove_focus_across_items(cx: &mut gpui::TestAppContext) {
369            let ([first, second, third], cx) = harness(cx);
370
371            cx.simulate_keystrokes("right");
372            assert_focused(cx, &second, "second");
373            cx.simulate_keystrokes("right");
374            assert_focused(cx, &third, "third");
375
376            // Wrapping: past the last item, focus returns to the first.
377            cx.simulate_keystrokes("right");
378            assert_focused(cx, &first, "first (wrapped)");
379
380            cx.simulate_keystrokes("left");
381            assert_focused(cx, &third, "third (wrapped back)");
382        }
383
384        #[gpui::test]
385        fn a_vertical_toolbar_roves_with_up_and_down(cx: &mut TestAppContext) {
386            struct Vertical([FocusHandle; 3]);
387
388            impl Render for Vertical {
389                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
390                    let [first, second, third] = &self.0;
391                    Toolbar::new("vertical-toolbar")
392                        .axis(Axis::Vertical)
393                        .child(div().id("first").size(px(20.)).track_focus(first))
394                        .child(div().id("second").size(px(20.)).track_focus(second))
395                        .child(div().id("third").size(px(20.)).track_focus(third))
396                }
397            }
398
399            let (state, cx) = cx.add_window_view(|window, cx| {
400                let items = [(); 3].map(|_| cx.focus_handle().tab_stop(true));
401                items[0].focus(window, cx);
402                Vertical(items)
403            });
404            let [first, second, third] = state.read_with(cx, |harness, _| harness.0.clone());
405            cx.update(|window, cx| window.draw(cx).clear(cx));
406
407            cx.simulate_keystrokes("right");
408            assert_focused(cx, &first, "first (Right is not a vertical key)");
409            cx.simulate_keystrokes("down");
410            assert_focused(cx, &second, "second");
411            cx.simulate_keystrokes("down down");
412            assert_focused(cx, &first, "first (wrapped)");
413            cx.simulate_keystrokes("up");
414            assert_focused(cx, &third, "third (wrapped back)");
415        }
416
417        #[gpui::test]
418        fn arrow_keys_wrap_inside_the_toolbar_among_many_page_stops(cx: &mut TestAppContext) {
419            // More than the hundred hops the search used to give up after.
420            const STOPS: usize = 120;
421
422            struct BusyPage {
423                before: Vec<FocusHandle>,
424                items: [FocusHandle; 3],
425                after: Vec<FocusHandle>,
426            }
427
428            impl Render for BusyPage {
429                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
430                    let [first, second, third] = &self.items;
431                    div()
432                        .flex()
433                        .flex_col()
434                        .children(self.before.iter().enumerate().map(|(at, handle)| {
435                            div().id(("before", at)).size(px(1.)).track_focus(handle)
436                        }))
437                        .child(
438                            Toolbar::new("busy-toolbar")
439                                .child(div().id("first").size(px(20.)).track_focus(first))
440                                .child(div().id("second").size(px(20.)).track_focus(second))
441                                .child(div().id("third").size(px(20.)).track_focus(third)),
442                        )
443                        .children(self.after.iter().enumerate().map(|(at, handle)| {
444                            div().id(("after", at)).size(px(1.)).track_focus(handle)
445                        }))
446                }
447            }
448
449            let (state, cx) = cx.add_window_view(|window, cx| {
450                let stop = |cx: &mut Context<BusyPage>| cx.focus_handle().tab_stop(true);
451                let page = BusyPage {
452                    before: (0..STOPS).map(|_| stop(cx)).collect(),
453                    items: [stop(cx), stop(cx), stop(cx)],
454                    after: (0..STOPS).map(|_| stop(cx)).collect(),
455                };
456                page.items[0].focus(window, cx);
457                page
458            });
459            let [first, second, third] = state.read_with(cx, |page, _| page.items.clone());
460            cx.update(|window, cx| {
461                window.draw(cx).clear(cx);
462            });
463
464            cx.simulate_keystrokes("left");
465            assert_focused(cx, &third, "third (Left from the first)");
466            cx.simulate_keystrokes("right");
467            assert_focused(cx, &first, "first (Right from the last)");
468            cx.simulate_keystrokes("right");
469            assert_focused(cx, &second, "second (one step right)");
470            cx.simulate_keystrokes("left");
471            assert_focused(cx, &first, "first (one step left)");
472        }
473
474        #[gpui::test]
475        fn arrow_keys_wrap_across_more_than_a_hundred_items(cx: &mut TestAppContext) {
476            const ITEMS: usize = 120;
477
478            struct LargeToolbar {
479                outside: FocusHandle,
480                items: Vec<FocusHandle>,
481            }
482
483            impl Render for LargeToolbar {
484                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
485                    div()
486                        .child(div().id("outside").size(px(1.)).track_focus(&self.outside))
487                        .child(Toolbar::new("large-toolbar").children(
488                            self.items.iter().enumerate().map(|(at, handle)| {
489                                div().id(("item", at)).size(px(1.)).track_focus(handle)
490                            }),
491                        ))
492                }
493            }
494
495            let (state, cx) = cx.add_window_view(|window, cx| {
496                let page = LargeToolbar {
497                    outside: cx.focus_handle().tab_stop(true),
498                    items: (0..ITEMS)
499                        .map(|_| cx.focus_handle().tab_stop(true))
500                        .collect(),
501                };
502                page.items[0].focus(window, cx);
503                page
504            });
505            let items = state.read_with(cx, |page, _| page.items.clone());
506            cx.update(|window, cx| {
507                window.draw(cx).clear(cx);
508            });
509
510            cx.simulate_keystrokes("left");
511            assert_focused(cx, &items[ITEMS - 1], "the last item (Left from the first)");
512            cx.simulate_keystrokes("right");
513            assert_focused(cx, &items[0], "the first item (Right from the last)");
514        }
515
516        #[gpui::test]
517        fn arrow_keys_pass_over_stops_ordered_between_the_items(cx: &mut TestAppContext) {
518            struct InterleavedPage {
519                items: [FocusHandle; 3],
520                outside: [FocusHandle; 2],
521            }
522
523            impl Render for InterleavedPage {
524                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
525                    let [first, second, third] = &self.items;
526                    let [before, after] = &self.outside;
527                    div()
528                        .child(
529                            Toolbar::new("interleaved-toolbar")
530                                .child(div().id("first").size(px(20.)).track_focus(first))
531                                .child(div().id("second").size(px(20.)).track_focus(second))
532                                .child(div().id("third").size(px(20.)).track_focus(third)),
533                        )
534                        .child(div().id("before").size(px(20.)).track_focus(before))
535                        .child(div().id("after").size(px(20.)).track_focus(after))
536                }
537            }
538
539            // The tab order runs first, before, second, after, third: each page stop's
540            // tab index puts it between two of the toolbar's items.
541            let (state, cx) = cx.add_window_view(|window, cx| {
542                let stop = |index, cx: &mut Context<InterleavedPage>| {
543                    cx.focus_handle().tab_stop(true).tab_index(index)
544                };
545                let page = InterleavedPage {
546                    items: [stop(1, cx), stop(3, cx), stop(5, cx)],
547                    outside: [stop(2, cx), stop(4, cx)],
548                };
549                page.items[0].focus(window, cx);
550                page
551            });
552            let [first, second, third] = state.read_with(cx, |page, _| page.items.clone());
553            cx.update(|window, cx| {
554                window.draw(cx).clear(cx);
555            });
556
557            cx.simulate_keystrokes("right");
558            assert_focused(cx, &second, "second (Right from the first)");
559            cx.simulate_keystrokes("right");
560            assert_focused(cx, &third, "third (Right from the second)");
561            cx.simulate_keystrokes("right");
562            assert_focused(cx, &first, "first (Right from the last)");
563            cx.simulate_keystrokes("left");
564            assert_focused(cx, &third, "third (Left from the first)");
565        }
566
567        #[gpui::test]
568        fn arrow_keys_keep_focus_when_no_other_item_is_a_tab_stop(cx: &mut TestAppContext) {
569            struct Unreachable {
570                item: FocusHandle,
571                outside: [FocusHandle; 2],
572            }
573
574            impl Render for Unreachable {
575                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
576                    let [before, after] = &self.outside;
577                    div()
578                        .child(div().id("before").size(px(20.)).track_focus(before))
579                        .child(
580                            Toolbar::new("unreachable-toolbar")
581                                .child(div().id("item").size(px(20.)).track_focus(&self.item)),
582                        )
583                        .child(div().id("after").size(px(20.)).track_focus(after))
584                }
585            }
586
587            // The focused item is not a tab stop, so the walk never comes back to
588            // it: only the page's own stops repeat.
589            let (state, cx) = cx.add_window_view(|window, cx| {
590                let page = Unreachable {
591                    item: cx.focus_handle(),
592                    outside: [
593                        cx.focus_handle().tab_stop(true),
594                        cx.focus_handle().tab_stop(true),
595                    ],
596                };
597                page.item.focus(window, cx);
598                page
599            });
600            let item = state.read_with(cx, |page, _| page.item.clone());
601            cx.update(|window, cx| {
602                window.draw(cx).clear(cx);
603            });
604
605            cx.simulate_keystrokes("right left");
606            assert_focused(cx, &item, "the item (no other stop in the toolbar)");
607        }
608
609        #[gpui::test]
610        fn toolbar_with_a_single_item_keeps_focus_on_it(cx: &mut gpui::TestAppContext) {
611            struct SingleHarness {
612                item: FocusHandle,
613            }
614
615            impl Render for SingleHarness {
616                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
617                    let item = &self.item;
618                    Toolbar::new("single-toolbar")
619                        .child(div().id("only").size(px(20.)).track_focus(item))
620                }
621            }
622
623            let (state, cx) = cx.add_window_view(|window, cx| {
624                let item = cx.focus_handle().tab_stop(true);
625                item.focus(window, cx);
626                SingleHarness { item }
627            });
628            let item = state.read_with(cx, |harness, _| harness.item.clone());
629            cx.update(|window, cx| {
630                window.draw(cx).clear(cx);
631            });
632
633            cx.simulate_keystrokes("right left right");
634            assert_focused(cx, &item, "the only item");
635        }
636
637        #[gpui::test]
638        fn snapshot_reports_the_toolbar_role_label_and_focus(cx: &mut gpui::TestAppContext) {
639            struct Labelled(FocusHandle);
640
641            impl Render for Labelled {
642                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
643                    Toolbar::new("formatting")
644                        .aria_label("Formatting")
645                        .child(div().id("bold").size(px(20.)).track_focus(&self.0))
646                }
647            }
648
649            let (_, cx) = cx.add_window_view(|window, cx| {
650                let item = cx.focus_handle().tab_stop(true);
651                item.focus(window, cx);
652                Labelled(item)
653            });
654            cx.update(|window, cx| {
655                window.draw(cx).clear(cx);
656                let snapshot = crate::test_support::find(window, &[], &"formatting".into())
657                    .expect("the toolbar is observed");
658                assert_eq!(snapshot.role(), Some(Role::Toolbar));
659                assert_eq!(snapshot.label(), Some("Formatting"));
660                // The toolbar's handle is not a tab stop; it reports focus
661                // while one of its items holds it.
662                assert_eq!(snapshot.focused(), Some(true));
663            });
664        }
665
666        #[gpui::test]
667        fn group_exposes_group_role_and_accessible_name(cx: &mut gpui::TestAppContext) {
668            type Captured = Arc<Mutex<Option<accesskit::Node>>>;
669
670            struct Probe(Captured);
671
672            impl Render for Probe {
673                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
674                    let captured = self.0.clone();
675                    canvas(
676                        move |_, window, cx| {
677                            let mut node = accesskit::Node::new(Role::Group);
678                            ToolbarGroup::new("history")
679                                .label("History")
680                                .child(div().size(px(20.)))
681                                .render(window, cx)
682                                .into_element()
683                                .write_a11y_info(&mut node);
684                            *captured.lock().unwrap() = Some(node);
685                        },
686                        |_, _, _, _| {},
687                    )
688                }
689            }
690
691            let captured: Captured = Arc::new(Mutex::new(None));
692            let result = captured.clone();
693            let (_, cx) = cx.add_window_view(move |_, _| Probe(captured));
694            cx.update(|window, cx| window.draw(cx).clear(cx));
695            let node = result.lock().unwrap().take().unwrap();
696
697            assert_eq!(node.role(), Role::Group);
698            assert_eq!(node.label(), Some("History"));
699        }
700
701        #[gpui::test]
702        fn disabled_toolbar_ignores_arrow_keys(cx: &mut gpui::TestAppContext) {
703            struct DisabledHarness {
704                items: [FocusHandle; 2],
705            }
706
707            impl Render for DisabledHarness {
708                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
709                    let [first, second] = &self.items;
710                    Toolbar::new("disabled-toolbar")
711                        .disabled(true)
712                        .child(div().id("first").size(px(20.)).track_focus(first))
713                        .child(div().id("second").size(px(20.)).track_focus(second))
714                }
715            }
716
717            let (state, cx) = cx.add_window_view(|window, cx| {
718                let items = [
719                    cx.focus_handle().tab_stop(true),
720                    cx.focus_handle().tab_stop(true),
721                ];
722                items[0].focus(window, cx);
723                DisabledHarness { items }
724            });
725            let items = state.read_with(cx, |harness, _| harness.items.clone());
726            cx.update(|window, cx| {
727                window.draw(cx).clear(cx);
728            });
729
730            cx.simulate_keystrokes("right");
731            assert_focused(cx, &items[0], "first (disabled toolbar)");
732        }
733
734        #[gpui::test]
735        fn clicking_blank_toolbar_space_keeps_focus_outside(cx: &mut gpui::TestAppContext) {
736            struct ClickHarness {
737                outside: FocusHandle,
738                item: FocusHandle,
739            }
740
741            impl Render for ClickHarness {
742                fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
743                    div()
744                        .child(div().id("outside").size(px(20.)).track_focus(&self.outside))
745                        .child(
746                            Toolbar::new("click-toolbar")
747                                .flex()
748                                .w(px(100.))
749                                .h(px(20.))
750                                .child(div().id("item").size(px(20.)).track_focus(&self.item)),
751                        )
752                }
753            }
754
755            let (state, cx) = cx.add_window_view(|window, cx| {
756                let outside = cx.focus_handle().tab_stop(true);
757                outside.focus(window, cx);
758                ClickHarness {
759                    outside,
760                    item: cx.focus_handle().tab_stop(true),
761                }
762            });
763            let (outside, item) = state.read_with(cx, |harness, _| {
764                (harness.outside.clone(), harness.item.clone())
765            });
766            cx.update(|window, cx| {
767                window.draw(cx).clear(cx);
768            });
769
770            // Blank space to the right of the item: focus stays where it was.
771            cx.simulate_click(point(px(80.), px(30.)), Modifiers::default());
772            assert_focused(cx, &outside, "outside (blank toolbar click)");
773
774            // A focusable item still takes focus when clicked.
775            cx.simulate_click(point(px(10.), px(30.)), Modifiers::default());
776            assert_focused(cx, &item, "item (clicked)");
777        }
778    }
779}