Skip to main content

rgpui_component/sidebar/
mod.rs

1use crate::{
2    ActiveTheme, Collapsible, Icon, IconName, Side, Sizable, StyledExt,
3    button::{Button, ButtonVariants},
4    h_flex,
5    scroll::ScrollableElement,
6    v_flex,
7};
8use rgpui::{
9    AbsoluteLength, AnyElement, App, ClickEvent, DefiniteLength, EdgesRefinement, ElementId,
10    InteractiveElement as _, IntoElement, Length, ListAlignment, ListState, ParentElement, Pixels,
11    RenderOnce, SharedString, StyleRefinement, Styled, Window, div, list, prelude::FluentBuilder,
12    px,
13};
14use std::{rc::Rc, time::Duration};
15
16use crate::animation::{Transition, ease_in_out_cubic};
17
18mod footer;
19mod group;
20mod header;
21mod menu;
22pub use footer::*;
23pub use group::*;
24pub use header::*;
25pub use menu::*;
26
27const DEFAULT_WIDTH: Pixels = px(255.);
28const COLLAPSED_WIDTH: Pixels = px(48.);
29const SIDEBAR_TRANSITION_DURATION: Duration = Duration::from_millis(200);
30
31/// The way a [`Sidebar`] behaves when it is collapsed.
32///
33/// This follows the shadcn/ui sidebar modes:
34/// - [`SidebarCollapsible::Icon`] collapses the sidebar to icon width.
35/// - [`SidebarCollapsible::Offcanvas`] slides the sidebar out and releases its layout width.
36/// - [`SidebarCollapsible::None`] keeps the sidebar expanded and ignores collapsed state.
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub enum SidebarCollapsible {
39    /// Collapse the sidebar to icon width.
40    #[default]
41    Icon,
42    /// Collapse the sidebar completely out of the layout.
43    Offcanvas,
44    /// Disable sidebar collapse.
45    None,
46}
47
48impl From<bool> for SidebarCollapsible {
49    fn from(collapsible: bool) -> Self {
50        if collapsible { Self::Icon } else { Self::None }
51    }
52}
53
54#[derive(Clone, Copy, Debug, PartialEq)]
55enum SidebarWrapperLayout {
56    None,
57    Static { width: Pixels },
58    Animated { target_width: Pixels },
59}
60
61#[derive(Clone, Copy, Debug, PartialEq)]
62struct SidebarLayout {
63    icon_collapsed: bool,
64    offcanvas_collapsed: bool,
65    align_child_to_end: bool,
66    wrapper: SidebarWrapperLayout,
67}
68
69impl SidebarLayout {
70    fn new(
71        collapsible: SidebarCollapsible,
72        collapsed: bool,
73        expanded_width: Option<Pixels>,
74        side: Side,
75    ) -> Self {
76        let collapsed = collapsed && collapsible != SidebarCollapsible::None;
77        let wrapper = match collapsible {
78            SidebarCollapsible::None => SidebarWrapperLayout::None,
79            SidebarCollapsible::Icon => match expanded_width {
80                Some(expanded_width) => SidebarWrapperLayout::Animated {
81                    target_width: if collapsed {
82                        COLLAPSED_WIDTH
83                    } else {
84                        expanded_width
85                    },
86                },
87                None => SidebarWrapperLayout::None,
88            },
89            SidebarCollapsible::Offcanvas => match (expanded_width, collapsed) {
90                (Some(_), true) => SidebarWrapperLayout::Animated {
91                    target_width: px(0.),
92                },
93                (Some(expanded_width), false) => SidebarWrapperLayout::Animated {
94                    target_width: expanded_width,
95                },
96                (None, true) => SidebarWrapperLayout::Static { width: px(0.) },
97                (None, false) => SidebarWrapperLayout::None,
98            },
99        };
100        let align_child_to_end = match collapsible {
101            SidebarCollapsible::Offcanvas => side.is_left(),
102            _ => side.is_right(),
103        };
104
105        Self {
106            icon_collapsed: collapsed && collapsible == SidebarCollapsible::Icon,
107            offcanvas_collapsed: collapsed && collapsible == SidebarCollapsible::Offcanvas,
108            align_child_to_end,
109            wrapper,
110        }
111    }
112}
113
114#[derive(Clone, Copy, Debug, PartialEq)]
115struct SidebarAnimationState {
116    from_width: Pixels,
117    target_width: Pixels,
118    render_child: bool,
119    hide_scheduled: bool,
120    hide_request: u64,
121}
122
123impl SidebarAnimationState {
124    fn new(target_width: Pixels, render_child: bool) -> Self {
125        Self {
126            from_width: target_width,
127            target_width,
128            render_child,
129            hide_scheduled: false,
130            hide_request: 0,
131        }
132    }
133
134    fn needs_update(&self, target_width: Pixels, offcanvas_collapsed: bool) -> bool {
135        let child_state_changed = if offcanvas_collapsed {
136            self.render_child && !self.hide_scheduled
137        } else {
138            !self.render_child || self.hide_scheduled
139        };
140
141        self.target_width != target_width || child_state_changed
142    }
143
144    fn update_target(&mut self, target_width: Pixels, offcanvas_collapsed: bool) -> Option<u64> {
145        if self.target_width != target_width {
146            self.from_width = self.target_width;
147            self.target_width = target_width;
148        }
149
150        if offcanvas_collapsed {
151            if self.render_child && !self.hide_scheduled {
152                self.hide_scheduled = true;
153                self.hide_request = self.hide_request.wrapping_add(1);
154                Some(self.hide_request)
155            } else {
156                None
157            }
158        } else {
159            self.render_child = true;
160            if self.hide_scheduled {
161                self.hide_request = self.hide_request.wrapping_add(1);
162            }
163            self.hide_scheduled = false;
164            None
165        }
166    }
167
168    fn finish_hide(&mut self, request: u64) -> bool {
169        if self.render_child
170            && self.hide_scheduled
171            && self.hide_request == request
172            && self.target_width == px(0.)
173        {
174            self.render_child = false;
175            self.hide_scheduled = false;
176            true
177        } else {
178            false
179        }
180    }
181}
182
183fn sidebar_wrapper(
184    id: impl Into<ElementId>,
185    align_child_to_end: bool,
186) -> impl ParentElement + IntoElement + Styled {
187    div()
188        .id(id)
189        .flex()
190        .h_full()
191        .flex_shrink_0()
192        .overflow_hidden()
193        .when(align_child_to_end, |this| this.justify_end())
194}
195
196fn sidebar_expanded_width(style: &StyleRefinement) -> Option<Pixels> {
197    match style.size.width {
198        Some(Length::Definite(DefiniteLength::Absolute(AbsoluteLength::Pixels(px)))) => Some(px),
199        Some(_) => None,
200        None => Some(DEFAULT_WIDTH),
201    }
202}
203
204fn sidebar_animation_id(id: &ElementId, from: Pixels, to: Pixels) -> ElementId {
205    ElementId::NamedInteger(
206        format!("{id}-anim-w").into(),
207        (from.as_f32().to_bits() as u64) << 32 | to.as_f32().to_bits() as u64,
208    )
209}
210
211pub trait SidebarItem: Collapsible + Clone {
212    fn render(
213        self,
214        id: impl Into<ElementId>,
215        window: &mut Window,
216        cx: &mut App,
217    ) -> impl IntoElement;
218}
219
220/// A Sidebar element that can contain collapsible child elements.
221#[derive(IntoElement)]
222pub struct Sidebar<E: SidebarItem + 'static> {
223    id: ElementId,
224    style: StyleRefinement,
225    content: Vec<E>,
226    /// header view
227    header: Option<AnyElement>,
228    /// footer view
229    footer: Option<AnyElement>,
230    /// The side of the sidebar
231    side: Side,
232    collapsible: SidebarCollapsible,
233    collapsed: bool,
234}
235
236impl<E: SidebarItem> Sidebar<E> {
237    /// Create a new Sidebar with the given ID.
238    pub fn new(id: impl Into<ElementId>) -> Self {
239        Self {
240            id: id.into(),
241            style: StyleRefinement::default(),
242            content: vec![],
243            header: None,
244            footer: None,
245            side: Side::Left,
246            collapsible: SidebarCollapsible::Icon,
247            collapsed: false,
248        }
249    }
250
251    /// Set the side of the sidebar.
252    ///
253    /// Default is `Side::Left`.
254    pub fn side(mut self, side: Side) -> Self {
255        self.side = side;
256        self
257    }
258
259    /// Set how the sidebar collapses.
260    ///
261    /// Passing `true` keeps the previous behavior and maps to
262    /// [`SidebarCollapsible::Icon`]. Passing `false` maps to
263    /// [`SidebarCollapsible::None`].
264    pub fn collapsible(mut self, collapsible: impl Into<SidebarCollapsible>) -> Self {
265        self.collapsible = collapsible.into();
266        self
267    }
268
269    /// Set the sidebar to be collapsed
270    pub fn collapsed(mut self, collapsed: bool) -> Self {
271        self.collapsed = collapsed;
272        self
273    }
274
275    /// Set the header of the sidebar.
276    pub fn header(mut self, header: impl IntoElement) -> Self {
277        self.header = Some(header.into_any_element());
278        self
279    }
280
281    /// Set the footer of the sidebar.
282    pub fn footer(mut self, footer: impl IntoElement) -> Self {
283        self.footer = Some(footer.into_any_element());
284        self
285    }
286
287    /// Add a child element to the sidebar, the child must implement `Collapsible`
288    pub fn child(mut self, child: E) -> Self {
289        self.content.push(child);
290        self
291    }
292
293    /// Add multiple children to the sidebar, the children must implement `Collapsible`
294    pub fn children(mut self, children: impl IntoIterator<Item = E>) -> Self {
295        self.content.extend(children);
296        self
297    }
298}
299
300/// Toggle button to collapse/expand the [`Sidebar`].
301#[derive(IntoElement)]
302pub struct SidebarToggleButton {
303    btn: Button,
304    collapsed: bool,
305    side: Side,
306    on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
307}
308
309impl SidebarToggleButton {
310    /// Create a new SidebarToggleButton.
311    pub fn new() -> Self {
312        Self {
313            btn: Button::new("collapse").ghost().small(),
314            collapsed: false,
315            side: Side::Left,
316            on_click: None,
317        }
318    }
319
320    /// Set the side of the toggle button.
321    ///
322    /// Default is `Side::Left`.
323    pub fn side(mut self, side: Side) -> Self {
324        self.side = side;
325        self
326    }
327
328    /// Set the collapsed state of the toggle button.
329    pub fn collapsed(mut self, collapsed: bool) -> Self {
330        self.collapsed = collapsed;
331        self
332    }
333
334    /// Add a click handler to the toggle button.
335    pub fn on_click(
336        mut self,
337        on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
338    ) -> Self {
339        self.on_click = Some(Rc::new(on_click));
340        self
341    }
342}
343
344impl RenderOnce for SidebarToggleButton {
345    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
346        let collapsed = self.collapsed;
347        let on_click = self.on_click.clone();
348
349        let icon = if collapsed {
350            if self.side.is_left() {
351                IconName::PanelLeftOpen
352            } else {
353                IconName::PanelRightOpen
354            }
355        } else {
356            if self.side.is_left() {
357                IconName::PanelLeftClose
358            } else {
359                IconName::PanelRightClose
360            }
361        };
362
363        self.btn
364            .when_some(on_click, |this, on_click| {
365                this.on_click(move |ev, window, cx| {
366                    on_click(ev, window, cx);
367                })
368            })
369            .icon(Icon::new(icon).size_4())
370    }
371}
372
373impl<E: SidebarItem> Styled for Sidebar<E> {
374    fn style(&mut self) -> &mut StyleRefinement {
375        &mut self.style
376    }
377}
378
379impl<E: SidebarItem> RenderOnce for Sidebar<E> {
380    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
381        self.style.padding = EdgesRefinement::default();
382
383        let id = self.id;
384        let content_len = self.content.len();
385        let overdraw = px(window.viewport_size().height.as_f32() * 0.3);
386        let list_state = window
387            .use_keyed_state(
388                SharedString::from(format!("{}-list-state", id)),
389                cx,
390                |_, _| ListState::new(content_len, ListAlignment::Top, overdraw),
391            )
392            .read(cx)
393            .clone();
394        if list_state.item_count() != content_len {
395            list_state.reset(content_len);
396        }
397
398        // Determine effective expanded width from user's custom style or default.
399        // Non-pixel widths still render correctly, but cannot use pixel width transitions.
400        let expanded_width = sidebar_expanded_width(&self.style);
401        let layout =
402            SidebarLayout::new(self.collapsible, self.collapsed, expanded_width, self.side);
403
404        // Sidebar content renders at its target width immediately. A wrapper
405        // div animates clip-width for smooth transitions without re-laying out
406        // sidebar content each animation frame.
407        let sidebar = v_flex()
408            .id(id.clone())
409            .flex_shrink_0()
410            .h_full()
411            .overflow_hidden()
412            .relative()
413            .bg(cx.theme().sidebar)
414            .text_color(cx.theme().sidebar_foreground)
415            .border_color(cx.theme().sidebar_border)
416            .map(|this| match self.side {
417                Side::Left => this.border_r_1(),
418                Side::Right => this.border_l_1(),
419            })
420            .when(self.style.size.width.is_none(), |this| {
421                this.w(DEFAULT_WIDTH)
422            })
423            .refine_style(&self.style)
424            .when(layout.icon_collapsed, |this| {
425                this.w(COLLAPSED_WIDTH).gap_2()
426            })
427            .when_some(self.header.take(), |this, header| {
428                this.child(
429                    h_flex()
430                        .id("header")
431                        .pt_3()
432                        .px_3()
433                        .gap_2()
434                        .when(layout.icon_collapsed, |this| this.pt_2().px_2())
435                        .child(header),
436                )
437            })
438            .child(
439                v_flex().id("content").flex_1().min_h_0().child(
440                    v_flex()
441                        .id("inner")
442                        .size_full()
443                        .px_3()
444                        .gap_y_3()
445                        .when(layout.icon_collapsed, |this| this.p_2())
446                        .child(
447                            list(list_state.clone(), {
448                                move |ix, window, cx| {
449                                    let group = self.content.get(ix).cloned();
450                                    let is_first = ix == 0;
451                                    let is_last =
452                                        content_len > 0 && ix == content_len.saturating_sub(1);
453                                    div()
454                                        .id(ix)
455                                        .when_some(group, |this, group| {
456                                            this.child(
457                                                group
458                                                    .collapsed(layout.icon_collapsed)
459                                                    .render(ix, window, cx)
460                                                    .into_any_element(),
461                                            )
462                                        })
463                                        .when(is_first, |this| this.pt_3())
464                                        .when(is_last, |this| this.pb_3())
465                                        .into_any_element()
466                                }
467                            })
468                            .size_full(),
469                        )
470                        .vertical_scrollbar(&list_state),
471                ),
472            )
473            .when_some(self.footer.take(), |this, footer| {
474                this.child(
475                    h_flex()
476                        .id("footer")
477                        .pb_3()
478                        .px_3()
479                        .gap_2()
480                        .when(layout.icon_collapsed, |this| this.pt_2().px_2())
481                        .child(footer),
482                )
483            });
484
485        let target_width = match layout.wrapper {
486            SidebarWrapperLayout::None => return sidebar.into_any_element(),
487            SidebarWrapperLayout::Static { width } => {
488                return sidebar_wrapper(format!("{}-anim", id), layout.align_child_to_end)
489                    .w(width)
490                    .when(!layout.offcanvas_collapsed, |this| this.child(sidebar))
491                    .into_any_element();
492            }
493            SidebarWrapperLayout::Animated { target_width } => target_width,
494        };
495
496        // Store animation state in keyed state so it remains stable across
497        // re-renders (GPUI re-renders the whole tree on each animation frame).
498        // The target width is derived from the current layout, so changes to
499        // collapsible mode or expanded width are handled even if `collapsed`
500        // itself does not change. Offcanvas keeps content mounted while the
501        // close transition runs, then unmounts it so hidden controls leave the
502        // tab order.
503        let animation_state = window.use_keyed_state(format!("{}-anim-w", id), cx, |_, _| {
504            SidebarAnimationState::new(target_width, !layout.offcanvas_collapsed)
505        });
506
507        let hide_request = if animation_state
508            .read(cx)
509            .needs_update(target_width, layout.offcanvas_collapsed)
510        {
511            animation_state.update(cx, |state, _| {
512                state.update_target(target_width, layout.offcanvas_collapsed)
513            })
514        } else {
515            None
516        };
517        if let Some(hide_request) = hide_request {
518            cx.spawn({
519                let animation_state = animation_state.clone();
520                async move |cx| {
521                    cx.background_executor()
522                        .timer(SIDEBAR_TRANSITION_DURATION)
523                        .await;
524                    _ = animation_state.update(cx, |state, cx| {
525                        if state.finish_hide(hide_request) {
526                            cx.notify();
527                        }
528                    });
529                }
530            })
531            .detach();
532        }
533        let animation_state = *animation_state.read(cx);
534        let from_w = animation_state.from_width;
535        let to_w = animation_state.target_width;
536
537        let wrapper = sidebar_wrapper(format!("{}-anim", id), layout.align_child_to_end)
538            .when(animation_state.render_child, |this| this.child(sidebar));
539
540        Transition::new(SIDEBAR_TRANSITION_DURATION)
541            .ease(ease_in_out_cubic)
542            .width(from_w, to_w)
543            .apply(wrapper, sidebar_animation_id(&id, from_w, to_w))
544            .into_any_element()
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    fn layout(
553        collapsible: SidebarCollapsible,
554        collapsed: bool,
555        expanded_width: Option<Pixels>,
556        side: Side,
557    ) -> SidebarLayout {
558        SidebarLayout::new(collapsible, collapsed, expanded_width, side)
559    }
560
561    #[test]
562    fn bool_collapsible_should_remain_backward_compatible() {
563        assert_eq!(SidebarCollapsible::from(true), SidebarCollapsible::Icon);
564        assert_eq!(SidebarCollapsible::from(false), SidebarCollapsible::None);
565    }
566
567    #[test]
568    fn icon_collapsed_should_use_icon_width_and_icon_rendering() {
569        let layout = layout(SidebarCollapsible::Icon, true, Some(px(240.)), Side::Left);
570
571        assert!(layout.icon_collapsed);
572        assert!(!layout.offcanvas_collapsed);
573        assert!(!layout.align_child_to_end);
574        assert_eq!(
575            layout.wrapper,
576            SidebarWrapperLayout::Animated {
577                target_width: COLLAPSED_WIDTH,
578            }
579        );
580    }
581
582    #[test]
583    fn icon_expanded_should_use_expanded_width() {
584        let layout = layout(SidebarCollapsible::Icon, false, Some(px(240.)), Side::Left);
585
586        assert!(!layout.icon_collapsed);
587        assert!(!layout.offcanvas_collapsed);
588        assert_eq!(
589            layout.wrapper,
590            SidebarWrapperLayout::Animated {
591                target_width: px(240.),
592            }
593        );
594    }
595
596    #[test]
597    fn icon_expanded_with_non_pixel_width_should_keep_original_layout() {
598        let layout = layout(SidebarCollapsible::Icon, false, None, Side::Left);
599
600        assert!(!layout.icon_collapsed);
601        assert!(!layout.offcanvas_collapsed);
602        assert_eq!(layout.wrapper, SidebarWrapperLayout::None);
603    }
604
605    #[test]
606    fn none_should_ignore_collapsed_state() {
607        let layout = layout(SidebarCollapsible::None, true, Some(px(240.)), Side::Right);
608
609        assert!(!layout.icon_collapsed);
610        assert!(!layout.offcanvas_collapsed);
611        assert!(layout.align_child_to_end);
612        assert_eq!(layout.wrapper, SidebarWrapperLayout::None);
613    }
614
615    #[test]
616    fn offcanvas_collapsed_with_pixel_width_should_animate_to_zero() {
617        let layout = layout(
618            SidebarCollapsible::Offcanvas,
619            true,
620            Some(px(240.)),
621            Side::Left,
622        );
623
624        assert!(!layout.icon_collapsed);
625        assert!(layout.offcanvas_collapsed);
626        assert!(layout.align_child_to_end);
627        assert_eq!(
628            layout.wrapper,
629            SidebarWrapperLayout::Animated {
630                target_width: px(0.),
631            }
632        );
633    }
634
635    #[test]
636    fn offcanvas_expanded_with_pixel_width_should_use_expanded_width() {
637        let layout = layout(
638            SidebarCollapsible::Offcanvas,
639            false,
640            Some(px(240.)),
641            Side::Left,
642        );
643
644        assert!(!layout.icon_collapsed);
645        assert!(!layout.offcanvas_collapsed);
646        assert_eq!(
647            layout.wrapper,
648            SidebarWrapperLayout::Animated {
649                target_width: px(240.),
650            }
651        );
652    }
653
654    #[test]
655    fn offcanvas_collapsed_with_non_pixel_width_should_statically_release_layout() {
656        let layout = layout(SidebarCollapsible::Offcanvas, true, None, Side::Left);
657
658        assert!(!layout.icon_collapsed);
659        assert!(layout.offcanvas_collapsed);
660        assert_eq!(
661            layout.wrapper,
662            SidebarWrapperLayout::Static { width: px(0.) }
663        );
664    }
665
666    #[test]
667    fn offcanvas_expanded_with_non_pixel_width_should_keep_original_layout() {
668        let layout = layout(SidebarCollapsible::Offcanvas, false, None, Side::Left);
669
670        assert!(!layout.icon_collapsed);
671        assert!(!layout.offcanvas_collapsed);
672        assert_eq!(layout.wrapper, SidebarWrapperLayout::None);
673    }
674
675    #[test]
676    fn offcanvas_should_anchor_child_toward_the_content_edge() {
677        let left = layout(
678            SidebarCollapsible::Offcanvas,
679            true,
680            Some(px(240.)),
681            Side::Left,
682        );
683        let right = layout(
684            SidebarCollapsible::Offcanvas,
685            true,
686            Some(px(240.)),
687            Side::Right,
688        );
689
690        assert!(left.align_child_to_end);
691        assert!(!right.align_child_to_end);
692    }
693
694    #[test]
695    fn animation_id_should_be_scoped_to_sidebar_id() {
696        let from = px(240.);
697        let to = COLLAPSED_WIDTH;
698
699        assert_ne!(
700            sidebar_animation_id(&ElementId::Name("sidebar-a".into()), from, to),
701            sidebar_animation_id(&ElementId::Name("sidebar-b".into()), from, to)
702        );
703    }
704
705    #[test]
706    fn animation_state_should_keep_child_until_offcanvas_hide_finishes() {
707        let mut state = SidebarAnimationState::new(px(240.), true);
708
709        let request = state.update_target(px(0.), true);
710
711        assert_eq!(request, Some(1));
712        assert_eq!(state.from_width, px(240.));
713        assert_eq!(state.target_width, px(0.));
714        assert!(state.render_child);
715
716        assert!(state.finish_hide(1));
717
718        assert!(!state.render_child);
719        assert!(!state.hide_scheduled);
720    }
721
722    #[test]
723    fn animation_state_should_not_reschedule_pending_offcanvas_hide() {
724        let mut state = SidebarAnimationState::new(px(240.), true);
725
726        let request = state.update_target(px(0.), true);
727
728        assert_eq!(request, Some(1));
729        assert!(!state.needs_update(px(0.), true));
730        assert_eq!(state.update_target(px(0.), true), None);
731        assert_eq!(state.hide_request, 1);
732    }
733
734    #[test]
735    fn animation_state_should_cancel_pending_hide_when_reexpanded() {
736        let mut state = SidebarAnimationState::new(px(240.), true);
737
738        let request = state.update_target(px(0.), true).unwrap();
739        state.update_target(px(240.), false);
740
741        assert!(!state.finish_hide(request));
742        assert!(state.render_child);
743        assert!(!state.hide_scheduled);
744        assert_eq!(state.from_width, px(0.));
745        assert_eq!(state.target_width, px(240.));
746    }
747
748    #[test]
749    fn animation_state_should_ignore_stale_hide_request() {
750        let mut state = SidebarAnimationState::new(px(240.), true);
751
752        let request = state.update_target(px(0.), true).unwrap();
753        state.update_target(px(240.), false);
754        state.update_target(px(0.), true);
755
756        assert!(!state.finish_hide(request));
757        assert!(state.render_child);
758        assert!(state.hide_scheduled);
759    }
760
761    #[test]
762    fn animation_state_should_start_hidden_when_initially_offcanvas_collapsed() {
763        let state = SidebarAnimationState::new(px(0.), false);
764
765        assert!(!state.render_child);
766        assert_eq!(state.from_width, px(0.));
767        assert_eq!(state.target_width, px(0.));
768    }
769}