Skip to main content

tessera_components/
side_sheet.rs

1//! Side sheet provider - slide supporting content in from the screen edge.
2//!
3//! ## Usage
4//!
5//! Show filters, details and secondary tasks beside the main content.
6
7mod input;
8
9use tessera_ui::{
10    Callback, CallbackWith, Color, ComputedData, Constraint, Dp, FocusProperties, FocusScopeNode,
11    FocusTraversalPolicy, LayoutResult, MeasurementError, Modifier, Px, PxPosition, PxSize,
12    RenderSlot, State, current_frame_nanos,
13    layout::{MeasureScope, PlacementResult, PlacementScope, layout},
14    modifier::FocusModifierExt as _,
15    receive_frame_nanos, remember, tessera, use_context, winit,
16};
17
18use crate::{
19    animation::{
20        sheet_drag::SheetDrag,
21        visibility_motion::{VisibilityMotion, VisibilitySample, modal_back_handler},
22    },
23    modal_focus::{ModalFocusState, modal_focus_modifier},
24    modifier::{ModifierExt, SemanticsArgs, with_keyboard_input},
25    nested_scroll::{
26        NestedScrollConnection, PostScrollInput, PreFlingInput, PreScrollInput, ScrollDelta,
27        ScrollVelocity,
28    },
29    overlay_semantics::{dialog_semantics, overlay_background},
30    shape_def::{RoundedCorner, Shape},
31    surface::surface,
32    theme::MaterialTheme,
33};
34
35use input::side_sheet_body;
36
37const SCRIM_ALPHA: f32 = 0.32;
38const MAX_SHEET_WIDTH: Dp = Dp(400.0);
39const CORNER_RADIUS: Dp = Dp(16.0);
40const MODAL_ELEVATION: Dp = Dp(1.0);
41
42fn side_sheet_dimension(value: Option<Dp>, default: Dp) -> Dp {
43    let value = value.filter(|value| value.0.is_finite()).unwrap_or(default);
44    Dp(value.0.max(0.0))
45}
46
47/// Defines how the side sheet behaves relative to the main content.
48#[derive(Default, Clone, Copy, PartialEq)]
49enum SideSheetType {
50    /// A modal sheet that blocks interaction with content behind it.
51    #[default]
52    Modal,
53    /// A standard sheet that does not block interaction behind it.
54    Standard,
55}
56
57/// Defines which edge the sheet is attached to.
58#[derive(Default, Clone, Copy, PartialEq)]
59pub enum SideSheetPosition {
60    /// Attach to the start (left) edge.
61    #[default]
62    Start,
63    /// Attach to the end (right) edge.
64    End,
65}
66
67/// Controller for side sheet providers, managing open/closed state.
68///
69/// This controller can be created by the application and passed to the
70/// [`modal_side_sheet_provider`] or
71/// [`standard_side_sheet_provider`]. It is used to control the
72/// visibility of the sheet programmatically.
73///
74/// # Example
75///
76/// ```
77/// use tessera_components::side_sheet::SideSheetController;
78///
79/// let session = tessera_ui::RuntimeSession::new();
80/// let _scope = session.enter();
81/// let mut controller = SideSheetController::new(false);
82/// assert!(!controller.is_open());
83/// controller.open();
84/// assert!(controller.is_open());
85/// controller.close();
86/// assert!(!controller.is_open());
87/// ```
88#[derive(Clone, PartialEq)]
89pub struct SideSheetController {
90    is_open: bool,
91    motion: VisibilityMotion,
92    drag: SheetDrag,
93}
94
95impl SideSheetController {
96    /// Creates a new controller.
97    pub fn new(initial_open: bool) -> Self {
98        Self {
99            is_open: initial_open,
100            motion: VisibilityMotion::new(initial_open, true),
101            drag: SheetDrag::new(),
102        }
103    }
104
105    /// Initiates the animation to open the side sheet.
106    ///
107    /// If the sheet is already open this has no effect. If it is currently
108    /// closing, the animation will reverse direction and start opening from the
109    /// current animated position.
110    pub fn open(&mut self) {
111        if !self.is_open {
112            self.motion.retarget(true, current_frame_nanos());
113            self.is_open = true;
114            self.drag.settle(current_frame_nanos());
115        }
116    }
117
118    /// Initiates the animation to close the side sheet.
119    ///
120    /// If the sheet is already closed this has no effect. If it is currently
121    /// opening, the animation will reverse direction and start closing from the
122    /// current animated position.
123    pub fn close(&mut self) {
124        if self.is_open {
125            self.motion.retarget(false, current_frame_nanos());
126            self.is_open = false;
127            self.drag.settle(current_frame_nanos());
128        }
129    }
130
131    /// Returns whether the side sheet is currently open.
132    pub fn is_open(&self) -> bool {
133        self.is_open
134    }
135
136    /// Returns whether the side sheet is currently animating.
137    pub fn is_animating(&self) -> bool {
138        self.is_animating_at(current_frame_nanos())
139    }
140
141    fn is_animating_at(&self, frame: u64) -> bool {
142        self.motion.sample(frame).active || (self.is_open && self.drag.sample(frame).1)
143    }
144
145    fn snapshot(&self) -> (bool, VisibilitySample, f32) {
146        (
147            self.is_open,
148            self.motion.sample(current_frame_nanos()),
149            self.drag_offset(),
150        )
151    }
152
153    fn drag_offset(&self) -> f32 {
154        self.drag.sample(current_frame_nanos()).0
155    }
156
157    fn apply_drag_delta(&mut self, delta_x: f32, position: SideSheetPosition) -> f32 {
158        let direction = if position == SideSheetPosition::Start {
159            -1.0
160        } else {
161            1.0
162        };
163        self.drag
164            .apply_delta(delta_x * direction, current_frame_nanos())
165            * direction
166    }
167
168    fn complete_drag(&mut self, velocity: f32, position: SideSheetPosition) -> bool {
169        let direction = if position == SideSheetPosition::Start {
170            -1.0
171        } else {
172            1.0
173        };
174        let extent = if self.drag.extent() > 0.0 {
175            self.drag.extent()
176        } else {
177            MAX_SHEET_WIDTH.0
178        };
179        self.drag.release(
180            current_frame_nanos(),
181            velocity * direction,
182            extent * 0.5,
183            500.0,
184        )
185    }
186}
187
188impl Default for SideSheetController {
189    fn default() -> Self {
190        Self::new(false)
191    }
192}
193
194fn sheet_shape(position: SideSheetPosition) -> Shape {
195    let rounded = RoundedCorner::manual(CORNER_RADIUS, 3.0);
196    match position {
197        SideSheetPosition::Start => Shape::RoundedRectangle {
198            top_left: RoundedCorner::ZERO,
199            top_right: rounded,
200            bottom_right: rounded,
201            bottom_left: RoundedCorner::ZERO,
202        },
203        SideSheetPosition::End => Shape::RoundedRectangle {
204            top_left: rounded,
205            top_right: RoundedCorner::ZERO,
206            bottom_right: RoundedCorner::ZERO,
207            bottom_left: rounded,
208        },
209    }
210}
211
212/// Compute X position for side sheet placement.
213fn compute_side_sheet_x(
214    parent_width: Px,
215    sheet_width: Px,
216    progress: f32,
217    is_open: bool,
218    position: SideSheetPosition,
219    drag_offset: f32,
220) -> f32 {
221    let parent = parent_width.0 as f32;
222    let sheet = sheet_width.0 as f32;
223    let (closed_x, open_x) = match position {
224        SideSheetPosition::Start => (-sheet, 0.0),
225        SideSheetPosition::End => (parent, parent - sheet),
226    };
227    let x = if is_open {
228        closed_x + (open_x - closed_x) * progress
229    } else {
230        open_x + (closed_x - open_x) * progress
231    };
232    x + drag_offset
233}
234
235/// Create the keyboard handler closure used to close the sheet on Escape.
236fn make_keyboard_closure(
237    on_close: Callback,
238) -> Box<dyn Fn(tessera_ui::KeyboardInput<'_>) + Send + Sync> {
239    Box::new(move |mut input: tessera_ui::KeyboardInput<'_>| {
240        let mut handled = false;
241        input.keyboard_events.retain(|event| {
242            if event.state != winit::event::ElementState::Pressed {
243                return true;
244            }
245
246            if let winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape) =
247                event.physical_key
248            {
249                on_close.call();
250                handled = true;
251                return false;
252            }
253
254            true
255        });
256        if handled {
257            input.block_keyboard();
258        }
259    })
260}
261
262fn is_close_direction(delta_x: f32, position: SideSheetPosition) -> bool {
263    match position {
264        SideSheetPosition::Start => delta_x < 0.0,
265        SideSheetPosition::End => delta_x > 0.0,
266    }
267}
268
269fn side_sheet_extent_modifier(controller: State<SideSheetController>) -> Modifier {
270    Modifier::new().fill_max_height().on_layout_coordinates(
271        move |coordinates: tessera_ui::LayoutCoordinates| {
272            let extent = Dp::from_pixels_f64(f64::from(coordinates.local_size.width.0)).0;
273            if controller.with(|c| c.drag.extent() != extent) {
274                controller.with_mut(|c| c.drag.set_extent(extent, current_frame_nanos()));
275            }
276        },
277    )
278}
279
280fn is_open_direction(delta_x: f32, position: SideSheetPosition) -> bool {
281    match position {
282        SideSheetPosition::Start => delta_x > 0.0,
283        SideSheetPosition::End => delta_x < 0.0,
284    }
285}
286
287fn build_side_sheet_nested_scroll_connection(
288    controller: State<SideSheetController>,
289    on_close_request: Callback,
290    position: State<SideSheetPosition>,
291    parent: Option<NestedScrollConnection>,
292) -> NestedScrollConnection {
293    NestedScrollConnection::new()
294        .with_pre_scroll_handler(CallbackWith::new({
295            move |input: PreScrollInput| {
296                if input.source != tessera_ui::ScrollEventSource::Touch
297                    || !is_open_direction(input.available.x, position.get())
298                    || controller.with(|c| c.drag_offset()) == 0.0
299                {
300                    return ScrollDelta::ZERO;
301                }
302
303                let consumed_x =
304                    controller.with_mut(|c| c.apply_drag_delta(input.available.x, position.get()));
305                ScrollDelta::new(consumed_x, 0.0)
306            }
307        }))
308        .with_post_scroll_handler(CallbackWith::new({
309            move |input: PostScrollInput| {
310                if input.source != tessera_ui::ScrollEventSource::Touch
311                    || !is_close_direction(input.available.x, position.get())
312                {
313                    return ScrollDelta::ZERO;
314                }
315
316                let consumed_x =
317                    controller.with_mut(|c| c.apply_drag_delta(input.available.x, position.get()));
318                ScrollDelta::new(consumed_x, 0.0)
319            }
320        }))
321        .with_pre_fling_handler(CallbackWith::new({
322            move |input: PreFlingInput| {
323                if controller.with(|c| c.drag_offset()) == 0.0 {
324                    return ScrollVelocity::ZERO;
325                }
326
327                let should_close =
328                    controller.with_mut(|c| c.complete_drag(input.available.x, position.get()));
329                if should_close {
330                    on_close_request.call();
331                }
332
333                ScrollVelocity::new(input.available.x, 0.0)
334            }
335        }))
336        .with_parent(parent)
337}
338
339fn place_side_sheet_if_present(
340    input: &mut MeasureScope<'_>,
341    result: &mut LayoutResult,
342    host_size: PxSize,
343    max_width: Dp,
344    is_open: bool,
345    progress: f32,
346    position: SideSheetPosition,
347    drag_offset: f32,
348) {
349    let children = input.children();
350    if children.len() <= 2 {
351        return;
352    }
353
354    let side_sheet = children[2];
355    let parent_width = host_size.width;
356    let parent_height = host_size.height;
357
358    let max_width_px = max_width.to_px();
359    let sheet_width = if parent_width > max_width_px {
360        max_width_px
361    } else {
362        parent_width
363    };
364
365    let constraint = Constraint::exact(sheet_width, parent_height);
366
367    let child_size = match side_sheet.measure(input, &constraint) {
368        Ok(s) => s,
369        Err(_) => return,
370    };
371
372    let x = compute_side_sheet_x(
373        parent_width,
374        child_size.width,
375        progress,
376        is_open,
377        position,
378        drag_offset,
379    );
380    result.place_child(side_sheet, PxPosition::from_f32_xy(x, 0.0));
381}
382
383/// # modal_side_sheet_provider
384///
385/// Show a modal side sheet that blocks interaction with the main content.
386///
387/// ## Usage
388///
389/// Present filters or details that require an explicit dismissal.
390///
391/// ## Parameters
392///
393/// - `accessibility_label` — accessible name describing the dialog or sheet.
394/// - `on_close_request` — optional close-request callback.
395/// - `position` — side sheet edge position.
396/// - `is_open` — declarative open state.
397/// - `max_width` — maximum surface width, constrained by the host; defaults to
398///   400dp.
399/// - `content_padding` — inset inside the surface; defaults to 24dp. Negative
400///   dimensions become zero; non-finite dimensions use their defaults.
401/// - `controller` — optional external controller.
402/// - `main_content` — optional main content rendered behind the sheet.
403/// - `side_sheet_content` — optional side sheet content.
404///
405/// ## Examples
406///
407/// ```
408/// # use tessera_ui::tessera;
409/// # use tessera_components::theme::{MaterialTheme, material_theme};
410/// # #[tessera]
411/// # fn component() {
412/// use tessera_components::side_sheet::modal_side_sheet_provider;
413/// material_theme()
414///     .theme(|| MaterialTheme::default())
415///     .child(|| {
416///         modal_side_sheet_provider()
417///             .accessibility_label("Filters")
418///             .on_close_request(|| {})
419///             .is_open(true)
420///             .max_width(tessera_ui::Dp(360.0))
421///             .content_padding(tessera_ui::Dp::ZERO)
422///             .main_content(|| { /* main content */ })
423///             .side_sheet_content(|| { /* side sheet content */ });
424///     });
425/// # }
426/// # tessera_ui::testing::layout_test(component).run();
427/// ```
428#[tessera]
429pub fn modal_side_sheet_provider(
430    #[prop(into)] accessibility_label: Option<String>,
431    on_close_request: Option<Callback>,
432    position: Option<SideSheetPosition>,
433    is_open: Option<bool>,
434    max_width: Option<Dp>,
435    content_padding: Option<Dp>,
436    controller: Option<State<SideSheetController>>,
437    main_content: Option<RenderSlot>,
438    side_sheet_content: Option<RenderSlot>,
439) {
440    let position = position.unwrap_or_default();
441    let is_open = is_open.unwrap_or(false);
442    side_sheet_provider_inner()
443        .accessibility_label_optional(accessibility_label)
444        .sheet_type(SideSheetType::Modal)
445        .on_close_request_optional(on_close_request)
446        .position(position)
447        .is_open(is_open)
448        .max_width_optional(max_width)
449        .content_padding_optional(content_padding)
450        .main_content_shared(main_content.unwrap_or_else(RenderSlot::empty))
451        .side_sheet_content_shared(side_sheet_content.unwrap_or_else(RenderSlot::empty))
452        .controller_optional(controller);
453}
454
455/// # standard_side_sheet_provider
456///
457/// Show a standard side sheet that keeps the main content interactive.
458///
459/// ## Usage
460///
461/// Present supplementary tools or information that can remain open while the
462/// user continues interacting with the main UI.
463///
464/// ## Parameters
465///
466/// - `accessibility_label` — accessible name describing the dialog or sheet.
467/// - `on_close_request` — optional close-request callback.
468/// - `position` — side sheet edge position.
469/// - `is_open` — declarative open state.
470/// - `controller` — optional external controller.
471/// - `main_content` — optional main content rendered behind the sheet.
472/// - `side_sheet_content` — optional side sheet content.
473///
474/// ## Examples
475///
476/// ```
477/// # use tessera_ui::tessera;
478/// # use tessera_components::theme::{MaterialTheme, material_theme};
479/// # #[tessera]
480/// # fn component() {
481/// use tessera_components::side_sheet::standard_side_sheet_provider;
482/// material_theme()
483///     .theme(|| MaterialTheme::default())
484///     .child(|| {
485///         standard_side_sheet_provider()
486///             .accessibility_label("Details")
487///             .on_close_request(|| {})
488///             .is_open(true)
489///             .main_content(|| { /* main content */ })
490///             .side_sheet_content(|| { /* side sheet content */ });
491///     });
492/// # }
493/// # tessera_ui::testing::layout_test(component).run();
494/// ```
495#[tessera]
496pub fn standard_side_sheet_provider(
497    #[prop(into)] accessibility_label: Option<String>,
498    on_close_request: Option<Callback>,
499    position: Option<SideSheetPosition>,
500    is_open: Option<bool>,
501    controller: Option<State<SideSheetController>>,
502    main_content: Option<RenderSlot>,
503    side_sheet_content: Option<RenderSlot>,
504) {
505    let position = position.unwrap_or_default();
506    let is_open = is_open.unwrap_or(false);
507    side_sheet_provider_inner()
508        .accessibility_label_optional(accessibility_label)
509        .sheet_type(SideSheetType::Standard)
510        .on_close_request_shared(on_close_request.unwrap_or_default())
511        .position(position)
512        .is_open(is_open)
513        .main_content_shared(main_content.unwrap_or_else(RenderSlot::empty))
514        .side_sheet_content_shared(side_sheet_content.unwrap_or_else(RenderSlot::empty))
515        .controller_optional(controller);
516}
517
518#[tessera]
519fn side_sheet_provider_inner(
520    accessibility_label: Option<String>,
521    max_width: Option<Dp>,
522    content_padding: Option<Dp>,
523    sheet_type: Option<SideSheetType>,
524    on_close_request: Option<Callback>,
525    position: Option<SideSheetPosition>,
526    is_open: Option<bool>,
527    controller: Option<State<SideSheetController>>,
528    main_content: Option<RenderSlot>,
529    side_sheet_content: Option<RenderSlot>,
530) {
531    let max_width = side_sheet_dimension(max_width, MAX_SHEET_WIDTH);
532    let content_padding = side_sheet_dimension(content_padding, Dp(24.0));
533    let sheet_type = sheet_type.unwrap_or(SideSheetType::Modal);
534    let position = position.unwrap_or_default();
535    let is_open = is_open.unwrap_or(false);
536    let main_content = main_content.unwrap_or_else(RenderSlot::empty);
537    let side_sheet_content = side_sheet_content.unwrap_or_else(RenderSlot::empty);
538    let external_controller = controller;
539    let controller = match external_controller {
540        Some(value) => value,
541        None => remember(|| SideSheetController::new(is_open)),
542    };
543    let on_close_request = if let Some(on_close_request) = on_close_request {
544        on_close_request
545    } else if external_controller.is_some() && sheet_type == SideSheetType::Modal {
546        Callback::new(move || controller.with_mut(SideSheetController::close))
547    } else {
548        Callback::default()
549    };
550
551    // In controlled mode (external controller provided), do not override
552    // controller state from `is_open`.
553    if external_controller.is_none() && is_open != controller.with(|c| c.is_open()) {
554        if is_open {
555            controller.with_mut(|c| c.open());
556        } else {
557            controller.with_mut(|c| c.close());
558        }
559    }
560
561    side_sheet_provider_render()
562        .accessibility_label_optional(accessibility_label)
563        .max_width(max_width)
564        .content_padding(content_padding)
565        .sheet_type(sheet_type)
566        .on_close_request_shared(on_close_request)
567        .position(position)
568        .controller(controller)
569        .main_content_shared(main_content)
570        .side_sheet_content_shared(side_sheet_content);
571}
572
573#[tessera]
574fn side_sheet_provider_render(
575    accessibility_label: Option<String>,
576    max_width: Option<Dp>,
577    content_padding: Option<Dp>,
578    sheet_type: Option<SideSheetType>,
579    on_close_request: Option<Callback>,
580    position: Option<SideSheetPosition>,
581    controller: Option<State<SideSheetController>>,
582    main_content: Option<RenderSlot>,
583    side_sheet_content: Option<RenderSlot>,
584) {
585    let max_width = side_sheet_dimension(max_width, MAX_SHEET_WIDTH);
586    let content_padding = side_sheet_dimension(content_padding, Dp(24.0));
587    let sheet_type = sheet_type.unwrap_or(SideSheetType::Modal);
588    let position = position.unwrap_or_default();
589    let on_close_request = on_close_request.unwrap_or_default();
590    let controller = controller.expect("side_sheet_provider_render requires controller");
591    let main_content = main_content.unwrap_or_else(RenderSlot::empty);
592    let side_sheet_content = side_sheet_content.unwrap_or_else(RenderSlot::empty);
593
594    let current_position = remember(|| position);
595    if current_position.get() != position {
596        current_position.set(position);
597    }
598    let parent_nested_scroll = NestedScrollConnection::nearest();
599    let coordinate_scale = remember(|| 1.0_f32);
600    let nested_scroll_connection = build_side_sheet_nested_scroll_connection(
601        controller,
602        on_close_request,
603        current_position,
604        parent_nested_scroll,
605    )
606    .with_coordinate_scale(coordinate_scale);
607    let (is_open, visibility, drag_offset) = controller.get().snapshot();
608    let is_animating = controller.get().is_animating();
609    let side_sheet_open_state = remember(|| false);
610    let mut just_opened = false;
611    side_sheet_open_state.with_mut(|was_open| {
612        just_opened = !*was_open && is_open;
613        *was_open = is_open;
614    });
615    if is_animating {
616        receive_frame_nanos(move |frame_nanos| {
617            let is_animating =
618                controller.with(|controller| controller.is_animating_at(frame_nanos));
619            if is_animating {
620                tessera_ui::FrameNanosControl::Continue
621            } else {
622                tessera_ui::FrameNanosControl::Stop
623            }
624        });
625    }
626
627    let show_side_sheet = is_open || is_animating;
628    let drag_offset = if position == SideSheetPosition::Start {
629        -drag_offset
630    } else {
631        drag_offset
632    };
633    let focus_scope = remember(FocusScopeNode::new).get();
634    let focus_state = remember(ModalFocusState::default);
635    let modifier = modal_focus_modifier(Modifier::new(), focus_state, show_side_sheet, focus_scope);
636
637    let progress = if is_open {
638        visibility.position
639    } else {
640        1.0 - visibility.position
641    };
642
643    layout()
644        .modifier(
645            modifier.on_local_to_window(move |transform: tessera_ui::LocalToWindow| {
646                if coordinate_scale.get() != transform.scale() {
647                    coordinate_scale.set(transform.scale());
648                }
649            }),
650        )
651        .policy(SideSheetLayout {
652            max_width,
653            progress,
654            is_open,
655            position,
656            drag_offset,
657        })
658        .child(move || {
659            let side_sheet_content = side_sheet_content;
660            overlay_background(
661                main_content,
662                show_side_sheet && sheet_type == SideSheetType::Modal,
663            );
664            modal_back_handler(
665                is_open && sheet_type == SideSheetType::Modal,
666                on_close_request,
667            )
668            .on_preview(move |event| {
669                controller.with_mut(|controller| {
670                    controller.motion.preview_back(event, current_frame_nanos());
671                });
672            });
673
674            if show_side_sheet {
675                match sheet_type {
676                    SideSheetType::Modal => {
677                        let scrim_alpha = visibility.opacity * SCRIM_ALPHA;
678                        let scrim_color = use_context::<MaterialTheme>()
679                            .expect("MaterialTheme must be provided")
680                            .get()
681                            .color_scheme
682                            .scrim;
683                        surface()
684                            .style(scrim_color.with_alpha(scrim_alpha).into())
685                            .on_click_shared(on_close_request)
686                            .show_ripple(false)
687                            .show_state_layer(false)
688                            .modifier(
689                                Modifier::new()
690                                    .fill_max_size()
691                                    .semantics(SemanticsArgs {
692                                        hidden: true,
693                                        ..Default::default()
694                                    })
695                                    .focusable()
696                                    .focus_properties(
697                                        FocusProperties::new()
698                                            .can_focus(false)
699                                            .can_request_focus(false)
700                                            .skip_traversal(true),
701                                    ),
702                            )
703                            .block_input(true)
704                            .child(|| {});
705                    }
706                    SideSheetType::Standard => {
707                        surface()
708                            .style(Color::TRANSPARENT.into())
709                            .modifier(Modifier::new().fill_max_size())
710                            .child(|| {});
711                    }
712                }
713
714                side_sheet_content_wrapper()
715                    .content_padding(content_padding)
716                    .nested_scroll_connection(nested_scroll_connection.clone())
717                    .accessibility_label_optional(accessibility_label.clone())
718                    .focus_scope(focus_scope)
719                    .sheet_type(sheet_type)
720                    .position(current_position)
721                    .controller(controller)
722                    .on_close_request_shared(on_close_request)
723                    .just_opened(just_opened)
724                    .content_shared(side_sheet_content);
725            }
726        });
727}
728
729#[tessera]
730fn side_sheet_content_wrapper(
731    accessibility_label: Option<String>,
732    content_padding: Option<Dp>,
733    nested_scroll_connection: Option<NestedScrollConnection>,
734    focus_scope: Option<FocusScopeNode>,
735    sheet_type: Option<SideSheetType>,
736    position: Option<State<SideSheetPosition>>,
737    controller: Option<State<SideSheetController>>,
738    on_close_request: Option<Callback>,
739    just_opened: Option<bool>,
740    content: Option<RenderSlot>,
741) {
742    let content_padding = side_sheet_dimension(content_padding, Dp(24.0));
743    let sheet_type = sheet_type.unwrap_or(SideSheetType::Modal);
744    let position = position.expect("sheet wrapper requires its edge state");
745    let just_opened = just_opened.unwrap_or(false);
746    let controller = controller.expect("side_sheet_content_wrapper requires controller");
747    let on_close_request = on_close_request.unwrap_or_default();
748    let content = content.expect("side_sheet_content_wrapper requires content");
749    let nested_scroll_connection =
750        nested_scroll_connection.expect("sheet content requires its nested scroll connection");
751    let scheme = use_context::<MaterialTheme>()
752        .expect("MaterialTheme must be provided")
753        .get()
754        .color_scheme;
755    let container_color = match sheet_type {
756        SideSheetType::Modal => scheme.surface_container_low,
757        SideSheetType::Standard => scheme.surface,
758    };
759    let is_modal = sheet_type == SideSheetType::Modal;
760    let on_close_request_for_keyboard = on_close_request;
761
762    let focus_scope = focus_scope.expect("side sheet content requires its focus scope");
763    let modifier = dialog_semantics(
764        with_keyboard_input(
765            Modifier::new()
766                .focus_scope_with(focus_scope)
767                .focus_properties(FocusProperties::new().skip_traversal(true))
768                .focus_traversal_policy(
769                    FocusTraversalPolicy::linear()
770                        .wrap(is_modal)
771                        .tab_navigation(true),
772                ),
773            make_keyboard_closure(on_close_request_for_keyboard),
774        ),
775        accessibility_label,
776        is_modal,
777    );
778    if just_opened {
779        if is_modal {
780            focus_scope.request_focus();
781        } else {
782            focus_scope.restore_focus();
783        }
784    }
785    layout().modifier(modifier).child(move || {
786        let content = content;
787        let nested_scroll_connection = nested_scroll_connection.clone();
788        let edge = position.get();
789        if is_modal {
790            surface()
791                .elevation(MODAL_ELEVATION)
792                .style(container_color.into())
793                .shape(sheet_shape(edge))
794                .modifier(side_sheet_extent_modifier(controller))
795                .block_input(true)
796                .child(move || {
797                    side_sheet_body()
798                        .content_padding(content_padding)
799                        .controller(controller)
800                        .position(position)
801                        .on_close_shared(on_close_request)
802                        .content_shared(content)
803                        .nested_scroll_connection(nested_scroll_connection.clone());
804                });
805        } else {
806            surface()
807                .style(container_color.into())
808                .shape(sheet_shape(edge))
809                .modifier(side_sheet_extent_modifier(controller))
810                .block_input(true)
811                .child(move || {
812                    side_sheet_body()
813                        .content_padding(content_padding)
814                        .controller(controller)
815                        .position(position)
816                        .on_close_shared(on_close_request)
817                        .content_shared(content)
818                        .nested_scroll_connection(nested_scroll_connection.clone());
819                });
820        }
821    });
822}
823
824#[derive(Clone, PartialEq)]
825struct SideSheetLayout {
826    max_width: Dp,
827    progress: f32,
828    is_open: bool,
829    position: SideSheetPosition,
830    drag_offset: f32,
831}
832
833impl tessera_ui::LayoutModifierNode for SideSheetLayout {
834    fn measure_eq(&self, other: &Self) -> bool {
835        self.max_width == other.max_width
836    }
837
838    fn place_children(
839        &self,
840        input: &PlacementScope<'_>,
841        _inner: &PlacementResult,
842    ) -> Option<PlacementResult> {
843        let children = input.children();
844        let main_content = children.first()?;
845        let mut result = LayoutResult::new(input.size());
846        result.place_child(*main_content, PxPosition::ZERO);
847        if let Some(scrim) = children.get(1) {
848            result.place_child(*scrim, PxPosition::ZERO);
849        }
850        if let Some(sheet) = children.get(2) {
851            let parent_width = input.size().width;
852            let x = compute_side_sheet_x(
853                parent_width,
854                sheet.size().width,
855                self.progress,
856                self.is_open,
857                self.position,
858                self.drag_offset,
859            );
860            result.place_child(*sheet, PxPosition::from_f32_xy(x, 0.0));
861        }
862        Some(result.into_placement_result())
863    }
864
865    fn measure(&self, input: &mut MeasureScope<'_>) -> Result<LayoutResult, MeasurementError> {
866        let mut result = LayoutResult::default();
867        let children = input.children();
868        let child_constraint = input.parent_constraint().without_min();
869        let main_content = children[0];
870        let main_content_size = main_content.measure(input, &child_constraint)?;
871        let host_size = PxSize::new(
872            input
873                .parent_constraint()
874                .width()
875                .clamp(main_content_size.width),
876            input
877                .parent_constraint()
878                .height()
879                .clamp(main_content_size.height),
880        );
881        result.place_child(main_content, PxPosition::new(Px(0), Px(0)));
882
883        if children.len() > 1 {
884            let scrim = children[1];
885            scrim.measure(input, &Constraint::exact(host_size.width, host_size.height))?;
886            result.place_child(scrim, PxPosition::new(Px(0), Px(0)));
887        }
888
889        place_side_sheet_if_present(
890            input,
891            &mut result,
892            host_size,
893            self.max_width,
894            self.is_open,
895            self.progress,
896            self.position,
897            self.drag_offset,
898        );
899
900        Ok(result.with_size(ComputedData {
901            width: host_size.width,
902            height: host_size.height,
903        }))
904    }
905}
906
907#[cfg(test)]
908mod placement_tests {
909    use tessera_ui::Px;
910
911    use super::{SideSheetPosition, compute_side_sheet_x};
912
913    #[test]
914    fn both_edges_and_directions_keep_fractional_sheet_and_drag_placement() {
915        for (open, progress) in [(true, 0.3125), (false, 0.6875)] {
916            assert_eq!(
917                compute_side_sheet_x(
918                    Px(800),
919                    Px(360),
920                    progress,
921                    open,
922                    SideSheetPosition::Start,
923                    -2.25
924                ),
925                -249.75
926            );
927            assert_eq!(
928                compute_side_sheet_x(
929                    Px(800),
930                    Px(360),
931                    progress,
932                    open,
933                    SideSheetPosition::End,
934                    2.25
935                ),
936                689.75
937            );
938        }
939    }
940}
941
942#[cfg(all(test, not(target_family = "wasm")))]
943#[path = "side_sheet_tests.rs"]
944mod host_tests;