Skip to main content

gpui_kit_material/components/
sheets.rs

1//! M3 sheets: side sheets (standard and modal) and bottom sheets (standard
2//! and modal, with a drag handle, drag-to-dismiss and snapping).
3//!
4//! Visuals follow the Figma "Material 3 Design Kit" `Side Sheet` and
5//! `Bottom sheet` components: a standard side sheet is the surface with an
6//! outline-variant divider on its leading edge; a modal side sheet is
7//! surface-container-low with 16dp corners over the 32% scrim. Its header
8//! holds an optional back button, a title-large on-surface-variant headline
9//! and a close button; actions sit under an outline-variant divider. A bottom
10//! sheet is surface-container-low with 28dp top corners and a 32×4 outline
11//! drag handle in a 36dp header; standard bottom sheets float at level 1,
12//! modal ones over the scrim.
13//!
14//! Side sheets dock to either edge ([`SideSheetEdge`]). A bottom sheet's
15//! detent can be controlled ([`BottomSheetDetent`]: hidden, partial or
16//! expanded) and reported back as drags and its handle move it; its width
17//! caps at 640dp or a caller's maximum. Whether the scrim and Escape
18//! dismiss a modal sheet is configurable, and a `contained` modal sheet
19//! presents over its positioned parent instead of the whole window.
20//!
21//! Modal behavior (focus trap, Escape, scrim dismissal) is GPUI Base's
22//! [`gpui_base::Sheet`] host; Material adds focus into and back out of the
23//! sheet and the motion. Sheets enter and leave on the default spatial
24//! spring with fades on the default effects spring; a closed sheet's content
25//! leaves the tree once the close settles, so it offers no tab stops or
26//! accessibility nodes off screen.
27use super::dialog::{
28    OverlayFrame, container_state, header_icon_button, modal_focus, overlay_area, overlay_frame,
29    place_host, scrim, with_container_probe,
30};
31use crate::{
32    color::ColorRole,
33    icons::{Icon, IconName, Style, Symbol},
34    motion::{self, MotionToken},
35    theme::color,
36    tokens::{
37        elevation,
38        typography::{TypeRole, rem},
39    },
40};
41use gpui::{prelude::*, *};
42use std::{rc::Rc, sync::OnceLock};
43
44#[cfg(not(target_family = "wasm"))]
45use std::time::Instant;
46#[cfg(target_family = "wasm")]
47use web_time::Instant;
48
49type Handler = Rc<dyn Fn(&mut Window, &mut App)>;
50type DetentHandler = Rc<dyn Fn(BottomSheetDetent, &mut Window, &mut App)>;
51
52/// The modal side sheet's corners and the bottom sheet's top corners.
53const SIDE_SHEET_RADIUS: Pixels = px(16.);
54const BOTTOM_SHEET_RADIUS: Pixels = px(28.);
55/// M3 caps a bottom sheet's width on large windows.
56const BOTTOM_SHEET_MAX_WIDTH: f32 = 640.;
57/// Pointer travel before a press on a bottom sheet becomes a drag.
58const DRAG_SLOP: f32 = 4.;
59
60fn typed<E: Styled>(element: E, role: TypeRole) -> E {
61    let scale = role.scale();
62    element
63        .font_family(scale.font_family)
64        .text_size(scale.font_size)
65        .line_height(scale.line_height)
66        .font_weight(FontWeight(scale.font_weight as f32))
67}
68
69fn arrow_back() -> Icon {
70    static PATH: OnceLock<String> = OnceLock::new();
71    Icon::new(SharedString::new_static(
72        PATH.get_or_init(|| {
73            Symbol::named("arrow_back")
74                .expect("a Material Symbol")
75                .icon()
76                .style(Style::Rounded)
77                .asset_path()
78        })
79        .as_str(),
80    ))
81}
82
83/// The edge a side sheet docks to.
84#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
85pub enum SideSheetEdge {
86    Start,
87    /// The trailing edge (M3's default).
88    #[default]
89    End,
90}
91
92/// Whether a sheet has finished closing: closed, with its reveal settled.
93fn sheet_hidden(open: bool, reveal: f32) -> bool {
94    !open && reveal == 0.
95}
96
97/// An M3 side sheet. By default it is a nonmodal standard sheet docked to
98/// its parent's trailing edge that leaves the main content interactive;
99/// [`MaterialSideSheet::modal`] presents it over the window instead, and
100/// [`MaterialSideSheet::specimen`] renders it in place. Its controlled
101/// visibility retargets the spatial spring, including on close.
102#[derive(gpui::IntoElement)]
103pub struct MaterialSideSheet {
104    id: ElementId,
105    open: bool,
106    modal: bool,
107    specimen: bool,
108    edge: SideSheetEdge,
109    contained: bool,
110    dismiss_on_scrim: bool,
111    dismiss_on_escape: bool,
112    width: Rems,
113    headline: Option<SharedString>,
114    label: Option<SharedString>,
115    back: Option<(SharedString, Handler)>,
116    close_label: Option<SharedString>,
117    actions: Vec<AnyElement>,
118    on_dismiss: Option<Handler>,
119    children: Vec<AnyElement>,
120}
121
122impl MaterialSideSheet {
123    pub fn new(id: impl Into<ElementId>) -> Self {
124        Self {
125            id: id.into(),
126            open: false,
127            modal: false,
128            specimen: false,
129            edge: SideSheetEdge::End,
130            contained: false,
131            dismiss_on_scrim: true,
132            dismiss_on_escape: true,
133            width: rem(320.),
134            headline: None,
135            label: None,
136            back: None,
137            close_label: None,
138            actions: Vec::new(),
139            on_dismiss: None,
140            children: vec![],
141        }
142    }
143    pub fn open(mut self, open: bool) -> Self {
144        self.open = open;
145        self
146    }
147    pub fn width(mut self, width: Rems) -> Self {
148        self.width = width;
149        self
150    }
151    /// Present over the window with a scrim; Escape, the scrim and the close
152    /// button dismiss it.
153    pub fn modal(mut self, modal: bool) -> Self {
154        self.modal = modal;
155        self
156    }
157    /// Render the sheet's surface in place, without motion (a specimen).
158    pub fn specimen(mut self, specimen: bool) -> Self {
159        self.specimen = specimen;
160        self
161    }
162    /// The edge the sheet docks to (default the trailing edge).
163    pub fn edge(mut self, edge: SideSheetEdge) -> Self {
164        self.edge = edge;
165        self
166    }
167    /// Present the modal sheet over its positioned parent's bounds instead
168    /// of the whole window.
169    pub fn contained(mut self, contained: bool) -> Self {
170        self.contained = contained;
171        self
172    }
173    /// Whether pressing the modal sheet's scrim dismisses it (default true).
174    pub fn dismiss_on_scrim(mut self, dismiss: bool) -> Self {
175        self.dismiss_on_scrim = dismiss;
176        self
177    }
178    /// Whether Escape dismisses the modal sheet (default true).
179    pub fn dismiss_on_escape(mut self, dismiss: bool) -> Self {
180        self.dismiss_on_escape = dismiss;
181        self
182    }
183    /// The title-large headline in the header.
184    pub fn headline(mut self, headline: impl Into<SharedString>) -> Self {
185        self.headline = Some(headline.into());
186        self
187    }
188    /// The modal sheet's accessible name when it differs from (or there is
189    /// no) headline.
190    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
191        self.label = Some(label.into());
192        self
193    }
194    /// A leading back button named `label`.
195    pub fn back(
196        mut self,
197        label: impl Into<SharedString>,
198        on_back: impl Fn(&mut Window, &mut App) + 'static,
199    ) -> Self {
200        self.back = Some((label.into(), Rc::new(on_back)));
201        self
202    }
203    /// A trailing close button named `label`; it calls `on_dismiss`.
204    pub fn close_button(mut self, label: impl Into<SharedString>) -> Self {
205        self.close_label = Some(label.into());
206        self
207    }
208    /// An action under the divider at the sheet's bottom, in call order.
209    pub fn action(mut self, action: impl IntoElement) -> Self {
210        self.actions.push(action.into_any_element());
211        self
212    }
213    pub fn on_dismiss(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
214        self.on_dismiss = Some(Rc::new(f));
215        self
216    }
217}
218
219impl ParentElement for MaterialSideSheet {
220    fn extend(&mut self, children: impl IntoIterator<Item = AnyElement>) {
221        self.children.extend(children);
222    }
223}
224
225impl MaterialSideSheet {
226    fn has_header(&self) -> bool {
227        self.headline.is_some() || self.back.is_some() || self.close_label.is_some()
228    }
229
230    /// The header, content and actions in a column.
231    fn body(self) -> Div {
232        let has_header = self.has_header();
233        let dismiss = self.on_dismiss.clone();
234        let back = self.back;
235        let header = has_header.then(|| {
236            div()
237                .flex()
238                .items_start()
239                .pt(rem(12.))
240                .pr(rem(12.))
241                .pb(rem(16.))
242                .pl(rem(if back.is_some() { 4. } else { 24. }))
243                .when_some(back, |header, (label, on_back)| {
244                    header.child(header_icon_button(
245                        "sheet-back",
246                        arrow_back(),
247                        label,
248                        move |_, window, cx| on_back(window, cx),
249                    ))
250                })
251                .child(div().flex_1().min_w_0().h(rem(40.)).pt(rem(12.)).when_some(
252                    self.headline,
253                    |content, headline| {
254                        content.child(
255                            typed(div(), TypeRole::TitleLarge)
256                                .truncate()
257                                .text_color(color(ColorRole::ON_SURFACE_VARIANT))
258                                .child(headline),
259                        )
260                    },
261                ))
262                .when_some(self.close_label, |header, label| {
263                    header.child(header_icon_button(
264                        "sheet-close",
265                        IconName::Close.icon(),
266                        label,
267                        move |_, window, cx| {
268                            if let Some(dismiss) = &dismiss {
269                                dismiss(window, cx);
270                            }
271                        },
272                    ))
273                })
274        });
275        let has_actions = !self.actions.is_empty();
276        div()
277            .flex()
278            .flex_col()
279            .size_full()
280            .when(has_actions, |body| body.pb(rem(20.)))
281            .children(header)
282            .child(
283                div()
284                    .flex()
285                    .flex_col()
286                    .flex_1()
287                    .min_h_0()
288                    .children(self.children),
289            )
290            .when(has_actions, |body| {
291                body.child(
292                    div()
293                        .h(px(1.))
294                        .w_full()
295                        .bg(color(ColorRole::OUTLINE_VARIANT)),
296                )
297                .child(
298                    div()
299                        .flex()
300                        .items_center()
301                        .gap(rem(8.))
302                        .pt(rem(12.))
303                        .px(rem(24.))
304                        .children(self.actions),
305                )
306            })
307    }
308
309    /// The modal container: surface-container-low with 16dp corners.
310    fn modal_surface(self, corners: Corners<Pixels>) -> Div {
311        let width = self.width;
312        div()
313            .w(width)
314            .h_full()
315            .overflow_hidden()
316            .rounded_tl(corners.top_left)
317            .rounded_tr(corners.top_right)
318            .rounded_br(corners.bottom_right)
319            .rounded_bl(corners.bottom_left)
320            .bg(color(ColorRole::SURFACE_CONTAINER_LOW))
321            .child(self.body())
322    }
323
324    /// The standard container: the surface with a divider on the edge
325    /// facing the content.
326    fn standard_surface(self) -> Div {
327        let start = self.edge == SideSheetEdge::Start;
328        div()
329            .size_full()
330            .flex()
331            .when(start, |sheet| sheet.flex_row_reverse())
332            .bg(color(ColorRole::SURFACE))
333            .child(
334                div()
335                    .w(px(1.))
336                    .h_full()
337                    .flex_shrink_0()
338                    .bg(color(ColorRole::OUTLINE_VARIANT)),
339            )
340            .child(div().flex_1().min_w_0().h_full().child(self.body()))
341    }
342}
343
344impl RenderOnce for MaterialSideSheet {
345    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
346        if self.specimen {
347            let width = self.width;
348            let id = self.id.clone();
349            return if self.modal {
350                div()
351                    .id(id)
352                    .h_full()
353                    .child(self.modal_surface(Corners::all(SIDE_SHEET_RADIUS)))
354                    .into_any_element()
355            } else {
356                div()
357                    .id(id)
358                    .w(width)
359                    .h_full()
360                    .child(self.standard_surface())
361                    .into_any_element()
362            };
363        }
364        let start = self.edge == SideSheetEdge::Start;
365        if self.modal {
366            let id = self.id.clone();
367            let open = self.open;
368            let container = self.contained.then(|| container_state(&id, window, cx));
369            let frame = overlay_frame(&id, open, window, cx);
370            let focus = modal_focus(&id, open, window, cx);
371            if frame.settled_closed(open) {
372                return with_container_probe(container, div().into_any_element());
373            }
374            let Some(area) = overlay_area(container.as_ref(), window, cx) else {
375                return with_container_probe(container, div().into_any_element());
376            };
377            let width = self.width.to_pixels(window.rem_size());
378            let dismiss = self.on_dismiss.clone().filter(|_| open);
379            let dismissals = Dismissals {
380                escape: dismiss.clone().filter(|_| self.dismiss_on_escape),
381                scrim: dismiss.filter(|_| self.dismiss_on_scrim),
382            };
383            let name = self.label.clone().or_else(|| self.headline.clone());
384            let corners = if start {
385                Corners {
386                    top_right: SIDE_SHEET_RADIUS,
387                    bottom_right: SIDE_SHEET_RADIUS,
388                    ..Corners::default()
389                }
390            } else {
391                Corners {
392                    top_left: SIDE_SHEET_RADIUS,
393                    bottom_left: SIDE_SHEET_RADIUS,
394                    ..Corners::default()
395                }
396            };
397            let inset = width * (frame.spatial - 1.);
398            let surface = div()
399                .id(id)
400                .role(Role::Dialog)
401                .when_some(name, |surface, name| surface.aria_label(name))
402                .absolute()
403                .top_0()
404                .bottom_0()
405                .map(|surface| {
406                    if start {
407                        surface.left(inset)
408                    } else {
409                        surface.right(inset)
410                    }
411                })
412                .opacity(frame.opacity())
413                .occlude()
414                .child(self.modal_surface(corners));
415            let area = container.is_some().then_some(area);
416            return with_container_probe(
417                container,
418                modal_host(focus, frame, dismissals, area, surface).into_any_element(),
419            );
420        }
421        let reveal = motion::value(
422            (self.id.clone(), "reveal"),
423            if self.open { 1. } else { 0. },
424            window,
425            cx,
426        );
427        let progress = reveal.clamp(0., 1.);
428        let width = self.width.to_pixels(window.rem_size());
429        let hidden = sheet_hidden(self.open, reveal);
430        let id = self.id.clone();
431        let inset = width * (progress - 1.);
432        div()
433            .id(id)
434            .absolute()
435            .top_0()
436            .bottom_0()
437            .map(|sheet| {
438                if start {
439                    sheet.left(inset)
440                } else {
441                    sheet.right(inset)
442                }
443            })
444            .w(width)
445            .opacity(progress)
446            // Once the close has settled, the sheet's content leaves the tree,
447            // so it offers no tab stops or accessibility nodes off screen. The
448            // spring lands exactly on its target; an overshoot does not.
449            .when(!hidden, |sheet| sheet.child(self.standard_surface()))
450            .into_any_element()
451    }
452}
453
454/// What dismisses a modal sheet: Escape and the scrim, each optional.
455struct Dismissals {
456    escape: Option<Handler>,
457    scrim: Option<Handler>,
458}
459
460/// Base's modal sheet host over the window (or `area` of it), with the
461/// Material scrim.
462fn modal_host(
463    focus: FocusHandle,
464    frame: OverlayFrame,
465    dismissals: Dismissals,
466    area: Option<Bounds<Pixels>>,
467    surface: impl IntoElement,
468) -> impl IntoElement {
469    deferred(
470        ModalSheetHost {
471            focus,
472            fade: frame.fade,
473            dismissals,
474            area,
475            surface: surface.into_any_element(),
476        }
477        .into_any_element(),
478    )
479    .with_priority(10)
480}
481
482#[derive(gpui::IntoElement)]
483struct ModalSheetHost {
484    focus: FocusHandle,
485    fade: f32,
486    dismissals: Dismissals,
487    area: Option<Bounds<Pixels>>,
488    surface: AnyElement,
489}
490
491impl RenderOnce for ModalSheetHost {
492    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
493        let Dismissals {
494            escape,
495            scrim: on_scrim,
496        } = self.dismissals;
497        // Base's host closes on Escape and the overlay through one callback;
498        // the scrim dismisses itself so each can be turned off alone. The
499        // scrim still blocks the pointer when it does not dismiss.
500        let overlay = scrim(self.fade).on_mouse_down(MouseButton::Left, move |_, window, cx| {
501            cx.stop_propagation();
502            if let Some(dismiss) = &on_scrim {
503                dismiss(window, cx);
504            }
505        });
506        let host = gpui_base::Sheet::new(cx)
507            .focus_handle(self.focus)
508            .overlay_interactive(false)
509            .overlay_closable(false)
510            .on_close(move |_, window, cx| {
511                if let Some(dismiss) = &escape {
512                    dismiss(window, cx);
513                }
514            })
515            .overlay(overlay)
516            .surface(self.surface);
517        match self.area {
518            Some(area) => place_host(host, area),
519            None => host,
520        }
521    }
522}
523
524/// An in-progress drag of a bottom sheet, in window pixels.
525#[derive(Clone, Copy, Debug, PartialEq)]
526struct Drag {
527    start: f32,
528    offset: f32,
529    moved: bool,
530}
531
532/// A snap after release: the residual offset eases from `from` to 0.
533#[derive(Clone, Copy, Debug)]
534struct Settle {
535    from: f32,
536    started: Instant,
537}
538
539struct BottomSheetState {
540    height: Pixels,
541    /// Whether `height` is the sheet's measured height yet (it starts as
542    /// the viewport's).
543    measured: bool,
544    expanded: bool,
545    /// The controlled detent last applied, to apply changes once.
546    applied: Option<BottomSheetDetent>,
547    /// Whether the last press moved the sheet: the handle's click that
548    /// ends a drag does not toggle the detent.
549    dragged: bool,
550    drag: Option<Drag>,
551    settle: Option<Settle>,
552}
553
554/// A bottom sheet's rest position (md3.io's State).
555#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
556pub enum BottomSheetDetent {
557    /// Closed.
558    #[default]
559    Hidden,
560    /// Showing its partial height ([`MaterialBottomSheet::partial_height`]);
561    /// without one, the same as expanded.
562    Partial,
563    Expanded,
564}
565
566/// Where a released bottom sheet goes.
567#[derive(Clone, Copy, Debug, PartialEq, Eq)]
568pub(crate) enum SnapTarget {
569    Expanded,
570    Partial,
571    Dismiss,
572}
573
574/// The rest position nearest to `position` (px below fully expanded) for a
575/// sheet `height` tall, with an optional partial detent showing `partial`.
576pub(crate) fn snap_target(position: f32, height: f32, partial: Option<f32>) -> SnapTarget {
577    let mut best = (SnapTarget::Expanded, position.abs());
578    if let Some(partial) = partial.filter(|&p| p < height) {
579        let at = height - partial;
580        if (position - at).abs() < best.1 {
581            best = (SnapTarget::Partial, (position - at).abs());
582        }
583    }
584    if (position - height).abs() < best.1 {
585        best = (SnapTarget::Dismiss, 0.);
586    }
587    best.0
588}
589
590/// An M3 bottom sheet. Standard sheets dock to their parent's bottom edge
591/// and leave the content interactive; modal ones present over the window.
592/// Dragging the sheet down past its nearest rest dismisses it; otherwise it
593/// snaps to the expanded or partial detent.
594#[derive(gpui::IntoElement)]
595pub struct MaterialBottomSheet {
596    id: ElementId,
597    open: Option<bool>,
598    detent: Option<BottomSheetDetent>,
599    modal: bool,
600    contained: bool,
601    dismiss_on_scrim: bool,
602    dismiss_on_escape: bool,
603    label: Option<SharedString>,
604    drag_handle: Option<SharedString>,
605    partial: Option<Rems>,
606    max_width: Rems,
607    on_dismiss: Option<Handler>,
608    on_detent_change: Option<DetentHandler>,
609    children: Vec<AnyElement>,
610}
611
612impl MaterialBottomSheet {
613    pub fn new(id: impl Into<ElementId>) -> Self {
614        Self {
615            id: id.into(),
616            open: None,
617            detent: None,
618            modal: false,
619            contained: false,
620            dismiss_on_scrim: true,
621            dismiss_on_escape: true,
622            label: None,
623            drag_handle: None,
624            partial: None,
625            max_width: rem(BOTTOM_SHEET_MAX_WIDTH),
626            on_dismiss: None,
627            on_detent_change: None,
628            children: Vec::new(),
629        }
630    }
631    /// Present the sheet while `open`. Without it the sheet renders in place
632    /// (a specimen).
633    pub fn open(mut self, open: bool) -> Self {
634        self.open = Some(open);
635        self
636    }
637    /// Present the sheet at `detent` (controlled): hidden closes it, and a
638    /// change moves it to the partial or expanded detent. Report the
639    /// person's changes with [`Self::on_detent_change`].
640    pub fn detent(mut self, detent: BottomSheetDetent) -> Self {
641        self.detent = Some(detent);
642        self.open = Some(detent != BottomSheetDetent::Hidden);
643        self
644    }
645    /// Called with the detent a drag, the handle or a dismissal moves the
646    /// sheet to.
647    pub fn on_detent_change(
648        mut self,
649        f: impl Fn(BottomSheetDetent, &mut Window, &mut App) + 'static,
650    ) -> Self {
651        self.on_detent_change = Some(Rc::new(f));
652        self
653    }
654    pub fn modal(mut self, modal: bool) -> Self {
655        self.modal = modal;
656        self
657    }
658    /// Present the modal sheet over its positioned parent's bounds instead
659    /// of the whole window.
660    pub fn contained(mut self, contained: bool) -> Self {
661        self.contained = contained;
662        self
663    }
664    /// Whether pressing the modal sheet's scrim dismisses it (default true).
665    pub fn dismiss_on_scrim(mut self, dismiss: bool) -> Self {
666        self.dismiss_on_scrim = dismiss;
667        self
668    }
669    /// Whether Escape dismisses the modal sheet (default true).
670    pub fn dismiss_on_escape(mut self, dismiss: bool) -> Self {
671        self.dismiss_on_escape = dismiss;
672        self
673    }
674    /// The sheet's maximum width (default M3's 640dp).
675    pub fn max_width(mut self, width: Rems) -> Self {
676        self.max_width = width;
677        self
678    }
679    /// The modal sheet's accessible name.
680    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
681        self.label = Some(label.into());
682        self
683    }
684    /// Show the drag handle; `label` is its accessible name. Activating it
685    /// toggles between the partial and expanded detents.
686    pub fn drag_handle(mut self, label: impl Into<SharedString>) -> Self {
687        self.drag_handle = Some(label.into());
688        self
689    }
690    /// A partially expanded detent showing `height` of the sheet; the sheet
691    /// opens there and expands when dragged up.
692    pub fn partial_height(mut self, height: Rems) -> Self {
693        self.partial = Some(height);
694        self
695    }
696    /// Called when a drag, Escape or the scrim dismisses the sheet.
697    pub fn on_dismiss(mut self, f: impl Fn(&mut Window, &mut App) + 'static) -> Self {
698        self.on_dismiss = Some(Rc::new(f));
699        self
700    }
701}
702
703impl ParentElement for MaterialBottomSheet {
704    fn extend(&mut self, children: impl IntoIterator<Item = AnyElement>) {
705        self.children.extend(children);
706    }
707}
708
709fn drag_handle_bar() -> Div {
710    div()
711        .w(rem(32.))
712        .h(rem(4.))
713        .rounded_full()
714        .bg(color(ColorRole::OUTLINE))
715}
716
717impl MaterialBottomSheet {
718    /// The sheet surface. `drag_layer` sits under the handle and content;
719    /// the content blocks it, so drags start from the handle row and the
720    /// sheet's own background, never from text, sliders or buttons inside.
721    fn surface(
722        modal: bool,
723        max_width: Rems,
724        drag_layer: Option<AnyElement>,
725        handle: Option<AnyElement>,
726        children: Vec<AnyElement>,
727    ) -> Div {
728        div()
729            .relative()
730            .w_full()
731            .max_w(max_width)
732            .flex()
733            .flex_col()
734            .items_center()
735            .rounded_t(BOTTOM_SHEET_RADIUS)
736            .bg(color(ColorRole::SURFACE_CONTAINER_LOW))
737            .when(!modal, |sheet| sheet.shadow(elevation::level1()))
738            .children(drag_layer)
739            .children(handle)
740            .child(
741                div()
742                    .w_full()
743                    .flex()
744                    .flex_col()
745                    .block_mouse_except_scroll()
746                    .children(children),
747            )
748    }
749}
750
751/// The settle progress at `elapsed` seconds on the default spatial curve.
752fn settle_progress(elapsed: f32, reduced: bool) -> f32 {
753    if reduced {
754        return 1.;
755    }
756    let curve = motion::curve(MotionToken::DefaultSpatial);
757    let duration = curve.duration().as_secs_f32().max(f32::EPSILON);
758    if elapsed >= duration {
759        1.
760    } else {
761        curve.ease()(elapsed / duration)
762    }
763}
764
765impl RenderOnce for MaterialBottomSheet {
766    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
767        let modal = self.modal;
768        let max_width = self.max_width;
769        let Some(open) = self.open else {
770            let handle = self.drag_handle.map(|_| {
771                div()
772                    .w_full()
773                    .flex()
774                    .justify_center()
775                    .py(rem(16.))
776                    .child(drag_handle_bar())
777                    .into_any_element()
778            });
779            return div()
780                .id(self.id)
781                .w_full()
782                .h_full()
783                .flex()
784                .flex_col()
785                .justify_end()
786                .items_center()
787                .child(Self::surface(modal, max_width, None, handle, self.children).flex_1())
788                .into_any_element();
789        };
790        let id = self.id.clone();
791        let container = (modal && self.contained).then(|| container_state(&id, window, cx));
792        let frame = overlay_frame(&id, open, window, cx);
793        let focus = modal.then(|| modal_focus(&id, open, window, cx));
794        let state = window.use_keyed_state((id.clone(), "bottom-sheet"), cx, |window, _| {
795            BottomSheetState {
796                height: window.viewport_size().height,
797                measured: false,
798                expanded: false,
799                applied: None,
800                dragged: false,
801                drag: None,
802                settle: None,
803            }
804        });
805        // A controlled detent moves the sheet once per change.
806        if let Some(detent) = self.detent
807            && state.read(cx).applied != Some(detent)
808        {
809            state.update(cx, |state, _| {
810                state.applied = Some(detent);
811                if detent != BottomSheetDetent::Hidden {
812                    state.expanded = detent == BottomSheetDetent::Expanded;
813                    state.drag = None;
814                    state.settle = None;
815                }
816            });
817        }
818        if frame.settled_closed(open) {
819            state.update(cx, |state, _| {
820                state.expanded = false;
821                state.drag = None;
822                state.settle = None;
823            });
824            return with_container_probe(container, div().into_any_element());
825        }
826        let Some(area) = overlay_area(container.as_ref(), window, cx) else {
827            return with_container_probe(container, div().into_any_element());
828        };
829        let on_detent = self.on_detent_change.clone();
830        let reduced = cx.reduce_motion();
831        let partial = self
832            .partial
833            .map(|partial| f32::from(partial.to_pixels(window.rem_size())));
834        let (height, measured, expanded, drag, settle) = {
835            let s = state.read(cx);
836            (
837                f32::from(s.height),
838                s.measured,
839                s.expanded,
840                s.drag,
841                s.settle,
842            )
843        };
844        let visible = match partial {
845            Some(partial) if !expanded => partial.min(height),
846            _ => height,
847        };
848        let rest = height - visible;
849        let residual = match (drag, settle) {
850            (Some(drag), _) if drag.moved => drag.offset,
851            (_, Some(settle)) => {
852                let t = settle_progress(settle.started.elapsed().as_secs_f32(), reduced);
853                if t >= 1. {
854                    state.update(cx, |state, _| state.settle = None);
855                    0.
856                } else {
857                    window.request_animation_frame();
858                    settle.from * (1. - t)
859                }
860            }
861            _ => 0.,
862        };
863        // Upward drags stop at the expanded detent.
864        let position = (rest + residual).max(0.);
865        let offset = position + (1. - frame.spatial) * height;
866        let report = on_detent.clone();
867        let dismiss: Option<Handler> = open
868            .then(|| -> Option<Handler> {
869                match (self.on_dismiss.clone(), report) {
870                    (None, None) => None,
871                    (dismiss, report) => Some(Rc::new(move |window: &mut Window, cx: &mut App| {
872                        if let Some(dismiss) = &dismiss {
873                            dismiss(window, cx);
874                        }
875                        if let Some(report) = &report {
876                            report(BottomSheetDetent::Hidden, window, cx);
877                        }
878                    })),
879                }
880            })
881            .flatten();
882
883        let handle = self.drag_handle.map(|label| {
884            let toggle = state.clone();
885            let press = state.clone();
886            let report = on_detent.clone();
887            div()
888                .w_full()
889                .flex()
890                .justify_center()
891                .child(
892                    gpui_base::Button::new("drag-handle")
893                        .debug_selector(|| "drag-handle".into())
894                        .accessibility_label(label)
895                        .w(rem(48.))
896                        .h(rem(36.))
897                        .flex()
898                        .items_center()
899                        .justify_center()
900                        .cursor_grab()
901                        .child(drag_handle_bar())
902                        // The handle drags the sheet too; the drag layer
903                        // under it does not see presses on the button.
904                        .on_mouse_down(MouseButton::Left, move |event, window, cx| {
905                            let y = f32::from(event.position.y);
906                            press.update(cx, |state, _| {
907                                state.drag = Some(Drag {
908                                    start: y,
909                                    offset: 0.,
910                                    moved: false,
911                                });
912                                state.dragged = false;
913                                state.settle = None;
914                            });
915                            window.refresh();
916                        })
917                        .on_click(move |_, window, cx| {
918                            if partial.is_some() && !toggle.read(cx).dragged {
919                                let expanded = toggle.update(cx, |state, _| {
920                                    state.expanded = !state.expanded;
921                                    state.settle = None;
922                                    state.expanded
923                                });
924                                window.refresh();
925                                if let Some(report) = &report {
926                                    report(
927                                        if expanded {
928                                            BottomSheetDetent::Expanded
929                                        } else {
930                                            BottomSheetDetent::Partial
931                                        },
932                                        window,
933                                        cx,
934                                    );
935                                }
936                            }
937                        }),
938                )
939                .into_any_element()
940        });
941
942        let measure = state.clone();
943        let tracker = state.clone();
944        let release_dismiss = dismiss.clone();
945        let release_report = on_detent.clone();
946        let dragging = drag.is_some();
947        let starter = state.clone();
948        let drag_layer = canvas(
949            |bounds, window, _| window.insert_hitbox(bounds, HitboxBehavior::Normal),
950            move |_, hitbox, window, _| {
951                let starter = starter.clone();
952                window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
953                    if phase != DispatchPhase::Bubble
954                        || event.button != MouseButton::Left
955                        || !hitbox.is_hovered(window)
956                    {
957                        return;
958                    }
959                    let y = f32::from(event.position.y);
960                    starter.update(cx, |state, _| {
961                        state.drag = Some(Drag {
962                            start: y,
963                            offset: 0.,
964                            moved: false,
965                        });
966                        state.dragged = false;
967                        state.settle = None;
968                    });
969                    window.refresh();
970                });
971            },
972        )
973        .absolute()
974        .size_full()
975        .into_any_element();
976        let sheet = div()
977            .id(id.clone())
978            .when(modal, |sheet| {
979                sheet
980                    .role(Role::Dialog)
981                    .when_some(self.label, |sheet, label| sheet.aria_label(label))
982            })
983            .absolute()
984            .left_0()
985            .right_0()
986            .bottom(px(-offset))
987            .flex()
988            .justify_center()
989            .opacity(frame.opacity())
990            // Until its height is measured the sheet would sit at the
991            // viewport-height guess; the next frame places it at its detent.
992            .when(!measured, |sheet| sheet.invisible())
993            .occlude()
994            .child(
995                Self::surface(modal, max_width, Some(drag_layer), handle, self.children).child(
996                    canvas(
997                        |_, _, _| {},
998                        move |_, _, window, _| {
999                            if !dragging {
1000                                return;
1001                            }
1002                            let moving = tracker.clone();
1003                            window.on_mouse_event(
1004                                move |event: &MouseMoveEvent, phase, window, cx| {
1005                                    if phase != DispatchPhase::Bubble {
1006                                        return;
1007                                    }
1008                                    moving.update(cx, |state, _| {
1009                                        if let Some(drag) = state.drag.as_mut() {
1010                                            let delta = f32::from(event.position.y) - drag.start;
1011                                            drag.moved |= delta.abs() > DRAG_SLOP;
1012                                            drag.offset = delta;
1013                                        }
1014                                    });
1015                                    window.refresh();
1016                                },
1017                            );
1018                            let releasing = tracker.clone();
1019                            let dismiss = release_dismiss.clone();
1020                            let report = release_report.clone();
1021                            window.on_mouse_event(move |_: &MouseUpEvent, phase, window, cx| {
1022                                if phase != DispatchPhase::Bubble {
1023                                    return;
1024                                }
1025                                let Some(drag) = releasing.update(cx, |state, _| state.drag.take())
1026                                else {
1027                                    return;
1028                                };
1029                                window.refresh();
1030                                if !drag.moved {
1031                                    return;
1032                                }
1033                                releasing.update(cx, |state, _| state.dragged = true);
1034                                let position = (rest + drag.offset).max(0.);
1035                                let target = snap_target(position, height, partial);
1036                                let new_rest = match target {
1037                                    SnapTarget::Expanded => 0.,
1038                                    SnapTarget::Partial => height - partial.unwrap_or(height),
1039                                    SnapTarget::Dismiss => height,
1040                                };
1041                                releasing.update(cx, |state, _| {
1042                                    state.expanded = target == SnapTarget::Expanded;
1043                                    let from = position
1044                                        - if target == SnapTarget::Dismiss {
1045                                            rest
1046                                        } else {
1047                                            new_rest
1048                                        };
1049                                    state.settle = Some(Settle {
1050                                        from,
1051                                        started: Instant::now(),
1052                                    });
1053                                });
1054                                match target {
1055                                    SnapTarget::Dismiss => {
1056                                        if let Some(dismiss) = &dismiss {
1057                                            dismiss(window, cx);
1058                                        }
1059                                    }
1060                                    SnapTarget::Expanded | SnapTarget::Partial => {
1061                                        if let Some(report) = &report {
1062                                            report(
1063                                                if target == SnapTarget::Expanded
1064                                                    || partial.is_none()
1065                                                {
1066                                                    BottomSheetDetent::Expanded
1067                                                } else {
1068                                                    BottomSheetDetent::Partial
1069                                                },
1070                                                window,
1071                                                cx,
1072                                            );
1073                                        }
1074                                    }
1075                                }
1076                            });
1077                        },
1078                    )
1079                    .absolute()
1080                    .size_full(),
1081                ),
1082            )
1083            .child(
1084                // Measure the sheet's height for the next frame's offsets.
1085                canvas(
1086                    move |bounds, window, cx| {
1087                        let height = bounds.size.height;
1088                        let stale = {
1089                            let state = measure.read(cx);
1090                            !state.measured || state.height != height
1091                        };
1092                        if stale {
1093                            measure.update(cx, |state, _| {
1094                                state.height = height;
1095                                state.measured = true;
1096                            });
1097                            // This frame used the old height (`refresh` is
1098                            // ignored while drawing): draw again at the detent.
1099                            window.request_animation_frame();
1100                        }
1101                    },
1102                    |_, _, _, _| {},
1103                )
1104                .absolute()
1105                .top_0()
1106                .left_0()
1107                .w(px(1.))
1108                .h_full(),
1109            );
1110
1111        match focus {
1112            Some(focus) => {
1113                let dismissals = Dismissals {
1114                    escape: dismiss.clone().filter(|_| self.dismiss_on_escape),
1115                    scrim: dismiss.filter(|_| self.dismiss_on_scrim),
1116                };
1117                let area = container.is_some().then_some(area);
1118                with_container_probe(
1119                    container,
1120                    modal_host(focus, frame, dismissals, area, sheet).into_any_element(),
1121                )
1122            }
1123            None => div()
1124                .absolute()
1125                .inset_0()
1126                .overflow_hidden()
1127                .child(sheet)
1128                .into_any_element(),
1129        }
1130    }
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135    use super::*;
1136    use core::prelude::v1::test;
1137    use gpui::{Context, TestAppContext, VisualTestContext};
1138
1139    fn draw(cx: &mut VisualTestContext) {
1140        cx.update(|window, cx| {
1141            window.refresh();
1142            window.draw(cx).clear(cx);
1143        });
1144        cx.run_until_parked();
1145    }
1146
1147    struct SheetHarness(bool);
1148    impl Render for SheetHarness {
1149        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1150            div().relative().size_full().overflow_hidden().child(
1151                MaterialSideSheet::new("sheet")
1152                    .open(self.0)
1153                    .width(rem(100.))
1154                    .child(div().debug_selector(|| "sheet-body".into()).size_full()),
1155            )
1156        }
1157    }
1158    #[gpui::test]
1159    fn reduced_motion_sheet_opens_and_closes_outside_viewport(cx: &mut TestAppContext) {
1160        cx.update(|cx| cx.set_reduce_motion(true));
1161        let (view, cx) = cx.add_window_view(|_, _| SheetHarness(false));
1162        draw(cx);
1163        assert!(cx.debug_bounds("sheet-body").is_none());
1164        view.update(cx, |v, cx| {
1165            v.0 = true;
1166            cx.notify();
1167        });
1168        draw(cx);
1169        let open = cx.debug_bounds("sheet-body").unwrap();
1170        let viewport = cx.update(|window, _| window.viewport_size());
1171        assert_eq!(open.right(), viewport.width, "an open sheet meets the edge");
1172        view.update(cx, |v, cx| {
1173            v.0 = false;
1174            cx.notify();
1175        });
1176        draw(cx);
1177        assert!(
1178            cx.debug_bounds("sheet-body").is_none(),
1179            "a settled closed sheet renders no content"
1180        );
1181    }
1182
1183    #[test]
1184    fn the_sheet_keeps_its_content_until_the_close_settles() {
1185        assert!(
1186            !sheet_hidden(true, 0.),
1187            "opening starts from 0 with content"
1188        );
1189        assert!(
1190            !sheet_hidden(false, 0.4),
1191            "closing animates its content out"
1192        );
1193        assert!(!sheet_hidden(false, -0.01), "an overshoot is still moving");
1194        assert!(sheet_hidden(false, 0.));
1195    }
1196
1197    struct FocusSheetHarness {
1198        open: bool,
1199        focus: FocusHandle,
1200    }
1201    impl Render for FocusSheetHarness {
1202        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1203            div().relative().size_full().child(
1204                MaterialSideSheet::new("focus-sheet").open(self.open).child(
1205                    div()
1206                        .id("sheet-control")
1207                        .track_focus(&self.focus)
1208                        .debug_selector(|| "sheet-control".into())
1209                        .size(px(20.)),
1210                ),
1211            )
1212        }
1213    }
1214
1215    #[gpui::test]
1216    fn a_closed_sheet_offers_no_tab_stops(cx: &mut TestAppContext) {
1217        cx.update(|cx| cx.set_reduce_motion(true));
1218        let (view, cx) = cx.add_window_view(|_, cx| FocusSheetHarness {
1219            open: true,
1220            focus: cx.focus_handle(),
1221        });
1222        draw(cx);
1223        assert!(cx.debug_bounds("sheet-control").is_some());
1224        view.update(cx, |v, cx| {
1225            v.open = false;
1226            cx.notify();
1227        });
1228        draw(cx);
1229        assert!(cx.debug_bounds("sheet-control").is_none());
1230        cx.update(|window, cx| {
1231            window.focus_next(cx);
1232            let focus = view.read(cx).focus.clone();
1233            assert!(!focus.is_focused(window));
1234        });
1235    }
1236
1237    #[test]
1238    fn released_sheets_snap_to_the_nearest_rest_or_dismiss() {
1239        // A 400px sheet with a 200px partial detent (rest at 200 below).
1240        assert_eq!(snap_target(30., 400., Some(200.)), SnapTarget::Expanded);
1241        assert_eq!(snap_target(180., 400., Some(200.)), SnapTarget::Partial);
1242        assert_eq!(snap_target(330., 400., Some(200.)), SnapTarget::Dismiss);
1243        // Without a partial detent, past half the height dismisses.
1244        assert_eq!(snap_target(190., 400., None), SnapTarget::Expanded);
1245        assert_eq!(snap_target(210., 400., None), SnapTarget::Dismiss);
1246        // A partial detent taller than the sheet is ignored.
1247        assert_eq!(snap_target(250., 400., Some(500.)), SnapTarget::Dismiss);
1248    }
1249
1250    #[test]
1251    fn reduced_motion_settles_at_once() {
1252        assert_eq!(settle_progress(0., true), 1.);
1253        assert!(settle_progress(0., false) < 1.);
1254        assert_eq!(settle_progress(10., false), 1.);
1255    }
1256
1257    struct BottomHarness {
1258        open: bool,
1259        dismissals: usize,
1260    }
1261    impl Render for BottomHarness {
1262        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1263            div().relative().size(px(400.)).child(
1264                MaterialBottomSheet::new("bottom")
1265                    .open(self.open)
1266                    .drag_handle("Drag handle")
1267                    .on_dismiss({
1268                        let this = cx.entity().downgrade();
1269                        move |_, cx| {
1270                            let _ = this.update(cx, |this, cx| {
1271                                this.dismissals += 1;
1272                                this.open = false;
1273                                cx.notify();
1274                            });
1275                        }
1276                    })
1277                    .child(div().debug_selector(|| "bottom-body".into()).h(px(200.))),
1278            )
1279        }
1280    }
1281
1282    #[gpui::test]
1283    fn dragging_a_bottom_sheet_down_dismisses_it(cx: &mut TestAppContext) {
1284        cx.update(|cx| {
1285            gpui_base::init(cx);
1286            cx.set_reduce_motion(true);
1287        });
1288        let (view, cx) = cx.add_window_view(|_, _| BottomHarness {
1289            open: true,
1290            dismissals: 0,
1291        });
1292        draw(cx);
1293        draw(cx);
1294        let body = cx.debug_bounds("bottom-body").unwrap();
1295        assert_eq!(body.bottom(), px(400.), "an open sheet meets the bottom");
1296        // Drags start from the handle row above the content.
1297        let start = point(body.left() + px(20.), body.top() - px(10.));
1298        cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1299        draw(cx);
1300        cx.simulate_mouse_move(
1301            point(start.x, start.y + px(60.)),
1302            Some(MouseButton::Left),
1303            Modifiers::default(),
1304        );
1305        draw(cx);
1306        let dragged = cx.debug_bounds("bottom-body").unwrap();
1307        assert_eq!(dragged.top() - body.top(), px(60.), "the sheet follows");
1308        cx.simulate_mouse_up(
1309            point(start.x, start.y + px(60.)),
1310            MouseButton::Left,
1311            Modifiers::default(),
1312        );
1313        draw(cx);
1314        assert_eq!(view.read_with(cx, |v, _| v.dismissals), 0, "snapped back");
1315        assert_eq!(cx.debug_bounds("bottom-body").unwrap().top(), body.top());
1316        cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1317        draw(cx);
1318        cx.simulate_mouse_move(
1319            point(start.x, start.y + px(180.)),
1320            Some(MouseButton::Left),
1321            Modifiers::default(),
1322        );
1323        draw(cx);
1324        cx.simulate_mouse_up(
1325            point(start.x, start.y + px(180.)),
1326            MouseButton::Left,
1327            Modifiers::default(),
1328        );
1329        draw(cx);
1330        assert_eq!(view.read_with(cx, |v, _| v.dismissals), 1);
1331        // Reduced motion keeps the fade; the content leaves once it settles.
1332        cx.executor()
1333            .advance_clock(std::time::Duration::from_secs(1));
1334        draw(cx);
1335        draw(cx);
1336        assert!(cx.debug_bounds("bottom-body").is_none());
1337    }
1338
1339    #[gpui::test]
1340    fn pressing_content_does_not_drag_the_sheet(cx: &mut TestAppContext) {
1341        cx.update(|cx| {
1342            gpui_base::init(cx);
1343            cx.set_reduce_motion(true);
1344        });
1345        let (view, cx) = cx.add_window_view(|_, _| BottomHarness {
1346            open: true,
1347            dismissals: 0,
1348        });
1349        draw(cx);
1350        draw(cx);
1351        let body = cx.debug_bounds("bottom-body").unwrap();
1352        let start = body.center();
1353        cx.simulate_mouse_down(start, MouseButton::Left, Modifiers::default());
1354        draw(cx);
1355        let end = point(start.x, start.y + px(180.));
1356        cx.simulate_mouse_move(end, Some(MouseButton::Left), Modifiers::default());
1357        draw(cx);
1358        assert_eq!(
1359            cx.debug_bounds("bottom-body").unwrap().top(),
1360            body.top(),
1361            "a press on the content (text, a slider) moved the sheet"
1362        );
1363        cx.simulate_mouse_up(end, MouseButton::Left, Modifiers::default());
1364        draw(cx);
1365        assert_eq!(view.read_with(cx, |v, _| v.dismissals), 0);
1366    }
1367
1368    struct PartialHarness;
1369    impl Render for PartialHarness {
1370        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
1371            div().relative().size(px(400.)).child(
1372                MaterialBottomSheet::new("partial")
1373                    .open(true)
1374                    .partial_height(rem(100.))
1375                    .child(div().debug_selector(|| "partial-body".into()).h(px(300.))),
1376            )
1377        }
1378    }
1379
1380    #[gpui::test]
1381    fn a_bottom_sheet_opens_at_its_measured_detent(cx: &mut TestAppContext) {
1382        cx.update(|cx| {
1383            gpui_base::init(cx);
1384            cx.set_reduce_motion(true);
1385        });
1386        let (_, cx) = cx.add_window_view(|window, _| {
1387            window.set_rem_size(px(crate::tokens::typography::BASE_PX));
1388            PartialHarness
1389        });
1390        cx.update(|window, cx| {
1391            window.refresh();
1392            window.draw(cx).clear(cx);
1393        });
1394        // The first frame guessed the viewport height; it asks for another.
1395        let frames = cx.update(|window, cx| window.simulate_next_frame(cx));
1396        assert!(frames > 0, "the measured height was stored silently");
1397        cx.update(|window, cx| window.draw(cx).clear(cx));
1398        let body = cx.debug_bounds("partial-body").unwrap();
1399        // 100dp of the sheet shows above the bottom edge.
1400        assert_eq!(body.top(), px(300.), "{body:?}");
1401        let frames = cx.update(|window, cx| window.simulate_next_frame(cx));
1402        assert_eq!(frames, 0, "a settled sheet requests nothing");
1403    }
1404
1405    struct EdgeHarness {
1406        modal: bool,
1407        escape: bool,
1408        dismissals: usize,
1409    }
1410    impl Render for EdgeHarness {
1411        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1412            let this = cx.entity().downgrade();
1413            div().size_full().pl(px(100.)).child(
1414                div()
1415                    .relative()
1416                    .debug_selector(|| "frame".into())
1417                    .w(px(600.))
1418                    .h(px(400.))
1419                    .child(
1420                        MaterialSideSheet::new("edge")
1421                            .open(true)
1422                            .modal(self.modal)
1423                            .edge(SideSheetEdge::Start)
1424                            .width(rem(200.))
1425                            .contained(true)
1426                            .dismiss_on_escape(self.escape)
1427                            .on_dismiss(move |_, cx| {
1428                                let _ = this.update(cx, |this, cx| {
1429                                    this.dismissals += 1;
1430                                    cx.notify();
1431                                });
1432                            })
1433                            .child(div().debug_selector(|| "edge-body".into()).size_full()),
1434                    ),
1435            )
1436        }
1437    }
1438
1439    #[gpui::test]
1440    fn a_start_sheet_docks_left_and_escape_dismissal_is_optional(cx: &mut TestAppContext) {
1441        cx.update(|cx| {
1442            gpui_base::init(cx);
1443            cx.set_reduce_motion(true);
1444        });
1445        let (view, cx) = cx.add_window_view(|window, _| {
1446            window.set_rem_size(px(crate::tokens::typography::BASE_PX));
1447            EdgeHarness {
1448                modal: false,
1449                escape: false,
1450                dismissals: 0,
1451            }
1452        });
1453        draw(cx);
1454        let frame = cx.debug_bounds("frame").unwrap();
1455        let body = cx.debug_bounds("edge-body").unwrap();
1456        assert_eq!(
1457            body.right(),
1458            frame.left() + px(199.),
1459            "the divider faces the content"
1460        );
1461        // The modal sheet presents over its container, at its start edge.
1462        view.update(cx, |v, cx| {
1463            v.modal = true;
1464            cx.notify();
1465        });
1466        for _ in 0..3 {
1467            draw(cx);
1468        }
1469        let body = cx.debug_bounds("edge-body").unwrap();
1470        assert_eq!(body.left(), frame.left());
1471        cx.simulate_keystrokes("escape");
1472        draw(cx);
1473        assert_eq!(view.read_with(cx, |v, _| v.dismissals), 0, "Escape is off");
1474        view.update(cx, |v, cx| {
1475            v.escape = true;
1476            cx.notify();
1477        });
1478        draw(cx);
1479        cx.simulate_keystrokes("escape");
1480        draw(cx);
1481        assert_eq!(view.read_with(cx, |v, _| v.dismissals), 1);
1482    }
1483
1484    struct DetentHarness {
1485        detent: BottomSheetDetent,
1486        reported: Vec<BottomSheetDetent>,
1487    }
1488    impl Render for DetentHarness {
1489        fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1490            let this = cx.entity().downgrade();
1491            div().relative().size(px(400.)).child(
1492                MaterialBottomSheet::new("detent")
1493                    .detent(self.detent)
1494                    .partial_height(rem(100.))
1495                    .max_width(rem(300.))
1496                    .drag_handle("Drag handle")
1497                    .on_detent_change(move |detent, _, cx| {
1498                        let _ = this.update(cx, |this, cx| {
1499                            this.reported.push(detent);
1500                            this.detent = detent;
1501                            cx.notify();
1502                        });
1503                    })
1504                    .child(div().debug_selector(|| "detent-body".into()).h(px(300.))),
1505            )
1506        }
1507    }
1508
1509    #[gpui::test]
1510    fn a_controlled_bottom_sheet_follows_and_reports_its_detent(cx: &mut TestAppContext) {
1511        cx.update(|cx| {
1512            gpui_base::init(cx);
1513            cx.set_reduce_motion(true);
1514        });
1515        let (view, cx) = cx.add_window_view(|window, _| {
1516            window.set_rem_size(px(crate::tokens::typography::BASE_PX));
1517            DetentHarness {
1518                detent: BottomSheetDetent::Expanded,
1519                reported: Vec::new(),
1520            }
1521        });
1522        for _ in 0..3 {
1523            draw(cx);
1524        }
1525        let body = cx.debug_bounds("detent-body").unwrap();
1526        assert_eq!(body.bottom(), px(400.), "expanded");
1527        assert_eq!(body.size.width, px(300.), "capped at its maximum width");
1528        view.update(cx, |v, cx| {
1529            v.detent = BottomSheetDetent::Partial;
1530            cx.notify();
1531        });
1532        for _ in 0..3 {
1533            draw(cx);
1534        }
1535        let partial = cx.debug_bounds("detent-body").unwrap();
1536        // 100dp of the sheet (its 36dp handle row, then the body) peeks.
1537        assert_eq!(partial.top(), px(336.), "{partial:?}");
1538        // The handle expands it and reports the change.
1539        let handle = cx.debug_bounds("drag-handle").unwrap();
1540        cx.simulate_click(handle.center(), Modifiers::default());
1541        for _ in 0..3 {
1542            draw(cx);
1543        }
1544        assert_eq!(
1545            view.read_with(cx, |v, _| v.reported.clone()),
1546            [BottomSheetDetent::Expanded]
1547        );
1548        assert_eq!(cx.debug_bounds("detent-body").unwrap().bottom(), px(400.));
1549        // Hidden closes it.
1550        view.update(cx, |v, cx| {
1551            v.detent = BottomSheetDetent::Hidden;
1552            cx.notify();
1553        });
1554        cx.executor()
1555            .advance_clock(std::time::Duration::from_secs(1));
1556        for _ in 0..3 {
1557            draw(cx);
1558        }
1559        assert!(cx.debug_bounds("detent-body").is_none());
1560    }
1561}