Skip to main content

gpui_kit_material/components/
snackbar.rs

1//! M3 snackbar: a brief message on the inverse surface with an optional
2//! text action and close affordance. Reference: the Figma kit's Snackbar
3//! set (single/two-line, action, close, longer action) and
4//! <https://m3e.kyralabs.dev/components/snackbar/>.
5use super::InteractionPreview;
6use super::focus::{RingPlacement, RingedButton};
7use super::text::accessible_text;
8use crate::color::ColorRole::*;
9use crate::icons::IconName;
10use crate::motion::{self, MotionToken};
11use crate::theme::color;
12use crate::tokens::{elevation, shape::*, state, typography::*};
13use gpui::{prelude::*, *};
14use std::rc::Rc;
15
16/// M3 snackbars sit at elevation level 3.
17const SNACKBAR_ELEVATION: u8 = 3;
18/// The close affordance: a 24dp icon in a 40dp target inside a 48dp box.
19pub const SNACKBAR_CLOSE_ICON: Rems = rem(24.);
20const BUTTON_HEIGHT: f32 = 40.;
21const CLOSE_BOX: f32 = 48.;
22/// How far a snackbar rises as it enters or sinks as it leaves.
23const TRAVEL: f32 = 12.;
24
25type SnackbarHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
26
27/// M3 snackbar.
28///
29/// One line grows to two by wrapping; [`Self::longer_action`] moves the
30/// action below the text. The message is a status node (announced politely
31/// by platforms that treat status as a live region). It rises and fades in
32/// when mounted; with [`Self::open`]`(false)` it sinks and fades out, then
33/// renders nothing, so the caller can keep it mounted until the exit
34/// settles. The caller places it (usually bottom-leading, 16dp from the
35/// window edges) and supplies every string.
36#[derive(gpui::IntoElement)]
37pub struct Snackbar {
38    id: ElementId,
39    message: SharedString,
40    action: Option<(SharedString, SnackbarHandler)>,
41    close: Option<(SharedString, SnackbarHandler)>,
42    longer_action: bool,
43    open: bool,
44    preview: Option<InteractionPreview>,
45}
46
47impl Snackbar {
48    pub fn new(id: impl Into<ElementId>, message: impl Into<SharedString>) -> Self {
49        Self {
50            id: id.into(),
51            message: message.into(),
52            action: None,
53            close: None,
54            longer_action: false,
55            open: true,
56            preview: None,
57        }
58    }
59
60    /// A text button after the message, e.g. "Undo".
61    pub fn action(
62        mut self,
63        label: impl Into<SharedString>,
64        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
65    ) -> Self {
66        self.action = Some((label.into(), Rc::new(handler)));
67        self
68    }
69
70    /// A close icon button, announced with `label`.
71    pub fn close(
72        mut self,
73        label: impl Into<SharedString>,
74        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
75    ) -> Self {
76        self.close = Some((label.into(), Rc::new(handler)));
77        self
78    }
79
80    /// Place a long action label on its own row below the message.
81    pub fn longer_action(mut self, longer: bool) -> Self {
82        self.longer_action = longer;
83        self
84    }
85
86    /// Controlled visibility: `false` plays the exit, then hides.
87    pub fn open(mut self, open: bool) -> Self {
88        self.open = open;
89        self
90    }
91
92    /// Show the action and close affordance in one forced state (a
93    /// static specimen; the buttons leave the tab order).
94    pub fn preview_state(mut self, state: InteractionPreview) -> Self {
95        self.preview = Some(state);
96        self
97    }
98}
99
100/// A button on the inverse surface, with its state layer in `fg` and the
101/// shared keyboard focus ring.
102fn inverse_button(
103    id: ElementId,
104    fg: Rgba,
105    handler: SnackbarHandler,
106    preview: Option<InteractionPreview>,
107) -> RingedButton {
108    let clear = Rgba { a: 0., ..fg };
109    let (hover, press) = (state::hovered(clear, fg), state::pressed(clear, fg));
110    let focused = state::layer(clear, fg, state::FOCUS);
111    let button = RingedButton::new(id, RingPlacement::Outer(Corners::all(RADIUS_FULL)))
112        .map(move |button| {
113            button
114                .flex()
115                .items_center()
116                .justify_center()
117                .flex_shrink_0()
118                .h(rem(BUTTON_HEIGHT))
119                .rounded_full()
120                .text_color(fg)
121                .when(preview == Some(InteractionPreview::Focused), |b| {
122                    b.bg(focused)
123                })
124        })
125        .state_layers(hover, press)
126        .on_click(move |event, window, cx| handler(event, window, cx));
127    match preview {
128        Some(state) => button.preview_state(state),
129        None => button,
130    }
131}
132
133/// Whether a closing snackbar has finished leaving.
134fn hidden(open: bool, shown: f32) -> bool {
135    !open && shown <= 0.
136}
137
138impl RenderOnce for Snackbar {
139    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
140        let id = self.id;
141        let child = |name: &'static str| ElementId::NamedChild(id.clone().into(), name.into());
142        let shown = motion::token_value(
143            child("shown"),
144            if self.open { 1. } else { 0. },
145            MotionToken::DefaultEffects,
146            window,
147            cx,
148        )
149        .clamp(0., 1.);
150        if hidden(self.open, shown) {
151            return div().id(id).hidden().into_any_element();
152        }
153        let preview = self.preview;
154        let label = TypeRole::LabelLarge.scale();
155        let action = self.action.map(|(text, handler)| {
156            inverse_button(child("action"), color(INVERSE_PRIMARY), handler, preview)
157                .when(cfg!(test), |b| {
158                    b.debug_selector(|| "snackbar-action".into())
159                })
160                .px(rem(12.))
161                .text_size(label.font_size)
162                .line_height(label.line_height)
163                .font_weight(FontWeight(label.font_weight as f32))
164                .child(text)
165        });
166        let close = self.close.map(|(text, handler)| {
167            div()
168                .flex()
169                .items_center()
170                .justify_center()
171                .flex_shrink_0()
172                .size(rem(CLOSE_BOX))
173                .child(
174                    inverse_button(child("close"), color(INVERSE_ON_SURFACE), handler, preview)
175                        .when(cfg!(test), |b| b.debug_selector(|| "snackbar-close".into()))
176                        .accessibility_label(text)
177                        .w(rem(BUTTON_HEIGHT))
178                        .child(IconName::Close.icon().size(SNACKBAR_CLOSE_ICON)),
179                )
180        });
181        let has_close = close.is_some();
182        let has_buttons = action.is_some() || has_close;
183        let body = TypeRole::BodyMedium.scale();
184        let message = accessible_text(child("message"), self.message)
185            .role(Role::Status)
186            .flex_1()
187            .min_w_0()
188            .py(rem(14.))
189            .whitespace_normal();
190        let buttons = div()
191            .flex()
192            .flex_shrink_0()
193            .items_center()
194            .children(action)
195            .children(close);
196        let surface = div()
197            .id(id.clone())
198            .flex()
199            .min_h(rem(48.))
200            .max_w(rem(560.))
201            .rounded(RADIUS_EXTRA_SMALL)
202            .bg(color(INVERSE_SURFACE))
203            .text_color(color(INVERSE_ON_SURFACE))
204            .text_size(body.font_size)
205            .line_height(body.line_height)
206            .shadow(elevation::level(SNACKBAR_ELEVATION));
207        let surface = if self.longer_action && has_buttons {
208            surface
209                .flex_col()
210                .gap(rem(10.))
211                .child(div().flex().px(rem(16.)).child(message.pb_0()))
212                .child(
213                    buttons
214                        .justify_end()
215                        .h(rem(48.))
216                        .pl(rem(8.))
217                        .pr(rem(if has_close { 0. } else { 8. })),
218                )
219        } else {
220            surface
221                .items_center()
222                .gap(rem(4.))
223                .pl(rem(16.))
224                .pr(rem(if has_close {
225                    0.
226                } else if has_buttons {
227                    8.
228                } else {
229                    16.
230                }))
231                .child(message)
232                .child(buttons)
233        };
234        // Rises and fades in on mount (reduced motion settles at once); the
235        // controlled exit sinks and fades on the same effects spring.
236        let surface = surface
237            .relative()
238            .top(rem(TRAVEL * (1. - shown)))
239            .opacity(shown);
240        if self.open {
241            motion::state_enter(child("enter"), surface)
242        } else {
243            surface.into_any_element()
244        }
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use core::prelude::v1::test;
252    use gpui::{Context, Modifiers, TestAppContext};
253
254    struct Harness {
255        buttons: bool,
256        longer: bool,
257        open: bool,
258        actions: usize,
259        closes: usize,
260    }
261
262    impl Render for Harness {
263        fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
264            window.set_rem_size(px(14.));
265            let snackbar = Snackbar::new("saved", "Icon saved")
266                .longer_action(self.longer)
267                .open(self.open);
268            let snackbar = if self.buttons {
269                snackbar
270                    .action("Undo", cx.listener(|this, _, _, _| this.actions += 1))
271                    .close("Dismiss", cx.listener(|this, _, _, _| this.closes += 1))
272            } else {
273                snackbar
274            };
275            div().size_full().child(
276                div()
277                    .w(px(344.))
278                    .debug_selector(|| "bar".into())
279                    .child(snackbar),
280            )
281        }
282    }
283
284    fn open(
285        cx: &mut TestAppContext,
286        buttons: bool,
287    ) -> (Entity<Harness>, &mut gpui::VisualTestContext) {
288        cx.update(|cx| cx.set_reduce_motion(true));
289        let (view, cx) = cx.add_window_view(move |_, _| Harness {
290            buttons,
291            longer: false,
292            open: true,
293            actions: 0,
294            closes: 0,
295        });
296        cx.run_until_parked();
297        (view, cx)
298    }
299
300    #[gpui::test]
301    fn action_and_close_report_their_own_clicks(cx: &mut TestAppContext) {
302        let (view, cx) = open(cx, true);
303        let action = cx.debug_bounds("snackbar-action").expect("action").center();
304        let close = cx.debug_bounds("snackbar-close").expect("close").center();
305        cx.simulate_click(action, Modifiers::default());
306        cx.simulate_click(close, Modifiers::default());
307        cx.simulate_click(close, Modifiers::default());
308        view.read_with(cx, |h, _| {
309            assert_eq!(h.actions, 1);
310            assert_eq!(h.closes, 2);
311        });
312    }
313
314    #[gpui::test]
315    fn single_line_is_48dp_and_the_close_target_is_40dp(cx: &mut TestAppContext) {
316        let (_, cx) = open(cx, true);
317        assert_eq!(cx.debug_bounds("bar").unwrap().size.height, px(48.));
318        let close = cx.debug_bounds("snackbar-close").unwrap();
319        assert_eq!(close.size, size(px(40.), px(40.)));
320        assert_eq!(SNACKBAR_CLOSE_ICON, rem(24.));
321    }
322
323    #[gpui::test]
324    fn a_longer_action_moves_below_the_message(cx: &mut TestAppContext) {
325        let (view, cx) = open(cx, true);
326        view.update(cx, |h, cx| {
327            h.longer = true;
328            cx.notify();
329        });
330        redraw(cx);
331        let bar = cx.debug_bounds("bar").unwrap();
332        let action = cx.debug_bounds("snackbar-action").unwrap();
333        assert!(
334            action.top() > bar.top() + px(40.),
335            "the action sits on its own row: {bar:?} {action:?}"
336        );
337        assert_eq!(bar.size.height, px(14. + 20. + 10. + 48.));
338    }
339
340    #[gpui::test]
341    fn closing_hides_once_the_exit_settles(cx: &mut TestAppContext) {
342        let (view, cx) = open(cx, true);
343        view.update(cx, |h, cx| {
344            h.open = false;
345            cx.notify();
346        });
347        redraw(cx);
348        cx.executor()
349            .advance_clock(std::time::Duration::from_secs(1));
350        redraw(cx);
351        assert!(cx.debug_bounds("snackbar-action").is_none());
352        assert!(hidden(false, 0.) && !hidden(true, 0.) && !hidden(false, 0.5));
353    }
354
355    #[test]
356    fn snackbar_uses_m3_elevation_level_3() {
357        assert_eq!(SNACKBAR_ELEVATION, 3);
358        assert_eq!(elevation::level(SNACKBAR_ELEVATION), elevation::level3());
359    }
360
361    #[gpui::test]
362    fn snackbar_buttons_show_the_ring_only_for_keyboard_focus(cx: &mut TestAppContext) {
363        cx.update(gpui_base::init);
364        let (_, cx) = open(cx, true);
365        let action = cx.debug_bounds("snackbar-action").expect("action");
366        cx.simulate_click(action.center(), Modifiers::default());
367        redraw(cx);
368        assert!(
369            crate::components::focus::test_rings::painted("action").is_none(),
370            "pointer focus must not show the ring"
371        );
372        press_key(cx);
373        assert_eq!(
374            crate::components::focus::test_rings::painted("action"),
375            Some(action.dilate(px(5.))),
376            "keyboard focus shows the 3dp ring 2dp outside the button"
377        );
378    }
379
380    fn redraw(cx: &mut gpui::VisualTestContext) {
381        cx.update(|window, cx| {
382            window.refresh();
383            window.draw(cx).clear(cx);
384        });
385    }
386
387    /// A key press switches the window to keyboard modality.
388    fn press_key(cx: &mut gpui::VisualTestContext) {
389        let keystroke =
390            cx.update(|_, cx| gpui::Keystroke::parse_with_os("a", cx.platform_info().os).unwrap());
391        cx.simulate_event(gpui::KeyDownEvent {
392            keystroke,
393            is_held: false,
394            prefer_character_input: false,
395        });
396        redraw(cx);
397    }
398
399    #[gpui::test]
400    fn a_plain_snackbar_has_no_buttons(cx: &mut TestAppContext) {
401        let (_, cx) = open(cx, false);
402        assert!(cx.debug_bounds("snackbar-action").is_none());
403        assert!(cx.debug_bounds("snackbar-close").is_none());
404    }
405}