Skip to main content

dioxus_ui_system/molecules/
sheet.rs

1//! Sheet molecule component
2//!
3//! A side panel/drawer that slides in from any edge of the screen.
4//! Similar to shadcn/ui Sheet component.
5
6use crate::atoms::{Box, Button, ButtonVariant};
7use crate::styles::Style;
8use crate::theme::tokens::Color;
9use crate::theme::{use_style, use_theme};
10use dioxus::prelude::*;
11
12#[cfg(all(feature = "web", target_arch = "wasm32"))]
13use wasm_bindgen::prelude::*;
14#[cfg(all(feature = "web", target_arch = "wasm32"))]
15use wasm_bindgen::JsCast;
16
17/// Which edge the sheet slides in from
18#[derive(Default, Clone, PartialEq)]
19pub enum SheetSide {
20    /// Slides in from the top
21    Top,
22    /// Slides in from the right (default)
23    #[default]
24    Right,
25    /// Slides in from the bottom
26    Bottom,
27    /// Slides in from the left
28    Left,
29}
30
31impl SheetSide {
32    /// Get the CSS transform value for the closed state
33    fn closed_transform(&self) -> &'static str {
34        match self {
35            SheetSide::Top => "translateY(-100%)",
36            SheetSide::Right => "translateX(100%)",
37            SheetSide::Bottom => "translateY(100%)",
38            SheetSide::Left => "translateX(-100%)",
39        }
40    }
41
42    /// Get the CSS transform value for the open state
43    fn open_transform(&self) -> &'static str {
44        "translate(0, 0)"
45    }
46
47    /// Check if this is a horizontal side (left or right)
48    #[allow(dead_code)]
49    fn is_horizontal(&self) -> bool {
50        matches!(self, SheetSide::Left | SheetSide::Right)
51    }
52
53    /// Check if this is a vertical side (top or bottom)
54    #[allow(dead_code)]
55    fn is_vertical(&self) -> bool {
56        matches!(self, SheetSide::Top | SheetSide::Bottom)
57    }
58}
59
60/// Sheet component properties
61#[derive(Props, Clone, PartialEq)]
62pub struct SheetProps {
63    /// Whether the sheet is open
64    pub open: bool,
65    /// Callback when open state changes
66    pub on_open_change: EventHandler<bool>,
67    /// Which edge to slide in from
68    #[props(default)]
69    pub side: SheetSide,
70    /// Sheet content
71    pub children: Element,
72    /// Sheet title (shown in header)
73    #[props(default)]
74    pub title: Option<String>,
75    /// Sheet description (shown below title)
76    #[props(default)]
77    pub description: Option<String>,
78    /// Whether to show the close button
79    #[props(default = true)]
80    pub show_close_button: bool,
81    /// Whether clicking the overlay closes the sheet
82    #[props(default = true)]
83    pub close_on_overlay_click: bool,
84}
85
86/// Sheet molecule component
87///
88/// A side panel that slides in from any edge of the screen.
89///
90/// # Example
91/// ```rust,ignore
92/// use dioxus::prelude::*;
93/// use dioxus_ui_system::molecules::{Sheet, SheetSide};
94///
95/// fn MyComponent() -> Element {
96///     let mut open = use_signal(|| false);
97///     
98///     rsx! {
99///         button {
100///             onclick: move |_| open.set(true),
101///             "Open Sheet"
102///         }
103///         
104///         Sheet {
105///             open: open(),
106///             on_open_change: move |is_open| open.set(is_open),
107///             side: SheetSide::Right,
108///             title: "Sheet Title".to_string(),
109///             "Sheet content goes here"
110///         }
111///     }
112/// }
113/// ```
114#[component]
115pub fn Sheet(props: SheetProps) -> Element {
116    let _theme = use_theme();
117    let side = props.side.clone();
118    let side_for_transform = props.side.clone();
119
120    // Handle escape key
121    use_effect(move || {
122        if !props.open {
123            return;
124        }
125
126        // Register keyboard event listener
127        #[cfg(all(feature = "web", target_arch = "wasm32"))]
128        {
129            let on_close = props.on_open_change.clone();
130            let closure = Closure::wrap(Box::new(move |event: web_sys::KeyboardEvent| {
131                if event.key() == "Escape" {
132                    on_close.call(false);
133                }
134            }) as Box<dyn FnMut(_)>);
135            if let Some(window) = web_sys::window() {
136                window
137                    .add_event_listener_with_callback("keydown", closure.as_ref().unchecked_ref())
138                    .unwrap();
139            }
140            closure.forget(); // Keep the closure alive
141        }
142
143        #[cfg(not(all(feature = "web", target_arch = "wasm32")))]
144        {
145            // Keyboard handling not implemented for non-web targets
146            let _ = props.on_open_change;
147        }
148    });
149
150    if !props.open {
151        return rsx! {};
152    }
153
154    let overlay_style = use_style(|_| {
155        Style::new()
156            .fixed()
157            .inset("0")
158            .w_full()
159            .h_full()
160            .bg(&Color::new_rgba(0, 0, 0, 0.5))
161            .z_index(9999)
162            .build()
163    });
164
165    let overlay_opacity = if props.open { "1" } else { "0" };
166    let overlay_transition = "transition: opacity 0.3s ease-out;";
167
168    // Build sheet content style based on side
169    let sheet_style = use_style(move |t| {
170        let mut style = Style::new()
171            .fixed()
172            .z_index(10000)
173            .bg(&t.colors.background)
174            .shadow(&t.shadows.xl)
175            .overflow_hidden()
176            .flex()
177            .flex_col();
178
179        // Position and sizing based on side
180        style = match side {
181            SheetSide::Top => style
182                .top("0")
183                .left("0")
184                .right("0")
185                .h_px(300)
186                .max_h("85vh")
187                .rounded(&t.radius, "lg")
188                .rounded_px(0), // Remove border radius on bottom
189            SheetSide::Right => style
190                .top("0")
191                .right("0")
192                .bottom("0")
193                .w_px(400)
194                .max_w("100%")
195                .rounded(&t.radius, "lg")
196                .rounded_px(0), // Remove border radius on left
197            SheetSide::Bottom => style
198                .left("0")
199                .right("0")
200                .bottom("0")
201                .h_px(300)
202                .max_h("85vh")
203                .rounded(&t.radius, "lg")
204                .rounded_px(0), // Remove border radius on top
205            SheetSide::Left => style
206                .top("0")
207                .left("0")
208                .bottom("0")
209                .w_px(400)
210                .max_w("100%")
211                .rounded(&t.radius, "lg")
212                .rounded_px(0), // Remove border radius on right
213        };
214
215        style.build()
216    });
217
218    // Transform for slide animation
219    let transform = if props.open {
220        side_for_transform.open_transform()
221    } else {
222        side_for_transform.closed_transform()
223    };
224    let sheet_transition = "transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);";
225
226    let handle_overlay_click = move |_| {
227        if props.close_on_overlay_click {
228            props.on_open_change.call(false);
229        }
230    };
231
232    rsx! {
233        // Overlay - separate element at root level
234        div {
235            style: "{overlay_style} opacity: {overlay_opacity}; {overlay_transition}",
236            onclick: handle_overlay_click,
237        }
238
239        // Sheet content - sibling to overlay, prevents click propagation issues
240        div {
241            style: "{sheet_style} transform: {transform}; {sheet_transition}",
242            onclick: move |e| e.stop_propagation(),
243
244            // Header
245            if props.title.is_some() || props.show_close_button {
246                SheetHeader {
247                    title: props.title.clone(),
248                    description: props.description.clone(),
249                    show_close_button: props.show_close_button,
250                    on_close: props.on_open_change.clone(),
251                }
252            }
253
254            // Content
255            Box {
256                style: "flex: 1; overflow-y: auto; padding: 24px;",
257                {props.children}
258            }
259        }
260    }
261}
262
263#[derive(Props, Clone, PartialEq)]
264struct SheetHeaderProps {
265    title: Option<String>,
266    description: Option<String>,
267    show_close_button: bool,
268    on_close: EventHandler<bool>,
269}
270
271#[component]
272fn SheetHeader(props: SheetHeaderProps) -> Element {
273    let _theme = use_theme();
274
275    let header_style = use_style(|t| {
276        Style::new()
277            .flex()
278            .items_center()
279            .justify_between()
280            .p(&t.spacing, "lg")
281            .border_bottom(1, &t.colors.border)
282            .build()
283    });
284
285    let title_section_style = use_style(|_| Style::new().flex().flex_col().gap_px(4).build());
286
287    rsx! {
288        div {
289            style: "{header_style}",
290
291            if props.title.is_some() || props.description.is_some() {
292                div {
293                    style: "{title_section_style}",
294
295                    if let Some(title) = props.title {
296                        h2 {
297                            style: "margin: 0; font-size: 18px; font-weight: 600;",
298                            "{title}"
299                        }
300                    }
301
302                    if let Some(description) = props.description {
303                        p {
304                            style: "margin: 0; font-size: 14px; color: #64748b;",
305                            "{description}"
306                        }
307                    }
308                }
309            } else {
310                div {}
311            }
312
313            if props.show_close_button {
314                Button {
315                    variant: ButtonVariant::Ghost,
316                    onclick: move |_| props.on_close.call(false),
317                    "✕"
318                }
319            }
320        }
321    }
322}
323
324/// Sheet footer component for action buttons
325#[derive(Props, Clone, PartialEq)]
326pub struct SheetFooterProps {
327    /// Footer content (usually buttons)
328    pub children: Element,
329    /// Align content
330    #[props(default)]
331    pub align: SheetFooterAlign,
332}
333
334/// Sheet footer alignment
335#[derive(Default, Clone, PartialEq)]
336pub enum SheetFooterAlign {
337    /// Start alignment
338    #[default]
339    Start,
340    /// Center alignment
341    Center,
342    /// End alignment
343    End,
344    /// Space between
345    Between,
346}
347
348/// Sheet footer component
349#[component]
350pub fn SheetFooter(props: SheetFooterProps) -> Element {
351    let _theme = use_theme();
352
353    let justify = match props.align {
354        SheetFooterAlign::Start => "flex-start",
355        SheetFooterAlign::Center => "center",
356        SheetFooterAlign::End => "flex-end",
357        SheetFooterAlign::Between => "space-between",
358    };
359
360    let footer_style = use_style(|t| {
361        Style::new()
362            .flex()
363            .items_center()
364            .gap(&t.spacing, "sm")
365            .p(&t.spacing, "lg")
366            .border_top(1, &t.colors.border)
367            .build()
368    });
369
370    rsx! {
371        div {
372            style: "{footer_style} justify-content: {justify};",
373            {props.children}
374        }
375    }
376}