dioxus_ui_system/molecules/
sheet.rs1use 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#[derive(Default, Clone, PartialEq)]
19pub enum SheetSide {
20 Top,
22 #[default]
24 Right,
25 Bottom,
27 Left,
29}
30
31impl SheetSide {
32 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 fn open_transform(&self) -> &'static str {
44 "translate(0, 0)"
45 }
46
47 #[allow(dead_code)]
49 fn is_horizontal(&self) -> bool {
50 matches!(self, SheetSide::Left | SheetSide::Right)
51 }
52
53 #[allow(dead_code)]
55 fn is_vertical(&self) -> bool {
56 matches!(self, SheetSide::Top | SheetSide::Bottom)
57 }
58}
59
60#[derive(Props, Clone, PartialEq)]
62pub struct SheetProps {
63 pub open: bool,
65 pub on_open_change: EventHandler<bool>,
67 #[props(default)]
69 pub side: SheetSide,
70 pub children: Element,
72 #[props(default)]
74 pub title: Option<String>,
75 #[props(default)]
77 pub description: Option<String>,
78 #[props(default = true)]
80 pub show_close_button: bool,
81 #[props(default = true)]
83 pub close_on_overlay_click: bool,
84}
85
86#[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 use_effect(move || {
122 if !props.open {
123 return;
124 }
125
126 #[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(); }
142
143 #[cfg(not(all(feature = "web", target_arch = "wasm32")))]
144 {
145 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 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 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), 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), 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), 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), };
214
215 style.build()
216 });
217
218 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 div {
235 style: "{overlay_style} opacity: {overlay_opacity}; {overlay_transition}",
236 onclick: handle_overlay_click,
237 }
238
239 div {
241 style: "{sheet_style} transform: {transform}; {sheet_transition}",
242 onclick: move |e| e.stop_propagation(),
243
244 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 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#[derive(Props, Clone, PartialEq)]
326pub struct SheetFooterProps {
327 pub children: Element,
329 #[props(default)]
331 pub align: SheetFooterAlign,
332}
333
334#[derive(Default, Clone, PartialEq)]
336pub enum SheetFooterAlign {
337 #[default]
339 Start,
340 Center,
342 End,
344 Between,
346}
347
348#[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}