From 6310eaef0e40781066d1e0c6a4783eba71703084 Mon Sep 17 00:00:00 2001 From: krishpranav Date: Sat, 18 Apr 2026 17:34:19 +0530 Subject: [PATCH 1/2] drawer: replace vaul runtime with rust Drop vaul JS/CSS assets and move drawer behavior into Rust context and event handling. Also fix family demo text contrast and sync registry snapshots. --- app/src/domain/icons/page_icons.rs | 6 +- .../registry/src/demos/demo_drawer_family.rs | 6 +- .../src/demos/demo_drawer_non_dismissable.rs | 2 +- app_crates/registry/src/ui/drawer.rs | 959 +++++++++++++++++- e2e/tests/components/drawer.spec.ts | 13 +- public/components/vaul_drawer.css | 271 ----- public/components/vaul_drawer.js | 473 --------- .../styles/default/demo_drawer_family.md | 6 +- .../default/demo_drawer_non_dismissable.md | 2 +- public/registry/styles/default/drawer.md | 96 +- 10 files changed, 1040 insertions(+), 794 deletions(-) delete mode 100644 public/components/vaul_drawer.css delete mode 100644 public/components/vaul_drawer.js diff --git a/app/src/domain/icons/page_icons.rs b/app/src/domain/icons/page_icons.rs index 42c06ee..1f44446 100644 --- a/app/src/domain/icons/page_icons.rs +++ b/app/src/domain/icons/page_icons.rs @@ -94,7 +94,7 @@ pub fn PageIcons() -> impl IntoView { selected_icon_name.set(icon_name); selected_icon_function.set(Some(icon_func)); - // Click the hidden trigger to let JavaScript handle the drawer opening + // Click the hidden trigger inside the Drawer subtree to open it reactively. let window = window(); if let Some(document) = window.document() && let Ok(Some(trigger)) = document.query_selector("[data-name=\"DrawerTrigger\"]") @@ -294,10 +294,8 @@ pub fn PageIcons() -> impl IntoView { - // Hidden trigger for JavaScript to wire up - - + diff --git a/app_crates/registry/src/demos/demo_drawer_family.rs b/app_crates/registry/src/demos/demo_drawer_family.rs index 9f9919a..ca87a23 100644 --- a/app_crates/registry/src/demos/demo_drawer_family.rs +++ b/app_crates/registry/src/demos/demo_drawer_family.rs @@ -14,19 +14,19 @@ pub fn DemoDrawerFamily() -> impl IntoView {

"Options"

- - diff --git a/app_crates/registry/src/demos/demo_drawer_non_dismissable.rs b/app_crates/registry/src/demos/demo_drawer_non_dismissable.rs index 0e24b01..bb3263b 100644 --- a/app_crates/registry/src/demos/demo_drawer_non_dismissable.rs +++ b/app_crates/registry/src/demos/demo_drawer_non_dismissable.rs @@ -11,7 +11,7 @@ pub fn DemoDrawerNonDismissable() -> impl IntoView { "Open Drawer" - + diff --git a/app_crates/registry/src/ui/drawer.rs b/app_crates/registry/src/ui/drawer.rs index 35e78af..1931196 100644 --- a/app_crates/registry/src/ui/drawer.rs +++ b/app_crates/registry/src/ui/drawer.rs @@ -1,13 +1,161 @@ -use leptos::prelude::*; +use std::time::Duration; + +use leptos::{ev, html, leptos_dom::helpers::window_event_listener, prelude::*}; use leptos_ui::clx; use tw_merge::*; +use wasm_bindgen::{JsCast, closure::Closure}; +use web_sys::{Element, HtmlElement, KeyboardEvent, PointerEvent}; use crate::ui::button::{Button, ButtonSize, ButtonVariant}; +const VELOCITY_THRESHOLD: f64 = 0.4; +const CLOSE_THRESHOLD: f64 = 0.25; +const TRANSITION_DURATION_MS: u64 = 500; +const TRANSITION_DURATION_MS_F64: f64 = 500.0; +const TRANSITION_EASING: &str = "cubic-bezier(0.32, 0.72, 0, 1)"; +const WINDOW_TOP_OFFSET: f64 = 26.0; +const BORDER_RADIUS: f64 = 8.0; +const WRAPPER_TRANSLATE_Y: f64 = 14.0; +const SCROLL_LOCK_TIMEOUT_MS: f64 = 500.0; +const FOCUS_DELAY_MS: u64 = 100; + +const DRAWER_STYLE: &str = r#" +[data-vaul-drawer] { + touch-action: none; + will-change: transform; + transition: transform 0.5s cubic-bezier(0.32, 0.72, 0, 1); + animation-duration: 0.5s; + animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); +} + +[data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-position='Bottom'][data-state='open'] { + animation-name: slideFromBottom; +} + +[data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-position='Bottom'][data-state='closed'] { + animation-name: slideToBottom; +} + +[data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-position='Left'][data-state='open'] { + animation-name: slideFromLeft; +} + +[data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-position='Left'][data-state='closed'] { + animation-name: slideToLeft; +} + +[data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-position='Right'][data-state='open'] { + animation-name: slideFromRight; +} + +[data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-position='Right'][data-state='closed'] { + animation-name: slideToRight; +} + +[data-vaul-overlay][data-vaul-snap-points='false'] { + animation-duration: 0.5s; + animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); +} + +[data-vaul-overlay][data-vaul-snap-points='false'][data-state='open'] { + animation-name: fadeIn; +} + +[data-vaul-overlay][data-state='closed'] { + animation-name: fadeOut; +} + +[data-vaul-animate='false'] { + animation: none !important; +} + +[data-vaul-drawer]:not([data-vaul-variant='Floating'])::after { + content: ''; + position: absolute; + background: inherit; + background-color: inherit; +} + +[data-vaul-drawer][data-vaul-drawer-position='Bottom']::after { + top: 100%; + left: 0; + right: 0; + height: 200%; +} + +[data-vaul-drawer][data-vaul-drawer-position='Left']::after { + right: 100%; + top: 0; + bottom: 0; + width: 200%; +} + +[data-vaul-drawer][data-vaul-drawer-position='Right']::after { + left: 100%; + top: 0; + bottom: 0; + width: 200%; +} + +[data-vaul-handle] { + touch-action: pan-y; +} + +[data-vaul-handle-hitarea] { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: max(100%, 2.75rem); + height: max(100%, 2.75rem); + touch-action: inherit; +} + +[data-vaul-variant='Floating'][data-vaul-drawer-position='Right'][data-state='closed'] { + transform: translate3d(calc(100% + 8px), 0, 0); +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes fadeOut { + to { opacity: 0; } +} + +@keyframes slideFromBottom { + from { transform: translate3d(0, var(--initial-transform, 100%), 0); } + to { transform: translate3d(0, 0, 0); } +} + +@keyframes slideToBottom { + to { transform: translate3d(0, var(--initial-transform, 100%), 0); } +} + +@keyframes slideFromLeft { + from { transform: translate3d(calc(var(--initial-transform, 100%) * -1), 0, 0); } + to { transform: translate3d(0, 0, 0); } +} + +@keyframes slideToLeft { + to { transform: translate3d(calc(var(--initial-transform, 100%) * -1), 0, 0); } +} + +@keyframes slideFromRight { + from { transform: translate3d(var(--initial-transform, 100%), 0, 0); } + to { transform: translate3d(0, 0, 0); } +} + +@keyframes slideToRight { + to { transform: translate3d(var(--initial-transform, 100%), 0, 0); } +} +"#; + mod components { use super::*; clx! {DrawerBody, div, "flex flex-col gap-4 mx-auto max-w-[500px]"} - clx! {DrawerTitle, h3, "text-lg leading-none font-semibold"} + clx! {DrawerTitle, h2, "text-lg leading-none font-semibold"} clx! {DrawerDescription, p, "text-sm text-muted-foreground"} clx! {DrawerHeader, div, "flex flex-col gap-2"} clx! {DrawerFooter, footer, "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"} @@ -15,6 +163,44 @@ mod components { pub use components::*; +#[derive(Clone)] +pub struct DrawerContext { + open: RwSignal, + overlay_ref: NodeRef, + content_ref: NodeRef, + hidden: RwSignal, + state: RwSignal<&'static str>, + content_animate: RwSignal, + overlay_animate: RwSignal, + dismissible: RwSignal, + lock_body_scroll: bool, + previous_active_element: RwSignal>, + animation_epoch: RwSignal, +} + +#[derive(Clone, Copy)] +struct DragState { + is_dragging: RwSignal, + current_pos: RwSignal, + start_pos: RwSignal, + drag_start_time: RwSignal, + drawer_size: RwSignal, +} + +impl DrawerContext { + pub fn open(&self) { + if !self.open.get_untracked() { + self.open.set(true); + } + } + + pub fn close(&self) { + if self.open.get_untracked() { + self.open.set(false); + } + } +} + #[component] pub fn DrawerTrigger( children: Children, @@ -22,8 +208,20 @@ pub fn DrawerTrigger( #[prop(default = ButtonVariant::Outline)] variant: ButtonVariant, #[prop(default = ButtonSize::Default)] size: ButtonSize, ) -> impl IntoView { + let ctx = use_context::(); + view! { - } @@ -36,42 +234,85 @@ pub fn DrawerClose( #[prop(default = ButtonVariant::Outline)] variant: ButtonVariant, #[prop(default = ButtonSize::Default)] size: ButtonSize, ) -> impl IntoView { + let ctx = use_context::(); + view! { - } } -/* ========================================================== */ -/* ✨ FUNCTIONS ✨ */ -/* ========================================================== */ - #[component] pub fn Drawer( children: Children, #[prop(optional, default = true)] show_overlay: bool, #[prop(optional, default = true)] lock_body_scroll: bool, ) -> impl IntoView { - let overlay_class = if show_overlay { "hidden fixed inset-0 z-200 bg-black/50" } else { "!hidden" }; - let lock_scroll_attr = if lock_body_scroll { "true" } else { "false" }; + let overlay_ref = NodeRef::::new(); + let content_ref = NodeRef::::new(); + let hidden = RwSignal::new(true); + let state = RwSignal::new("closed"); + let content_animate = RwSignal::new(true); + let overlay_animate = RwSignal::new(true); + let dismissible = RwSignal::new(true); + let previous_active_element = RwSignal::new(None::); + let animation_epoch = RwSignal::new(0_u64); + let open = RwSignal::new(false); + + let ctx = DrawerContext { + open, + overlay_ref, + content_ref, + hidden, + state, + content_animate, + overlay_animate, + dismissible, + lock_body_scroll, + previous_active_element, + animation_epoch, + }; + provide_context(ctx.clone()); + + let overlay_class = move || { + let mut class = "fixed inset-0 z-200 bg-black/50".to_string(); + if hidden.get() || !show_overlay { + class.push_str(" hidden"); + } + class + }; view! { - +
{children()} - - } } @@ -97,25 +338,235 @@ pub fn DrawerContent( #[prop(optional, default = DrawerPosition::default())] position: DrawerPosition, #[prop(optional, default = DrawerVariant::default())] variant: DrawerVariant, #[prop(into, default = "--initial-transform: 100%;".to_string())] style: String, - #[prop(into, default = "true".to_string())] dismissible: String, + #[prop(optional, default = true)] dismissible: bool, ) -> impl IntoView { + let ctx = expect_context::(); + let is_dragging = RwSignal::new(false); + let is_allowed_to_drag = RwSignal::new(false); + let start_pos = RwSignal::new(0.0); + let current_pos = RwSignal::new(0.0); + let drawer_size = RwSignal::new(0.0); + let drag_start_time = RwSignal::new(0.0); + let open_time = RwSignal::new(None::); + let last_time_drag_prevented = RwSignal::new(None::); + let drag_state = DragState { is_dragging, current_pos, start_pos, drag_start_time, drawer_size }; + + ctx.dismissible.set(dismissible); + + let watch_ctx = ctx.clone(); + Effect::watch( + move || ctx.open.get(), + move |is_open, previous, _| { + if previous.is_none() { + return; + } + + if *is_open { + open_drawer_dom(&watch_ctx, position, variant, open_time); + } else { + close_drawer_dom(&watch_ctx, position, variant); + } + }, + false, + ); + + let keydown_ctx = ctx.clone(); + Effect::new(move |_| { + let keydown_ctx = keydown_ctx.clone(); + let handle = window_event_listener(ev::keydown, move |event: KeyboardEvent| { + if !keydown_ctx.open.get_untracked() { + return; + } + + if event.key() == "Escape" { + if keydown_ctx.dismissible.get_untracked() { + event.prevent_default(); + keydown_ctx.close(); + } + return; + } + + if event.key() != "Tab" { + return; + } + + let Some(drawer) = content_element(&keydown_ctx) else { return }; + let focusable = focusable_elements(&drawer); + if focusable.is_empty() { + return; + } + + let Some(document) = window().document() else { return }; + let first = focusable.first().cloned(); + let last = focusable.last().cloned(); + let active = document.active_element(); + + if event.shift_key() { + if active.as_ref() == first.as_ref().map(|el| el.as_ref()) { + event.prevent_default(); + if let Some(last) = last { + let _ = last.focus(); + } + } + } else if active.as_ref() == last.as_ref().map(|el| el.as_ref()) { + event.prevent_default(); + if let Some(first) = first { + let _ = first.focus(); + } + } + }); + + on_cleanup(move || handle.remove()); + }); + + let handle_content_click = { + let ctx = ctx.clone(); + move |event: web_sys::MouseEvent| { + if !event_target_matches(event.target(), "[data-name=\"DrawerClose\"]") { + return; + } + + ctx.close(); + } + }; + + let handle_select_start = move |event: web_sys::Event| { + if is_dragging.get() && is_allowed_to_drag.get() { + event.prevent_default(); + } + }; + + let handle_pointer_down = { + let ctx = ctx.clone(); + move |event: PointerEvent| { + if !ctx.open.get() || !ctx.dismissible.get() { + return; + } + + if event_target_matches(event.target(), "[data-name=\"DrawerClose\"]") { + return; + } + + let Some(drawer) = content_element(&ctx) else { return }; + is_dragging.set(true); + is_allowed_to_drag.set(false); + + let pointer_position = pointer_axis_value(&event, position); + start_pos.set(pointer_position); + current_pos.set(pointer_position); + drag_start_time.set(now_ms()); + drawer_size.set(measure_drawer_size(&drawer, position)); + + let _ = drawer.style().set_property("transition", "none"); + let _ = drawer.set_pointer_capture(event.pointer_id()); + } + }; + + let handle_pointer_move = { + let ctx = ctx.clone(); + move |event: PointerEvent| { + if !is_dragging.get() || !ctx.dismissible.get() { + return; + } + + let Some(drawer) = content_element(&ctx) else { return }; + let Some(overlay) = overlay_element(&ctx) else { return }; + + current_pos.set(pointer_axis_value(&event, position)); + let delta = current_pos.get_untracked() - start_pos.get_untracked(); + let closing_direction = dragging_in_closing_direction(delta, position); + + if !is_allowed_to_drag.get() + && !should_drag(event.target(), &drawer, open_time, last_time_drag_prevented) + { + return; + } + + is_allowed_to_drag.set(true); + + if closing_direction { + let abs_delta = delta.abs(); + let _ = drawer + .style() + .set_property("transform", &drag_transform(delta, closing_direction, position)); + + if let Some(wrapper) = query_drawer_wrapper() { + let percentage_dragged = percentage_dragged(abs_delta, drawer_size.get_untracked()); + let scale = get_scale() + percentage_dragged * (1.0 - get_scale()); + let border_radius = (BORDER_RADIUS - percentage_dragged * BORDER_RADIUS).max(0.0); + let translate = (WRAPPER_TRANSLATE_Y - percentage_dragged * WRAPPER_TRANSLATE_Y).max(0.0); + + let _ = wrapper.style().set_property("transition", "none"); + let _ = wrapper.style().set_property("border-radius", &format!("{border_radius}px")); + let _ = wrapper + .style() + .set_property("transform", &format!("scale({scale}) translate3d(0, {translate}px, 0)")); + } + + let opacity = (1.0 - percentage_dragged(abs_delta, drawer_size.get_untracked())).max(0.0); + let _ = overlay.style().set_property("transition", "none"); + let _ = overlay.style().set_property("opacity", &opacity.to_string()); + } else { + let _ = drawer + .style() + .set_property("transform", &drag_transform(delta, closing_direction, position)); + } + } + }; + + let pointer_up_ctx = ctx.clone(); + let pointer_up_cancel_ctx = ctx.clone(); + let merged_class = tw_merge!( - "flex flex-col pt-3 pb-6 px-6 hidden fixed right-0 bottom-0 left-0 z-210 bg-background max-h-[96vh] rounded-t-[10px]", + "flex flex-col pt-3 pb-6 px-6 fixed right-0 bottom-0 left-0 z-210 bg-background max-h-[96vh] rounded-t-[10px] hidden outline-none", class ); + let class_ctx = ctx.clone(); + let content_animate_ctx = ctx.clone(); + let dismissible_ctx = ctx.clone(); + let state_ctx = ctx.clone(); + view! {
{children()}
@@ -133,3 +584,467 @@ pub fn DrawerHandle() -> impl IntoView {
} } + +fn bool_attr(value: bool) -> &'static str { + if value { "true" } else { "false" } +} + +fn now_ms() -> f64 { + js_sys::Date::now() +} + +fn content_element(ctx: &DrawerContext) -> Option { + ctx.content_ref.get_untracked().map(|element| element.unchecked_into::()) +} + +fn overlay_element(ctx: &DrawerContext) -> Option { + ctx.overlay_ref.get_untracked().map(|element| element.unchecked_into::()) +} + +fn query_drawer_wrapper() -> Option { + let document = window().document()?; + let element = document.query_selector("[data-vaul-drawer-wrapper]").ok()??; + element.dyn_into::().ok() +} + +fn measure_drawer_size(drawer: &HtmlElement, position: DrawerPosition) -> f64 { + let rect = drawer.get_bounding_client_rect(); + if is_horizontal(position) { rect.width() } else { rect.height() } +} + +fn is_horizontal(position: DrawerPosition) -> bool { + matches!(position, DrawerPosition::Left | DrawerPosition::Right) +} + +fn pointer_axis_value(event: &PointerEvent, position: DrawerPosition) -> f64 { + if is_horizontal(position) { f64::from(event.page_x()) } else { f64::from(event.page_y()) } +} + +fn dragging_in_closing_direction(delta: f64, position: DrawerPosition) -> bool { + match position { + DrawerPosition::Bottom | DrawerPosition::Right => delta > 0.0, + DrawerPosition::Left => delta < 0.0, + } +} + +fn percentage_dragged(delta: f64, drawer_size: f64) -> f64 { + if drawer_size <= 0.0 { 0.0 } else { (delta / drawer_size).clamp(0.0, 1.0) } +} + +fn get_scale() -> f64 { + let Some(inner_width) = window().inner_width().ok().and_then(|value| value.as_f64()) else { + return 1.0; + }; + (inner_width - WINDOW_TOP_OFFSET) / inner_width +} + +fn dampen_value(value: f64) -> f64 { + 8.0 * ((value + 1.0).ln() - 2.0) +} + +fn drag_transform(delta: f64, closing_direction: bool, position: DrawerPosition) -> String { + if closing_direction { + return if is_horizontal(position) { + format!("translate3d({delta}px, 0, 0)") + } else { + format!("translate3d(0, {delta}px, 0)") + }; + } + + let damped = dampen_value(delta.abs()); + match position { + DrawerPosition::Bottom => format!("translate3d(0, {}px, 0)", -damped), + DrawerPosition::Left | DrawerPosition::Right => { + let signed = if delta > 0.0 { damped } else { -damped }; + format!("translate3d({signed}px, 0, 0)") + } + } +} + +fn should_close_from_drag(delta: f64, velocity: f64, drawer_size: f64, position: DrawerPosition) -> bool { + match position { + DrawerPosition::Bottom | DrawerPosition::Right => { + (velocity > VELOCITY_THRESHOLD || delta / drawer_size >= CLOSE_THRESHOLD) && delta > 0.0 + } + DrawerPosition::Left => { + (velocity > VELOCITY_THRESHOLD || delta.abs() / drawer_size >= CLOSE_THRESHOLD) && delta < 0.0 + } + } +} + +fn finish_pointer_drag( + ctx: &DrawerContext, + event: PointerEvent, + position: DrawerPosition, + drag_state: DragState, +) { + if !drag_state.is_dragging.get() || !ctx.dismissible.get() { + return; + } + + drag_state.is_dragging.set(false); + + let Some(drawer) = content_element(ctx) else { return }; + let overlay = overlay_element(ctx); + let delta = drag_state.current_pos.get_untracked() - drag_state.start_pos.get_untracked(); + let elapsed = (now_ms() - drag_state.drag_start_time.get_untracked()).max(1.0); + let velocity = delta.abs() / elapsed; + + let _ = drawer + .style() + .set_property("transition", &format!("transform 0.5s {TRANSITION_EASING}")); + + if let Some(wrapper) = query_drawer_wrapper() { + let _ = wrapper.style().set_property( + "transition", + &format!("transform 0.5s {TRANSITION_EASING}, border-radius 0.5s {TRANSITION_EASING}"), + ); + } + + if let Some(overlay) = &overlay { + let _ = overlay + .style() + .set_property("transition", &format!("opacity 0.5s {TRANSITION_EASING}")); + } + + let size = drag_state.drawer_size.get_untracked().max(1.0); + if should_close_from_drag(delta, velocity, size, position) { + ctx.close(); + } else { + let _ = drawer.style().set_property("transform", "translate3d(0, 0, 0)"); + + if let Some(wrapper) = query_drawer_wrapper() { + let scale = get_scale(); + let _ = wrapper.style().set_property("border-radius", &format!("{BORDER_RADIUS}px")); + let _ = wrapper + .style() + .set_property("transform", &format!("scale({scale}) translate3d(0, {WRAPPER_TRANSLATE_Y}px, 0)")); + } + + if let Some(overlay) = overlay { + let _ = overlay.style().set_property("opacity", "1"); + } + } + + if drawer.has_pointer_capture(event.pointer_id()) { + let _ = drawer.release_pointer_capture(event.pointer_id()); + } +} + +fn event_target_matches(target: Option, selector: &str) -> bool { + target + .and_then(|target| target.dyn_into::().ok()) + .and_then(|element| element.closest(selector).ok().flatten()) + .is_some() +} + +fn should_drag( + target: Option, + drawer: &HtmlElement, + open_time: RwSignal>, + last_time_drag_prevented: RwSignal>, +) -> bool { + let current_time = now_ms(); + + if let Some(opened_at) = open_time.get_untracked() + && current_time - opened_at < TRANSITION_DURATION_MS_F64 + { + return false; + } + + if let Some(last_prevented) = last_time_drag_prevented.get_untracked() + && current_time - last_prevented < SCROLL_LOCK_TIMEOUT_MS + { + last_time_drag_prevented.set(Some(current_time)); + return false; + } + + let mut current = target.and_then(|target| target.dyn_into::().ok()); + while let Some(element) = current { + if element.is_same_node(Some(drawer.as_ref())) { + break; + } + + if let Ok(html_element) = element.clone().dyn_into::() + && html_element.scroll_height() > html_element.client_height() + { + if html_element.scroll_top() != 0 { + last_time_drag_prevented.set(Some(current_time)); + return false; + } + + if html_element.get_attribute("role").as_deref() == Some("dialog") { + return true; + } + } + + current = element.parent_element(); + } + + true +} + +fn focusable_elements(drawer: &HtmlElement) -> Vec { + const FOCUSABLE_SELECTOR: &str = + "a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex=\"-1\"])"; + + let Ok(elements) = drawer.query_selector_all(FOCUSABLE_SELECTOR) else { + return Vec::new(); + }; + + let mut focusable = Vec::new(); + for index in 0..elements.length() { + let Some(element) = elements.item(index) else { continue }; + let Ok(html_element) = element.dyn_into::() else { continue }; + if html_element.offset_parent().is_some() { + focusable.push(html_element); + } + } + + focusable +} + +fn focus_first_drawer_element(ctx: &DrawerContext) { + let ctx = ctx.clone(); + set_timeout( + move || { + if !ctx.open.get_untracked() { + return; + } + + let Some(drawer) = content_element(&ctx) else { return }; + let focusable = focusable_elements(&drawer); + if focusable.is_empty() { + let _ = drawer.focus(); + return; + } + + let is_only_close_button = focusable.len() == 1 + && focusable + .first() + .and_then(|element| element.get_attribute("data-name")) + .as_deref() + == Some("DrawerClose"); + + if is_only_close_button { + let _ = drawer.focus(); + } else if let Some(first) = focusable.first() { + let _ = first.focus(); + } + }, + Duration::from_millis(FOCUS_DELAY_MS), + ); +} + +fn fix_drawer_position(drawer: &HtmlElement) { + let Some(document) = window().document() else { return }; + let viewport_height = window().inner_height().ok().and_then(|value| value.as_f64()).unwrap_or_default(); + let scroll_height = document.document_element().map(|element| element.scroll_height()).unwrap_or_default(); + if f64::from(scroll_height) <= viewport_height { + return; + } + + let rect = drawer.get_bounding_client_rect(); + let offset = viewport_height - rect.bottom(); + if offset.abs() <= f64::EPSILON { + return; + } + + let _ = drawer.style().set_property("top", &format!("{}px", rect.top() + offset)); +} + +fn lock_body_scroll() { + let Some(document) = window().document() else { return }; + let Some(body) = document.body() else { return }; + let scrollbar_width = + window().inner_width().ok().and_then(|width| width.as_f64()).unwrap_or_default() - f64::from(body.client_width()); + + let _ = body.set_attribute("data-state", "open"); + if scrollbar_width > 0.0 { + let _ = body.style().set_property("padding-right", &format!("{scrollbar_width}px")); + } + let _ = body.style().set_property("overflow", "hidden"); +} + +fn unlock_body_scroll() { + let Some(document) = window().document() else { return }; + let Some(body) = document.body() else { return }; + let _ = body.remove_attribute("data-state"); + let _ = body.style().remove_property("padding-right"); + let _ = body.style().remove_property("overflow"); +} + +fn apply_open_wrapper_styles() { + let Some(wrapper) = query_drawer_wrapper() else { return }; + let scale = get_scale(); + + let _ = wrapper.style().set_property("transform-origin", "top"); + let _ = wrapper.style().set_property("transition-property", "transform, border-radius"); + let _ = wrapper.style().set_property("transition-duration", "0.5s"); + let _ = wrapper.style().set_property("transition-timing-function", TRANSITION_EASING); + let _ = wrapper.style().set_property("border-radius", &format!("{BORDER_RADIUS}px")); + let _ = wrapper.style().set_property("overflow", "hidden"); + let _ = wrapper.style().set_property( + "transform", + &format!("scale({scale}) translate3d(0, calc(env(safe-area-inset-top) + {WRAPPER_TRANSLATE_Y}px), 0)"), + ); + + if let Some(body) = window().document().and_then(|document| document.body()) { + let _ = body.style().set_property("background", "black"); + } +} + +fn reset_wrapper_styles() { + let Some(wrapper) = query_drawer_wrapper() else { return }; + + let _ = wrapper.style().set_property( + "transition", + &format!("transform 0.5s {TRANSITION_EASING}, border-radius 0.5s {TRANSITION_EASING}"), + ); + let _ = wrapper.style().set_property("transform", "scale(1) translate3d(0, 0, 0)"); + let _ = wrapper.style().set_property("border-radius", "0px"); +} + +fn clear_wrapper_styles() { + let Some(wrapper) = query_drawer_wrapper() else { return }; + for property in [ + "transform-origin", + "transition-property", + "transition-duration", + "transition-timing-function", + "transition", + "border-radius", + "overflow", + "transform", + ] { + let _ = wrapper.style().remove_property(property); + } + + if let Some(body) = window().document().and_then(|document| document.body()) { + let _ = body.style().remove_property("background"); + } +} + +fn close_transform(drawer_size: f64, position: DrawerPosition, variant: DrawerVariant) -> String { + match position { + DrawerPosition::Bottom => format!("translate3d(0, {drawer_size}px, 0)"), + DrawerPosition::Left => format!("translate3d(-{drawer_size}px, 0, 0)"), + DrawerPosition::Right => { + let distance = if variant == DrawerVariant::Floating { drawer_size + 8.0 } else { drawer_size }; + format!("translate3d({distance}px, 0, 0)") + } + } +} + +fn next_epoch(ctx: &DrawerContext) -> u64 { + let next = ctx.animation_epoch.get_untracked().saturating_add(1); + ctx.animation_epoch.set(next); + next +} + +fn open_drawer_dom( + ctx: &DrawerContext, + _position: DrawerPosition, + variant: DrawerVariant, + open_time: RwSignal>, +) { + let epoch = next_epoch(ctx); + let Some(document) = window().document() else { return }; + let Some(drawer) = content_element(ctx) else { return }; + let Some(overlay) = overlay_element(ctx) else { return }; + + ctx.hidden.set(false); + ctx.state.set("closed"); + ctx.content_animate.set(true); + ctx.overlay_animate.set(true); + ctx.previous_active_element.set(document.active_element()); + + if variant == DrawerVariant::Floating { + let _ = overlay.style().set_property("opacity", "1"); + } + + if ctx.lock_body_scroll { + lock_body_scroll(); + fix_drawer_position(&drawer); + } + + apply_open_wrapper_styles(); + ctx.previous_active_element.set(document.active_element()); + + let ctx = ctx.clone(); + let callback = Closure::once(move || { + if ctx.animation_epoch.get_untracked() != epoch || !ctx.open.get_untracked() { + return; + } + + if variant == DrawerVariant::Floating { + ctx.overlay_animate.set(false); + } + + ctx.state.set("open"); + open_time.set(Some(now_ms())); + focus_first_drawer_element(&ctx); + }); + + let _ = window().request_animation_frame(callback.as_ref().unchecked_ref()); + callback.forget(); +} + +fn close_drawer_dom(ctx: &DrawerContext, position: DrawerPosition, variant: DrawerVariant) { + let epoch = next_epoch(ctx); + let Some(drawer) = content_element(ctx) else { return }; + let Some(overlay) = overlay_element(ctx) else { return }; + let size = measure_drawer_size(&drawer, position); + + ctx.content_animate.set(false); + ctx.overlay_animate.set(false); + + let _ = drawer + .style() + .set_property("transition", &format!("transform 0.5s {TRANSITION_EASING}")); + let _ = drawer.style().set_property("transform", &close_transform(size, position, variant)); + + let _ = overlay + .style() + .set_property("transition", &format!("opacity 0.5s {TRANSITION_EASING}")); + let _ = overlay.style().set_property("opacity", "0"); + + reset_wrapper_styles(); + ctx.state.set("closed"); + + if ctx.lock_body_scroll { + unlock_body_scroll(); + } + + let ctx = ctx.clone(); + set_timeout( + move || { + if ctx.animation_epoch.get_untracked() != epoch || ctx.open.get_untracked() { + return; + } + + let Some(drawer) = content_element(&ctx) else { return }; + let Some(overlay) = overlay_element(&ctx) else { return }; + + ctx.hidden.set(true); + let _ = drawer.style().remove_property("transform"); + let _ = drawer.style().remove_property("transition"); + let _ = drawer.style().remove_property("top"); + + let _ = overlay.style().remove_property("opacity"); + let _ = overlay.style().remove_property("transition"); + + ctx.content_animate.set(true); + ctx.overlay_animate.set(true); + clear_wrapper_styles(); + + if let Some(previous) = ctx.previous_active_element.get_untracked() + && let Ok(html_element) = previous.dyn_into::() + { + let _ = html_element.focus(); + } + ctx.previous_active_element.set(None); + }, + Duration::from_millis(TRANSITION_DURATION_MS), + ); +} diff --git a/e2e/tests/components/drawer.spec.ts b/e2e/tests/components/drawer.spec.ts index 37d554c..52c5546 100644 --- a/e2e/tests/components/drawer.spec.ts +++ b/e2e/tests/components/drawer.spec.ts @@ -89,10 +89,15 @@ class DrawerPage extends BasePage { this.closeButton = this.drawerContent.getByRole("button", { name: "Close" }); // All instances - triggers in preview, content in portal - this.allTriggers = this.preview.getByRole("button", { name: "Open Drawer" }); + this.allTriggers = page.getByRole("button", { name: "Open Drawer" }); this.allDrawerContents = page.locator('[data-name="DrawerContent"]'); } + override async goto(section?: string) { + await super.goto(section); + await this.page.waitForLoadState("networkidle"); + } + async openDrawer() { await this.triggerButton.click(); await this.waitForDataState(this.drawerContent, "open"); @@ -668,8 +673,10 @@ test.describe("Drawer Page", () => { await sideTrigger.click(); // Use first() for the Inset variant (not Floating) - const rightDrawer = page.locator( - '[data-vaul-drawer-position="Right"][data-vaul-variant="Inset"]' + const rightDrawer = ui.getNthDrawerContent(5); + await expect(rightDrawer).toHaveAttribute( + "data-vaul-drawer-position", + "Right" ); await expect(rightDrawer).toHaveAttribute("data-state", "open"); }); diff --git a/public/components/vaul_drawer.css b/public/components/vaul_drawer.css deleted file mode 100644 index 67b7f85..0000000 --- a/public/components/vaul_drawer.css +++ /dev/null @@ -1,271 +0,0 @@ -/* ===== VAUL DRAWER STYLES ===== */ -[data-vaul-drawer] { - touch-action: none; - will-change: transform; - transition: transform 0.5s cubic-bezier(0.32, 0.72, 0, 1); - animation-duration: 0.5s; - animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); -} - -[data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-position='Bottom'][data-state='open'] { - animation-name: slideFromBottom; -} -[data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-position='Bottom'][data-state='closed'] { - animation-name: slideToBottom; -} - -[data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-position='Left'][data-state='open'] { - animation-name: slideFromLeft; -} -[data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-position='Left'][data-state='closed'] { - animation-name: slideToLeft; -} - -[data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-position='Right'][data-state='open'] { - animation-name: slideFromRight; -} -[data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-position='Right'][data-state='closed'] { - animation-name: slideToRight; -} - -[data-vaul-drawer][data-vaul-snap-points='true'][data-vaul-drawer-position='Bottom'] { - transform: translate3d(0, var(--initial-transform, 100%), 0); -} - -[data-vaul-drawer][data-vaul-snap-points='true'][data-vaul-drawer-position='Top'] { - transform: translate3d(0, calc(var(--initial-transform, 100%) * -1), 0); -} - -[data-vaul-drawer][data-vaul-snap-points='true'][data-vaul-drawer-position='Left'] { - transform: translate3d(calc(var(--initial-transform, 100%) * -1), 0, 0); -} - -[data-vaul-drawer][data-vaul-snap-points='true'][data-vaul-drawer-position='Right'] { - transform: translate3d(var(--initial-transform, 100%), 0, 0); -} - -[data-vaul-drawer][data-vaul-delayed-snap-points='true'][data-vaul-drawer-position='Top'] { - transform: translate3d(0, var(--snap-point-height, 0), 0); -} - -[data-vaul-drawer][data-vaul-delayed-snap-points='true'][data-vaul-drawer-position='Bottom'] { - transform: translate3d(0, var(--snap-point-height, 0), 0); -} - -[data-vaul-drawer][data-vaul-delayed-snap-points='true'][data-vaul-drawer-position='Left'] { - transform: translate3d(var(--snap-point-height, 0), 0, 0); -} - -[data-vaul-drawer][data-vaul-delayed-snap-points='true'][data-vaul-drawer-position='Right'] { - transform: translate3d(var(--snap-point-height, 0), 0, 0); -} - -[data-vaul-overlay][data-vaul-snap-points='false'] { - animation-duration: 0.5s; - animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); -} -[data-vaul-overlay][data-vaul-snap-points='false'][data-state='open'] { - animation-name: fadeIn; -} -[data-vaul-overlay][data-state='closed'] { - animation-name: fadeOut; -} - -[data-vaul-animate='false'] { - animation: none !important; -} - -[data-vaul-overlay][data-vaul-snap-points='true'] { - opacity: 0; - transition: opacity 0.5s cubic-bezier(0.32, 0.72, 0, 1); -} - -[data-vaul-overlay][data-vaul-snap-points='true'] { - opacity: 1; -} - -[data-vaul-drawer]:not([data-vaul-variant='Floating'])::after { - content: ''; - position: absolute; - background: inherit; - background-color: inherit; -} - -[data-vaul-drawer][data-vaul-drawer-position='Top']::after { - top: initial; - bottom: 100%; - left: 0; - right: 0; - height: 200%; -} - -[data-vaul-drawer][data-vaul-drawer-position='Bottom']::after { - top: 100%; - bottom: initial; - left: 0; - right: 0; - height: 200%; -} - -[data-vaul-drawer][data-vaul-drawer-position='Left']::after { - left: initial; - right: 100%; - top: 0; - bottom: 0; - width: 200%; -} - -[data-vaul-drawer][data-vaul-drawer-position='Right']::after { - left: 100%; - right: initial; - top: 0; - bottom: 0; - width: 200%; -} - -[data-vaul-overlay][data-vaul-snap-points='true']:not([data-vaul-snap-points-overlay='true']):not( - [data-state='closed'] - ) { - opacity: 0; -} - -[data-vaul-overlay][data-vaul-snap-points-overlay='true'] { - opacity: 1; -} - -[data-vaul-handle] { - touch-action: pan-y; -} - - -[data-vaul-handle-hitarea] { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - width: max(100%, 2.75rem); /* 44px */ - height: max(100%, 2.75rem); /* 44px */ - touch-action: inherit; -} - -/* Removed user-select: none to allow text selection in drawer content. - Text selection is now prevented via JavaScript only when actively dragging. */ - -@media (pointer: fine) { - [data-vaul-handle-hitarea]: { - width: 100%; - height: 100%; - } -} - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes fadeOut { - to { - opacity: 0; - } -} - -@keyframes slideFromBottom { - from { - transform: translate3d(0, var(--initial-transform, 100%), 0); - } - to { - transform: translate3d(0, 0, 0); - } -} - -@keyframes slideToBottom { - to { - transform: translate3d(0, var(--initial-transform, 100%), 0); - } -} - -@keyframes slideFromLeft { - from { - transform: translate3d(calc(var(--initial-transform, 100%) * -1), 0, 0); - } - to { - transform: translate3d(0, 0, 0); - } -} - -@keyframes slideToLeft { - to { - transform: translate3d(calc(var(--initial-transform, 100%) * -1), 0, 0); - } -} - -@keyframes slideFromRight { - from { - transform: translate3d(var(--initial-transform, 100%), 0, 0); - } - to { - transform: translate3d(0, 0, 0); - } -} - -@keyframes slideToRight { - to { - transform: translate3d(var(--initial-transform, 100%), 0, 0); - } -} - -/* Custom drawer styling */ -[data-name='DrawerContent'][data-state='closed'] { - transform: translate3d(0, 100%, 0); -} - -body[data-state="open"] { - overflow: hidden; -} - - - - -/* ===== ADDITIONAL DRAWER CLASSES ===== */ -/* ===== ADDITIONAL DRAWER CLASSES ===== */ -/* ===== ADDITIONAL DRAWER CLASSES ===== */ -/* ===== ADDITIONAL DRAWER CLASSES ===== */ -/* ===== ADDITIONAL DRAWER CLASSES ===== */ - -/* -* IMPORTANT: Ensure hidden class takes priority -* Fixed issue with parent that was flex. -* And also fix scrollbar padding when animation triggered 😍😍😍 -*/ -[data-name='DrawerContent'].hidden { - display: none !important; -} - -/* * Nested drawer styling - higher z-index */ -.drawer__nested { - z-index: 220; -} - -/* * Nested drawer overlay - higher z-index */ -.overlay__nested { - z-index: 215; -} - - - -/* ===== FLOATING SIDE DRAWER ===== */ -/* ===== FLOATING SIDE DRAWER ===== */ -/* ===== FLOATING SIDE DRAWER ===== */ -/* ===== FLOATING SIDE DRAWER ===== */ - -/* Floating drawer closed state - accounts for 8px margin */ -[data-vaul-variant='Floating'][data-state='closed'] { - transform: translate3d(calc(100% + 8px), 0, 0); -} - - - diff --git a/public/components/vaul_drawer.js b/public/components/vaul_drawer.js deleted file mode 100644 index 02b7845..0000000 --- a/public/components/vaul_drawer.js +++ /dev/null @@ -1,473 +0,0 @@ -// Constants -const VELOCITY_THRESHOLD = 0.4; -const CLOSE_THRESHOLD = 0.25; -const TRANSITION_DURATION = 500; // ms -const WINDOW_TOP_OFFSET = 26; // px -const BORDER_RADIUS = 8; // px -const SCROLL_LOCK_TIMEOUT = 500; // ms - prevents dragging after scrolling - -// Helper to calculate scale -function getScale() { - return (window.innerWidth - WINDOW_TOP_OFFSET) / window.innerWidth; -} - -// Damping function - adds resistance when dragging beyond limits -function dampenValue(v) { - return 8 * (Math.log(v + 1) - 2); -} - -// Initialize a single drawer instance -function initDrawerInstance(drawer, index) { - // Find the trigger and close button for this drawer - // We look for the trigger that comes before this drawer in the DOM - const allTriggers = Array.from( - document.querySelectorAll('[data-name="DrawerTrigger"]'), - ); - const trigger = allTriggers[index]; - - // Find shared overlay - const overlay = document.querySelector('[data-name="DrawerOverlay"]'); - const closeBtn = drawer.querySelector('[data-name="DrawerClose"]'); - const wrapper = document.querySelector("[data-vaul-drawer-wrapper]"); - - // Detect drawer direction and properties - const position = drawer.getAttribute("data-vaul-drawer-position") || "Bottom"; - const isHorizontal = position === "Left" || position === "Right"; - const isFloating = drawer.getAttribute("data-vaul-variant") === "Floating"; - const isDismissible = drawer.getAttribute("data-vaul-dismissible") !== "false"; - const lockBodyScroll = overlay?.getAttribute("data-lock-body-scroll") !== "false"; - - // State for this drawer instance - let isOpen = false; - let isDragging = false; - let startPos = 0; - let currentPos = 0; - let drawerSize = 0; - let dragStartTime = 0; - let previousActiveElement = null; - let openTime = null; - let lastTimeDragPrevented = null; - let isAllowedToDrag = false; - - // Get all focusable elements in the drawer for tab trapping - function getFocusableElements() { - const focusableSelectors = [ - "a[href]", - "button:not([disabled])", - "textarea:not([disabled])", - "input:not([disabled])", - "select:not([disabled])", - '[tabindex]:not([tabindex="-1"])', - ].join(", "); - - return Array.from(drawer.querySelectorAll(focusableSelectors)); - } - - // Check if element should allow dragging (Vaul's scroll detection logic) - function shouldDrag(target) { - let element = target; - const currentDate = Date.now(); - - if (openTime && currentDate - openTime < 500) { - return false; - } - - if ( - lastTimeDragPrevented && - currentDate - lastTimeDragPrevented < SCROLL_LOCK_TIMEOUT - ) { - lastTimeDragPrevented = currentDate; - return false; - } - - while (element && element !== drawer) { - if (element.scrollHeight > element.clientHeight) { - if (element.scrollTop !== 0) { - lastTimeDragPrevented = currentDate; - return false; - } - - if (element.getAttribute("role") === "dialog") { - return true; - } - } - - element = element.parentElement; - } - - return true; - } - - // Fix drawer position to viewport bottom - // * This allows us to have the data-vaul-drawer-wrapper="" in app.rs and - // * thus to make the depth effect to the entire app. - function fixDrawerPosition() { - // Return early if there's no scroll on the page (no need to fix position) - const hasScroll = document.documentElement.scrollHeight > window.innerHeight; - if (!hasScroll) { - return; - } - - const viewportHeight = window.innerHeight; - const drawerRect = drawer.getBoundingClientRect(); - - // Calculate where the drawer should be (at viewport bottom) - const targetBottom = viewportHeight; - const currentBottom = drawerRect.bottom; - - // Calculate offset needed - const offset = targetBottom - currentBottom; - - // Apply position fix using top CSS property - if (offset !== 0) { - const currentTop = drawerRect.top; - drawer.style.top = `${currentTop + offset}px`; - } - } - - // Open drawer - function openDrawer() { - // If drawer is already open, don't re-run open logic - // Content updates are handled by Leptos reactivity - if (isOpen) { - return; - } - - isOpen = true; - openTime = Date.now(); - - previousActiveElement = document.activeElement; - - if (isFloating) { - overlay.style.opacity = "1"; - } - - overlay.classList.remove("hidden"); - drawer.classList.remove("hidden"); - - // Conditionally lock body scroll - if (lockBodyScroll) { - // Calculate scrollbar width for compensation before body overflow changes - const body = document.body; - const scrollbarWidth = window.innerWidth - body.clientWidth; - - document.body.setAttribute("data-state", "open"); - - // Add padding-right to body to compensate for scrollbar removal - if (scrollbarWidth > 0) { - body.style.paddingRight = `${scrollbarWidth}px`; - } - } - - // Fix position to viewport bottom to have data-vaul-drawer-wrapper="" accessible from app.rs - // Only fix position when body scroll is locked - if (lockBodyScroll) { - fixDrawerPosition(); - } - - if (wrapper) { - const scale = getScale(); - wrapper.style.transformOrigin = "top"; - wrapper.style.transitionProperty = "transform, border-radius"; - wrapper.style.transitionDuration = "0.5s"; - wrapper.style.transitionTimingFunction = "cubic-bezier(0.32, 0.72, 0, 1)"; - wrapper.style.borderRadius = `${BORDER_RADIUS}px`; - wrapper.style.overflow = "hidden"; - wrapper.style.transform = `scale(${scale}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)`; - - document.body.style.background = "black"; - } - - requestAnimationFrame(() => { - if (isFloating) { - overlay.setAttribute("data-vaul-animate", "false"); - } - - overlay.setAttribute("data-state", "open"); - drawer.setAttribute("data-state", "open"); - - setTimeout(() => { - const focusableElements = getFocusableElements(); - if (focusableElements.length > 0) { - const isOnlyCloseButton = - focusableElements.length === 1 && - focusableElements[0].getAttribute("data-name") === "DrawerClose"; - - if (isOnlyCloseButton) { - drawer.focus(); - } else { - focusableElements[0].focus(); - } - } - }, 100); - - setTimeout(() => {}, TRANSITION_DURATION); - }); - } - - // Close drawer - function closeDrawer() { - isOpen = false; - - drawerSize = isHorizontal - ? drawer.getBoundingClientRect().width - : drawer.getBoundingClientRect().height; - - drawer.setAttribute("data-vaul-animate", "false"); - overlay.setAttribute("data-vaul-animate", "false"); - - drawer.style.transition = "transform 0.5s cubic-bezier(0.32, 0.72, 0, 1)"; - - let closeTransform; - if (isHorizontal) { - const closeDistance = isFloating ? drawerSize + 8 : drawerSize; - const xValue = position === "Right" ? closeDistance : -closeDistance; - closeTransform = `translate3d(${xValue}px, 0, 0)`; - } else { - const yValue = drawerSize; - closeTransform = `translate3d(0, ${yValue}px, 0)`; - } - drawer.style.transform = closeTransform; - - overlay.style.transition = "opacity 0.5s cubic-bezier(0.32, 0.72, 0, 1)"; - overlay.style.opacity = "0"; - - if (wrapper) { - wrapper.style.transition = - "transform 0.5s cubic-bezier(0.32, 0.72, 0, 1), border-radius 0.5s cubic-bezier(0.32, 0.72, 0, 1)"; - wrapper.style.transform = "scale(1) translate3d(0, 0, 0)"; - wrapper.style.borderRadius = "0px"; - } - - overlay.setAttribute("data-state", "closed"); - drawer.setAttribute("data-state", "closed"); - - // Conditionally unlock body scroll - if (lockBodyScroll) { - document.body.removeAttribute("data-state"); - document.body.style.paddingRight = ""; // Reset scrollbar compensation immediately - } - - setTimeout(() => { - overlay.classList.add("hidden"); - drawer.classList.add("hidden"); - - drawer.style.transform = ""; - drawer.style.transition = ""; - drawer.style.top = ""; // Reset top position - overlay.style.opacity = ""; - overlay.style.transition = ""; - drawer.setAttribute("data-vaul-animate", "true"); - overlay.setAttribute("data-vaul-animate", "true"); - - if (wrapper) { - wrapper.style.overflow = ""; - document.body.style.background = ""; - } - - if (previousActiveElement && typeof previousActiveElement.focus === "function") { - previousActiveElement.focus(); - previousActiveElement = null; - } - }, TRANSITION_DURATION); - } - - // Handle pointer down - function onPointerDown(event) { - if (!isOpen) return; - - if (!drawer.contains(event.target)) return; - - // Don't interfere with close button clicks - const isCloseButton = event.target.closest('[data-name="DrawerClose"]'); - if (isCloseButton) return; - - isDragging = true; - isAllowedToDrag = false; - startPos = isHorizontal ? event.pageX : event.pageY; - currentPos = startPos; - dragStartTime = Date.now(); - drawerSize = isHorizontal - ? drawer.getBoundingClientRect().width - : drawer.getBoundingClientRect().height; - - drawer.style.transition = "none"; - - drawer.setPointerCapture(event.pointerId); - } - - // Handle pointer move - function onPointerMove(event) { - if (!isDragging) return; - - currentPos = isHorizontal ? event.pageX : event.pageY; - const delta = currentPos - startPos; - - let isDraggingInClosingDirection = false; - if (position === "Bottom" || position === "Right") { - isDraggingInClosingDirection = delta > 0; - } else { - isDraggingInClosingDirection = delta < 0; - } - - if (!isAllowedToDrag && !shouldDrag(event.target)) { - return; - } - - isAllowedToDrag = true; - - if (isDraggingInClosingDirection) { - const absDelta = Math.abs(delta); - const transform = isHorizontal - ? `translate3d(${delta}px, 0, 0)` - : `translate3d(0, ${delta}px, 0)`; - drawer.style.transform = transform; - - if (wrapper) { - const percentageDragged = Math.min(absDelta / drawerSize, 1); - - const scaleValue = Math.min(getScale() + percentageDragged * (1 - getScale()), 1); - - const borderRadiusValue = Math.max( - 0, - BORDER_RADIUS - percentageDragged * BORDER_RADIUS, - ); - - const translateValue = Math.max(0, 14 - percentageDragged * 14); - - wrapper.style.transition = "none"; - wrapper.style.borderRadius = `${borderRadiusValue}px`; - wrapper.style.transform = `scale(${scaleValue}) translate3d(0, ${translateValue}px, 0)`; - } - - if (overlay) { - const percentageDragged = Math.min(absDelta / drawerSize, 1); - const opacityValue = Math.max(0, 1 - percentageDragged); - overlay.style.transition = "none"; - overlay.style.opacity = String(opacityValue); - } - } else { - const absDelta = Math.abs(delta); - const dampedDelta = dampenValue(absDelta); - const signedDampedDelta = delta > 0 ? dampedDelta : -dampedDelta; - const transform = isHorizontal - ? `translate3d(${signedDampedDelta}px, 0, 0)` - : `translate3d(0, ${-dampedDelta}px, 0)`; - drawer.style.transform = transform; - } - } - - // Handle pointer up - function onPointerUp(event) { - if (!isDragging) return; - - isDragging = false; - const delta = currentPos - startPos; - const dragEndTime = Date.now(); - const timeTaken = dragEndTime - dragStartTime; - const velocity = Math.abs(delta) / timeTaken; - - drawer.style.transition = "transform 0.5s cubic-bezier(0.32, 0.72, 0, 1)"; - - if (wrapper) { - wrapper.style.transition = - "transform 0.5s cubic-bezier(0.32, 0.72, 0, 1), border-radius 0.5s cubic-bezier(0.32, 0.72, 0, 1)"; - } - - if (overlay) { - overlay.style.transition = "opacity 0.5s cubic-bezier(0.32, 0.72, 0, 1)"; - } - - let shouldClose = false; - if (position === "Bottom" || position === "Right") { - shouldClose = - (velocity > VELOCITY_THRESHOLD || delta / drawerSize >= CLOSE_THRESHOLD) && - delta > 0; - } else { - shouldClose = - (velocity > VELOCITY_THRESHOLD || - Math.abs(delta) / drawerSize >= CLOSE_THRESHOLD) && - delta < 0; - } - - if (shouldClose) { - closeDrawer(); - } else { - drawer.style.transform = "translate3d(0, 0, 0)"; - - if (wrapper) { - const scale = getScale(); - wrapper.style.borderRadius = `${BORDER_RADIUS}px`; - wrapper.style.transform = `scale(${scale}) translate3d(0, 14px, 0)`; - } - - if (overlay) { - overlay.style.opacity = "1"; - } - } - - if (drawer.hasPointerCapture(event.pointerId)) { - drawer.releasePointerCapture(event.pointerId); - } - } - - // Keyboard event handler - function handleKeyDown(event) { - if (!isOpen) return; - - if (event.key === "Escape") { - event.preventDefault(); - closeDrawer(); - return; - } - - if (event.key === "Tab") { - const focusableElements = getFocusableElements(); - if (focusableElements.length === 0) return; - - const firstElement = focusableElements[0]; - const lastElement = focusableElements[focusableElements.length - 1]; - - if (event.shiftKey && document.activeElement === firstElement) { - event.preventDefault(); - lastElement.focus(); - } else if (!event.shiftKey && document.activeElement === lastElement) { - event.preventDefault(); - firstElement.focus(); - } - } - } - - // Event listeners - if (trigger) { - trigger.addEventListener("click", openDrawer); - } - - if (closeBtn) { - closeBtn.addEventListener("click", closeDrawer); - } - - // Only add dismissible features if enabled - if (isDismissible) { - overlay.addEventListener("click", closeDrawer); - - drawer.addEventListener("pointerdown", onPointerDown); - drawer.addEventListener("pointermove", onPointerMove); - drawer.addEventListener("pointerup", onPointerUp); - drawer.addEventListener("pointercancel", onPointerUp); - - document.addEventListener("keydown", handleKeyDown); - - drawer.addEventListener("selectstart", (event) => { - if (isDragging && isAllowedToDrag) { - event.preventDefault(); - } - }); - } -} - -// Initialize all drawer instances -const allDrawers = document.querySelectorAll('[data-name="DrawerContent"]'); -allDrawers.forEach((drawer, index) => { - initDrawerInstance(drawer, index); -}); diff --git a/public/registry/styles/default/demo_drawer_family.md b/public/registry/styles/default/demo_drawer_family.md index 7ce4409..1c8e97f 100644 --- a/public/registry/styles/default/demo_drawer_family.md +++ b/public/registry/styles/default/demo_drawer_family.md @@ -39,19 +39,19 @@ pub fn DemoDrawerFamily() -> impl IntoView {

"Options"

- - diff --git a/public/registry/styles/default/demo_drawer_non_dismissable.md b/public/registry/styles/default/demo_drawer_non_dismissable.md index 964cbed..8bc1a9d 100644 --- a/public/registry/styles/default/demo_drawer_non_dismissable.md +++ b/public/registry/styles/default/demo_drawer_non_dismissable.md @@ -36,7 +36,7 @@ pub fn DemoDrawerNonDismissable() -> impl IntoView { "Open Drawer" - + diff --git a/public/registry/styles/default/drawer.md b/public/registry/styles/default/drawer.md index ca512f7..3d964ec 100644 --- a/public/registry/styles/default/drawer.md +++ b/public/registry/styles/default/drawer.md @@ -34,7 +34,7 @@ use crate::components::ui::button::{Button, ButtonSize, ButtonVariant}; mod components { use super::*; clx! {DrawerBody, div, "flex flex-col gap-4 mx-auto max-w-[500px]"} - clx! {DrawerTitle, h3, "text-lg leading-none font-semibold"} + clx! {DrawerTitle, h2, "text-lg leading-none font-semibold"} clx! {DrawerDescription, p, "text-sm text-muted-foreground"} clx! {DrawerHeader, div, "flex flex-col gap-2"} clx! {DrawerFooter, footer, "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"} @@ -42,6 +42,25 @@ mod components { pub use components::*; +#[derive(Clone)] +pub struct DrawerContext { + open: RwSignal, +} + +impl DrawerContext { + pub fn open(&self) { + if !self.open.get_untracked() { + self.open.set(true); + } + } + + pub fn close(&self) { + if self.open.get_untracked() { + self.open.set(false); + } + } +} + #[component] pub fn DrawerTrigger( children: Children, @@ -49,8 +68,20 @@ pub fn DrawerTrigger( #[prop(default = ButtonVariant::Outline)] variant: ButtonVariant, #[prop(default = ButtonSize::Default)] size: ButtonSize, ) -> impl IntoView { + let ctx = use_context::(); + view! { - } @@ -63,8 +94,20 @@ pub fn DrawerClose( #[prop(default = ButtonVariant::Outline)] variant: ButtonVariant, #[prop(default = ButtonSize::Default)] size: ButtonSize, ) -> impl IntoView { + let ctx = use_context::(); + view! { - } @@ -80,25 +123,52 @@ pub fn Drawer( #[prop(optional, default = true)] show_overlay: bool, #[prop(optional, default = true)] lock_body_scroll: bool, ) -> impl IntoView { - let overlay_class = if show_overlay { "hidden fixed inset-0 z-200 bg-black/50" } else { "!hidden" }; - let lock_scroll_attr = if lock_body_scroll { "true" } else { "false" }; + let overlay_ref = NodeRef::::new(); + let content_ref = NodeRef::::new(); + let hidden = RwSignal::new(true); + let state = RwSignal::new("closed"); + let content_animate = RwSignal::new(true); + let overlay_animate = RwSignal::new(true); + let dismissible = RwSignal::new(true); + let previous_active_element = RwSignal::new(None::); + let animation_epoch = RwSignal::new(0_u64); + let open = RwSignal::new(false); + + provide_context(DrawerContext { + open, + overlay_ref, + content_ref, + hidden, + state, + content_animate, + overlay_animate, + dismissible, + lock_body_scroll, + previous_active_element, + animation_epoch, + }); view! { - +
{children()} - - } } @@ -124,7 +194,7 @@ pub fn DrawerContent( #[prop(optional, default = DrawerPosition::default())] position: DrawerPosition, #[prop(optional, default = DrawerVariant::default())] variant: DrawerVariant, #[prop(into, default = "--initial-transform: 100%;".to_string())] style: String, - #[prop(into, default = "true".to_string())] dismissible: String, + #[prop(optional, default = true)] dismissible: bool, ) -> impl IntoView { let merged_class = tw_merge!( "flex flex-col pt-3 pb-6 px-6 hidden fixed right-0 bottom-0 left-0 z-210 bg-background max-h-[96vh] rounded-t-[10px]", From 26843e67a13517870b94e69ac442467cf8b19a1f Mon Sep 17 00:00:00 2001 From: krishpranav Date: Sun, 19 Apr 2026 11:27:57 +0530 Subject: [PATCH 2/2] drawer: restore external CSS and tighten vaul parity Move drawer styles back to public/components/vaul_drawer.css and load it from shell. Remove inline style constant, keep animate/drag behavior aligned with the original vaul runtime, and fix timeout typing cleanup. --- app/src/shell.rs | 2 + app_crates/registry/src/ui/drawer.rs | 139 +---------------------- public/components/vaul_drawer.css | 130 +++++++++++++++++++++ public/registry/styles/default/drawer.md | 2 - 4 files changed, 134 insertions(+), 139 deletions(-) create mode 100644 public/components/vaul_drawer.css diff --git a/app/src/shell.rs b/app/src/shell.rs index e50f0b6..b5d7904 100644 --- a/app/src/shell.rs +++ b/app/src/shell.rs @@ -138,6 +138,8 @@ pub fn shell(options: LeptosOptions) -> impl IntoView { + + // Load scripts (async for non-blocking parallel download, executes as soon as ready) diff --git a/app_crates/registry/src/ui/drawer.rs b/app_crates/registry/src/ui/drawer.rs index 1931196..764f13c 100644 --- a/app_crates/registry/src/ui/drawer.rs +++ b/app_crates/registry/src/ui/drawer.rs @@ -16,142 +16,9 @@ const TRANSITION_EASING: &str = "cubic-bezier(0.32, 0.72, 0, 1)"; const WINDOW_TOP_OFFSET: f64 = 26.0; const BORDER_RADIUS: f64 = 8.0; const WRAPPER_TRANSLATE_Y: f64 = 14.0; -const SCROLL_LOCK_TIMEOUT_MS: f64 = 500.0; +const SCROLL_LOCK_TIMEOUT_MS: u32 = 500; const FOCUS_DELAY_MS: u64 = 100; -const DRAWER_STYLE: &str = r#" -[data-vaul-drawer] { - touch-action: none; - will-change: transform; - transition: transform 0.5s cubic-bezier(0.32, 0.72, 0, 1); - animation-duration: 0.5s; - animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); -} - -[data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-position='Bottom'][data-state='open'] { - animation-name: slideFromBottom; -} - -[data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-position='Bottom'][data-state='closed'] { - animation-name: slideToBottom; -} - -[data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-position='Left'][data-state='open'] { - animation-name: slideFromLeft; -} - -[data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-position='Left'][data-state='closed'] { - animation-name: slideToLeft; -} - -[data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-position='Right'][data-state='open'] { - animation-name: slideFromRight; -} - -[data-vaul-drawer][data-vaul-snap-points='false'][data-vaul-drawer-position='Right'][data-state='closed'] { - animation-name: slideToRight; -} - -[data-vaul-overlay][data-vaul-snap-points='false'] { - animation-duration: 0.5s; - animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1); -} - -[data-vaul-overlay][data-vaul-snap-points='false'][data-state='open'] { - animation-name: fadeIn; -} - -[data-vaul-overlay][data-state='closed'] { - animation-name: fadeOut; -} - -[data-vaul-animate='false'] { - animation: none !important; -} - -[data-vaul-drawer]:not([data-vaul-variant='Floating'])::after { - content: ''; - position: absolute; - background: inherit; - background-color: inherit; -} - -[data-vaul-drawer][data-vaul-drawer-position='Bottom']::after { - top: 100%; - left: 0; - right: 0; - height: 200%; -} - -[data-vaul-drawer][data-vaul-drawer-position='Left']::after { - right: 100%; - top: 0; - bottom: 0; - width: 200%; -} - -[data-vaul-drawer][data-vaul-drawer-position='Right']::after { - left: 100%; - top: 0; - bottom: 0; - width: 200%; -} - -[data-vaul-handle] { - touch-action: pan-y; -} - -[data-vaul-handle-hitarea] { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - width: max(100%, 2.75rem); - height: max(100%, 2.75rem); - touch-action: inherit; -} - -[data-vaul-variant='Floating'][data-vaul-drawer-position='Right'][data-state='closed'] { - transform: translate3d(calc(100% + 8px), 0, 0); -} - -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes fadeOut { - to { opacity: 0; } -} - -@keyframes slideFromBottom { - from { transform: translate3d(0, var(--initial-transform, 100%), 0); } - to { transform: translate3d(0, 0, 0); } -} - -@keyframes slideToBottom { - to { transform: translate3d(0, var(--initial-transform, 100%), 0); } -} - -@keyframes slideFromLeft { - from { transform: translate3d(calc(var(--initial-transform, 100%) * -1), 0, 0); } - to { transform: translate3d(0, 0, 0); } -} - -@keyframes slideToLeft { - to { transform: translate3d(calc(var(--initial-transform, 100%) * -1), 0, 0); } -} - -@keyframes slideFromRight { - from { transform: translate3d(var(--initial-transform, 100%), 0, 0); } - to { transform: translate3d(0, 0, 0); } -} - -@keyframes slideToRight { - to { transform: translate3d(var(--initial-transform, 100%), 0, 0); } -} -"#; - mod components { use super::*; clx! {DrawerBody, div, "flex flex-col gap-4 mx-auto max-w-[500px]"} @@ -294,8 +161,6 @@ pub fn Drawer( }; view! { - -
{DRAWER_STYLE} -