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, 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///     vec![any(text_button("Close").on_press(|s: &mut State| { .. }))],
430/// )
431/// ```
432pub fn side_sheet<State: 'static, V: View<State>>(
433    title: impl Into<String>,
434    body: V,
435    actions: Vec<AnyView<State>>,
436) -> SideSheetView<State> {
437    let title = title.into();
438    let content = SideSheetContentView {
439        title: title.clone(),
440        body: any(body),
441        actions,
442    };
443    SideSheetView(overlay_modal(content, side_sheet_config()).label(title))
444}
445
446impl<State: 'static> SideSheetView<State> {
447    /// Whether the user can dismiss this sheet at all — the scrim tap, the
448    /// host's own close affordance, `Escape`, and an Android back press
449    /// (default `true`; see [`mod@crate::overlay::modal`]'s module docs).
450    /// `false` disables all of them; only an explicit control inside `body`/
451    /// `actions` still dismisses it.
452    pub fn dismissable(mut self, dismissable: bool) -> Self {
453        self.0 = self.0.dismissable(dismissable);
454        self
455    }
456
457    /// Set the **unstaged** dismiss callback — delivered with `&mut State`
458    /// during the event pass. [`show_side_sheet`] wires the staged
459    /// (navigator-pop, slide-out) path instead; set this only for a
460    /// `Stack`-mounted sheet with no navigator underneath it.
461    pub fn on_dismiss<F: Fn(&mut State) + 'static>(mut self, on_dismiss: F) -> Self {
462        self.0 = self.0.on_dismiss(on_dismiss);
463        self
464    }
465
466    /// Install the app-triggered staged-dismiss handle, so an `actions` button
467    /// ("Apply", "Reset") closes the sheet through the **same** slide-out ramp
468    /// the close affordance, the scrim tap, `Escape`, and a back press take —
469    /// rather than the raw `controller.pop()` that dismisses on the spot. See
470    /// [`ModalDismiss`], and mint the handle outside [`show_side_sheet`]'s page
471    /// builder.
472    ///
473    /// ```ignore
474    /// let dismiss = ModalDismiss::new();
475    /// let apply = dismiss.clone();
476    /// side_sheet(title, body, vec![
477    ///     any(filled_button("Apply", move |_: &mut State| apply.dismiss())),
478    /// ])
479    /// .dismiss_handle(dismiss.clone())
480    /// ```
481    pub fn dismiss_handle(mut self, dismiss: ModalDismiss) -> Self {
482        self.0 = self.0.dismiss_handle(dismiss);
483        self
484    }
485}
486
487impl<State: 'static> View<State> for SideSheetView<State> {
488    type Element = OverlayModalWidget;
489
490    fn build(&self, ctx: &mut BuildCtx<'_>) -> OverlayModalWidget {
491        View::build(&self.0, ctx)
492    }
493
494    fn rebuild(
495        &self,
496        prev: &Self,
497        element: &mut OverlayModalWidget,
498        ctx: &mut BuildCtx<'_>,
499    ) -> ChangeFlags {
500        View::rebuild(&self.0, &prev.0, element, ctx)
501    }
502
503    fn teardown(&self, element: &mut OverlayModalWidget, ctx: &mut BuildCtx<'_>) {
504        View::teardown(&self.0, element, ctx);
505    }
506}
507
508impl<State: 'static> OverlayModalContent<State> for SideSheetView<State> {
509    fn on_modal_dismiss(mut self, on_dismiss: Rc<dyn Fn(&mut State)>) -> Self {
510        self.0 = self.0.on_modal_dismiss(on_dismiss);
511        self
512    }
513
514    fn modal_dismissable(&self) -> bool {
515        self.0.modal_dismissable()
516    }
517}
518
519/// Push `build`'s side sheet as a transparent navigator page (the page below
520/// stays visible under the scrim), sliding in from the trailing edge, and
521/// register `on_result` for the value it pops with — the
522/// [`crate::overlay::modal`] host's shared [`show_overlay_modal`] push, since
523/// [`SideSheetView`] *is* a pre-configured modal host (see
524/// [`OverlayModalContent`]). The scrim tap, the host's own close affordance,
525/// `Escape`, and an Android back press are all wired to `controller.pop()`
526/// for you, staged behind the slide-out exit ramp (see
527/// [`mod@crate::overlay::modal`]'s Exit motion / Back-dismiss sections).
528///
529/// The sheet's own `actions` reach that same ramp through
530/// [`SideSheetView::dismiss_handle`] — minted here, outside `build`, and
531/// cloned into both the sheet and its buttons.
532///
533/// ```ignore
534/// show_side_sheet(
535///     &state.nav,
536///     || side_sheet("Settings", settings_body(), Vec::new()),
537///     |state: &mut State, result: PopResult| { /* ... */ },
538/// );
539/// ```
540pub fn show_side_sheet<State, B, R>(controller: &NavigatorController<State>, build: B, on_result: R)
541where
542    State: 'static,
543    B: Fn() -> SideSheetView<State> + 'static,
544    R: Fn(&mut State, PopResult) + 'static,
545{
546    show_overlay_modal(controller, build, on_result)
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use crate::overlay::{OverlayCorners, OverlayEntrance, OverlayGeometry};
553    use frust::FrameTime;
554    use frust::NavigatorView;
555    use frust::authoring::text::TextContext;
556    use frust::authoring::{
557        BuildCtx, ChangeFlags, CornerRadii, EventCtx, PointerButton, PointerEvent, PointerPhase,
558        Role, Widget, any as core_any,
559    };
560    use frust_core::RenderRoot;
561    use frust_widgets::navigator;
562    use frust_widgets::test_support::leaf_any;
563    use kurbo::Rect;
564    use peniko::Color;
565    use std::any::Any;
566
567    /// The window every test lays out in.
568    const WINDOW: Size = Size::new(400.0, 600.0);
569
570    /// A recording `PaintScene`: fills, per-corner rounded rects, and clips.
571    #[derive(Default)]
572    struct Recorder {
573        rects: Vec<(Point, Size, Color)>,
574        panels: Vec<(Point, Size, CornerRadii, Color)>,
575    }
576    impl PaintScene for Recorder {
577        fn fill_rect(&mut self, origin: Point, size: Size, color: Color) {
578            self.rects.push((origin, size, color));
579        }
580        fn draw_text(&mut self, _origin: Point, _text: &str) {}
581        fn fill_rounded_rect_radii(
582            &mut self,
583            origin: Point,
584            size: Size,
585            radii: CornerRadii,
586            color: Color,
587        ) {
588            self.panels.push((origin, size, radii, color));
589        }
590        fn push_clip_rounded_radii(&mut self, _origin: Point, _size: Size, _radii: CornerRadii) {}
591        fn push_clip(&mut self, _origin: Point, _size: Size) {}
592        fn pop_clip(&mut self) {}
593    }
594
595    /// A `SideSheetView` built directly (bypassing [`side_sheet`]'s own
596    /// `Slide` entrance) so a paint/layout assertion sees the panel already
597    /// at rest — [`mod@crate::overlay::modal`]'s own `settled` test
598    /// precedent.
599    fn settled_view<State: 'static, V: View<State>>(
600        title: &str,
601        body: V,
602        actions: Vec<AnyView<State>>,
603    ) -> SideSheetView<State> {
604        let content = SideSheetContentView {
605            title: title.to_string(),
606            body: any(body),
607            actions,
608        };
609        SideSheetView(
610            overlay_modal(content, side_sheet_config().entrance(OverlayEntrance::None))
611                .label(title.to_string()),
612        )
613    }
614
615    fn build<S: 'static>(view: &SideSheetView<S>) -> OverlayModalWidget {
616        let mut counter = 0u64;
617        View::<S>::build(view, &mut BuildCtx::new(&mut counter))
618    }
619
620    fn layout(w: &mut OverlayModalWidget) -> Size {
621        let mut tcx = TextContext::new();
622        let mut lctx = LayoutCtx::with_text_context(&mut tcx as &mut dyn Any);
623        w.layout(&mut lctx, &BoxConstraints::tight(WINDOW))
624    }
625
626    // ---- Config: geometry, corners, entrance, close affordance, width. ----
627
628    #[test]
629    fn side_sheet_config_pins_a_right_edge_fixed_width_panel_with_left_corners_only() {
630        let config = side_sheet_config();
631        assert_eq!(
632            config.geometry,
633            OverlayGeometry::Edge {
634                side: OverlaySide::Right,
635                extent: OverlayExtent::Content,
636                limit: OverlayLimit::Px(SIDE_SHEET_WIDTH),
637            }
638        );
639        assert_eq!(
640            config.corners,
641            OverlayCorners::Start,
642            "a right-pinned panel rounds its left (screen-facing) corners only"
643        );
644        assert_eq!(
645            config.entrance,
646            OverlayEntrance::Slide,
647            "a side sheet slides in from its own edge"
648        );
649        assert!(
650            config.close_button,
651            "the host's own close affordance is enabled, not a second one"
652        );
653    }
654
655    #[test]
656    fn the_fixed_width_sits_inside_the_m3_side_sheet_spec_band() {
657        assert!(
658            (SIDE_SHEET_MIN_WIDTH..=crate::overlay::OVERLAY_SIDE_SHEET_MAX_WIDTH)
659                .contains(&SIDE_SHEET_WIDTH),
660            "320dp (the reference's own default) must sit inside the M3 [256, 400]dp band"
661        );
662    }
663
664    // ---- Layout: header reserve, body fill, optional footer. ----
665
666    #[test]
667    fn layout_reserves_the_header_and_fills_the_body_with_no_footer() {
668        let view: SideSheetView<()> = settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
669        let mut w = build(&view);
670        let size = layout(&mut w);
671        assert_eq!(size, WINDOW, "the host fills its own area (the scrim)");
672
673        let panel = w.panel_rect();
674        assert_eq!(
675            panel.width(),
676            SIDE_SHEET_WIDTH,
677            "a 400px window is wider than SIDE_SHEET_WIDTH, so no clamp bites"
678        );
679        assert_eq!(panel.height(), WINDOW.height, "full height");
680        // The panel is pinned to the trailing (right) edge.
681        assert_eq!(panel.x1, WINDOW.width);
682    }
683
684    #[test]
685    fn layout_adds_a_divider_and_a_left_packed_footer_row_when_actions_is_non_empty() {
686        let view: SideSheetView<()> = settled_view(
687            "Filters",
688            leaf_any(100.0, 100.0),
689            vec![leaf_any(60.0, 32.0), leaf_any(60.0, 32.0)],
690        );
691        let mut w = build(&view);
692        layout(&mut w);
693        let panel_h = w.panel_rect().height();
694
695        // Reach into the content widget through the public accessors this
696        // test module has: since `content` itself is crate-private, assert
697        // the *painted* geometry instead (paint test below covers the exact
698        // positions; here just prove the panel's own height accounts for a
699        // footer at all by checking a fresh, footer-less build differs).
700        let no_actions: SideSheetView<()> =
701            settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
702        let mut w2 = build(&no_actions);
703        layout(&mut w2);
704        assert_eq!(
705            w.panel_rect().height(),
706            w2.panel_rect().height(),
707            "the panel itself is always full height regardless of a footer"
708        );
709        assert_eq!(panel_h, WINDOW.height);
710    }
711
712    // ---- Paint: corner asymmetry + width. ----
713
714    fn paint(w: &mut OverlayModalWidget) -> Recorder {
715        let mut rec = Recorder::default();
716        let mut pctx = PaintCtx::new(Point::ZERO, WINDOW);
717        w.paint(&mut pctx, &mut rec);
718        rec
719    }
720
721    #[test]
722    fn unthemed_paint_rounds_only_the_left_corners_at_the_fallback_radius() {
723        let view: SideSheetView<()> = settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
724        let mut w = build(&view);
725        layout(&mut w);
726        let rec = paint(&mut w);
727
728        assert_eq!(rec.panels.len(), 1, "one panel fill");
729        let (origin, size, radii, _color) = rec.panels[0];
730        assert_eq!(size.width, SIDE_SHEET_WIDTH);
731        assert_eq!(origin.x, WINDOW.width - SIDE_SHEET_WIDTH, "pinned trailing");
732
733        let radius = crate::overlay::radius(None);
734        assert_eq!(radii.top_left, radius, "left corners round");
735        assert_eq!(radii.bottom_left, radius, "left corners round");
736        assert_eq!(
737            radii.top_right, 0.0,
738            "right corners are square (screen edge)"
739        );
740        assert_eq!(
741            radii.bottom_right, 0.0,
742            "right corners are square (screen edge)"
743        );
744    }
745
746    #[test]
747    fn themed_paint_resolves_the_extra_large_shape_token_on_the_rounded_corners() {
748        let theme = crate::baseline();
749        let view: SideSheetView<()> = settled_view("Filters", leaf_any(100.0, 100.0), Vec::new());
750        let mut w = build(&view);
751        layout(&mut w);
752
753        let mut rec = Recorder::default();
754        let mut pctx = PaintCtx::new(Point::ZERO, WINDOW).with_theme(&theme);
755        w.paint(&mut pctx, &mut rec);
756
757        let (_, _, radii, _) = rec.panels[0];
758        assert_eq!(radii.top_left, theme.shape.extra_large);
759        assert_eq!(radii.bottom_left, theme.shape.extra_large);
760        assert_eq!(radii.top_right, 0.0);
761        assert_eq!(radii.bottom_right, 0.0);
762    }
763
764    // ---- Motion: the real (non-settled) constructor slides. ----
765
766    #[test]
767    fn the_real_constructor_starts_off_screen_and_slides_to_the_trailing_edge() {
768        let view: SideSheetView<()> = side_sheet("Filters", leaf_any(100.0, 100.0), Vec::new());
769        let mut w = build(&view);
770        layout(&mut w);
771        assert_eq!(
772            w.progress(),
773            0.0,
774            "an un-settled Slide entrance starts closed"
775        );
776
777        // Settle the ramp: paint once to seed it, once well past its duration.
778        let mut rec = Recorder::default();
779        let mut pctx = PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::ZERO);
780        w.paint(&mut pctx, &mut rec);
781        layout(&mut w);
782        let mut rec2 = Recorder::default();
783        let mut pctx2 =
784            PaintCtx::for_test(Point::ZERO, WINDOW, FrameTime::from_nanos(2_000_000_000));
785        w.paint(&mut pctx2, &mut rec2);
786        layout(&mut w);
787
788        assert_eq!(w.progress(), 1.0, "the slide settles fully open");
789        assert_eq!(
790            w.panel_rect().x1,
791            WINDOW.width,
792            "settled, the panel is flush against the trailing edge"
793        );
794    }
795
796    // ---- Dismiss: standalone (unstaged) close affordance + scrim tap. ----
797
798    #[derive(Default)]
799    struct Flag {
800        dismissed: u32,
801    }
802
803    fn dispatch(w: &mut OverlayModalWidget, state: &mut Flag, event: &InputEvent) -> EventResult {
804        let state_any: &mut dyn Any = state;
805        let mut ctx = EventCtx::new(state_any, Point::ZERO, WINDOW);
806        w.event(&mut ctx, event)
807    }
808
809    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
810        InputEvent::Pointer(PointerEvent {
811            phase,
812            position: Point::new(x, y),
813            button: PointerButton::Primary,
814        })
815    }
816
817    fn settled_flag_view(actions: Vec<AnyView<Flag>>) -> SideSheetView<Flag> {
818        let mut view = settled_view("Filters", leaf_any_flag(100.0, 100.0), actions);
819        view = view.on_dismiss(|s: &mut Flag| s.dismissed += 1);
820        view
821    }
822
823    // A `()`-erased leaf is `AnyView<()>`; the `Flag`-state sheet needs an
824    // `AnyView<Flag>` — mirrors `crate::sheet`'s own test-only adapter.
825    fn leaf_any_flag(w: f64, h: f64) -> AnyView<Flag> {
826        core_any::<Flag, _>(FixedLeaf {
827            size: Size::new(w, h),
828        })
829    }
830    struct FixedLeaf {
831        size: Size,
832    }
833    struct FixedLeafW {
834        size: Size,
835    }
836    impl View<Flag> for FixedLeaf {
837        type Element = FixedLeafW;
838        fn build(&self, _c: &mut BuildCtx<'_>) -> FixedLeafW {
839            FixedLeafW { size: self.size }
840        }
841        fn rebuild(&self, _p: &Self, _e: &mut FixedLeafW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
842            ChangeFlags::NONE
843        }
844    }
845    impl Widget for FixedLeafW {
846        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
847            bc.constrain(self.size)
848        }
849        fn paint(&mut self, _c: &mut PaintCtx, _s: &mut dyn PaintScene) {}
850    }
851
852    #[test]
853    fn the_hosts_close_affordance_dismisses_via_on_dismiss() {
854        let view = settled_flag_view(Vec::new());
855        let mut w = build(&view);
856        layout(&mut w);
857        let close: Rect = w.close_rect().expect("close_button(true) is configured");
858        let center = close.center();
859
860        let mut state = Flag::default();
861        dispatch(
862            &mut w,
863            &mut state,
864            &ev(PointerPhase::Down, center.x, center.y),
865        );
866        dispatch(
867            &mut w,
868            &mut state,
869            &ev(PointerPhase::Up, center.x, center.y),
870        );
871        assert_eq!(state.dismissed, 1);
872    }
873
874    #[test]
875    fn a_scrim_tap_outside_the_panel_dismisses() {
876        let view = settled_flag_view(Vec::new());
877        let mut w = build(&view);
878        layout(&mut w);
879        // The panel is pinned to the trailing edge; the leading edge is scrim.
880        assert!(!w.panel_rect().contains(Point::new(5.0, 5.0)));
881
882        let mut state = Flag::default();
883        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 5.0, 5.0));
884        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 5.0, 5.0));
885        assert_eq!(state.dismissed, 1);
886    }
887
888    // ---- Dismiss + motion: navigator-pushed, staged exit. ----
889
890    #[derive(Default)]
891    struct NavState {
892        results: Vec<Option<i32>>,
893    }
894
895    fn ft(ms: u64) -> FrameTime {
896        FrameTime::from_nanos(ms * 1_000_000)
897    }
898
899    #[test]
900    fn show_side_sheet_stages_a_scrim_tap_dismiss_through_the_navigator() {
901        let controller: NavigatorController<NavState> = NavigatorController::new();
902        let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
903        let mut app = {
904            let ctrl = controller.clone();
905            move |_: &mut NavState| {
906                navigator(&ctrl, || {
907                    core_any::<NavState, _>(bg_page(WINDOW.width, WINDOW.height))
908                })
909            }
910        };
911        let mut state = NavState::default();
912
913        root.rebuild(&mut app, &mut state);
914        let mut tcx = TextContext::new();
915        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
916
917        show_side_sheet(
918            &controller,
919            || side_sheet("Filters", bg_page(100.0, 100.0), Vec::new()),
920            |state: &mut NavState, result: PopResult| {
921                state.results.push(result.take::<i32>());
922            },
923        );
924        root.rebuild(&mut app, &mut state);
925        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
926        // Settle the Slide entrance (LONG_2, 500ms M3 default).
927        for t in [0u64, 100, 200, 300, 400, 550] {
928            root.paint(&mut Recorder::default(), ft(t));
929        }
930
931        // Tap the leading-edge scrim, outside the trailing-pinned panel.
932        root.event(&mut state, &ev(PointerPhase::Down, 5.0, 5.0));
933        root.event(&mut state, &ev(PointerPhase::Up, 5.0, 5.0));
934        root.rebuild(&mut app, &mut state);
935        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
936
937        // The dismiss is staged behind the exit ramp: drive frames until it
938        // settles and the deferred pop drains.
939        let mut frames = 0u64;
940        loop {
941            root.rebuild(&mut app, &mut state);
942            root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
943            let outcome = root.paint(&mut Recorder::default(), ft(600 + frames * 100));
944            frames += 1;
945            assert!(
946                frames < 30,
947                "the staged exit settles within a bounded number of frames"
948            );
949            if !outcome.needs_frame {
950                break;
951            }
952        }
953        root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
954
955        assert_eq!(
956            state.results,
957            vec![None],
958            "a scrim tap stages the exit and pops with an empty result"
959        );
960    }
961
962    #[test]
963    fn back_request_stages_the_exit_and_pops_via_the_navigator() {
964        let controller: NavigatorController<NavState> = NavigatorController::new();
965        let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
966        let mut app = {
967            let ctrl = controller.clone();
968            move |_: &mut NavState| {
969                navigator(&ctrl, || {
970                    core_any::<NavState, _>(bg_page(WINDOW.width, WINDOW.height))
971                })
972            }
973        };
974        let mut state = NavState::default();
975
976        root.rebuild(&mut app, &mut state);
977        let mut tcx = TextContext::new();
978        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
979
980        show_side_sheet(
981            &controller,
982            || side_sheet("Filters", bg_page(100.0, 100.0), Vec::new()),
983            |state: &mut NavState, result: PopResult| {
984                state.results.push(result.take::<i32>());
985            },
986        );
987        root.rebuild(&mut app, &mut state);
988        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
989        for t in [0u64, 100, 200, 300, 400, 550] {
990            root.paint(&mut Recorder::default(), ft(t));
991        }
992
993        assert!(
994            controller.back_interest(),
995            "a dismissable side sheet claims back interest"
996        );
997
998        controller.request_back();
999        root.rebuild(&mut app, &mut state);
1000        assert_eq!(
1001            controller.depth(),
1002            2,
1003            "DismissAnimated: unchanged immediately"
1004        );
1005
1006        let mut frames = 0u64;
1007        loop {
1008            root.rebuild(&mut app, &mut state);
1009            root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1010            let outcome = root.paint(&mut Recorder::default(), ft(600 + frames * 100));
1011            frames += 1;
1012            assert!(
1013                frames < 30,
1014                "the staged exit settles within a bounded number of frames"
1015            );
1016            if !outcome.needs_frame {
1017                break;
1018            }
1019        }
1020        root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
1021
1022        assert_eq!(
1023            state.results,
1024            vec![None],
1025            "a back request dismisses the sheet"
1026        );
1027        assert_eq!(controller.depth(), 1);
1028    }
1029
1030    #[test]
1031    fn an_actions_dismiss_handle_stages_the_slide_out_instead_of_popping_at_once() {
1032        // The sheet's own Reset/Apply buttons: a raw `controller.pop()` from
1033        // one takes the page out from under the host and the panel vanishes
1034        // mid-slide. Through the handle it takes the very same staged exit the
1035        // scrim tap above takes — and can carry the pop result with it.
1036        let controller: NavigatorController<NavState> = NavigatorController::new();
1037        let mut root: RenderRoot<NavState, NavigatorView<NavState>> = RenderRoot::new();
1038        let mut app = {
1039            let ctrl = controller.clone();
1040            move |_: &mut NavState| {
1041                navigator(&ctrl, || {
1042                    core_any::<NavState, _>(bg_page(WINDOW.width, WINDOW.height))
1043                })
1044            }
1045        };
1046        let mut state = NavState::default();
1047
1048        root.rebuild(&mut app, &mut state);
1049        let mut tcx = TextContext::new();
1050        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1051
1052        // Minted outside the page builder, exactly as an app does it.
1053        let dismiss = ModalDismiss::new();
1054        let installed = dismiss.clone();
1055        show_side_sheet(
1056            &controller,
1057            move || {
1058                side_sheet("Filters", bg_page(100.0, 100.0), Vec::new())
1059                    .dismiss_handle(installed.clone())
1060            },
1061            |state: &mut NavState, result: PopResult| {
1062                state.results.push(result.take::<i32>());
1063            },
1064        );
1065        root.rebuild(&mut app, &mut state);
1066        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1067        for t in [0u64, 100, 200, 300, 400, 550] {
1068            root.paint(&mut Recorder::default(), ft(t));
1069        }
1070        assert_eq!(controller.depth(), 2, "the sheet is up");
1071
1072        // "Apply".
1073        dismiss.dismiss_with(PopResult::of(3));
1074        root.rebuild(&mut app, &mut state);
1075        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1076        root.paint(&mut Recorder::default(), ft(600));
1077        assert_eq!(
1078            controller.depth(),
1079            2,
1080            "nothing pops while the slide-out runs"
1081        );
1082
1083        let mut frames = 0u64;
1084        loop {
1085            root.rebuild(&mut app, &mut state);
1086            root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1087            let outcome = root.paint(&mut Recorder::default(), ft(700 + frames * 100));
1088            frames += 1;
1089            assert!(
1090                frames < 30,
1091                "the staged exit settles within a bounded number of frames"
1092            );
1093            if !outcome.needs_frame {
1094                break;
1095            }
1096        }
1097        root.event(&mut state, &ev(PointerPhase::Move, 5.0, 5.0));
1098
1099        assert_eq!(
1100            state.results,
1101            vec![Some(3)],
1102            "the action's own result rides the staged pop"
1103        );
1104        assert_eq!(controller.depth(), 1);
1105    }
1106
1107    // A minimal opaque background page for the navigator integration tests.
1108    struct BgPage {
1109        size: Size,
1110    }
1111    struct BgPageW {
1112        size: Size,
1113    }
1114    impl View<NavState> for BgPage {
1115        type Element = BgPageW;
1116        fn build(&self, _c: &mut BuildCtx<'_>) -> BgPageW {
1117            BgPageW { size: self.size }
1118        }
1119        fn rebuild(&self, _p: &Self, _e: &mut BgPageW, _c: &mut BuildCtx<'_>) -> ChangeFlags {
1120            ChangeFlags::NONE
1121        }
1122    }
1123    impl Widget for BgPageW {
1124        fn layout(&mut self, _c: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
1125            bc.constrain(self.size)
1126        }
1127        fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
1128            scene.fill_rect(ctx.origin(), ctx.size(), Color::BLACK);
1129        }
1130    }
1131    fn bg_page(w: f64, h: f64) -> BgPage {
1132        BgPage {
1133            size: Size::new(w, h),
1134        }
1135    }
1136
1137    // ---- Semantics: forwarded to the host's Role::Dialog node. ----
1138
1139    #[test]
1140    fn semantics_is_a_modal_dialog_labelled_by_title_with_header_body_and_actions_as_children() {
1141        fn logic(_s: &mut ()) -> SideSheetView<()> {
1142            side_sheet(
1143                "Filters",
1144                leaf_any(100.0, 100.0),
1145                vec![leaf_any(60.0, 32.0)],
1146            )
1147        }
1148        let mut root: RenderRoot<(), SideSheetView<()>> = RenderRoot::new();
1149        let mut state = ();
1150        root.rebuild(&mut logic, &mut state);
1151        let mut tcx = TextContext::new();
1152        root.layout_with_text(WINDOW, &mut tcx as &mut dyn Any);
1153        let update = root.semantics();
1154        let (_, node) = update
1155            .nodes
1156            .iter()
1157            .find(|(_, n)| n.role() == Role::Dialog)
1158            .expect("a Role::Dialog node is contributed");
1159        assert!(node.is_modal(), "the side sheet node sets the modal flag");
1160        assert_eq!(node.label(), Some("Filters"));
1161        assert!(
1162            !node.children().is_empty(),
1163            "title/body/actions are semantics children"
1164        );
1165    }
1166}