Skip to main content

gpui_kit_material/components/
fab.rs

1//! M3 Expressive floating action buttons: the FAB (small, regular, medium,
2//! large) and the extended FAB (icon and label; regular, medium, large) with
3//! its expand/collapse animation.
4//!
5//! Both are [`MaterialButton`]s in a FAB form, so activation, focus, the
6//! state layer, ripple, focus ring and elevation transitions (level 3 at
7//! rest, 4 on hover) are the button's.
8use super::{
9    ButtonIconPosition, FabSize, InteractionPreview, MaterialButton,
10    button::{Form, Palette, Roles},
11};
12use crate::{
13    color::ColorRole,
14    icons::Icon,
15    motion::{self, MotionToken},
16    tokens::typography::{BASE_PX, TypeRole, rem},
17};
18use gpui::{prelude::*, *};
19
20/// FAB color styles.
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
22pub enum FabColor {
23    /// Surface container high with a primary icon (baseline surface FAB).
24    Surface,
25    #[default]
26    PrimaryContainer,
27    SecondaryContainer,
28    TertiaryContainer,
29    Primary,
30    Secondary,
31    Tertiary,
32}
33
34impl FabColor {
35    pub const ALL: [Self; 7] = [
36        Self::Surface,
37        Self::PrimaryContainer,
38        Self::SecondaryContainer,
39        Self::TertiaryContainer,
40        Self::Primary,
41        Self::Secondary,
42        Self::Tertiary,
43    ];
44    pub const fn name(self) -> &'static str {
45        match self {
46            Self::Surface => "surface",
47            Self::PrimaryContainer => "primary container",
48            Self::SecondaryContainer => "secondary container",
49            Self::TertiaryContainer => "tertiary container",
50            Self::Primary => "primary",
51            Self::Secondary => "secondary",
52            Self::Tertiary => "tertiary",
53        }
54    }
55    /// (container, content) roles.
56    pub const fn roles(self) -> (ColorRole, ColorRole) {
57        use ColorRole as C;
58        match self {
59            Self::Surface => (C::SURFACE_CONTAINER_HIGH, C::PRIMARY),
60            Self::PrimaryContainer => (C::PRIMARY_CONTAINER, C::ON_PRIMARY_CONTAINER),
61            Self::SecondaryContainer => (C::SECONDARY_CONTAINER, C::ON_SECONDARY_CONTAINER),
62            Self::TertiaryContainer => (C::TERTIARY_CONTAINER, C::ON_TERTIARY_CONTAINER),
63            Self::Primary => (C::PRIMARY, C::ON_PRIMARY),
64            Self::Secondary => (C::SECONDARY, C::ON_SECONDARY),
65            Self::Tertiary => (C::TERTIARY, C::ON_TERTIARY),
66        }
67    }
68    pub(crate) const fn palette(self, elevation: (u8, u8)) -> Palette {
69        let (container, content) = self.roles();
70        Palette {
71            rest: Roles {
72                container: Some(container),
73                content,
74            },
75            selected: None,
76            disabled_container: true,
77            elevation,
78        }
79    }
80}
81
82/// FAB elevation: level 3 at rest, focused and pressed; 4 when hovered.
83pub(crate) const FAB_ELEVATION: (u8, u8) = (3, 4);
84/// Lowered FAB elevation: level 1 at rest, 2 when hovered.
85pub(crate) const LOWERED_ELEVATION: (u8, u8) = (1, 2);
86
87/// The (resting, hovered) elevation of a FAB, lowered or not.
88pub(crate) const fn fab_elevation(lowered: bool) -> (u8, u8) {
89    if lowered {
90        LOWERED_ELEVATION
91    } else {
92        FAB_ELEVATION
93    }
94}
95
96type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
97
98macro_rules! fab_builders {
99    () => {
100        pub fn size(mut self, size: FabSize) -> Self {
101            self.size = size;
102            self
103        }
104        pub fn color(mut self, color: FabColor) -> Self {
105            self.color = color;
106            self
107        }
108        /// The lowered FAB: elevation level 1 (2 on hover) instead of 3
109        /// (4), for surfaces where the FAB should not float as high.
110        pub fn lowered(mut self, lowered: bool) -> Self {
111            self.lowered = lowered;
112            self
113        }
114        /// Disabled: on-surface at 10% container and 38% content, no
115        /// elevation, no activation.
116        pub fn disabled(mut self, disabled: bool) -> Self {
117            self.disabled = disabled;
118            self
119        }
120        /// Render as a static specimen in one interaction state.
121        pub fn preview_state(mut self, state: InteractionPreview) -> Self {
122            self.preview = Some(state);
123            self
124        }
125        pub fn track_focus(mut self, focus: &FocusHandle) -> Self {
126            self.focus = Some(focus.clone());
127            self
128        }
129        pub fn on_click(
130            mut self,
131            callback: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
132        ) -> Self {
133            self.on_click = Some(Box::new(callback));
134            self
135        }
136    };
137}
138
139/// A square FAB holding one icon. `label` names it for assistive technology.
140#[derive(gpui::IntoElement)]
141pub struct Fab {
142    id: ElementId,
143    icon: Icon,
144    label: SharedString,
145    size: FabSize,
146    color: FabColor,
147    lowered: bool,
148    disabled: bool,
149    preview: Option<InteractionPreview>,
150    focus: Option<FocusHandle>,
151    on_click: Option<ClickHandler>,
152}
153
154impl Fab {
155    pub fn new(
156        id: impl Into<ElementId>,
157        icon: impl Into<Icon>,
158        label: impl Into<SharedString>,
159    ) -> Self {
160        Self {
161            id: id.into(),
162            icon: icon.into(),
163            label: label.into(),
164            size: FabSize::Regular,
165            color: FabColor::PrimaryContainer,
166            lowered: false,
167            disabled: false,
168            preview: None,
169            focus: None,
170            on_click: None,
171        }
172    }
173    fab_builders!();
174}
175
176fn finish(
177    mut button: MaterialButton,
178    preview: Option<InteractionPreview>,
179    focus: Option<FocusHandle>,
180    on_click: Option<ClickHandler>,
181) -> MaterialButton {
182    if let Some(preview) = preview {
183        button = button.preview_state(preview);
184    }
185    if let Some(focus) = &focus {
186        button = button.track_focus(focus);
187    }
188    if let Some(callback) = on_click {
189        button = button.on_click(callback);
190    }
191    button
192}
193
194impl RenderOnce for Fab {
195    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
196        finish(
197            MaterialButton::new(self.id)
198                .form(Form::Fab(self.size))
199                .palette(self.color.palette(fab_elevation(self.lowered)))
200                .disabled(self.disabled)
201                .icon(self.icon)
202                .accessibility_label(self.label),
203            self.preview,
204            self.focus,
205            self.on_click,
206        )
207    }
208}
209
210/// An extended FAB: icon and visible label. `expanded(false)` collapses it
211/// to a FAB of the same height, the label clipping away while the padding
212/// and icon-label gap close on the default spatial spring (reduced motion
213/// jumps). A label-only FAB ([`ExtendedFab::label_only`]) has nothing to
214/// collapse to and stays extended.
215#[derive(gpui::IntoElement)]
216pub struct ExtendedFab {
217    id: ElementId,
218    icon: Option<Icon>,
219    icon_position: ButtonIconPosition,
220    label: SharedString,
221    size: FabSize,
222    color: FabColor,
223    lowered: bool,
224    disabled: bool,
225    fluid: bool,
226    expanded: bool,
227    preview: Option<InteractionPreview>,
228    focus: Option<FocusHandle>,
229    on_click: Option<ClickHandler>,
230}
231
232impl ExtendedFab {
233    pub fn new(
234        id: impl Into<ElementId>,
235        icon: impl Into<Icon>,
236        label: impl Into<SharedString>,
237    ) -> Self {
238        Self {
239            icon: Some(icon.into()),
240            ..Self::label_only(id, label)
241        }
242    }
243    /// An extended FAB showing only its label.
244    pub fn label_only(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
245        Self {
246            id: id.into(),
247            icon: None,
248            icon_position: ButtonIconPosition::Leading,
249            label: label.into(),
250            size: FabSize::Regular,
251            color: FabColor::PrimaryContainer,
252            lowered: false,
253            disabled: false,
254            fluid: false,
255            expanded: true,
256            preview: None,
257            focus: None,
258            on_click: None,
259        }
260    }
261    pub fn expanded(mut self, expanded: bool) -> Self {
262        self.expanded = expanded;
263        self
264    }
265    /// The icon before (default) or after the label.
266    pub fn icon_position(mut self, position: ButtonIconPosition) -> Self {
267        self.icon_position = position;
268        self
269    }
270    /// Fill the parent's width (content centered) instead of fitting the
271    /// content.
272    pub fn fluid(mut self, fluid: bool) -> Self {
273        self.fluid = fluid;
274        self
275    }
276    fab_builders!();
277
278    /// The extension the FAB animates toward: collapsing needs an icon.
279    fn target_expansion(&self) -> f32 {
280        if self.expanded || self.icon.is_none() {
281            1.
282        } else {
283            0.
284        }
285    }
286}
287
288/// The label's width in design px.
289fn label_width(label: &SharedString, role: TypeRole, window: &Window) -> f32 {
290    let scale = role.scale();
291    let font_size = scale.font_size.to_pixels(window.rem_size());
292    let run = TextRun {
293        len: label.len(),
294        font: Font {
295            weight: FontWeight(scale.font_weight as f32),
296            ..font(scale.font_family)
297        },
298        color: Hsla::default(),
299        background_color: None,
300        underline: None,
301        strikethrough: None,
302    };
303    let width = window
304        .text_system()
305        .shape_line(label.clone(), font_size, &[run], None)
306        .width;
307    f32::from(width) * BASE_PX / f32::from(window.rem_size())
308}
309
310/// A label revealed to `expansion` (0 … 1) of its width: absent at 0, laid
311/// out naturally at 1, and only measured and clipped while it moves.
312pub(super) fn revealed_label(
313    label: &SharedString,
314    role: TypeRole,
315    expansion: f32,
316    window: &Window,
317) -> Option<AnyElement> {
318    (expansion > 0.).then(|| {
319        let text = div().whitespace_nowrap().child(label.clone());
320        if expansion >= 1. {
321            text.into_any_element()
322        } else {
323            let width = label_width(label, role, window) * expansion;
324            div()
325                .overflow_hidden()
326                .flex_shrink_0()
327                .w(rem(width))
328                .h(role.scale().line_height)
329                .opacity(expansion.clamp(0., 1.))
330                .child(text)
331                .into_any_element()
332        }
333    })
334}
335
336impl RenderOnce for ExtendedFab {
337    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
338        let expansion = motion::token_value(
339            (self.id.clone(), "fab-expansion"),
340            self.target_expansion(),
341            MotionToken::DefaultSpatial,
342            window,
343            cx,
344        );
345        let (_, _, _, role) = self.size.extended_size().extended();
346        let label = revealed_label(&self.label, role, expansion, window);
347        finish(
348            MaterialButton::new(self.id)
349                .form(Form::ExtendedFab {
350                    size: self.size,
351                    expansion,
352                })
353                .palette(self.color.palette(fab_elevation(self.lowered)))
354                .disabled(self.disabled)
355                .when_some(self.icon, |button, icon| button.icon(icon))
356                .icon_position(self.icon_position)
357                .accessibility_label(self.label)
358                .children(label)
359                .when(self.fluid, |button| button.w_full()),
360            self.preview,
361            self.focus,
362            self.on_click,
363        )
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::{ExtendedFab, FAB_ELEVATION, Fab, FabColor, FabSize, fab_elevation};
370    use crate::components::ButtonIconPosition;
371    use crate::{color::ColorRole, icons::IconName, motion};
372    use gpui::{
373        Context, InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _,
374        TestAppContext, VisualTestContext, Window, div, px, size,
375    };
376    use std::time::Duration;
377
378    struct Harness {
379        expanded: bool,
380    }
381    impl Render for Harness {
382        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
383            window.set_rem_size(px(14.));
384            div()
385                .flex()
386                .flex_col()
387                .items_start()
388                .p(px(40.))
389                .gap(px(20.))
390                .child(
391                    div()
392                        .flex()
393                        .debug_selector(|| "fab".into())
394                        .child(Fab::new("fab", IconName::Check, "Compose").size(FabSize::Large)),
395                )
396                .child(
397                    div().flex().debug_selector(|| "extended".into()).child(
398                        ExtendedFab::new("extended", IconName::Check, "Compose")
399                            .size(FabSize::Medium)
400                            .expanded(self.expanded),
401                    ),
402                )
403        }
404    }
405
406    fn draw(cx: &mut VisualTestContext) {
407        cx.update(|window, cx| {
408            window.refresh();
409            window.draw(cx).clear(cx);
410        });
411    }
412
413    #[gpui::test]
414    fn extended_fab_collapses_to_a_square_through_intermediate_widths(cx: &mut TestAppContext) {
415        cx.update(|cx| {
416            gpui_base::init(cx);
417            cx.set_reduce_motion(false);
418            motion::set_motion_scheme(motion::MotionScheme::Expressive);
419        });
420        let (view, cx) = cx.add_window_view(|_, _| Harness { expanded: true });
421        draw(cx);
422        let fab = cx.debug_bounds("fab").unwrap();
423        assert_eq!(fab.size, size(px(96.), px(96.)));
424        let extended = cx.debug_bounds("extended").unwrap();
425        assert_eq!(extended.size.height, px(80.));
426        assert!(extended.size.width > px(140.), "{extended:?}");
427        view.update(cx, |view, cx| {
428            view.expanded = false;
429            cx.notify();
430        });
431        draw(cx);
432        cx.executor().advance_clock(Duration::from_millis(80));
433        draw(cx);
434        let moving = cx.debug_bounds("extended").unwrap().size.width;
435        assert!(
436            moving < extended.size.width && moving > px(80.),
437            "{moving:?}"
438        );
439        cx.executor().advance_clock(Duration::from_secs(2));
440        draw(cx);
441        draw(cx);
442        let collapsed = cx.debug_bounds("extended").unwrap().size;
443        assert_eq!(collapsed, size(px(80.), px(80.)));
444    }
445
446    #[core::prelude::v1::test]
447    fn color_styles_pair_containers_with_their_content() {
448        assert_eq!(
449            FabColor::TertiaryContainer.roles(),
450            (
451                ColorRole::TERTIARY_CONTAINER,
452                ColorRole::ON_TERTIARY_CONTAINER
453            )
454        );
455        assert_eq!(
456            FabColor::Surface.roles(),
457            (ColorRole::SURFACE_CONTAINER_HIGH, ColorRole::PRIMARY)
458        );
459        assert_eq!(FabColor::Primary.palette(FAB_ELEVATION).elevation, (3, 4));
460    }
461
462    #[core::prelude::v1::test]
463    fn lowered_fabs_float_two_levels_lower() {
464        assert_eq!(fab_elevation(false), (3, 4));
465        assert_eq!(fab_elevation(true), (1, 2));
466        let fab = Fab::new("fab", IconName::Check, "Add").lowered(true);
467        assert!(fab.lowered);
468    }
469
470    #[core::prelude::v1::test]
471    fn only_a_fab_with_an_icon_collapses() {
472        let collapsed = ExtendedFab::new("fab", IconName::Check, "Compose").expanded(false);
473        assert_eq!(collapsed.target_expansion(), 0.);
474        let label_only = ExtendedFab::label_only("fab", "Compose").expanded(false);
475        assert_eq!(label_only.target_expansion(), 1.);
476    }
477
478    struct Variants {
479        clicks: std::rc::Rc<std::cell::Cell<usize>>,
480    }
481    impl Render for Variants {
482        fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
483            window.set_rem_size(px(14.));
484            let clicks = self.clicks.clone();
485            div()
486                .w(px(400.))
487                .flex()
488                .flex_col()
489                .items_start()
490                .gap(px(20.))
491                .child(
492                    div().flex().debug_selector(|| "end".into()).child(
493                        ExtendedFab::new("end", IconName::Check, "Compose")
494                            .icon_position(ButtonIconPosition::Trailing),
495                    ),
496                )
497                .child(
498                    div()
499                        .flex()
500                        .debug_selector(|| "label-only".into())
501                        .child(ExtendedFab::label_only("label-only", "Compose").expanded(false)),
502                )
503                .child(
504                    div()
505                        .w_full()
506                        .flex()
507                        .debug_selector(|| "fluid".into())
508                        .child(ExtendedFab::new("fluid", IconName::Check, "Compose").fluid(true)),
509                )
510                .child(
511                    div().flex().debug_selector(|| "disabled".into()).child(
512                        Fab::new("disabled", IconName::Check, "Add")
513                            .disabled(true)
514                            .on_click(move |_, _, _| clicks.set(clicks.get() + 1)),
515                    ),
516                )
517        }
518    }
519
520    #[gpui::test]
521    fn extended_fab_options_lay_out_and_disabled_fabs_ignore_clicks(cx: &mut TestAppContext) {
522        cx.update(|cx| {
523            gpui_base::init(cx);
524            cx.set_reduce_motion(true);
525        });
526        let clicks = std::rc::Rc::new(std::cell::Cell::new(0));
527        let (_, cx) = cx.add_window_view({
528            let clicks = clicks.clone();
529            move |_, _| Variants { clicks }
530        });
531        draw(cx);
532        draw(cx);
533        let label_only = cx.debug_bounds("label-only").unwrap().size;
534        assert_eq!(label_only.height, px(56.));
535        assert!(
536            label_only.width > px(56.),
537            "a label-only FAB stays extended"
538        );
539        assert_eq!(cx.debug_bounds("fluid").unwrap().size.width, px(400.));
540        let end = cx.debug_bounds("end").unwrap().size;
541        assert!(end.width > px(100.));
542        let disabled = cx.debug_bounds("disabled").unwrap();
543        cx.simulate_click(disabled.center(), gpui::Modifiers::none());
544        draw(cx);
545        assert_eq!(clicks.get(), 0);
546    }
547}