Skip to main content

gpui_kit_material/components/
navigation_suite.rs

1//! Navigation suite: one set of destinations shown as the bar, rail or drawer
2//! the window's size calls for, beside or above the content.
3//!
4//! Source: only m3e lists this component (the Figma kit and md3.io do not), so
5//! its rules are m3e's port of Compose `NavigationSuiteScaffold`
6//! (`packages/m3e-motion/src/window-size-class.ts`, `navigation-suite.ts`,
7//! `packages/m3e-css/src/navigation-suite.css`,
8//! `navigation-suite-defaults.css`, from androidx
9//! `19991f2c0858aadfcb442ce02b2c8e4743c96898`). The bar, rail and drawer keep
10//! their own Figma-sourced values. Showing and hiding plays the default spatial
11//! token, the same as the rail and drawer it moves: m3e's fixed 0.9 / 700 spring
12//! is that token under the Standard scheme.
13//!
14//! One deliberate difference from m3e: [`NavigationSuiteType::Drawer`] is the
15//! default expanded form, so the suite moves between bar, rail and drawer. m3e
16//! defaults to the expanded rail; [`NavigationSuite::expanded_type`] selects it.
17use super::adaptive::{WindowHeightClass, WindowSizeClass, WindowWidthClass};
18use super::navigation::{
19    NavItem, NavItemLayout, NavigationBar, NavigationRail, RAIL_COLLAPSED_WIDTH,
20    RAIL_EXPANDED_WIDTH,
21};
22use super::navigation_drawer::{DRAWER_WIDTH, NavigationDrawer};
23use crate::{
24    color::ColorRole,
25    motion::{self, MotionToken},
26    theme::color,
27    tokens::typography::rem,
28};
29use gpui::{prelude::*, *};
30use std::rc::Rc;
31
32/// The primary action's inset above a bar (Compose
33/// `PrimaryActionContentPadding`, through m3e), in dp.
34pub const SUITE_PRIMARY_ACTION_PADDING: f32 = 16.;
35/// The drawer's inset for a primary action, matching its item indicators.
36const DRAWER_ACTION_PADDING: f32 = 12.;
37/// Which navigation a suite shows.
38#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
39pub enum NavigationSuiteType {
40    /// A bar with the icon over the label.
41    BarCompact,
42    /// A bar with the icon beside the label, for short, wide windows.
43    BarMedium,
44    /// A collapsed rail.
45    RailCollapsed,
46    /// An expanded rail.
47    RailExpanded,
48    /// A standard drawer.
49    Drawer,
50    /// No navigation: the content alone.
51    None,
52}
53
54impl NavigationSuiteType {
55    /// Whether the navigation sits below the content.
56    pub const fn is_bar(self) -> bool {
57        matches!(self, Self::BarCompact | Self::BarMedium)
58    }
59}
60
61/// The navigation for a size class: a bar below 600dp, or when the window is
62/// under 480dp tall; a collapsed rail from 600dp; `expanded_type` from
63/// `expanded_from`. `None` for `expanded_from` never expands.
64///
65/// ```
66/// use gpui_kit_material::prelude::*;
67///
68/// let form = |width| {
69///     navigation_suite_type(
70///         WindowSizeClass::of(width, 800.),
71///         Some(WindowWidthClass::Large),
72///         NavigationSuiteType::Drawer,
73///     )
74/// };
75/// assert_eq!(form(360.), NavigationSuiteType::BarCompact);
76/// assert_eq!(form(800.), NavigationSuiteType::RailCollapsed);
77/// assert_eq!(form(1300.), NavigationSuiteType::Drawer);
78/// ```
79pub fn navigation_suite_type(
80    size: WindowSizeClass,
81    expanded_from: Option<WindowWidthClass>,
82    expanded_type: NavigationSuiteType,
83) -> NavigationSuiteType {
84    if size.width == WindowWidthClass::Compact {
85        NavigationSuiteType::BarCompact
86    } else if size.height == WindowHeightClass::Compact {
87        NavigationSuiteType::BarMedium
88    } else if expanded_from.is_some_and(|from| size.width >= from) {
89        expanded_type
90    } else {
91        NavigationSuiteType::RailCollapsed
92    }
93}
94
95type Change = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
96type TypeChange = Rc<dyn Fn(&NavigationSuiteType, &mut Window, &mut App)>;
97
98/// Adaptive navigation around caller-owned content.
99///
100/// The selection is controlled: report it through [`Self::on_change`] and pass
101/// it back with [`Self::selected`]. It survives a change of navigation, and so
102/// does keyboard focus, because all three forms share this element's id and
103/// therefore their focus handles.
104///
105/// ```
106/// use gpui_kit_material::prelude::*;
107///
108/// let suite = NavigationSuite::new("suite", "Main")
109///     .items([
110///         NavItem::new("home", "Home", NavIcon::new("home", "home")),
111///         NavItem::new("mail", "Mail", NavIcon::new("mail", "mail")),
112///     ])
113///     .selected("home")
114///     .expanded_type(NavigationSuiteType::RailExpanded);
115/// # let _ = suite;
116/// ```
117#[derive(gpui::IntoElement)]
118pub struct NavigationSuite {
119    id: ElementId,
120    label: SharedString,
121    items: Vec<NavItem>,
122    selected: Option<SharedString>,
123    layout_type: Option<NavigationSuiteType>,
124    size_class: Option<WindowSizeClass>,
125    expanded_from: Option<WindowWidthClass>,
126    expanded_type: NavigationSuiteType,
127    hidden: bool,
128    primary_action: Option<AnyElement>,
129    content: Vec<AnyElement>,
130    on_change: Option<Change>,
131    on_type_change: Option<TypeChange>,
132}
133
134impl NavigationSuite {
135    /// `label` is the navigation landmark's accessible name.
136    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
137        Self {
138            id: id.into(),
139            label: label.into(),
140            items: Vec::new(),
141            selected: None,
142            layout_type: None,
143            size_class: None,
144            expanded_from: Some(WindowWidthClass::Large),
145            expanded_type: NavigationSuiteType::Drawer,
146            hidden: false,
147            primary_action: None,
148            content: Vec::new(),
149            on_change: None,
150            on_type_change: None,
151        }
152    }
153
154    /// The destinations, shown by every form.
155    pub fn items(mut self, items: impl IntoIterator<Item = NavItem>) -> Self {
156        self.items = items.into_iter().collect();
157        self
158    }
159
160    /// The selected destination's value.
161    pub fn selected(mut self, value: impl Into<SharedString>) -> Self {
162        self.selected = Some(value.into());
163        self
164    }
165
166    /// Fix the navigation, overriding the adaptive rule. `None` is adaptive.
167    pub fn layout_type(mut self, layout_type: Option<NavigationSuiteType>) -> Self {
168        self.layout_type = layout_type;
169        self
170    }
171
172    /// Classify this size instead of the window's: for a suite framed inside a
173    /// larger window, as previews are. `None` reads the window.
174    pub fn size_class(mut self, size_class: Option<WindowSizeClass>) -> Self {
175        self.size_class = size_class;
176        self
177    }
178
179    /// The width class the navigation expands at (large by default). `None`
180    /// never expands.
181    pub fn expanded_from(mut self, from: Option<WindowWidthClass>) -> Self {
182        self.expanded_from = from;
183        self
184    }
185
186    /// What the expanded navigation is: [`NavigationSuiteType::Drawer`] by
187    /// default, or [`NavigationSuiteType::RailExpanded`] as m3e defaults to.
188    pub fn expanded_type(mut self, expanded_type: NavigationSuiteType) -> Self {
189        self.expanded_type = expanded_type;
190        self
191    }
192
193    /// Hide the navigation; the content takes the freed space. The change
194    /// animates, and a hidden navigation cannot take focus.
195    pub fn navigation_hidden(mut self, hidden: bool) -> Self {
196        self.hidden = hidden;
197        self
198    }
199
200    /// The primary action, typically a FAB: floating above a bar, heading a
201    /// rail, or first in a drawer. It stays above a hidden bar.
202    pub fn primary_action(mut self, action: impl IntoElement) -> Self {
203        self.primary_action = Some(action.into_any_element());
204        self
205    }
206
207    /// Add to the content area, which fills the space the navigation leaves.
208    pub fn child(mut self, child: impl IntoElement) -> Self {
209        self.content.push(child.into_any_element());
210        self
211    }
212
213    /// Called with a newly selected destination's value.
214    pub fn on_change(
215        mut self,
216        handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
217    ) -> Self {
218        self.on_change = Some(Rc::new(handler));
219        self
220    }
221
222    /// Called when the size or an option changes the navigation form.
223    pub fn on_type_change(
224        mut self,
225        handler: impl Fn(&NavigationSuiteType, &mut Window, &mut App) + 'static,
226    ) -> Self {
227        self.on_type_change = Some(Rc::new(handler));
228        self
229    }
230
231    /// The form this suite shows in `window`.
232    fn resolved_type(&self, window: &Window) -> NavigationSuiteType {
233        self.layout_type.unwrap_or_else(|| {
234            navigation_suite_type(
235                self.size_class
236                    .unwrap_or_else(|| WindowSizeClass::of_window(window)),
237                self.expanded_from,
238                self.expanded_type,
239            )
240        })
241    }
242}
243
244/// The navigation's size along the axis it hides on, in design px.
245fn navigation_extent(suite_type: NavigationSuiteType) -> f32 {
246    match suite_type {
247        NavigationSuiteType::BarCompact => NavItemLayout::Vertical.geometry().item_height,
248        NavigationSuiteType::BarMedium => NavItemLayout::Horizontal.geometry().item_height,
249        NavigationSuiteType::RailCollapsed => RAIL_COLLAPSED_WIDTH,
250        NavigationSuiteType::RailExpanded => RAIL_EXPANDED_WIDTH,
251        NavigationSuiteType::Drawer => DRAWER_WIDTH,
252        NavigationSuiteType::None => 0.,
253    }
254}
255
256/// Wrap an optional change handler for the navigation forms' `on_change`.
257fn forward(handler: &Option<Change>) -> impl Fn(&SharedString, &mut Window, &mut App) + 'static {
258    let handler = handler.clone();
259    move |value, window, cx| {
260        if let Some(handler) = &handler {
261            handler(value, window, cx);
262        }
263    }
264}
265
266impl RenderOnce for NavigationSuite {
267    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
268        let suite_type = self.resolved_type(window);
269        let shown = motion::token_value(
270            (self.id.clone(), "suite-visibility"),
271            if self.hidden { 0. } else { 1. },
272            MotionToken::DefaultSpatial,
273            window,
274            cx,
275        )
276        .clamp(0., 1.);
277
278        let previous =
279            window.use_keyed_state((self.id.clone(), "suite-type"), cx, |_, _| suite_type);
280        if *previous.read(cx) != suite_type {
281            previous.update(cx, |previous, _| *previous = suite_type);
282            if let Some(handler) = self.on_type_change.clone() {
283                window.defer(cx, move |window, cx| handler(&suite_type, window, cx));
284            }
285        }
286
287        let bar = suite_type.is_bar();
288        let offset = rem(-navigation_extent(suite_type) * (1. - shown));
289        let present = !(self.hidden && shown <= 0.) && suite_type != NavigationSuiteType::None;
290        let on_change = self.on_change;
291        let mut action = self.primary_action;
292        let selected = self.selected.unwrap_or_default();
293        let navigation: Option<AnyElement> = present.then(|| match suite_type {
294            NavigationSuiteType::RailCollapsed | NavigationSuiteType::RailExpanded => {
295                let rail = NavigationRail::new(self.id.clone(), self.label.clone())
296                    .items(self.items)
297                    .selected(selected)
298                    .expanded(suite_type == NavigationSuiteType::RailExpanded)
299                    .on_change(forward(&on_change));
300                match action.take() {
301                    Some(action) => rail.fab(action).into_any_element(),
302                    None => rail.into_any_element(),
303                }
304            }
305            NavigationSuiteType::Drawer => {
306                let drawer = NavigationDrawer::new(self.id.clone(), self.label.clone())
307                    .items(self.items)
308                    .selected(selected)
309                    .open(true)
310                    .on_change(forward(&on_change));
311                match action.take() {
312                    Some(action) => gpui_base::v_flex()
313                        .h_full()
314                        .flex_shrink_0()
315                        .bg(color(ColorRole::SURFACE_CONTAINER_LOW))
316                        .child(div().p(rem(DRAWER_ACTION_PADDING)).child(action))
317                        .child(div().flex_1().min_h_0().child(drawer))
318                        .into_any_element(),
319                    None => drawer.into_any_element(),
320                }
321            }
322            // Bars, and `None`, which `present` already excludes.
323            _ => NavigationBar::new(self.id.clone(), self.label.clone())
324                .items(self.items)
325                .selected(selected)
326                .layout(if suite_type == NavigationSuiteType::BarMedium {
327                    NavItemLayout::Horizontal
328                } else {
329                    NavItemLayout::Vertical
330                })
331                .on_change(forward(&on_change))
332                .into_any_element(),
333        });
334        // The bar's action floats over the content, so a hidden bar keeps it.
335        // A rail's or drawer's leaves with its navigation.
336        let floating = action.filter(|_| bar).map(|action| {
337            div()
338                .absolute()
339                .bottom(rem(SUITE_PRIMARY_ACTION_PADDING))
340                .right(rem(SUITE_PRIMARY_ACTION_PADDING))
341                .child(action)
342        });
343        let content = div()
344            .relative()
345            .flex_1()
346            .min_w_0()
347            .min_h_0()
348            .debug_selector(|| "navigation-suite-content".into())
349            .children(self.content)
350            .children(floating);
351        let navigation = navigation.map(|navigation| {
352            div()
353                .flex_shrink_0()
354                .debug_selector(|| "navigation-suite-navigation".into())
355                .map(|frame| {
356                    if bar {
357                        frame.w_full().mb(offset)
358                    } else {
359                        frame.h_full().ml(offset)
360                    }
361                })
362                .child(navigation)
363        });
364        div()
365            .id((self.id, "suite"))
366            .size_full()
367            .flex()
368            .overflow_hidden()
369            .bg(color(ColorRole::SURFACE))
370            .text_color(color(ColorRole::ON_SURFACE))
371            .map(|root| {
372                if bar {
373                    root.flex_col().child(content).children(navigation)
374                } else {
375                    root.flex_row().children(navigation).child(content)
376                }
377            })
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use crate::components::NavIcon;
385    use core::prelude::v1::test;
386
387    fn class(width: f32, height: f32) -> WindowSizeClass {
388        WindowSizeClass::of(width, height)
389    }
390
391    #[test]
392    fn the_rule_moves_between_bar_rail_and_drawer_by_width() {
393        use NavigationSuiteType::*;
394        let large = Some(WindowWidthClass::Large);
395        for (width, height, expected) in [
396            (360., 800., BarCompact),
397            (599., 800., BarCompact),
398            (600., 800., RailCollapsed),
399            (1199., 800., RailCollapsed),
400            (1200., 800., Drawer),
401            (1800., 800., Drawer),
402            // A short window keeps a bar, with its items laid out sideways.
403            (900., 479., BarMedium),
404            (1400., 479., BarMedium),
405            (360., 479., BarCompact),
406        ] {
407            assert_eq!(
408                navigation_suite_type(class(width, height), large, Drawer),
409                expected,
410                "{width}x{height}"
411            );
412        }
413    }
414
415    #[test]
416    fn the_expanded_form_and_its_breakpoint_are_options() {
417        use NavigationSuiteType::*;
418        let wide = class(1300., 800.);
419        assert_eq!(
420            navigation_suite_type(wide, Some(WindowWidthClass::Large), RailExpanded),
421            RailExpanded,
422        );
423        assert_eq!(
424            navigation_suite_type(wide, Option::None, Drawer),
425            RailCollapsed
426        );
427        assert_eq!(
428            navigation_suite_type(class(900., 800.), Some(WindowWidthClass::Expanded), Drawer),
429            Drawer,
430        );
431    }
432
433    struct Shell {
434        layout_type: Option<NavigationSuiteType>,
435        size_class: WindowSizeClass,
436        selected: SharedString,
437        hidden: bool,
438        changes: Vec<NavigationSuiteType>,
439    }
440
441    impl Render for Shell {
442        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
443            window.set_rem_size(px(crate::tokens::typography::BASE_PX));
444            div().size(px(1000.)).child(
445                NavigationSuite::new("suite", "Main")
446                    .items(
447                        ["home", "mail"]
448                            .map(|value| NavItem::new(value, value, NavIcon::new("home", "home"))),
449                    )
450                    .selected(self.selected.clone())
451                    .layout_type(self.layout_type)
452                    .size_class(Some(self.size_class))
453                    .navigation_hidden(self.hidden)
454                    .on_change(cx.listener(|this, value: &SharedString, _, cx| {
455                        this.selected = value.clone();
456                        cx.notify();
457                    }))
458                    .on_type_change(cx.listener(|this, kind: &NavigationSuiteType, _, cx| {
459                        this.changes.push(*kind);
460                        cx.notify();
461                    }))
462                    .child(div().size_full().child("Content")),
463            )
464        }
465    }
466
467    fn open(cx: &mut gpui::TestAppContext) -> (Entity<Shell>, &mut gpui::VisualTestContext) {
468        cx.update(|cx| {
469            cx.set_reduce_motion(true);
470        });
471        cx.add_window_view(|_, _| Shell {
472            layout_type: None,
473            size_class: class(360., 800.),
474            selected: "home".into(),
475            hidden: false,
476            changes: Vec::new(),
477        })
478    }
479
480    fn draw(cx: &mut gpui::VisualTestContext) {
481        for _ in 0..3 {
482            cx.update(|window, cx| {
483                window.refresh();
484                window.draw(cx).clear(cx);
485            });
486            cx.run_until_parked();
487        }
488    }
489
490    #[gpui::test]
491    fn each_form_sits_on_its_edge_and_the_content_takes_the_rest(cx: &mut gpui::TestAppContext) {
492        let (view, cx) = open(cx);
493        draw(cx);
494        let nav = cx.debug_bounds("navigation-suite-navigation").unwrap();
495        let content = cx.debug_bounds("navigation-suite-content").unwrap();
496        assert!(nav.top() >= content.bottom(), "a bar is below the content");
497        assert_eq!(nav.size.width, px(1000.));
498
499        for (size_class, width) in [
500            (class(800., 800.), RAIL_COLLAPSED_WIDTH),
501            (class(1300., 800.), DRAWER_WIDTH),
502        ] {
503            cx.update(|_, cx| view.update(cx, |view, _| view.size_class = size_class));
504            draw(cx);
505            let nav = cx.debug_bounds("navigation-suite-navigation").unwrap();
506            let content = cx.debug_bounds("navigation-suite-content").unwrap();
507            assert_eq!(nav.size.width, px(width), "{size_class:?}");
508            assert_eq!(content.left(), nav.right(), "{size_class:?}");
509            assert_eq!(content.size.width, px(1000. - width), "{size_class:?}");
510        }
511        let changes = view.read_with(cx, |view, _| view.changes.clone());
512        assert_eq!(
513            changes,
514            [
515                NavigationSuiteType::RailCollapsed,
516                NavigationSuiteType::Drawer
517            ]
518        );
519    }
520
521    #[gpui::test]
522    fn hiding_gives_the_content_the_space_and_removes_the_navigation(
523        cx: &mut gpui::TestAppContext,
524    ) {
525        let (view, cx) = open(cx);
526        cx.update(|_, cx| {
527            view.update(cx, |view, _| {
528                view.size_class = class(800., 800.);
529                view.hidden = true;
530            })
531        });
532        draw(cx);
533        assert!(cx.debug_bounds("navigation-suite-navigation").is_none());
534        let content = cx.debug_bounds("navigation-suite-content").unwrap();
535        assert_eq!(content.size.width, px(1000.));
536    }
537
538    #[gpui::test]
539    fn a_fixed_type_wins_and_none_shows_only_content(cx: &mut gpui::TestAppContext) {
540        let (view, cx) = open(cx);
541        cx.update(|_, cx| {
542            view.update(cx, |view, _| {
543                view.layout_type = Some(NavigationSuiteType::None)
544            })
545        });
546        draw(cx);
547        assert!(cx.debug_bounds("navigation-suite-navigation").is_none());
548        cx.update(|_, cx| {
549            view.update(cx, |view, _| {
550                view.layout_type = Some(NavigationSuiteType::RailExpanded)
551            })
552        });
553        draw(cx);
554        let nav = cx.debug_bounds("navigation-suite-navigation").unwrap();
555        assert_eq!(nav.size.width, px(RAIL_EXPANDED_WIDTH));
556    }
557
558    #[gpui::test]
559    fn selection_and_keyboard_focus_survive_a_change_of_form(cx: &mut gpui::TestAppContext) {
560        let (view, cx) = open(cx);
561        draw(cx);
562        // Keyboard focus enters the bar, and stays on that destination when
563        // the navigation becomes a rail.
564        cx.update(|window, cx| window.focus_next(cx));
565        draw(cx);
566        let focused = cx.update(|window, cx| window.focused(cx));
567        assert!(focused.is_some(), "tab reaches a destination");
568        cx.update(|_, cx| view.update(cx, |view, _| view.size_class = class(800., 800.)));
569        draw(cx);
570        assert_eq!(cx.update(|window, cx| window.focused(cx)), focused);
571        assert_eq!(
572            view.read_with(cx, |view, _| view.selected.clone()).as_ref(),
573            "home"
574        );
575    }
576}