Skip to main content

ui/components/
drawer.rs

1use crate::{ModalFooter, ModalHeader, prelude::*};
2use gpui::{AnyElement, ScrollHandle};
3use smallvec::SmallVec;
4
5/// Default width for a [`Drawer`] (Tailwind `w-96` ~= 384px).
6pub const DRAWER_WIDTH: Pixels = px(384.);
7
8/// Default height for top/bottom [`Drawer`] sheets (Tailwind `h-96` ~= 384px).
9pub const DRAWER_HEIGHT: Pixels = px(384.);
10
11/// Which edge of the container a [`Drawer`] (shadcn Sheet) anchors to.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum SheetSide {
14    Top,
15    #[default]
16    Right,
17    Bottom,
18    Left,
19}
20
21/// A side panel ("slide-over") anchored to the right edge of its container.
22///
23/// Structurally a sibling of [`crate::Modal`] (header/body/footer), but
24/// positioned `absolute` + `right-0` and spanning the full height of its
25/// containing element instead of being centered. The caller is responsible
26/// for placing [`Drawer`] inside a `relative()` + `size_full()` overlay layer
27/// (this component does not render its own backdrop/scrim, matching
28/// [`crate::Modal`]'s existing convention).
29#[derive(IntoElement, RegisterComponent)]
30pub struct Drawer {
31    id: ElementId,
32    body_id: ElementId,
33    header: ModalHeader,
34    children: SmallVec<[AnyElement; 2]>,
35    footer: Option<ModalFooter>,
36    width: Pixels,
37    height: Pixels,
38    side: SheetSide,
39    container_scroll_handle: Option<ScrollHandle>,
40    animate: bool,
41}
42
43impl Drawer {
44    pub fn new(id: impl Into<SharedString>) -> Self {
45        let id = id.into();
46        let body_id = ElementId::Name(format!("{}_body", id).into());
47
48        Self {
49            id: ElementId::Name(id),
50            body_id,
51            header: ModalHeader::new(),
52            children: SmallVec::new(),
53            footer: None,
54            width: DRAWER_WIDTH,
55            height: DRAWER_HEIGHT,
56            side: SheetSide::default(),
57            container_scroll_handle: None,
58            animate: true,
59        }
60    }
61
62    /// Sets which edge the sheet slides in from (shadcn Sheet `side` prop).
63    pub fn side(mut self, side: SheetSide) -> Self {
64        self.side = side;
65        self
66    }
67
68    pub fn header(mut self, header: ModalHeader) -> Self {
69        self.header = header;
70        self
71    }
72
73    pub fn footer(mut self, footer: ModalFooter) -> Self {
74        self.footer = Some(footer);
75        self
76    }
77
78    /// Overrides the default `w-96` (384px) panel width (left/right sides).
79    pub fn width(mut self, width: Pixels) -> Self {
80        self.width = width;
81        self
82    }
83
84    /// Overrides the default `h-96` (384px) panel height (top/bottom sides).
85    pub fn height(mut self, height: Pixels) -> Self {
86        self.height = height;
87        self
88    }
89
90    pub fn show_dismiss(mut self, show: bool) -> Self {
91        self.header = self.header.show_dismiss_button(show);
92        self
93    }
94
95    pub fn scroll_handle(mut self, handle: ScrollHandle) -> Self {
96        self.container_scroll_handle = Some(handle);
97        self
98    }
99
100    /// Disables the slide-in animation, e.g. for static previews/snapshots.
101    pub fn animate(mut self, animate: bool) -> Self {
102        self.animate = animate;
103        self
104    }
105}
106
107impl ParentElement for Drawer {
108    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
109        self.children.extend(elements)
110    }
111}
112
113impl RenderOnce for Drawer {
114    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
115        let border_color = semantic::border(cx);
116        let mut panel = v_flex()
117            .id(self.id.clone())
118            .absolute()
119            .bg(semantic::elevated_surface(cx))
120            .border_color(border_color)
121            .shadow_level(Shadow::Xl)
122            .overflow_hidden();
123
124        panel = match self.side {
125            SheetSide::Right => panel.top_0().right_0().h_full().w(self.width).border_l_1(),
126            SheetSide::Left => panel.top_0().left_0().h_full().w(self.width).border_r_1(),
127            SheetSide::Top => panel.top_0().left_0().w_full().h(self.height).border_b_1(),
128            SheetSide::Bottom => panel
129                .bottom_0()
130                .left_0()
131                .w_full()
132                .h(self.height)
133                .border_t_1(),
134        };
135
136        panel = panel
137            .child(self.header)
138            .child(
139                v_flex()
140                    .id(self.body_id)
141                    .flex_1()
142                    .w_full()
143                    .p(DynamicSpacing::Base24.rems(cx))
144                    .gap(DynamicSpacing::Base08.rems(cx))
145                    .when_some(self.container_scroll_handle, |this, handle| {
146                        this.overflow_y_scroll().track_scroll(&handle)
147                    })
148                    .children(self.children),
149            )
150            .children(self.footer);
151
152        if self.animate {
153            match self.side {
154                SheetSide::Right => panel.animate_in_from_right(false).into_any_element(),
155                SheetSide::Left => panel.animate_in_from_left(false).into_any_element(),
156                SheetSide::Top => panel.animate_in_from_top(false).into_any_element(),
157                SheetSide::Bottom => panel.animate_in_from_bottom(false).into_any_element(),
158            }
159        } else {
160            panel.into_any_element()
161        }
162    }
163}
164
165impl Component for Drawer {
166    fn scope() -> ComponentScope {
167        ComponentScope::Overlays
168    }
169
170    fn description() -> Option<&'static str> {
171        Some(
172            "A side panel (slide-over) anchored to the right edge, for detail views or forms that shouldn't fully replace the current context.",
173        )
174    }
175
176    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
177        Some(
178            example_group(vec![single_example(
179                "Basic",
180                div()
181                    .relative()
182                    .w(px(480.))
183                    .h(px(320.))
184                    .overflow_hidden()
185                    .child(
186                        Drawer::new("drawer-preview")
187                            .animate(false)
188                            .header(
189                                ModalHeader::new()
190                                    .headline("Drawer title")
191                                    .show_dismiss_button(true),
192                            )
193                            .child(Label::new("Drawer body content goes here."))
194                            .footer(
195                                ModalFooter::new()
196                                    .end_slot(Button::new("drawer-confirm", "Save").primary()),
197                            ),
198                    )
199                    .into_any_element(),
200            )])
201            .into_any_element(),
202        )
203    }
204}