Skip to main content

gpui_kit_material/components/
navigation_drawer.rs

1//! M3 navigation drawer, standard and modal.
2//!
3//! Geometry and color follow the Figma kit's Navigation Drawer (the
4//! "Navigation" page): a surface-container-low sheet with 12dp padding,
5//! 56dp destinations (label-large, 24dp icon, 12dp gap, 16dp leading and
6//! 24dp trailing padding) on a full-pill secondary-container indicator,
7//! 56dp title-small section headers and headline, and middle-inset
8//! outline-variant dividers between sections. Destination badges are the
9//! kit's trailing label-large text (e.g. "100+").
10//!
11//! - A standard drawer sits in the layout beside the content; opening and
12//!   closing springs its width on the default spatial spring.
13//! - A modal drawer floats over the content at level 1 with large trailing
14//!   corners, over a 32% scrim. It slides in from its edge on the default
15//!   spatial spring while the scrim fades; Escape or a scrim click calls
16//!   [`NavigationDrawer::on_dismiss`], and opening moves focus to the
17//!   selected destination.
18//!
19//! Selection cross-fades the indicator and colors on the fast effects
20//! spring. Reduced motion finishes spatial motion at once and keeps fades.
21//!
22//! Behavior is shared with the navigation rail: each destination is a GPUI
23//! Base `Tab` in a vertical Base `Tabs` list inside a navigation landmark;
24//! arrow keys rove focus (see [`super::roving`]), Enter or Space activates.
25use super::button::{ButtonFocusRing, ButtonRipple};
26use super::navigation::{
27    ClickHandler, ITEM_GROUP, NavBadge, NavItem, destinations, item_click, ripple_corners,
28    type_role,
29};
30use super::roving::{Change, Orientation};
31use super::{InteractionPreview, small_badge};
32use crate::color::ColorRole;
33use crate::icons::Icon;
34use crate::motion::{self, MotionToken};
35use crate::theme::{blend, color};
36use crate::tokens::{
37    elevation, state,
38    typography::{TypeRole, rem},
39};
40use gpui::{prelude::FluentBuilder as _, *};
41use std::rc::Rc;
42
43/// The M3 drawer width (360dp maximum).
44pub const DRAWER_WIDTH: f32 = 360.;
45/// The sheet's padding around its content.
46const DRAWER_PADDING: f32 = 12.;
47/// A destination's, section header's and headline's height.
48const ITEM_HEIGHT: f32 = 56.;
49/// The dense height.
50const DENSE_ITEM_HEIGHT: f32 = 48.;
51const ITEM_LEADING: f32 = 16.;
52const ITEM_TRAILING: f32 = 24.;
53const ITEM_GAP: f32 = 12.;
54/// The modal drawer's trailing corner radius (shape large).
55const MODAL_RADIUS: f32 = 16.;
56
57/// Which window edge a drawer sits on.
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59pub enum DrawerPosition {
60    #[default]
61    Start,
62    End,
63}
64
65/// The resolved paint of one destination for the current motion frame.
66#[derive(Clone, Copy, Debug, PartialEq)]
67pub(crate) struct DrawerPaint {
68    pub icon: Rgba,
69    pub label: Rgba,
70    pub indicator: Rgba,
71    pub hover: Rgba,
72    pub press: Rgba,
73}
74
75/// `fade` is the fast effects amount, 0 unselected and 1 selected.
76pub(crate) fn drawer_paint(selected: bool, fade: f32, disabled: bool) -> DrawerPaint {
77    let fade = fade.clamp(0., 1.);
78    if disabled {
79        let ink = state::disabled(color(ColorRole::ON_SURFACE));
80        return DrawerPaint {
81            icon: ink,
82            label: ink,
83            indicator: color(ColorRole::ON_SURFACE).opacity(state::DISABLED_CONTAINER * fade),
84            hover: Rgba::default(),
85            press: Rgba::default(),
86        };
87    }
88    let rest = color(ColorRole::ON_SURFACE_VARIANT);
89    let active = color(ColorRole::ON_SECONDARY_CONTAINER);
90    let layer = if selected {
91        active
92    } else {
93        color(ColorRole::ON_SURFACE)
94    };
95    DrawerPaint {
96        icon: blend(rest, active, fade),
97        label: blend(rest, active, fade),
98        indicator: color(ColorRole::SECONDARY_CONTAINER).opacity(fade),
99        hover: layer.opacity(state::HOVER),
100        press: layer.opacity(state::PRESS),
101    }
102}
103
104#[cfg(test)]
105thread_local! {
106    pub(crate) static DRAWER_PAINTED: std::cell::RefCell<std::collections::BTreeMap<String, DrawerPaint>> =
107        const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
108    /// The last rendered open amount and sheet width in design px.
109    pub(crate) static DRAWER_OPEN: std::cell::Cell<(f32, f32)> = const { std::cell::Cell::new((0., 0.)) };
110}
111
112/// An M3 navigation drawer over caller-controlled selection and open state.
113#[derive(IntoElement)]
114pub struct NavigationDrawer {
115    id: ElementId,
116    label: SharedString,
117    headline: Option<SharedString>,
118    items: Vec<NavItem>,
119    sections: Vec<(usize, SharedString)>,
120    selected: Option<SharedString>,
121    modal: bool,
122    open: bool,
123    dense: bool,
124    position: DrawerPosition,
125    width: f32,
126    icons: bool,
127    ripple: bool,
128    focus_on_open: bool,
129    on_change: Option<Change>,
130    on_dismiss: Option<ClickHandler>,
131}
132
133impl NavigationDrawer {
134    /// `label` is the navigation landmark's accessible name.
135    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
136        Self {
137            id: id.into(),
138            label: label.into(),
139            headline: None,
140            items: Vec::new(),
141            sections: Vec::new(),
142            selected: None,
143            modal: false,
144            open: true,
145            dense: false,
146            position: DrawerPosition::Start,
147            width: DRAWER_WIDTH,
148            icons: true,
149            ripple: true,
150            focus_on_open: true,
151            on_change: None,
152            on_dismiss: None,
153        }
154    }
155    /// The title-small headline above the destinations.
156    pub fn headline(mut self, headline: impl Into<SharedString>) -> Self {
157        self.headline = Some(headline.into());
158        self
159    }
160    /// The destinations. Values must be unique.
161    pub fn items(mut self, items: impl IntoIterator<Item = NavItem>) -> Self {
162        self.items = items.into_iter().collect();
163        self
164    }
165    /// A section label above item `before`; sections after the first are
166    /// separated by a middle-inset divider.
167    pub fn section(mut self, before: usize, label: impl Into<SharedString>) -> Self {
168        self.sections.push((before, label.into()));
169        self
170    }
171    pub fn selected(mut self, value: impl Into<SharedString>) -> Self {
172        self.selected = Some(value.into());
173        self
174    }
175    /// Float over the content with a scrim instead of sitting beside it.
176    pub fn modal(mut self, modal: bool) -> Self {
177        self.modal = modal;
178        self
179    }
180    /// Whether the drawer is shown (true by default).
181    pub fn open(mut self, open: bool) -> Self {
182        self.open = open;
183        self
184    }
185    /// 48dp rows instead of 56dp.
186    pub fn dense(mut self, dense: bool) -> Self {
187        self.dense = dense;
188        self
189    }
190    pub fn position(mut self, position: DrawerPosition) -> Self {
191        self.position = position;
192        self
193    }
194    /// The sheet width in design px, clamped to 240–360dp.
195    pub fn width(mut self, width: f32) -> Self {
196        self.width = width.clamp(240., DRAWER_WIDTH);
197        self
198    }
199    /// Show destination icons (true by default).
200    pub fn icons(mut self, icons: bool) -> Self {
201        self.icons = icons;
202        self
203    }
204    /// Whether presses spread a ripple (on by default).
205    pub fn ripple(mut self, ripple: bool) -> Self {
206        self.ripple = ripple;
207        self
208    }
209    /// Whether opening a modal drawer moves focus to its selected destination
210    /// (on by default). Turn it off when the drawer opens in response to
211    /// typing elsewhere, such as a search field filtering its destinations.
212    pub fn focus_on_open(mut self, focus: bool) -> Self {
213        self.focus_on_open = focus;
214        self
215    }
216    pub fn on_change(
217        mut self,
218        handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
219    ) -> Self {
220        self.on_change = Some(Rc::new(handler));
221        self
222    }
223    /// A modal drawer's Escape key and scrim click call this; without it
224    /// the drawer is not dismissible.
225    pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
226        self.on_dismiss = Some(Rc::new(move |_, window, cx| handler(window, cx)));
227        self
228    }
229}
230
231/// One destination row.
232struct DrawerRow {
233    id: ElementId,
234    item: NavItem,
235    selected: bool,
236    height: f32,
237    icon: bool,
238    ripple: bool,
239    focus: FocusHandle,
240    tab_stop: bool,
241    position: (usize, usize),
242    on_click: ClickHandler,
243}
244
245impl DrawerRow {
246    fn render(self, window: &mut Window, cx: &mut App) -> AnyElement {
247        let item = self.item;
248        let preview = item.preview_state_value();
249        let disabled = item.is_disabled_value();
250        let enabled = !disabled;
251        let selected = self.selected;
252        let fade = motion::token_value(
253            (self.id.clone(), "drawer-fade"),
254            if selected { 1. } else { 0. },
255            MotionToken::FastEffects,
256            window,
257            cx,
258        );
259        let paint = drawer_paint(selected, fade, disabled);
260        #[cfg(test)]
261        DRAWER_PAINTED.with(|painted| painted.borrow_mut().insert(item.value().to_string(), paint));
262        let focus = self
263            .focus
264            .tab_stop(self.tab_stop && enabled && preview.is_none());
265        let layer = div()
266            .id("drawer-state-layer")
267            .absolute()
268            .inset_0()
269            .rounded_full()
270            .map(|layer| match preview {
271                _ if disabled => layer,
272                None => layer
273                    .group_hover(ITEM_GROUP, move |style| style.bg(paint.hover))
274                    .group_active(ITEM_GROUP, move |style| style.bg(paint.press)),
275                Some(InteractionPreview::Hovered) => layer.bg(paint.hover),
276                Some(InteractionPreview::Pressed) => layer.bg(paint.press),
277                Some(_) => layer,
278            });
279        let indicator = (paint.indicator.a > 0.001).then(|| {
280            div()
281                .absolute()
282                .inset_0()
283                .rounded_full()
284                .bg(paint.indicator)
285        });
286        let badge = match item.badge_value() {
287            NavBadge::None => None,
288            NavBadge::Small => Some(small_badge().into_any_element()),
289            NavBadge::Large(text) => Some(
290                div()
291                    .flex_shrink_0()
292                    .text_color(paint.label)
293                    .map(|text| type_role(text, TypeRole::LabelLarge))
294                    .child(text.clone())
295                    .into_any_element(),
296            ),
297        };
298        let height = rem(self.height);
299        let content = div()
300            .relative()
301            .w_full()
302            .h(height)
303            .flex()
304            .items_center()
305            .gap(rem(ITEM_GAP))
306            .pl(rem(ITEM_LEADING))
307            .pr(rem(ITEM_TRAILING))
308            .rounded_full()
309            .when(cfg!(test), |content| {
310                let value = item.value().clone();
311                content.debug_selector(move || format!("drawer-item-{value}"))
312            })
313            .children(indicator)
314            .child(layer)
315            .when(self.icon, |content| {
316                content.child(
317                    Icon::new(item.icon_value().path(selected))
318                        .size(rem(24.))
319                        .color(paint.icon),
320                )
321            })
322            .child(
323                div()
324                    .relative()
325                    .flex_1()
326                    .min_w_0()
327                    .whitespace_nowrap()
328                    .text_ellipsis()
329                    .text_color(paint.label)
330                    .map(|label| type_role(label, TypeRole::LabelLarge))
331                    .child(item.label().clone()),
332            )
333            .children(badge);
334        let radius = height.to_pixels(window.rem_size()) / 2.;
335        let ripple = ButtonRipple::new(
336            content,
337            (self.id.clone(), "drawer-ripple").into(),
338            preview.is_none() && self.ripple,
339            enabled,
340            ripple_corners(radius),
341            Edges::all(px(0.).into()),
342            if selected {
343                color(ColorRole::ON_SECONDARY_CONTAINER)
344            } else {
345                color(ColorRole::ON_SURFACE)
346            },
347            window,
348            cx,
349        );
350        let ring =
351            ButtonFocusRing::with_corners(ripple, focus.clone(), enabled, Corners::all(radius))
352                .forced(preview == Some(InteractionPreview::Focused));
353        let click_focus = focus.clone();
354        let on_click = self.on_click;
355        let (index, count) = self.position;
356        gpui_base::Tab::new(self.id)
357            .group(ITEM_GROUP)
358            .selected(selected)
359            .disabled(disabled)
360            .accessibility_label(item.label().clone())
361            .set_position(index + 1, count)
362            .track_focus(&focus)
363            .w_full()
364            .h(height)
365            .flex_shrink_0()
366            .flex()
367            .child(ring)
368            .when(enabled && preview.is_none(), |tab| {
369                tab.cursor_pointer().on_click(move |event, window, cx| {
370                    click_focus.focus(window, cx);
371                    on_click(event, window, cx)
372                })
373            })
374            .into_any_element()
375    }
376}
377
378fn header(id: (&'static str, usize), text: SharedString, height: f32) -> AnyElement {
379    div()
380        .id(id)
381        .role(Role::Heading)
382        .aria_label(text.clone())
383        .flex_shrink_0()
384        .h(rem(height))
385        .flex()
386        .items_center()
387        .px(rem(16.))
388        .whitespace_nowrap()
389        .text_color(color(ColorRole::ON_SURFACE_VARIANT))
390        .map(|text| type_role(text, TypeRole::TitleSmall))
391        .child(text)
392        .into_any_element()
393}
394
395/// The middle-inset divider between sections.
396fn divider(index: usize) -> AnyElement {
397    div()
398        .id(("drawer-divider", index))
399        .flex_shrink_0()
400        .px(rem(16.))
401        .child(div().h(px(1.)).bg(color(ColorRole::OUTLINE_VARIANT)))
402        .into_any_element()
403}
404
405impl RenderOnce for NavigationDrawer {
406    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
407        let open = self.open;
408        let amount = motion::token_value(
409            (self.id.clone(), "drawer-open"),
410            if open { 1. } else { 0. },
411            MotionToken::DefaultSpatial,
412            window,
413            cx,
414        );
415        let visible = amount.clamp(0., 1.);
416        let width = self.width;
417        #[cfg(test)]
418        DRAWER_OPEN.with(|cell| cell.set((visible, width)));
419        let modal = self.modal;
420        let end = self.position == DrawerPosition::End;
421        // Fully closed: nothing to draw, focus or block the content with.
422        if !open && visible < 0.001 {
423            return div()
424                .id(self.id)
425                .when(!modal, |gap| gap.h_full().w(px(0.)))
426                .into_any_element();
427        }
428        let list = destinations(
429            &self.id,
430            &self.items,
431            self.selected.as_ref(),
432            Orientation::Column,
433            window,
434            cx,
435        );
436        // Opening a modal drawer moves focus into it.
437        let was_open =
438            window.use_keyed_state((self.id.clone(), "drawer-was-open"), cx, |_, _| false);
439        let opened = open && !*was_open.read(cx);
440        was_open.update(cx, |was, _| *was = open);
441        if modal
442            && opened
443            && self.focus_on_open
444            && let Some(handle) = list
445                .selected
446                .or(list.entry)
447                .map(|i| list.handles[i].clone())
448        {
449            window.defer(cx, move |window, cx| handle.focus(window, cx));
450        }
451
452        let row_height = if self.dense {
453            DENSE_ITEM_HEIGHT
454        } else {
455            ITEM_HEIGHT
456        };
457        let count = self.items.len();
458        let mut sections = self.sections;
459        sections.sort_by_key(|(before, _)| *before);
460        let mut sections = sections.into_iter().peekable();
461        let mut lane = gpui_base::Tabs::new((self.id.clone(), "drawer-items"))
462            .aria_label(self.label.clone())
463            .aria_orientation(accesskit::Orientation::Vertical)
464            .w_full()
465            .flex()
466            .flex_col();
467        for (index, item) in self.items.into_iter().enumerate() {
468            while let Some((_, text)) = sections.next_if(|(before, _)| *before <= index) {
469                if index > 0 {
470                    lane = lane.child(divider(index));
471                }
472                lane = lane.child(header(("drawer-section", index), text, row_height));
473            }
474            let selected = list.selected == Some(index);
475            let on_click = item_click(self.on_change.clone(), item.value().clone(), selected);
476            lane = lane.child(
477                DrawerRow {
478                    id: ElementId::NamedChild(self.id.clone().into(), item.value().clone()),
479                    item,
480                    selected,
481                    height: row_height,
482                    icon: self.icons,
483                    ripple: self.ripple,
484                    focus: list.handles[index].clone(),
485                    tab_stop: list.entry == Some(index),
486                    position: (index, count),
487                    on_click,
488                }
489                .render(window, cx),
490            );
491        }
492        let lane = list.roving.install(lane);
493        let on_dismiss = self.on_dismiss.filter(|_| modal);
494        let sheet = div()
495            .id((self.id.clone(), "drawer-sheet"))
496            .when(cfg!(test), |sheet| {
497                sheet.debug_selector(|| "navigation-drawer-sheet".into())
498            })
499            .w(rem(width))
500            .h_full()
501            .flex_shrink_0()
502            .flex()
503            .flex_col()
504            .p(rem(DRAWER_PADDING))
505            .overflow_y_scroll()
506            .bg(color(ColorRole::SURFACE_CONTAINER_LOW))
507            .when(modal, |sheet| {
508                let radius = rem(MODAL_RADIUS);
509                sheet
510                    .occlude()
511                    .map(|sheet| {
512                        if end {
513                            sheet.rounded_l(radius)
514                        } else {
515                            sheet.rounded_r(radius)
516                        }
517                    })
518                    .shadow(elevation::level(1))
519            })
520            .when_some(self.headline, |sheet, text| {
521                sheet.child(header(("drawer-headline", 0), text, row_height))
522            })
523            .child(lane)
524            .when_some(on_dismiss.clone(), |sheet, dismiss| {
525                sheet.on_key_down(move |event: &KeyDownEvent, window, cx| {
526                    if event.keystroke.key == "escape" {
527                        cx.stop_propagation();
528                        dismiss(&ClickEvent::default(), window, cx);
529                    }
530                })
531            });
532        let landmark = |element: Stateful<Div>| {
533            element
534                .role(Role::Navigation)
535                .aria_label(self.label.clone())
536        };
537        if modal {
538            let offset = rem((-(1. - amount) * width).min(0.));
539            let scrim = div()
540                .id((self.id.clone(), "drawer-scrim"))
541                .when(cfg!(test), |scrim| {
542                    scrim.debug_selector(|| "navigation-drawer-scrim".into())
543                })
544                .absolute()
545                .inset_0()
546                .occlude()
547                .bg(color(ColorRole::SCRIM).opacity(0.32 * visible))
548                .when_some(on_dismiss, |scrim, dismiss| {
549                    scrim.on_click(move |event, window, cx| dismiss(event, window, cx))
550                });
551            landmark(div().id(self.id))
552                .absolute()
553                .inset_0()
554                .overflow_hidden()
555                .child(scrim)
556                .child(
557                    div()
558                        .absolute()
559                        .top_0()
560                        .bottom_0()
561                        .map(|frame| {
562                            if end {
563                                frame.right(offset)
564                            } else {
565                                frame.left(offset)
566                            }
567                        })
568                        .child(sheet),
569                )
570                .into_any_element()
571        } else {
572            landmark(div().id(self.id))
573                .h_full()
574                .w(rem(width * visible))
575                .flex_shrink_0()
576                .flex()
577                .when(end, |gap| gap.justify_end())
578                .overflow_hidden()
579                .child(sheet)
580                .into_any_element()
581        }
582    }
583}
584
585#[cfg(test)]
586#[path = "navigation_drawer_tests.rs"]
587mod tests;