Skip to main content

frust_material/
side_sheet.rs

1// Ported from `material_3_expressive` v1.0.8's `M3ESideSheet`/
2// `M3ESideSheetTheme` (MIT, © 2026 Paa Developments;
3// `tmp/material_3_expressive/lib/components/side_sheets/m3e_side_sheets.dart`,
4// `styles/m3e_side_sheet_theme.dart`, retrieved 2026-08-20).
5// Upstream: <https://github.com/paadevelopments/material_3_expressive>
6//
7// Porting decisions: the header/body/footer *composition* (title + a close
8// affordance, an `Expanded` body, an optional divider-plus-actions footer) is
9// carried over from `M3ESideSheet`/`M3ESideSheetTheme`; the modal chrome
10// itself (scrim, edge-pinned slide, staged exit, back-dismiss, the close
11// affordance's hit-testing) is **not** re-derived here at all — it comes
12// entirely from this crate's own [`crate::overlay::modal`] host, configured
13// for a right-edge panel (`OverlayModalConfig::edge`). This module is that
14// host's first real consumer: [`SideSheetView`] is a thin, chrome-only
15// wrapper over [`OverlayModalView`] rather than a hand-rolled widget (unlike
16// [`mod@crate::dialog`]/[`crate::sheet`], which predate the host).
17
18//! The modal M3 side sheet: secondary content anchored to the trailing
19//! (right) edge of the screen, sliding in from that edge over the M3 32%
20//! scrim.
21//!
22//! # Built on the merged overlay modal host
23//!
24//! [`SideSheetView`] wraps an [`OverlayModalView`] configured via
25//! [`OverlayModalConfig::edge`]`(`[`OverlaySide::Right`]`)`: the scrim, the
26//! edge-pinned slide-in/out motion, the staged exit ramp, the back-press
27//! [`frust::BackPolicy::DismissAnimated`] seam, the scrim-tap dismiss, and the
28//! **close affordance** (the host's own top-trailing-corner control, an M3
29//! 48dp touch target) are every one of them the host's, reused wholesale —
30//! see [`mod@crate::overlay::modal`]'s own module docs for the full
31//! contract. This module contributes only the M3E side-sheet *chrome* the
32//! host has no opinion about: the header/body/footer composition below, and
33//! [`SIDE_SHEET_WIDTH`]'s fixed-width geometry.
34//!
35//! # Geometry: fixed width, full height, left corners only
36//!
37//! A side sheet is [`SIDE_SHEET_WIDTH`] wide (320dp — `M3ESideSheetTheme`'s
38//! own default, inside the M3 256–400dp side-sheet width band; capped down
39//! only when the viewport itself is narrower) and full height, via
40//! [`OverlayExtent::Content`] capped at [`OverlayLimit::Px`]`(SIDE_SHEET_WIDTH)`
41//! — a *fixed* width, not the host's own fraction-of-viewport `.edge()`
42//! default (that shape is left for a genuinely responsive edge panel; a side
43//! sheet is not one upstream). [`OverlayModalConfig::edge`]'s own
44//! `OverlaySide::Right` mapping already rounds only the panel's *left*
45//! corners (`OverlayCorners::Start` — the corners facing into the screen; the
46//! right corners sit at the screen edge and stay square), so no override is
47//! needed here for the asymmetry.
48//!
49//! # The close affordance is the host's, not a second one
50//!
51//! [`side_sheet_config`] turns on [`OverlayModalConfig::close_button`], so
52//! the same top-trailing-corner control every other host-built modal can
53//! offer is what dismisses this sheet — this module never paints a second
54//! close icon inside its own header. [`SideSheetContentWidget`]'s header
55//! layout reserves [`HEADER_TRAILING_RESERVE`] logical px on its trailing
56//! edge (matching the host's own private close-chrome footprint) so the
57//! title never sits under it.
58//!
59//! # Header / body / footer
60//!
61//! - **Header**: `title` (`titleLarge`, `onSurface`), left-inset
62//!   [`MaterialSpacing::XL`] (24dp), vertically centered in a fixed
63//!   [`HEADER_HEIGHT`] (80dp) row — `M3ESideSheetTheme`'s own
64//!   `headerPadding`/`closeButtonPadding`/`iconSize` numbers, re-expressed as
65//!   the trailing reserve above.
66//! - **Body**: `body` (a caller view of any shape), filling the remaining
67//!   height between the header and the footer, both axes tight — the
68//!   reference's `Expanded(child: body)` inside a `crossAxisAlignment:
69//!   stretch` column.
70//! - **Footer**: shown only when `actions` is non-empty — a
71//!   [`crate::divider`] (full width, `outlineVariant`, matching
72//!   `M3ESideSheetTheme::dividerColor`) plus a left-packed row of the
73//!   app-provided `actions`, inset [`MaterialSpacing::LG`] (16dp) on every
74//!   edge (`M3ESideSheetTheme`'s `actionsPadding`). The reference's own
75//!   `Row(children: actions)` carries no inter-action gap; this module
76//!   matches that exactly rather than inventing one. An action that closes
77//!   the sheet does it through [`SideSheetView::dismiss_handle`]'s
78//!   [`ModalDismiss`], not a raw `controller.pop()`: the handle stages the
79//!   host's slide-out ramp, a raw pop dismisses on the spot.
80//!
81//! # Content is fixed at construction
82//!
83//! [`side_sheet`] takes `title`/`body`/`actions` together and erases them
84//! into the host's own `content: AnyView<State>` slot immediately — there is
85//! no post-construction `.action`/`.actions` builder chaining, unlike
86//! [`mod@crate::dialog`]'s. The host's `content` field is a genuinely opaque
87//! `AnyView`, so mutating it after construction would need either a second,
88//! shared-ownership indirection this module has no other reason to carry, or
89//! reaching into the host's own private fields (a coupling this module
90//! deliberately avoids — see [`SideSheetView`]'s builder methods, which
91//! reach only the host's *public*
92//! `dismissable`/`on_dismiss`/`dismiss_handle` seam). Matches
93//! the reference's own constructor shape too: `M3ESideSheet({required title,
94//! required body, actions = const []})` takes `actions` as one parameter,
95//! never incrementally built either.
96//!
97//! # Docked (non-modal) variant: absent upstream
98//!
99//! `material_3_expressive`'s `side_sheets` family ships only the modal
100//! `M3ESideSheet`/`M3ESideSheet.show` — no non-modal, layout-participating
101//! "docked" sheet exists in the reference to port. Not invented here.
102//!
103//! # Semantics
104//!
105//! Delegated entirely to the host: [`OverlayModalWidget::semantics`]
106//! contributes one [`frust::authoring::Role::Dialog`] container node with the
107//! accesskit modal flag set, labelled with `title` (via
108//! [`OverlayModalView::label`], which [`side_sheet`] wires for you).
109//! [`SideSheetContentWidget`]'s own `semantics` is a transparent forward —
110//! title, body, and each action become the host node's children, in that
111//! order.
112
113use std::rc::Rc;
114
115use frust::authoring::text::{FontWeight, LineHeight};
116use frust::authoring::{
117    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
118    LayoutCtx, PaintCtx, PaintScene, SemanticsCtx, ThemeTextType, View, ViewSeq, Widget, any,
119};
120use frust::text;
121use kurbo::{Point, Size};
122
123use crate::overlay::{
124    ModalDismiss, OverlayExtent, OverlayLimit, OverlayModalConfig, OverlayModalContent,
125    OverlayModalView, OverlayModalWidget, OverlaySide, overlay_modal, show_overlay_modal,
126};
127use crate::tokens::MaterialSpacing;
128use frust::{NavigatorController, PopResult};
129
130/// The M3 side-sheet width band's lower bound, in logical px
131/// (m3.material.io's side-sheet spec) — read by this module's own tests to
132/// pin [`SIDE_SHEET_WIDTH`] inside the band.
133#[allow(dead_code)]
134const SIDE_SHEET_MIN_WIDTH: f64 = 256.0;
135
136/// This module's fixed panel width, in logical px — `M3ESideSheetTheme`'s own
137/// `width` default (320dp), inside the [`SIDE_SHEET_MIN_WIDTH`]–
138/// [`crate::overlay::OVERLAY_SIDE_SHEET_MAX_WIDTH`] (400dp) M3 side-sheet
139/// width band. Public so a caller can size a companion layout (e.g. a
140/// persistent rail alongside a docked variant, were one ever added) against
141/// it.
142pub const SIDE_SHEET_WIDTH: f64 = 320.0;
143
144/// Header left inset — `MaterialSpacing::XL` (24dp; `M3ESideSheetTheme`'s
145/// `headerPadding.left`).
146const HEADER_PAD_LEFT: f64 = MaterialSpacing::XL;
147/// Header top/bottom inset — `MaterialSpacing::LG` (16dp; `headerPadding`'s
148/// `.top`/`.bottom`).
149const HEADER_PAD_TOP: f64 = MaterialSpacing::LG;
150const HEADER_PAD_BOTTOM: f64 = MaterialSpacing::LG;
151/// The header row's own trailing inset before the close affordance —
152/// `MaterialSpacing::SM` (8dp; `headerPadding.right`).
153const HEADER_PAD_RIGHT: f64 = MaterialSpacing::SM;
154/// The close affordance's own M3 touch target: `MaterialSpacing::MD` (12dp)
155/// padding around the 24dp `M3EIcons.close` glyph on every edge
156/// (`closeButtonPadding`/`iconSize`) — the same 48dp minimum touch target the
157/// merged overlay modal host paints its own close chrome at. This module
158/// never paints a second close icon (see the module docs); it only reserves
159/// this width in its own header layout.
160const CLOSE_TAP_TARGET: f64 = 2.0 * MaterialSpacing::MD + 24.0;
161/// The header's trailing reserve, clear of the host's own close affordance —
162/// [`HEADER_PAD_RIGHT`] plus [`CLOSE_TAP_TARGET`].
163const HEADER_TRAILING_RESERVE: f64 = HEADER_PAD_RIGHT + CLOSE_TAP_TARGET;
164/// The header's fixed height: [`HEADER_PAD_TOP`] + [`CLOSE_TAP_TARGET`] +
165/// [`HEADER_PAD_BOTTOM`] (the close affordance, at 48dp, is the row's tallest
166/// element — taller than the title's own `titleLarge` line height).
167const HEADER_HEIGHT: f64 = HEADER_PAD_TOP + CLOSE_TAP_TARGET + HEADER_PAD_BOTTOM;
168
169/// The footer's inset on every edge — `MaterialSpacing::LG` (16dp;
170/// `M3ESideSheetTheme`'s `actionsPadding`).
171const ACTIONS_PADDING: f64 = MaterialSpacing::LG;
172
173/// Title type-scale token (M3 `titleLarge`, *not* the emphasized sibling —
174/// the reference's own `titleStyle` reads `type.titleLarge` unmodified).
175/// Hardcoded rather than read from a live `Theme::type_scale`: `Text` has no
176/// layout-time-deferred size/weight resolution seam (only a *color* role and
177/// an opted-in *family* role can be resolved after `View::build` — see
178/// `docs/CODE_STANDARDS.md`'s Theming conventions), the same precedent
179/// [`mod@crate::dialog`]/[`mod@crate::appbar`] follow. The title does opt its
180/// family into the `titleLarge` role.
181const TITLE_SIZE: f32 = 22.0;
182const TITLE_LINE_HEIGHT: f32 = 28.0;
183const TITLE_WEIGHT: FontWeight = FontWeight::REGULAR;
184
185/// The M3 side-sheet chrome: right-edge-pinned (the trailing edge in LTR),
186/// fixed [`SIDE_SHEET_WIDTH`] wide, full height, left corners only (the host's
187/// own `OverlaySide::Right` → `OverlayCorners::Start` mapping — no override
188/// needed), sliding in with the host's own [`OverlayEntrance::Slide`]
189/// (`.edge`'s default), and the host's own close affordance enabled. See the
190/// [module docs](self).
191fn side_sheet_config() -> OverlayModalConfig {
192    OverlayModalConfig::edge(OverlaySide::Right)
193        .extent(OverlayExtent::Content, OverlayLimit::Px(SIDE_SHEET_WIDTH))
194        .close_button(true)
195}
196
197/// Build the title's type-erased child view: `titleLarge`-styled text,
198/// defaulting to the `Text` widget's own `OnSurface` themed role (matches
199/// `M3ESideSheetTheme::titleStyle`'s `onSurface` ink), its family following
200/// the live theme's `titleLarge` role.
201fn title_view<State: 'static>(title: &str) -> AnyView<State> {
202    any::<State, _>(
203        text(title.to_string())
204            .size(TITLE_SIZE)
205            .weight(TITLE_WEIGHT)
206            .line_height(LineHeight::Absolute(TITLE_LINE_HEIGHT))
207            .themed_family(ThemeTextType::TitleLarge),
208    )
209}
210
211/// The composed header/body/footer content [`side_sheet`] hands to the
212/// [`crate::overlay::modal`] host as its `content` — see [`side_sheet_config`].
213struct SideSheetContentView<State: 'static> {
214    title: String,
215    body: AnyView<State>,
216    actions: Vec<AnyView<State>>,
217}
218
219impl<State: 'static> View<State> for SideSheetContentView<State> {
220    type Element = SideSheetContentWidget;
221
222    fn build(&self, ctx: &mut BuildCtx<'_>) -> SideSheetContentWidget {
223        SideSheetContentWidget {
224            title: frust::authoring::build_child(&title_view::<State>(&self.title), ctx),
225            body: frust::authoring::build_child(&self.body, ctx),
226            divider: (!self.actions.is_empty()).then(|| {
227                frust::authoring::build_child(&any::<State, _>(crate::divider::divider()), ctx)
228            }),
229            actions: self
230                .actions
231                .iter()
232                .map(|v| frust::authoring::build_child(v, ctx))
233                .collect(),
234        }
235    }
236
237    fn rebuild(
238        &self,
239        prev: &Self,
240        element: &mut SideSheetContentWidget,
241        ctx: &mut BuildCtx<'_>,
242    ) -> ChangeFlags {
243        let mut flags = frust::authoring::rebuild_child(
244            &title_view::<State>(&prev.title),
245            &title_view::<State>(&self.title),
246            &mut element.title,
247            ctx,
248        );
249        flags |= frust::authoring::rebuild_child(&prev.body, &self.body, &mut element.body, ctx);
250
251        match (prev.actions.is_empty(), self.actions.is_empty()) {
252            (true, false) => {
253                element.divider = Some(frust::authoring::build_child(
254                    &any::<State, _>(crate::divider::divider()),
255                    ctx,
256                ));
257                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
258            }
259            (false, true) => {
260                if let Some(pod) = element.divider.as_mut() {
261                    frust::authoring::teardown_child(
262                        &any::<State, _>(crate::divider::divider()),
263                        pod,
264                        ctx,
265                    );
266                }
267                element.divider = None;
268                flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
269            }
270            _ => {}
271        }
272
273        let prev_views: Vec<&AnyView<State>> = prev.actions.iter().collect();
274        let next_views: Vec<&AnyView<State>> = self.actions.iter().collect();
275        flags |= frust::authoring::rebuild_children(
276            &prev_views,
277            &next_views,
278            &mut element.actions,
279            ctx,
280            |view: &&AnyView<State>| *view,
281            |_| None,
282        );
283
284        flags
285    }
286
287    fn teardown(&self, element: &mut SideSheetContentWidget, ctx: &mut BuildCtx<'_>) {
288        frust::authoring::teardown_child(
289            &title_view::<State>(&self.title),
290            &mut element.title,
291            ctx,
292        );
293        frust::authoring::teardown_child(&self.body, &mut element.body, ctx);
294        if let Some(pod) = element.divider.as_mut() {
295            frust::authoring::teardown_child(&any::<State, _>(crate::divider::divider()), pod, ctx);
296        }
297        for (view, pod) in self.actions.iter().zip(element.actions.iter_mut()) {
298            frust::authoring::teardown_child(view, pod, ctx);
299        }
300    }
301}
302
303/// The retained widget for a [`SideSheetContentView`]. See the [module
304/// docs](self).
305struct SideSheetContentWidget {
306    title: ChildPod,
307    body: ChildPod,
308    /// Present exactly when `actions` is non-empty — the footer's separator.
309    divider: Option<ChildPod>,
310    actions: Vec<ChildPod>,
311}
312
313impl Widget for SideSheetContentWidget {
314    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
315        let width = crate::overlay::finite_or_zero(bc.max().width);
316        let height = crate::overlay::finite_or_zero(bc.max().height);
317
318        // Header: title, left-inset, vertically centered in the fixed
319        // HEADER_HEIGHT row, leaving HEADER_TRAILING_RESERVE clear on the
320        // trailing edge for the host's own close affordance.
321        let title_max_w = (width - HEADER_PAD_LEFT - HEADER_TRAILING_RESERVE).max(0.0);
322        let title_size = self.title.layout_child(
323            ctx,
324            &BoxConstraints::loose(Size::new(title_max_w, f64::INFINITY)),
325        );
326        self.title.set_origin(Point::new(
327            HEADER_PAD_LEFT,
328            ((HEADER_HEIGHT - title_size.height) / 2.0).max(0.0),
329        ));
330
331        // Footer: an optional divider + a left-packed row of actions, hugging
332        // the panel's bottom edge — present only when actions is non-empty.
333        let mut footer_h = 0.0;
334        if let Some(divider) = self.divider.as_mut() {
335            let divider_size =
336                divider.layout_child(ctx, &BoxConstraints::loose(Size::new(width, height)));
337
338            let action_bc = BoxConstraints::loose(Size::new(
339                (width - 2.0 * ACTIONS_PADDING).max(0.0),
340                f64::INFINITY,
341            ));
342            let mut actions_h: f64 = 0.0;
343            for pod in self.actions.iter_mut() {
344                let s = pod.layout_child(ctx, &action_bc);
345                actions_h = actions_h.max(s.height);
346            }
347
348            footer_h = divider_size.height + 2.0 * ACTIONS_PADDING + actions_h;
349            let footer_y = height - footer_h;
350            divider.set_origin(Point::new(0.0, footer_y));
351
352            // Left-packed, no inter-action gap — the reference's own bare
353            // `Row(children: actions)` (`m3e_side_sheets.dart`'s
354            // `_buildActions`) carries none either.
355            let mut ax = ACTIONS_PADDING;
356            let actions_y = footer_y + divider_size.height + ACTIONS_PADDING;
357            for pod in self.actions.iter_mut() {
358                let s = pod.size();
359                pod.set_origin(Point::new(ax, actions_y));
360                ax += s.width;
361            }
362        }
363
364        // Body: fills the remaining space between the header and the footer,
365        // both axes tight — `Expanded(child: body)` inside a
366        // `crossAxisAlignment: stretch` column.
367        let body_h = (height - HEADER_HEIGHT - footer_h).max(0.0);
368        self.body
369            .layout_child(ctx, &BoxConstraints::tight(Size::new(width, body_h)));
370        self.body.set_origin(Point::new(0.0, HEADER_HEIGHT));
371
372        Size::new(width, height)
373    }
374
375    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
376        self.title.paint_child(ctx, scene);
377        self.body.paint_child(ctx, scene);
378        if let Some(divider) = self.divider.as_mut() {
379            divider.paint_child(ctx, scene);
380        }
381        for pod in &mut self.actions {
382            pod.paint_child(ctx, scene);
383        }
384    }
385
386    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
387        if event.is_broadcast() {
388            self.body.event_child(ctx, event);
389            for pod in &mut self.actions {
390                pod.event_child(ctx, event);
391            }
392            return EventResult::Ignored;
393        }
394        if frust::authoring::route_event_single(&mut self.body, ctx, event) == EventResult::Handled
395        {
396            return EventResult::Handled;
397        }
398        frust::authoring::route_event(&mut self.actions, ctx, event)
399    }
400
401    fn semantics(&self, ctx: &mut SemanticsCtx) {
402        self.title.semantics_child(ctx);
403        self.body.semantics_child(ctx);
404        if let Some(divider) = &self.divider {
405            divider.semantics_child(ctx);
406        }
407        for pod in &self.actions {
408            pod.semantics_child(ctx);
409        }
410    }
411
412    frust::authoring::visit_children!(title, body, divider, actions);
413}
414
415/// A declarative modal M3 side sheet: a header (`title` + the host's own
416/// close affordance), `body`, and an optional `actions` footer, wrapped
417/// around the merged [`crate::overlay::modal`] host. See the [module
418/// docs](self).
419pub struct SideSheetView<State: 'static>(OverlayModalView<State>);
420
421/// Build a modal side sheet. See the [module docs](self)' Header/body/footer
422/// and Content-is-fixed-at-construction sections; [`show_side_sheet`] is the
423/// navigator-pushed convenience most callers want.
424///
425/// ```ignore
426/// side_sheet(
427///     "Settings",
428///     settings_body(),
429///     [text_button("Close").on_press(|s: &mut State| { .. })],
430/// )
431/// ```
432pub fn side_sheet<State: 'static, V: View<State>, M>(
433    title: impl Into<String>,
434    body: V,
435    actions: impl ViewSeq<State, M>,
436) -> SideSheetView<State> {
437    let mut erased = Vec::new();
438    actions.extend_views(&mut erased);
439    let title = title.into();
440    let content = SideSheetContentView {
441        title: title.clone(),
442        body: any(body),
443        actions: erased,
444    };
445    SideSheetView(overlay_modal(content, side_sheet_config()).label(title))
446}
447
448impl<State: 'static> SideSheetView<State> {
449    /// Whether the user can dismiss this sheet at all — the scrim tap, the
450    /// host's own close affordance, `Escape`, and an Android back press
451    /// (default `true`; see [`mod@crate::overlay::modal`]'s module docs).
452    /// `false` disables all of them; only an explicit control inside `body`/
453    /// `actions` still dismisses it.
454    pub fn dismissable(mut self, dismissable: bool) -> Self {
455        self.0 = self.0.dismissable(dismissable);
456        self
457    }
458
459    /// Set the **unstaged** dismiss callback — delivered with `&mut State`
460    /// during the event pass. [`show_side_sheet`] wires the staged
461    /// (navigator-pop, slide-out) path instead; set this only for a
462    /// `Stack`-mounted sheet with no navigator underneath it.
463    pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
464        self.0 = self.0.on_dismiss(on_dismiss);
465        self
466    }
467
468    /// Install the app-triggered staged-dismiss handle, so an `actions` button
469    /// ("Apply", "Reset") closes the sheet through the **same** slide-out ramp
470    /// the close affordance, the scrim tap, `Escape`, and a back press take —
471    /// rather than the raw `controller.pop()` that dismisses on the spot. See
472    /// [`ModalDismiss`], and mint the handle outside [`show_side_sheet`]'s page
473    /// builder.
474    ///
475    /// ```ignore
476    /// let dismiss = ModalDismiss::new();
477    /// let apply = dismiss.clone();
478    /// side_sheet(title, body, [
479    ///     filled_button("Apply", move |_: &mut State| apply.dismiss()),
480    /// ])
481    /// .dismiss_handle(dismiss.clone())
482    /// ```
483    pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
484        self.0 = self.0.dismiss_handle(dismiss);
485        self
486    }
487}
488
489impl<State: 'static> View<State> for SideSheetView<State> {
490    type Element = OverlayModalWidget;
491
492    fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
493        View::build(&self.0, ctx)
494    }
495
496    fn rebuild(
497        &self,
498        prev: &Self,
499        element: &mut OverlayModalWidget,
500        ctx: &mut BuildCtx<'_>,
501    ) -> ChangeFlags {
502        View::rebuild(&self.0, &prev.0, element, ctx)
503    }
504
505    fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
506        View::teardown(&self.0, element, ctx);
507    }
508}
509
510impl<State: 'static> OverlayModalContent<State> for SideSheetView<State> {
511    fn on_modal_dismiss(mut self, on_dismiss: Rc<dyn Fn(&mut State)>) -> Self {
512        self.0 = self.0.on_modal_dismiss(on_dismiss);
513        self
514    }
515
516    fn modal_dismissable(&self) -> bool {
517        self.0.modal_dismissable()
518    }
519}
520
521/// Push `build`'s side sheet as a transparent navigator page (the page below
522/// stays visible under the scrim), sliding in from the trailing edge, and
523/// register `on_result` for the value it pops with — the
524/// [`crate::overlay::modal`] host's shared [`show_overlay_modal`] push, since
525/// [`SideSheetView`] *is* a pre-configured modal host (see
526/// [`OverlayModalContent`]). The scrim tap, the host's own close affordance,
527/// `Escape`, and an Android back press are all wired to `controller.pop()`
528/// for you, staged behind the slide-out exit ramp (see
529/// [`mod@crate::overlay::modal`]'s Exit motion / Back-dismiss sections).
530///
531/// The sheet's own `actions` reach that same ramp through
532/// [`SideSheetView::dismiss_handle`] — minted here, outside `build`, and
533/// cloned into both the sheet and its buttons.
534///
535/// ```ignore
536/// show_side_sheet(
537///     &state.nav,
538///     || {
539///         side_sheet(
540///             "Settings",
541///             settings_body(),
542///             filled_button("Done", |_: &mut State| {}),
543///         )
544///     },
545///     |state: &mut State, result: PopResult| { /* ... */ },
546/// );
547/// ```
548pub fn show_side_sheet<State, B, R>(controller: &NavigatorController<State>, build: B, on_result: R)
549where
550    State: 'static,
551    B: Fn() -> SideSheetView<State> + 'static,
552    R: Fn(&mut State, PopResult) + 'static,
553{
554    show_overlay_modal(controller, build, on_result)
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    use crate::overlay::{OverlayCorners, OverlayEntrance, OverlayGeometry};
561    use frust::FrameTime;
562    use frust::NavigatorView;
563    use frust::authoring::text::TextContext;
564    use frust::authoring::{
565        BuildCtx, ChangeFlags, CornerRadii, EventCtx, PointerButton, PointerEvent, PointerPhase,
566        Role, Widget, any as core_any,
567    };
568    use frust_core::RenderRoot;
569    use frust_widgets::navigator;
570    use frust_widgets::test_support::leaf_any;
571    use kurbo::Rect;
572    use peniko::Color;
573    use std::any::Any;
574
575    /// The window every test lays out in.
576    const WINDOW: Size = Size::new(400.0, 600.0);
577
578    /// A recording `PaintScene`: fills, per-corner rounded rects, and clips.
579    #[derive(Default)]
580    struct Recorder {
581        rects: Vec<(Point, Size, Color)>,
582        panels: Vec<(Point, Size, CornerRadii, Color)>,
583    }
584    impl PaintScene for Recorder {
585        fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
586            self.rects.push((origin, size, color));
587        }
588        fn draw_text(&mut self, _origin: Point, _text: &str) {}
589        fn fill_rounded_rect_radii(
590            &mut self,
591            origin: Point,
592            size: Size,
593            radii: CornerRadii,
594            color: Color,
595        ) {
596            self.panels.push((origin, size, radii, color));
597        }
598        fn push_clip_rounded_radii(&mut self, _origin: Point, _size: Size, _radii: CornerRadii) {}
599        fn push_clip(&mut self, _origin: Point, _size: Size) {}
600        fn pop_clip(&mut self) {}
601    }
602
603    /// A `SideSheetView` built directly (bypassing [`side_sheet`]'s own
604    /// `Slide` entrance) so a paint/layout assertion sees the panel already
605    /// at rest — [`mod@crate::overlay::modal`]'s own `settled` test
606    /// precedent.
607    fn settled_view<State: 'static, V: View<State>>(
608        title: &str,
609        body: V,
610        actions: Vec<AnyView<State>>,
611    ) -> SideSheetView<State> {
612        let content = SideSheetContentView {
613            title: title.to_string(),
614            body: any(body),
615            actions,
616        };
617        SideSheetView(
618            overlay_modal(content, side_sheet_config().entrance(OverlayEntrance::None))
619                .label(title.to_string()),
620        )
621    }
622
623    fn build<S: 'static>(view: &SideSheetView<S>) -> OverlayModalWidget {
624        let mut counter = 0u64;
625        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
626    }
627
628    fn layout(w: &mut OverlayModalWidget) -> Size {
629        let mut tcx = TextContext::new();
630        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
631        w.layout(&mut lctx, &BoxConstraints::tight(WINDOW))
632    }
633
634    // ---- Config: geometry, corners, entrance, close affordance, width. ----
635
636    #[test]
637    fn side_sheet_config_pins_a_right_edge_fixed_width_panel_with_left_corners_only() {
638        let config = side_sheet_config();
639        assert_eq!(
640            config.geometry,
641            OverlayGeometry::Edge {
642                side: OverlaySide::Right,
643                extent: OverlayExtent::Content,
644                limit: OverlayLimit::Px(SIDE_SHEET_WIDTH),
645            }
646        );
647        assert_eq!(
648            config.corners,
649            OverlayCorners::Start,
650            "a right-pinned panel rounds its left (screen-facing) corners only"
651        );
652        assert_eq!(
653            config.entrance,
654            OverlayEntrance::Slide,
655            "a side sheet slides in from its own edge"
656        );
657        assert!(
658            config.close_button,
659            "the host's own close affordance is enabled, not a second one"
660        );
661    }
662
663    #[test]
664    fn the_fixed_width_sits_inside_the_m3_side_sheet_spec_band() {
665        assert!(
666            (SIDE_SHEET_MIN_WIDTH..=crate::overlay::OVERLAY_SIDE_SHEET_MAX_WIDTH)
667                .contains(&SIDE_SHEET_WIDTH),
668            "320dp (the reference's own default) must sit inside the M3 [256, 400]dp band"
669        );
670    }
671
672    // ---- Layout: header reserve, body fill, optional footer. ----
673
674    #[test]
675    fn layout_reserves_the_header_and_fills_the_body_with_no_footer() {
676        let view: SideSheetView<()> = settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
677        let mut w = build(&view);
678        let size = layout(&mut w);
679        assert_eq!(size, WINDOW, "the host fills its own area (the scrim)");
680
681        let panel = w.panel_rect();
682        assert_eq!(
683            panel.width(),
684            SIDE_SHEET_WIDTH,
685            "a 400px window is wider than SIDE_SHEET_WIDTH, so no clamp bites"
686        );
687        assert_eq!(panel.height(), WINDOW.height, "full height");
688        // The panel is pinned to the trailing (right) edge.
689        assert_eq!(panel.x1, WINDOW.width);
690    }
691
692    #[test]
693    fn layout_adds_a_divider_and_a_left_packed_footer_row_when_actions_is_non_empty() {
694        let view: SideSheetView<()> = settled_view(
695            "Filters",
696            leaf_any(100.0, 100.0),
697            vec![leaf_any(60.0, 32.0), leaf_any(60.0, 32.0)],
698        );
699        let mut w = build(&view);
700        layout(&mut w);
701        let panel_h = w.panel_rect().height();
702
703        // Reach into the content widget through the public accessors this
704        // test module has: since `content` itself is crate-private, assert
705        // the *painted* geometry instead (paint test below covers the exact
706        // positions; here just prove the panel's own height accounts for a
707        // footer at all by checking a fresh, footer-less build differs).
708        let no_actions: SideSheetView<()> =
709            settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
710        let mut w2 = build(&no_actions);
711        layout(&mut w2);
712        assert_eq!(
713            w.panel_rect().height(),
714            w2.panel_rect().height(),
715            "the panel itself is always full height regardless of a footer"
716        );
717        assert_eq!(panel_h, WINDOW.height);
718    }
719
720    // ---- Paint: corner asymmetry + width. ----
721
722    fn paint(w: &mut OverlayModalWidget) -> Recorder {
723        let mut rec = Recorder::default();
724        let mut pctx = PaintCtx::new(Point::ZERO, WINDOW);
725        w.paint(&mut pctx, &mut rec);
726        rec
727    }
728
729    #[test]
730    fn unthemed_paint_rounds_only_the_left_corners_at_the_fallback_radius() {
731        let view: SideSheetView<()> = settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
732        let mut w = build(&view);
733        layout(&mut w);
734        let rec = paint(&mut w);
735
736        assert_eq!(rec.panels.len(), 1, "one panel fill");
737        let (origin, size, radii, _color) = rec.panels[0];
738        assert_eq!(size.width, SIDE_SHEET_WIDTH);
739        assert_eq!(origin.x, WINDOW.width - SIDE_SHEET_WIDTH, "pinned trailing");
740
741        let radius = crate::overlay::radius(None);
742        assert_eq!(radii.top_left, radius, "left corners round");
743        assert_eq!(radii.bottom_left, radius, "left corners round");
744        assert_eq!(
745            radii.top_right, 0.0,
746            "right corners are square (screen edge)"
747        );
748        assert_eq!(
749            radii.bottom_right, 0.0,
750            "right corners are square (screen edge)"
751        );
752    }
753
754    #[test]
755    fn themed_paint_resolves_the_extra_large_shape_token_on_the_rounded_corners() {
756        let theme = crate::baseline();
757        let view: SideSheetView<()> = settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
758        let mut w = build(&view);
759        layout(&mut w);
760
761        let mut rec = Recorder::default();
762        let mut pctx = PaintCtx::new(Point::ZERO, WINDOW).with_theme(&theme);
763        w.paint(&mut pctx, &mut rec);
764
765        let (_, _, radii, _) = rec.panels[0];
766        assert_eq!(radii.top_left, theme.shape.extra_large);
767        assert_eq!(radii.bottom_left, theme.shape.extra_large);
768        assert_eq!(radii.top_right, 0.0);
769        assert_eq!(radii.bottom_right, 0.0);
770    }
771
772    // ---- Motion: the real (non-settled) constructor slides. ----
773
774    #[test]
775    fn the_real_constructor_starts_off_screen_and_slides_to_the_trailing_edge() {
776        let view: SideSheetView<()> =
777            side_sheet("Filters", leaf_any(100.0, 100.0), Vec::<AnyView<()>>::new());
778        let mut w = build(&view);
779        layout(&mut w);
780        assert_eq!(
781            w.progress(),
782            0.0,
783            "an un-settled Slide entrance starts closed"
784        );
785
786        // Settle the ramp: paint once to seed it, once well past its duration.
787        let mut rec = Recorder::default();
788        let mut pctx = PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::ZERO);
789        w.paint(&mut pctx, &mut rec);
790        layout(&mut w);
791        let mut rec2 = Recorder::default();
792        let mut pctx2 =
793            PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::from_nanos(2_000_000_000));
794        w.paint(&mut pctx2, &mut rec2);
795        layout(&mut w);
796
797        assert_eq!(w.progress(), 1.0, "the slide settles fully open");
798        assert_eq!(
799            w.panel_rect().x1,
800            WINDOW.width,
801            "settled, the panel is flush against the trailing edge"
802        );
803    }
804
805    // ---- Dismiss: standalone (unstaged) close affordance + scrim tap. ----
806
807    #[derive(Default)]
808    struct Flag {
809        dismissed: u32,
810    }
811
812    fn dispatch(w: &mut OverlayModalWidget, state: &mut Flag, event: &InputEvent) -> EventResult {
813        let state_any: &mut dyn Any = state;
814        let mut ctx = EventCtx::new(state_any, Point::ZERO, WINDOW);
815        w.event(&mut ctx, event)
816    }
817
818    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
819        InputEvent::Pointer(PointerEvent {
820            phase,
821            position: Point::new(x, y),
822            button: PointerButton::Primary,
823        })
824    }
825
826    fn settled_flag_view(actions: Vec<AnyView<Flag>>) -> SideSheetView<Flag> {
827        let mut view = settled_view("Filters", leaf_any_flag(100.0, 100.0), actions);
828        view = view.on_dismiss(|s: &mut Flag| s.dismissed += 1);
829        view
830    }
831
832    // A `()`-erased leaf is `AnyView<()>`; the `Flag`-state sheet needs an
833    // `AnyView<Flag>` — mirrors `crate::sheet`'s own test-only adapter.
834    fn leaf_any_flag(w: f64, h: f64) -> AnyView<Flag> {
835        core_any::<Flag, _>(FixedLeaf {
836            size: Size::new(w, h),
837        })
838    }
839    struct FixedLeaf {
840        size: Size,
841    }
842    struct FixedLeafW {
843        size: Size,
844    }
845    impl View<Flag> for FixedLeaf {
846        type Element = FixedLeafW;
847        fn build(&self, _c: &mut BuildCtx<'_>) -> FixedLeafW {
848            FixedLeafW { size: self.size }
849        }
850        fn rebuild(&self, _p: &Self, _e: &mut FixedLeafW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
851            ChangeFlags::NONE
852        }
853    }
854    impl Widget for FixedLeafW {
855        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
856            bc.constrain(self.size)
857        }
858        fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
859    }
860
861    #[test]
862    fn the_hosts_close_affordance_dismisses_via_on_dismiss() {
863        let view = settled_flag_view(Vec::new());
864        let mut w = build(&view);
865        layout(&mut w);
866        let close: Rect = w.close_rect().expect("close_button(true) is configured");
867        let center = close.center();
868
869        let mut state = Flag::default();
870        dispatch(
871            &mut w,
872            &mut state,
873            &ev(PointerPhase::Down, center.x, center.y),
874        );
875        dispatch(
876            &mut w,
877            &mut state,
878            &ev(PointerPhase::Up, center.x, center.y),
879        );
880        assert_eq!(state.dismissed, 1);
881    }
882
883    #[test]
884    fn a_scrim_tap_outside_the_panel_dismisses() {
885        let view = settled_flag_view(Vec::new());
886        let mut w = build(&view);
887        layout(&mut w);
888        // The panel is pinned to the trailing edge; the leading edge is scrim.
889        assert!(!w.panel_rect().contains(Point::new(5.0, 5.0)));
890
891        let mut state = Flag::default();
892        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
893        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
894        assert_eq!(state.dismissed, 1);
895    }
896
897    // ---- Dismiss + motion: navigator-pushed, staged exit. ----
898
899    #[derive(Default)]
900    struct NavState {
901        results: Vec<Option<i32>>,
902    }
903
904    fn ft(ms: u64) -> FrameTime {
905        FrameTime::from_nanos(ms * 1_000_000)
906    }
907
908    #[test]
909    fn show_side_sheet_stages_a_scrim_tap_dismiss_through_the_navigator() {
910        let controller: NavigatorController<NavState> = NavigatorController::new();
911        let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
912        let mut app = {
913            let ctrl = controller.clone();
914            move |_: &mut NavState| {
915                navigator(&ctrl, || {
916                    core_any::<NavState, _>(bg_page(WINDOW.width, WINDOW.height))
917                })
918            }
919        };
920        let mut state = NavState::default();
921
922        root.rebuild(&mut app, &mut state);
923        let mut tcx = TextContext::new();
924        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
925
926        show_side_sheet(
927            &controller,
928            || {
929                side_sheet(
930                    "Filters",
931                    bg_page(100.0, 100.0),
932                    Vec::<AnyView<NavState>>::new(),
933                )
934            },
935            |state: &mut NavState, result: PopResult| {
936                state.results.push(result.take::<i32>());
937            },
938        );
939        root.rebuild(&mut app, &mut state);
940        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
941        // Settle the Slide entrance (LONG_2, 500ms M3 default).
942        for t in [0u64, 100, 200, 300, 400, 550] {
943            root.paint(&mut Recorder::default(), ft(t));
944        }
945
946        // Tap the leading-edge scrim, outside the trailing-pinned panel.
947        root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
948        root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
949        root.rebuild(&mut app, &mut state);
950        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
951
952        // The dismiss is staged behind the exit ramp: drive frames until it
953        // settles and the deferred pop drains.
954        let mut frames = 0u64;
955        loop {
956            root.rebuild(&mut app, &mut state);
957            root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
958            let outcome = root.paint(&mut Recorder::default(), ft(600 + frames * 100));
959            frames += 1;
960            assert!(
961                frames < 30,
962                "the staged exit settles within a bounded number of frames"
963            );
964            if !outcome.needs_frame {
965                break;
966            }
967        }
968        root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
969
970        assert_eq!(
971            state.results,
972            vec![None],
973            "a scrim tap stages the exit and pops with an empty result"
974        );
975    }
976
977    #[test]
978    fn back_request_stages_the_exit_and_pops_via_the_navigator() {
979        let controller: NavigatorController<NavState> = NavigatorController::new();
980        let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
981        let mut app = {
982            let ctrl = controller.clone();
983            move |_: &mut NavState| {
984                navigator(&ctrl, || {
985                    core_any::<NavState, _>(bg_page(WINDOW.width, WINDOW.height))
986                })
987            }
988        };
989        let mut state = NavState::default();
990
991        root.rebuild(&mut app, &mut state);
992        let mut tcx = TextContext::new();
993        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
994
995        show_side_sheet(
996            &controller,
997            || {
998                side_sheet(
999                    "Filters",
1000                    bg_page(100.0, 100.0),
1001                    Vec::<AnyView<NavState>>::new(),
1002                )
1003            },
1004            |state: &mut NavState, result: PopResult| {
1005                state.results.push(result.take::<i32>());
1006            },
1007        );
1008        root.rebuild(&mut app, &mut state);
1009        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1010        for t in [0u64, 100, 200, 300, 400, 550] {
1011            root.paint(&mut Recorder::default(), ft(t));
1012        }
1013
1014        assert!(
1015            controller.back_interest(),
1016            "a dismissable side sheet claims back interest"
1017        );
1018
1019        controller.request_back();
1020        root.rebuild(&mut app, &mut state);
1021        assert_eq!(
1022            controller.depth(),
1023            2,
1024            "DismissAnimated: unchanged immediately"
1025        );
1026
1027        let mut frames = 0u64;
1028        loop {
1029            root.rebuild(&mut app, &mut state);
1030            root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1031            let outcome = root.paint(&mut Recorder::default(), ft(600 + frames * 100));
1032            frames += 1;
1033            assert!(
1034                frames < 30,
1035                "the staged exit settles within a bounded number of frames"
1036            );
1037            if !outcome.needs_frame {
1038                break;
1039            }
1040        }
1041        root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
1042
1043        assert_eq!(
1044            state.results,
1045            vec![None],
1046            "a back request dismisses the sheet"
1047        );
1048        assert_eq!(controller.depth(), 1);
1049    }
1050
1051    #[test]
1052    fn an_actions_dismiss_handle_stages_the_slide_out_instead_of_popping_at_once() {
1053        // The sheet's own Reset/Apply buttons: a raw `controller.pop()` from
1054        // one takes the page out from under the host and the panel vanishes
1055        // mid-slide. Through the handle it takes the very same staged exit the
1056        // scrim tap above takes — and can carry the pop result with it.
1057        let controller: NavigatorController<NavState> = NavigatorController::new();
1058        let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
1059        let mut app = {
1060            let ctrl = controller.clone();
1061            move |_: &mut NavState| {
1062                navigator(&ctrl, || {
1063                    core_any::<NavState, _>(bg_page(WINDOW.width, WINDOW.height))
1064                })
1065            }
1066        };
1067        let mut state = NavState::default();
1068
1069        root.rebuild(&mut app, &mut state);
1070        let mut tcx = TextContext::new();
1071        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1072
1073        // Minted outside the page builder, exactly as an app does it.
1074        let dismiss = ModalDismiss::new();
1075        let installed = dismiss.clone();
1076        show_side_sheet(
1077            &controller,
1078            move || {
1079                side_sheet(
1080                    "Filters",
1081                    bg_page(100.0, 100.0),
1082                    Vec::<AnyView<NavState>>::new(),
1083                )
1084                .dismiss_handle(installed.clone())
1085            },
1086            |state: &mut NavState, result: PopResult| {
1087                state.results.push(result.take::<i32>());
1088            },
1089        );
1090        root.rebuild(&mut app, &mut state);
1091        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1092        for t in [0u64, 100, 200, 300, 400, 550] {
1093            root.paint(&mut Recorder::default(), ft(t));
1094        }
1095        assert_eq!(controller.depth(), 2, "the sheet is up");
1096
1097        // "Apply".
1098        dismiss.dismiss_with(PopResult::of(3));
1099        root.rebuild(&mut app, &mut state);
1100        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1101        root.paint(&mut Recorder::default(), ft(600));
1102        assert_eq!(
1103            controller.depth(),
1104            2,
1105            "nothing pops while the slide-out runs"
1106        );
1107
1108        let mut frames = 0u64;
1109        loop {
1110            root.rebuild(&mut app, &mut state);
1111            root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1112            let outcome = root.paint(&mut Recorder::default(), ft(700 + frames * 100));
1113            frames += 1;
1114            assert!(
1115                frames < 30,
1116                "the staged exit settles within a bounded number of frames"
1117            );
1118            if !outcome.needs_frame {
1119                break;
1120            }
1121        }
1122        root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
1123
1124        assert_eq!(
1125            state.results,
1126            vec![Some(3)],
1127            "the action's own result rides the staged pop"
1128        );
1129        assert_eq!(controller.depth(), 1);
1130    }
1131
1132    // A minimal opaque background page for the navigator integration tests.
1133    struct BgPage {
1134        size: Size,
1135    }
1136    struct BgPageW {
1137        size: Size,
1138    }
1139    impl View<NavState> for BgPage {
1140        type Element = BgPageW;
1141        fn build(&self, _c: &mut BuildCtx<'_>) -> BgPageW {
1142            BgPageW { size: self.size }
1143        }
1144        fn rebuild(&self, _p: &Self, _e: &mut BgPageW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
1145            ChangeFlags::NONE
1146        }
1147    }
1148    impl Widget for BgPageW {
1149        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1150            bc.constrain(self.size)
1151        }
1152        fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1153            scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
1154        }
1155    }
1156    fn bg_page(w: f64, h: f64) -> BgPage {
1157        BgPage {
1158            size: Size::new(w, h),
1159        }
1160    }
1161
1162    // ---- Semantics: forwarded to the host's Role::Dialog node. ----
1163
1164    #[test]
1165    fn semantics_is_a_modal_dialog_labelled_by_title_with_header_body_and_actions_as_children() {
1166        fn logic(_s: &mut ()) -> SideSheetView<()> {
1167            side_sheet(
1168                "Filters",
1169                leaf_any(100.0, 100.0),
1170                vec![leaf_any(60.0, 32.0)],
1171            )
1172        }
1173        let mut root: RenderRoot<(), SideSheetView<()>> = RenderRoot::new();
1174        let mut state = ();
1175        root.rebuild(&mut logic, &mut state);
1176        let mut tcx = TextContext::new();
1177        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1178        let update = root.semantics();
1179        let (_, node) = update
1180            .nodes
1181            .iter()
1182            .find(|(_, n)| n.role() == Role::Dialog)
1183            .expect("a Role::Dialog node is contributed");
1184        assert!(node.is_modal(), "the side sheet node sets the modal flag");
1185        assert_eq!(node.label(), Some("Filters"));
1186        assert!(
1187            !node.children().is_empty(),
1188            "title/body/actions are semantics children"
1189        );
1190    }
1191}