1mod 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#[derive(Default, Clone, Copy, PartialEq)]
49enum SideSheetType {
50 #[default]
52 Modal,
53 Standard,
55}
56
57#[derive(Default, Clone, Copy, PartialEq)]
59pub enum SideSheetPosition {
60 #[default]
62 Start,
63 End,
65}
66
67#[derive(Clone, PartialEq)]
89pub struct SideSheetController {
90 is_open: bool,
91 motion: VisibilityMotion,
92 drag: SheetDrag,
93}
94
95impl SideSheetController {
96 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 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 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 pub fn is_open(&self) -> bool {
133 self.is_open
134 }
135
136 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
212fn 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
235fn 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#[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#[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 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;