Skip to main content

herogpui_components/
sidebar.rs

1//! `Sidebar` — a collapsible application sidebar (HeroGPUI extension; HeroUI
2//! v3 has no sidebar). The API follows gpui-kit's `Sidebar` and shadcn/ui's
3//! sidebar: a header, groups of menu items under optional labels, and a
4//! footer.
5//!
6//! - **Items.** Each [`SidebarItem`] has a label, an optional icon
7//!   ([`crate::IconName`] or any served SVG) and an optional badge. One item is
8//!   active (`active_key`, controlled, or `default_active_key`); pressing an
9//!   item, or Enter / Space on it, makes it active and reports it through
10//!   `on_select`.
11//! - **Groups.** A [`SidebarGroup`] may carry a label, and a collapsible group
12//!   turns its label into a disclosure trigger whose panel expands and
13//!   collapses with the same measured-height motion and rotating chevron as
14//!   `Disclosure`. Expanded groups are controlled (`expanded_keys`) or seeded
15//!   by each group's `default_expanded`.
16//! - **Collapse to icons.** `is_collapsed` (controlled) or `default_collapsed`
17//!   narrows the sidebar to `collapsed_width`, hides the labels and group
18//!   headings, lists every item as its icon and names it with a `Tooltip` to
19//!   the right, shown on hover and on keyboard focus. The built-in toggle in
20//!   the footer (`show_collapse_toggle`) flips it and reports through
21//!   `on_collapsed_change`.
22//! - **Keyboard.** The menu is one tab stop with a roving cursor: Up / Down /
23//!   Home / End move over the visible items and collapsible group headers,
24//!   skipping disabled items; Right expands and Left collapses a group from
25//!   its header; Enter and Space activate; typing a label's first letters
26//!   moves to it. The focus handle moves with the cursor row, which is what
27//!   lets a collapsed item's tooltip open on keyboard focus.
28//! - **Width.** `width` (256px) and `collapsed_width` (56px) are fixed. For a
29//!   user-resizable sidebar, put it in a `ResizablePanelGroup` panel; the
30//!   panel sizes are percentages, so there is no pixel-width drag built in.
31//!
32//! **Accessibility.** The root reports `Role::Navigation` named by `label`,
33//! each group `Role::Group` named by its label, a collapsible group's header
34//! `Role::Button` with `expanded`, and each item `Role::Link` named by its
35//! label, the cursor row marked as the active descendant. The active item's
36//! `aria-current="page"` is written through `A11y::a11y_current`, which the
37//! pinned gpui cannot express yet (a no-op, see `a11y.rs`).
38//!
39//! ```
40//! use herogpui_components::{IconName, Sidebar, SidebarGroup, SidebarItem};
41//!
42//! let sidebar = Sidebar::new(
43//!     "app-sidebar",
44//!     vec![
45//!         SidebarGroup::new("main").items(vec![
46//!             SidebarItem::new("inbox", "Inbox").icon(IconName::Inbox).badge("12"),
47//!             SidebarItem::new("drafts", "Drafts").icon(IconName::File),
48//!         ]),
49//!         SidebarGroup::new("projects")
50//!             .label("Projects")
51//!             .is_collapsible(true)
52//!             .items(vec![SidebarItem::new("web", "Website").icon(IconName::Globe)]),
53//!     ],
54//! )
55//! .default_active_key("inbox")
56//! .on_select(|key, _window, _cx| println!("go to {key}"));
57//! ```
58
59use std::collections::HashSet;
60use std::rc::Rc;
61use std::sync::Arc;
62
63use gpui::{
64    div, prelude::FluentBuilder as _, px, AnyElement, App, ElementId, InteractiveElement,
65    IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, SharedString,
66    StatefulInteractiveElement, Styled, Window,
67};
68use herogpui_core::element_id;
69use herogpui_theme::ActiveTheme;
70
71use crate::a11y::{self, A11y as _};
72use crate::{anim, util, IconName, Tooltip, TooltipPlacement};
73
74type KeyCallback = Arc<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>;
75type BoolCallback = Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>;
76type KeysCallback = Arc<dyn Fn(&HashSet<SharedString>, &mut Window, &mut App) + 'static>;
77
78/// The height of one menu item.
79const ITEM_HEIGHT: Pixels = px(32.);
80
81/// One menu item of a [`SidebarGroup`].
82#[must_use]
83pub struct SidebarItem {
84    key: SharedString,
85    label: SharedString,
86    icon: Option<SharedString>,
87    badge: Option<SharedString>,
88    is_disabled: bool,
89}
90
91impl SidebarItem {
92    /// An item `label` identified by `key`, unique within the sidebar.
93    pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
94        Self {
95            key: key.into(),
96            label: label.into(),
97            icon: None,
98            badge: None,
99            is_disabled: false,
100        }
101    }
102
103    /// The icon before the label, and the whole item when collapsed.
104    pub fn icon(mut self, icon: impl Into<SharedString>) -> Self {
105        self.icon = Some(icon.into());
106        self
107    }
108
109    /// A short trailing text (a count, "New"), hidden when collapsed.
110    pub fn badge(mut self, badge: impl Into<SharedString>) -> Self {
111        self.badge = Some(badge.into());
112        self
113    }
114
115    /// A disabled item renders dimmed and cannot be focused or activated.
116    pub fn is_disabled(mut self, disabled: bool) -> Self {
117        self.is_disabled = disabled;
118        self
119    }
120}
121
122/// A group of [`SidebarItem`]s under an optional label.
123#[must_use]
124pub struct SidebarGroup {
125    key: SharedString,
126    label: Option<SharedString>,
127    items: Vec<SidebarItem>,
128    is_collapsible: bool,
129    default_expanded: bool,
130}
131
132impl SidebarGroup {
133    /// A group identified by `key`, unique within the sidebar.
134    pub fn new(key: impl Into<SharedString>) -> Self {
135        Self {
136            key: key.into(),
137            label: None,
138            items: Vec::new(),
139            is_collapsible: false,
140            default_expanded: true,
141        }
142    }
143
144    /// The heading above the items.
145    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
146        self.label = Some(label.into());
147        self
148    }
149
150    /// Appends one item.
151    pub fn item(mut self, item: SidebarItem) -> Self {
152        self.items.push(item);
153        self
154    }
155
156    /// Appends items.
157    pub fn items(mut self, items: impl IntoIterator<Item = SidebarItem>) -> Self {
158        self.items.extend(items);
159        self
160    }
161
162    /// Makes the heading a disclosure trigger that expands and collapses the
163    /// items. A group without a label cannot collapse.
164    pub fn is_collapsible(mut self, collapsible: bool) -> Self {
165        self.is_collapsible = collapsible;
166        self
167    }
168
169    /// Whether a collapsible group starts expanded (the default) in an
170    /// uncontrolled sidebar.
171    pub fn default_expanded(mut self, expanded: bool) -> Self {
172        self.default_expanded = expanded;
173        self
174    }
175
176    fn collapses(&self) -> bool {
177        self.is_collapsible && self.label.is_some()
178    }
179}
180
181/// A collapsible application sidebar. See the [module docs](self).
182#[must_use]
183#[derive(IntoElement)]
184pub struct Sidebar {
185    id: ElementId,
186    groups: Vec<SidebarGroup>,
187    header: Vec<AnyElement>,
188    footer: Vec<AnyElement>,
189    label: SharedString,
190    active_key: Option<SharedString>,
191    is_active_controlled: bool,
192    default_active_key: Option<SharedString>,
193    is_collapsed: Option<bool>,
194    default_collapsed: bool,
195    expanded_keys: HashSet<SharedString>,
196    is_expansion_controlled: bool,
197    show_collapse_toggle: bool,
198    width: Pixels,
199    collapsed_width: Pixels,
200    is_disabled: bool,
201    on_select: Option<KeyCallback>,
202    on_collapsed_change: Option<BoolCallback>,
203    on_expanded_change: Option<KeysCallback>,
204    sx: Option<Box<gpui::StyleRefinement>>,
205}
206
207impl Sidebar {
208    /// A sidebar over `groups`. `id` keys its state; give every instance its
209    /// own.
210    pub fn new(id: impl Into<ElementId>, groups: Vec<SidebarGroup>) -> Self {
211        Self {
212            id: id.into(),
213            groups,
214            header: Vec::new(),
215            footer: Vec::new(),
216            label: SharedString::new_static("Sidebar"),
217            active_key: None,
218            is_active_controlled: false,
219            default_active_key: None,
220            is_collapsed: None,
221            default_collapsed: false,
222            expanded_keys: HashSet::new(),
223            is_expansion_controlled: false,
224            show_collapse_toggle: true,
225            width: px(256.),
226            collapsed_width: px(56.),
227            is_disabled: false,
228            on_select: None,
229            on_collapsed_change: None,
230            on_expanded_change: None,
231            sx: None,
232        }
233    }
234
235    /// Content above the menu (a logo, a workspace switcher). It is clipped,
236    /// not restyled, when the sidebar collapses; drive `is_collapsed` to adapt
237    /// it.
238    pub fn header(mut self, content: impl IntoElement) -> Self {
239        self.header.push(content.into_any_element());
240        self
241    }
242
243    /// Content below the menu, before the collapse toggle.
244    pub fn footer(mut self, content: impl IntoElement) -> Self {
245        self.footer.push(content.into_any_element());
246        self
247    }
248
249    /// The navigation landmark's accessible name; "Sidebar" by default.
250    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
251        self.label = label.into();
252        self
253    }
254
255    /// The controlled active item (`None` for none).
256    pub fn active_key(mut self, key: Option<SharedString>) -> Self {
257        self.active_key = key;
258        self.is_active_controlled = true;
259        self
260    }
261
262    /// The initially active item of an uncontrolled sidebar.
263    pub fn default_active_key(mut self, key: impl Into<SharedString>) -> Self {
264        self.default_active_key = Some(key.into());
265        self
266    }
267
268    /// The controlled collapsed (icons-only) state.
269    pub fn is_collapsed(mut self, collapsed: bool) -> Self {
270        self.is_collapsed = Some(collapsed);
271        self
272    }
273
274    /// The initial collapsed state of an uncontrolled sidebar.
275    pub fn default_collapsed(mut self, collapsed: bool) -> Self {
276        self.default_collapsed = collapsed;
277        self
278    }
279
280    /// The controlled set of expanded collapsible groups.
281    pub fn expanded_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
282        self.expanded_keys = keys.into_iter().collect();
283        self.is_expansion_controlled = true;
284        self
285    }
286
287    /// Whether the footer carries the built-in collapse toggle; `true` by
288    /// default.
289    pub fn show_collapse_toggle(mut self, show: bool) -> Self {
290        self.show_collapse_toggle = show;
291        self
292    }
293
294    /// The expanded width; 256px by default.
295    pub fn width(mut self, width: impl Into<Pixels>) -> Self {
296        self.width = width.into();
297        self
298    }
299
300    /// The collapsed (icons-only) width; 56px by default.
301    pub fn collapsed_width(mut self, width: impl Into<Pixels>) -> Self {
302        self.collapsed_width = width.into();
303        self
304    }
305
306    /// A disabled sidebar renders dimmed, leaves the tab order and ignores
307    /// input.
308    pub fn is_disabled(mut self, disabled: bool) -> Self {
309        self.is_disabled = disabled;
310        self
311    }
312
313    /// Runs with the key of the item a press, Enter or Space activated.
314    pub fn on_select(mut self, f: impl Fn(&SharedString, &mut Window, &mut App) + 'static) -> Self {
315        self.on_select = Some(Arc::new(f));
316        self
317    }
318
319    /// Reports the collapsed state the toggle asks for.
320    pub fn on_collapsed_change(
321        mut self,
322        f: impl Fn(&bool, &mut Window, &mut App) + 'static,
323    ) -> Self {
324        self.on_collapsed_change = Some(Arc::new(f));
325        self
326    }
327
328    /// Reports the expanded groups after a header press or key changed them.
329    pub fn on_expanded_change(
330        mut self,
331        f: impl Fn(&HashSet<SharedString>, &mut Window, &mut App) + 'static,
332    ) -> Self {
333        self.on_expanded_change = Some(Arc::new(f));
334        self
335    }
336
337    /// Caller-owned styling refined over the root after the theme's values.
338    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
339        util::refine_sx(&mut self.sx, style);
340        self
341    }
342}
343
344/// One keyboard stop: a collapsible group's header or an item.
345#[derive(Clone, Debug, PartialEq, Eq)]
346enum Stop {
347    Header { group: usize },
348    Item { group: usize, item: usize },
349}
350
351/// The stops in visual order: a collapsible header (not in icon mode), then
352/// its items when the group shows them. Disabled items are left out.
353fn stops_of(groups: &[SidebarGroup], expanded: &HashSet<SharedString>, icons: bool) -> Vec<Stop> {
354    let mut stops = Vec::new();
355    for (g, group) in groups.iter().enumerate() {
356        let collapses = group.collapses() && !icons;
357        if collapses {
358            stops.push(Stop::Header { group: g });
359        }
360        if collapses && !expanded.contains(&group.key) {
361            continue;
362        }
363        for (i, item) in group.items.iter().enumerate() {
364            if !item.is_disabled {
365                stops.push(Stop::Item { group: g, item: i });
366            }
367        }
368    }
369    stops
370}
371
372/// The keyed cursor: which stop is highlighted, by key so it survives groups
373/// opening and closing around it.
374#[derive(Clone, Debug, PartialEq, Eq)]
375enum CursorKey {
376    Header(SharedString),
377    Item(SharedString),
378}
379
380fn cursor_key(groups: &[SidebarGroup], stop: &Stop) -> CursorKey {
381    match *stop {
382        Stop::Header { group } => CursorKey::Header(groups[group].key.clone()),
383        Stop::Item { group, item } => CursorKey::Item(groups[group].items[item].key.clone()),
384    }
385}
386
387/// What the event closures share, cloned into each.
388#[derive(Clone)]
389struct SidebarState {
390    cursor: gpui::Entity<Option<CursorKey>>,
391    active_own: Option<gpui::Entity<Option<SharedString>>>,
392    expanded: HashSet<SharedString>,
393    expanded_own: Option<gpui::Entity<HashSet<SharedString>>>,
394    collapsed: bool,
395    collapsed_own: Option<gpui::Entity<bool>>,
396    on_select: Option<KeyCallback>,
397    on_collapsed_change: Option<BoolCallback>,
398    on_expanded_change: Option<KeysCallback>,
399}
400
401impl SidebarState {
402    fn set_cursor(&self, key: CursorKey, cx: &mut App) {
403        self.cursor.update(cx, |value, cx| {
404            if value.as_ref() != Some(&key) {
405                *value = Some(key);
406                cx.notify();
407            }
408        });
409    }
410
411    fn select(&self, key: &SharedString, window: &mut Window, cx: &mut App) {
412        if let Some(own) = &self.active_own {
413            own.update(cx, |value, cx| {
414                *value = Some(key.clone());
415                cx.notify();
416            });
417        }
418        if let Some(cb) = &self.on_select {
419            cb(key, window, cx);
420        }
421    }
422
423    fn set_expanded(&self, key: &SharedString, open: bool, window: &mut Window, cx: &mut App) {
424        if self.expanded.contains(key) == open {
425            return;
426        }
427        let mut next = self.expanded.clone();
428        if open {
429            next.insert(key.clone());
430        } else {
431            next.remove(key);
432        }
433        if let Some(own) = &self.expanded_own {
434            own.update(cx, |value, cx| {
435                value.clone_from(&next);
436                cx.notify();
437            });
438        }
439        if let Some(cb) = &self.on_expanded_change {
440            cb(&next, window, cx);
441        }
442    }
443
444    fn toggle_collapsed(&self, window: &mut Window, cx: &mut App) {
445        let next = !self.collapsed;
446        if let Some(own) = &self.collapsed_own {
447            own.update(cx, |value, cx| {
448                *value = next;
449                cx.notify();
450            });
451        }
452        if let Some(cb) = &self.on_collapsed_change {
453            cb(&next, window, cx);
454        }
455    }
456}
457
458/// The debug-selector spelling of an id: its name when it has one.
459fn selector_base(id: &ElementId) -> String {
460    match id {
461        ElementId::Name(name) => name.to_string(),
462        other => format!("{other:?}"),
463    }
464}
465
466impl RenderOnce for Sidebar {
467    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
468        let base = self.id.clone();
469        let selector = selector_base(&base);
470        let disabled = self.is_disabled;
471        let focus = window
472            .use_keyed_state(element_id::scoped(&base, "focus"), cx, |_, cx| {
473                cx.focus_handle()
474            })
475            .read(cx)
476            .clone()
477            .tab_stop(!disabled);
478        let cursor = window.use_keyed_state(element_id::scoped(&base, "cursor"), cx, |_, _| {
479            None::<CursorKey>
480        });
481        let typed = window.use_keyed_state(element_id::scoped(&base, "typed"), cx, |_, _| {
482            crate::list_nav::Typeahead::default()
483        });
484        let (active, active_own) = util::controlled(
485            window,
486            cx,
487            element_id::scoped(&base, "active"),
488            self.is_active_controlled.then(|| self.active_key.clone()),
489            self.default_active_key.clone(),
490        );
491        let (collapsed, collapsed_own) = util::controlled(
492            window,
493            cx,
494            element_id::scoped(&base, "collapsed"),
495            self.is_collapsed,
496            self.default_collapsed,
497        );
498        let default_expanded: HashSet<SharedString> = self
499            .groups
500            .iter()
501            .filter(|g| g.collapses() && g.default_expanded)
502            .map(|g| g.key.clone())
503            .collect();
504        let (expanded, expanded_own) = util::controlled(
505            window,
506            cx,
507            element_id::scoped(&base, "expanded"),
508            self.is_expansion_controlled
509                .then(|| self.expanded_keys.clone()),
510            default_expanded,
511        );
512
513        let groups = Rc::new(self.groups);
514        let stops = if disabled {
515            Vec::new()
516        } else {
517            stops_of(&groups, &expanded, collapsed)
518        };
519        let has_focus = focus.is_focused(window);
520        // The held cursor, or on focus the active item, then the first stop:
521        // how React Aria enters a collection.
522        let held = cursor
523            .read(cx)
524            .as_ref()
525            .and_then(|key| stops.iter().position(|s| &cursor_key(&groups, s) == key));
526        let cursor_at = held.or_else(|| {
527            has_focus
528                .then(|| {
529                    stops
530                        .iter()
531                        .position(|s| match *s {
532                            Stop::Item { group, item } => {
533                                Some(&groups[group].items[item].key) == active.as_ref()
534                            }
535                            Stop::Header { .. } => false,
536                        })
537                        .or((!stops.is_empty()).then_some(0))
538                })
539                .flatten()
540        });
541        let focused_at = (has_focus && window.is_window_active())
542            .then_some(cursor_at)
543            .flatten();
544        let cursor_stop = cursor_at.map(|at| stops[at].clone());
545
546        let state = SidebarState {
547            cursor,
548            active_own,
549            expanded: expanded.clone(),
550            expanded_own,
551            collapsed,
552            collapsed_own,
553            on_select: self.on_select.clone(),
554            on_collapsed_change: self.on_collapsed_change.clone(),
555            on_expanded_change: self.on_expanded_change.clone(),
556        };
557
558        let colors = cx.colors().clone();
559        let layout = cx.layout().clone();
560        let radius = util::soft_radius(cx);
561        let pointer = util::interactive_cursor(cx);
562        let width = if collapsed {
563            self.collapsed_width
564        } else {
565            self.width
566        };
567
568        // The menu: groups of items inside one scroller that owns the keys.
569        let mut menu = div()
570            .id(element_id::scoped(&base, "menu"))
571            .flex()
572            .flex_col()
573            .flex_1()
574            .min_h_0()
575            .overflow_y_scroll()
576            .gap(px(12.))
577            .p(px(8.))
578            .debug_selector({
579                let name = format!("{selector}-menu");
580                move || name
581            });
582        if cursor_stop.is_none() {
583            menu = menu.track_focus(&focus);
584        }
585        if !disabled {
586            let keys_state = state.clone();
587            let keys_groups = groups.clone();
588            let keys_focus = focus.clone();
589            let keys_stops = stops;
590            menu = menu
591                .key_context("Sidebar")
592                .on_mouse_down(MouseButton::Left, {
593                    let focus = focus.clone();
594                    move |_, window, cx| window.focus(&focus, cx)
595                })
596                .on_key_down(move |event, window, cx| {
597                    if !keys_focus.is_focused(window) {
598                        return;
599                    }
600                    let m = &event.keystroke.modifiers;
601                    if m.control || m.alt || m.platform || m.function {
602                        return;
603                    }
604                    let key = event.keystroke.key.as_str();
605                    let state = &keys_state;
606                    let groups = &keys_groups;
607                    let stops = &keys_stops;
608                    let moved = |to: usize, cx: &mut App| {
609                        state.set_cursor(cursor_key(groups, &stops[to]), cx);
610                        util::set_focus_visible(true, cx);
611                    };
612                    let here = cursor_at.map(|at| &stops[at]);
613                    // Every stop is navigable; `list_nav` walks their indices.
614                    let positions: Vec<usize> = (0..stops.len()).collect();
615                    match (key, here) {
616                        ("right" | "left", Some(Stop::Header { group })) => {
617                            let group_key = groups[*group].key.clone();
618                            state.set_expanded(&group_key, key == "right", window, cx);
619                            util::set_focus_visible(true, cx);
620                            cx.stop_propagation();
621                        }
622                        _ => match crate::list_nav::resolve(&positions, cursor_at, key, false) {
623                            crate::list_nav::Move::To(to) => {
624                                moved(to, cx);
625                                cx.stop_propagation();
626                            }
627                            crate::list_nav::Move::Activate => {
628                                // The cursor row owns the focus handle, and
629                                // gpui activates a focused clickable element
630                                // on Enter and Space itself: its `on_click`
631                                // runs, so answering here too would act twice.
632                                util::set_focus_visible(true, cx);
633                            }
634                            crate::list_nav::Move::Ignore => {
635                                if m.shift || !crate::list_nav::is_typeahead_key(key) {
636                                    return;
637                                }
638                                let labels: Vec<String> = stops
639                                    .iter()
640                                    .map(|s| match *s {
641                                        Stop::Header { group } => groups[group]
642                                            .label
643                                            .as_ref()
644                                            .map(|l| l.to_string())
645                                            .unwrap_or_default(),
646                                        Stop::Item { group, item } => {
647                                            groups[group].items[item].label.to_string()
648                                        }
649                                    })
650                                    .collect();
651                                let now = web_time::Instant::now();
652                                let (query, repeat) = typed.update(cx, |t, _| {
653                                    let query = t.push(key, now);
654                                    (query, t.is_repeat())
655                                });
656                                if let Some(found) = crate::list_nav::typeahead(
657                                    &labels, &positions, cursor_at, &query, repeat,
658                                ) {
659                                    moved(found, cx);
660                                }
661                            }
662                        },
663                    }
664                });
665        }
666
667        for (g, group) in groups.iter().enumerate() {
668            let group_open = expanded.contains(&group.key);
669            let collapses = group.collapses() && !collapsed;
670            let mut block = div()
671                .id(element_id::scoped(
672                    &element_id::scoped(&base, "group"),
673                    group.key.clone(),
674                ))
675                .a11y_named(a11y::Role::Group, &a11y::Name::maybe(group.label.clone()))
676                .flex()
677                .flex_col()
678                .gap(px(2.));
679            // The heading: text, or a disclosure trigger; a separator rule in
680            // icon mode.
681            if collapsed {
682                if g > 0 {
683                    block = block.child(
684                        div()
685                            .h(layout.border_width)
686                            .mx(px(8.))
687                            .mb(px(4.))
688                            .bg(colors.separator),
689                    );
690                }
691            } else if let Some(label) = &group.label {
692                let mut heading = div()
693                    .flex()
694                    .items_center()
695                    .gap(px(4.))
696                    .h(px(28.))
697                    .px(px(8.))
698                    .text_size(px(12.))
699                    .line_height(px(16.))
700                    .font_weight(gpui::FontWeight::MEDIUM)
701                    .text_color(colors.muted)
702                    .child(div().flex_1().min_w_0().truncate().child(label.clone()));
703                if collapses {
704                    let is_cursor = cursor_stop == Some(Stop::Header { group: g });
705                    let chevron_id = element_id::scoped(
706                        &element_id::scoped(&base, "chevron"),
707                        group.key.clone(),
708                    );
709                    let mut trigger = div()
710                        .id(element_id::scoped(
711                            &element_id::scoped(&base, "heading"),
712                            group.key.clone(),
713                        ))
714                        .a11y_named(a11y::Role::Button, &a11y::Name::labelled(label.clone()))
715                        .a11y_expanded(group_open)
716                        .when(is_cursor, |el| el.a11y_active_descendant())
717                        .relative()
718                        .rounded(radius)
719                        .debug_selector({
720                            let name = format!("{selector}-heading-{}", group.key);
721                            move || name
722                        })
723                        .child(
724                            heading.child(anim::rotating_indicator(
725                                &chevron_id,
726                                group_open,
727                                gpui::svg()
728                                    .size(px(14.))
729                                    .path(crate::icons::CHEVRON_DOWN)
730                                    .flex_shrink_0()
731                                    .text_color(colors.muted),
732                                window,
733                                cx,
734                            )),
735                        );
736                    if is_cursor {
737                        trigger = trigger.track_focus(&focus);
738                    }
739                    trigger = util::with_focus_ring_overlay(
740                        trigger,
741                        util::shows_focus_ring(is_cursor && focused_at.is_some(), cx),
742                        true,
743                        radius,
744                        Vec::new(),
745                        cx,
746                    );
747                    if !disabled {
748                        let press = state.clone();
749                        let focus = focus.clone();
750                        let key = group.key.clone();
751                        trigger = trigger
752                            .cursor(pointer)
753                            .hover(|s| s.bg(colors.default.color))
754                            .on_click(move |event, window, cx| {
755                                util::set_focus_visible(event.is_keyboard(), cx);
756                                window.focus(&focus, cx);
757                                press.set_cursor(CursorKey::Header(key.clone()), cx);
758                                press.set_expanded(&key, !group_open, window, cx);
759                            });
760                    }
761                    if is_cursor {
762                        trigger = util::record_focus_bounds(trigger, &focus, window, cx);
763                    }
764                    block = block.child(trigger);
765                } else {
766                    heading = heading.debug_selector({
767                        let name = format!("{selector}-heading-{}", group.key);
768                        move || name
769                    });
770                    block = block.child(heading);
771                }
772            }
773
774            let mut items = div().flex().flex_col().gap(px(2.));
775            for (i, item) in group.items.iter().enumerate() {
776                let is_active = active.as_ref() == Some(&item.key);
777                let is_cursor = cursor_stop == Some(Stop::Item { group: g, item: i });
778                let item_disabled = disabled || item.is_disabled;
779                let fg = if is_active {
780                    colors.accent.soft_foreground(colors.foreground)
781                } else {
782                    colors.foreground
783                };
784                let hover_bg = colors.default.color;
785                let mut row = div()
786                    .id(element_id::scoped(
787                        &element_id::scoped(&base, "item"),
788                        item.key.clone(),
789                    ))
790                    .a11y_named(a11y::Role::Link, &a11y::Name::labelled(item.label.clone()))
791                    .when(is_active, |el| el.a11y_current(a11y::AriaCurrent::Page))
792                    .when(is_cursor, |el| el.a11y_active_descendant())
793                    .relative()
794                    .flex()
795                    .flex_row()
796                    .items_center()
797                    .gap(px(8.))
798                    .h(ITEM_HEIGHT)
799                    .flex_shrink_0()
800                    .rounded(radius)
801                    .text_size(px(14.))
802                    .text_color(fg)
803                    .when(collapsed, |el| el.w(px(40.)).justify_center())
804                    .when(!collapsed, |el| el.w_full().px(px(8.)))
805                    .when(is_active, |el| el.bg(colors.accent.soft()))
806                    .debug_selector({
807                        let name = format!("{selector}-item-{}", item.key);
808                        move || name
809                    });
810                if is_cursor {
811                    row = row.track_focus(&focus);
812                }
813                let icon = item.icon.clone().unwrap_or_else(|| IconName::Circle.into());
814                if item.icon.is_some() || collapsed {
815                    row = row.child(
816                        gpui::svg()
817                            .size(util::FIELD_ICON)
818                            .flex_shrink_0()
819                            .path(icon)
820                            .text_color(fg),
821                    );
822                }
823                if !collapsed {
824                    row = row.child(
825                        div()
826                            .flex_1()
827                            .min_w_0()
828                            .truncate()
829                            .child(item.label.clone()),
830                    );
831                    if let Some(badge) = &item.badge {
832                        row = row.child(
833                            div()
834                                .flex_shrink_0()
835                                .text_size(px(12.))
836                                .text_color(colors.muted)
837                                .child(badge.clone()),
838                        );
839                    }
840                }
841                row = util::with_focus_ring_overlay(
842                    row,
843                    util::shows_focus_ring(is_cursor && focused_at.is_some(), cx),
844                    true,
845                    radius,
846                    Vec::new(),
847                    cx,
848                );
849                if item_disabled {
850                    row = row.opacity(layout.disabled_opacity);
851                } else {
852                    let press = state.clone();
853                    let focus = focus.clone();
854                    let key = item.key.clone();
855                    row = row
856                        .cursor(pointer)
857                        .when(!is_active, |el| el.hover(move |s| s.bg(hover_bg)))
858                        .on_click(move |event, window, cx| {
859                            util::set_focus_visible(event.is_keyboard(), cx);
860                            window.focus(&focus, cx);
861                            press.set_cursor(CursorKey::Item(key.clone()), cx);
862                            press.select(&key, window, cx);
863                        });
864                }
865                if is_cursor {
866                    row = util::record_focus_bounds(row, &focus, window, cx);
867                }
868                let row = if collapsed {
869                    Tooltip::new(item.label.clone())
870                        .id(element_id::scoped(
871                            &element_id::scoped(&base, "tip"),
872                            item.key.clone(),
873                        ))
874                        .placement(TooltipPlacement::Right)
875                        .delay(0)
876                        .child(row)
877                        .into_any_element()
878                } else {
879                    row.into_any_element()
880                };
881                items = items.child(row);
882            }
883            if collapses {
884                let panel_id =
885                    element_id::scoped(&element_id::scoped(&base, "panel"), group.key.clone());
886                if let Some(panel) = anim::collapsible_panel(
887                    &panel_id,
888                    group_open,
889                    div().id(panel_id.clone()),
890                    items.into_any_element(),
891                    window,
892                    cx,
893                ) {
894                    block = block.child(panel);
895                }
896            } else {
897                block = block.child(items);
898            }
899            menu = menu.child(block);
900        }
901        if cursor_stop.is_none() {
902            menu = util::record_focus_bounds(menu, &focus, window, cx);
903        }
904
905        let mut footer = div()
906            .flex()
907            .flex_col()
908            .gap(px(4.))
909            .p(px(8.))
910            .children(self.footer);
911        if self.show_collapse_toggle {
912            let toggle_state = state;
913            let toggle_label: SharedString = if collapsed {
914                "Expand sidebar".into()
915            } else {
916                "Collapse sidebar".into()
917            };
918            let mut toggle = crate::Button::new(element_id::scoped(&base, "toggle"))
919                .variant(herogpui_core::Variant::Tertiary)
920                .is_icon_only(true)
921                .size(herogpui_core::Size::Sm)
922                // The label is the accessible name; `content` draws the glyph
923                // in its place.
924                .label(toggle_label)
925                .is_disabled(disabled)
926                .content({
927                    let muted = colors.muted;
928                    move |_| {
929                        gpui::svg()
930                            .size(util::FIELD_ICON)
931                            .path(IconName::PanelLeft)
932                            .text_color(muted)
933                            .into_any_element()
934                    }
935                });
936            toggle =
937                toggle.on_press(move |_, window, cx| toggle_state.toggle_collapsed(window, cx));
938            footer = footer.child(
939                div()
940                    .flex()
941                    .when(!collapsed, |el| el.justify_end())
942                    .when(collapsed, |el| el.justify_center())
943                    .child(
944                        div()
945                            .debug_selector({
946                                let name = format!("{selector}-toggle");
947                                move || name
948                            })
949                            .child(toggle),
950                    ),
951            );
952        }
953
954        let root = div()
955            .id(base)
956            .a11y_named(
957                a11y::Role::Navigation,
958                &a11y::Name::labelled(self.label.clone()),
959            )
960            .flex()
961            .flex_col()
962            .h_full()
963            .w(width)
964            .flex_shrink_0()
965            .overflow_hidden()
966            .bg(colors.surface.background)
967            .text_color(colors.foreground)
968            .border_r(layout.border_width)
969            .border_color(colors.separator)
970            .when(disabled, |el| el.opacity(layout.disabled_opacity))
971            .debug_selector({
972                let name = format!("{selector}-sidebar");
973                move || name
974            })
975            .when(!self.header.is_empty(), |el| {
976                el.child(
977                    div()
978                        .flex()
979                        .flex_col()
980                        .gap(px(4.))
981                        .p(px(8.))
982                        .overflow_hidden()
983                        .children(self.header),
984                )
985            })
986            .child(menu)
987            .child(footer);
988        util::apply_sx(root, &self.sx)
989    }
990}
991
992#[cfg(test)]
993mod tests {
994    use super::*;
995
996    fn groups() -> Vec<SidebarGroup> {
997        vec![
998            SidebarGroup::new("main").items(vec![
999                SidebarItem::new("a", "A"),
1000                SidebarItem::new("b", "B").is_disabled(true),
1001            ]),
1002            SidebarGroup::new("more")
1003                .label("More")
1004                .is_collapsible(true)
1005                .items(vec![SidebarItem::new("c", "C")]),
1006            // Collapsible without a label cannot collapse.
1007            SidebarGroup::new("bare")
1008                .is_collapsible(true)
1009                .items(vec![SidebarItem::new("d", "D")]),
1010        ]
1011    }
1012
1013    #[test]
1014    fn stops_skip_disabled_items_and_closed_groups() {
1015        let open: HashSet<SharedString> = ["more".into()].into_iter().collect();
1016        assert_eq!(
1017            stops_of(&groups(), &open, false),
1018            [
1019                Stop::Item { group: 0, item: 0 },
1020                Stop::Header { group: 1 },
1021                Stop::Item { group: 1, item: 0 },
1022                Stop::Item { group: 2, item: 0 },
1023            ]
1024        );
1025        assert_eq!(
1026            stops_of(&groups(), &HashSet::new(), false),
1027            [
1028                Stop::Item { group: 0, item: 0 },
1029                Stop::Header { group: 1 },
1030                Stop::Item { group: 2, item: 0 },
1031            ]
1032        );
1033    }
1034
1035    #[test]
1036    fn icon_mode_lists_every_item_and_no_headers() {
1037        assert_eq!(
1038            stops_of(&groups(), &HashSet::new(), true),
1039            [
1040                Stop::Item { group: 0, item: 0 },
1041                Stop::Item { group: 1, item: 0 },
1042                Stop::Item { group: 2, item: 0 },
1043            ]
1044        );
1045    }
1046}
1047
1048crate::util::impl_component_styled!(Sidebar);