use dioxus::prelude::*;
use crate::{
cn,
uikit::{
DRAWER_BODY, DRAWER_CONTENT_BASE, DRAWER_DESCRIPTION, DRAWER_FOOTER, DRAWER_HANDLE, DRAWER_HEADER, DRAWER_OVERLAY, DRAWER_TITLE, DrawerDirection,
primitives::{Controllable, is_transform_transition, use_controllable},
},
};
#[component]
pub fn Drawer(
open: Option<bool>,
#[props(default)] default_open: bool,
on_open_change: Option<EventHandler<bool>>,
#[props(default)] direction: DrawerDirection,
children: Element,
) -> Element {
let open = use_controllable(open, default_open, on_open_change);
use_context_provider(|| DrawerCtx { open, direction });
rsx! {
div { "data-slot": "drawer", {children} }
}
}
#[component]
pub fn DrawerTrigger(#[props(default)] class: String, children: Element) -> Element {
let ctx = use_context::<DrawerCtx>();
rsx! {
button {
r#type: "button",
class,
"data-slot": "drawer-trigger",
onclick: move |_| ctx.open.set(true),
{children}
}
}
}
#[component]
pub fn DrawerOverlay(#[props(default)] class: String) -> Element {
let ctx = use_context::<DrawerCtx>();
if !ctx.open.get() {
return rsx! {};
}
let cls = cn!(DRAWER_OVERLAY, class);
rsx! {
div {
class: cls,
"data-slot": "drawer-overlay",
"data-state": "open",
onclick: move |_| ctx.open.set(false),
}
}
}
#[component]
pub fn DrawerContent(#[props(default)] class: String, children: Element) -> Element {
let ctx = use_context::<DrawerCtx>();
let mut present = use_signal(|| ctx.open.get());
let mut closing = use_signal(|| false);
use_effect(move || {
let open = ctx.open.get();
if open {
if !*present.peek() {
present.set(true);
}
if *closing.peek() {
closing.set(false);
}
} else if *present.peek() && !*closing.peek() {
closing.set(true);
}
});
if !present() {
return rsx! {};
}
let state = if closing() { "closed" } else { "open" };
let direction = ctx.direction;
let cls = cn!(DRAWER_CONTENT_BASE, direction.as_class(), class);
rsx! {
div {
class: DRAWER_OVERLAY,
"data-slot": "drawer-overlay",
"data-state": state,
onclick: move |_| ctx.open.set(false),
}
div {
role: "dialog",
class: cls,
"data-slot": "drawer-content",
"data-state": state,
"data-vaul-drawer-direction": direction.as_ref(),
onkeydown: move |e| {
if e.key() == Key::Escape {
ctx.open.set(false);
}
},
ontransitionend: move |e| {
if *closing.peek() && is_transform_transition(&e) {
present.set(false);
closing.set(false);
}
},
if direction == DrawerDirection::Bottom {
div { class: DRAWER_HANDLE, "data-slot": "drawer-handle" }
}
div { class: DRAWER_BODY, "data-slot": "drawer-body", {children} }
}
}
}
#[component]
pub fn DrawerClose(#[props(default)] class: String, children: Element) -> Element {
let ctx = use_context::<DrawerCtx>();
rsx! {
button {
r#type: "button",
class,
"data-slot": "drawer-close",
onclick: move |_| ctx.open.set(false),
{children}
}
}
}
#[component]
pub fn DrawerHeader(#[props(default)] class: String, children: Element) -> Element {
let cls = cn!(DRAWER_HEADER, class);
rsx! {
div { class: cls, "data-slot": "drawer-header", {children} }
}
}
#[component]
pub fn DrawerFooter(#[props(default)] class: String, children: Element) -> Element {
let cls = cn!(DRAWER_FOOTER, class);
rsx! {
div { class: cls, "data-slot": "drawer-footer", {children} }
}
}
#[component]
pub fn DrawerTitle(#[props(default)] class: String, children: Element) -> Element {
let cls = cn!(DRAWER_TITLE, class);
rsx! {
div { class: cls, "data-slot": "drawer-title", {children} }
}
}
#[component]
pub fn DrawerDescription(#[props(default)] class: String, children: Element) -> Element {
let cls = cn!(DRAWER_DESCRIPTION, class);
rsx! {
div { class: cls, "data-slot": "drawer-description", {children} }
}
}
#[derive(Clone, Copy)]
struct DrawerCtx {
open: Controllable<bool>,
direction: DrawerDirection,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::uikit::test_util::render;
#[test]
fn closed_hides_content() {
fn app() -> Element {
rsx! {
Drawer {
DrawerTrigger { "open" }
DrawerContent {
DrawerTitle { "Title" }
}
}
}
}
let html = render(app);
assert!(html.contains("data-slot=\"drawer-trigger\""), "{html}");
assert!(!html.contains("Title"), "content hidden while closed: {html}");
}
#[test]
fn open_shows_dialog_with_direction() {
fn app() -> Element {
rsx! {
Drawer { default_open: true, direction: DrawerDirection::Right,
DrawerContent {
DrawerTitle { "Title" }
}
}
}
}
let html = render(app);
assert!(html.contains("role=\"dialog\""), "{html}");
assert!(html.contains("data-vaul-drawer-direction=\"right\""), "{html}");
assert!(html.contains("Title"), "{html}");
}
#[test]
fn overlay_renders_when_open() {
fn app() -> Element {
rsx! {
Drawer { default_open: true,
DrawerContent { "body" }
}
}
}
let html = render(app);
assert!(html.contains("data-slot=\"drawer-overlay\""), "{html}");
}
#[test]
fn open_arms_motion_state_on_panel_and_scrim() {
fn app() -> Element {
rsx! {
Drawer { default_open: true,
DrawerContent { "body" }
}
}
}
let html = render(app);
let opening_tag = |slot: &str| {
let at = html.find(slot).unwrap_or_else(|| panic!("{slot} rendered: {html}"));
let start = html[..at].rfind('<').expect("tag start");
let end = at + html[at..].find('>').expect("tag end");
&html[start..end]
};
let scrim_tag = opening_tag("data-slot=\"drawer-overlay\"");
let panel_tag = opening_tag("data-slot=\"drawer-content\"");
assert!(scrim_tag.contains("data-state=\"open\""), "scrim: {scrim_tag}");
assert!(panel_tag.contains("data-state=\"open\""), "panel: {panel_tag}");
assert!(html.contains("data-slot=\"drawer-handle\""), "handle visible for bottom: {html}");
assert!(html.contains("data-slot=\"drawer-body\""), "children wrapped in the scroller: {html}");
}
}