Skip to content

Drawer rust port and demo fixes#31

Merged
max-wells merged 2 commits intorust-ui:mainfrom
krishpranav:drawer-rust-port-and-demo-fixes
Apr 20, 2026
Merged

Drawer rust port and demo fixes#31
max-wells merged 2 commits intorust-ui:mainfrom
krishpranav:drawer-rust-port-and-demo-fixes

Conversation

@krishpranav
Copy link
Copy Markdown
Contributor

@krishpranav krishpranav commented Apr 18, 2026

Description:

This branch ports the drawer runtime from JS to Rust/Leptos and keeps the docs page runnable at http://127.0.0.1:3000/docs/components/drawer. The main behavior now lives in app_crates/registry/src/ui/drawer.rs via DrawerContext + signals, not through injected <script>/<link> assets. DrawerTrigger and DrawerClose call context methods directly, and pointer drag, open/close sequencing, focus handling, keyboard handling, overlay/body-scroll behavior, and position/variant logic are handled in Rust.

Demo + Visual Fixes:

  • Docs demo page: http://127.0.0.1:3000/docs/components/drawer
  • Family demo readability fix was applied (the exact issue from your screenshot):
    • app_crates/registry/src/demos/demo_drawer_family.rs
    • public/registry/styles/default/demo_drawer_family.md
  • Fix: action rows now use explicit dark text on light surfaces (text-neutral-900) and close icon contrast was increased (text-neutral-700)

Check list:

  • DrawerContext + Rust open/close state: implemented
  • DrawerTrigger/DrawerClose wired to context: implemented
  • Open/close animation sequencing in Rust (request_animation_frame + set_timeout): implemented
  • Drag-to-dismiss thresholds and pointer capture in Rust: implemented
  • Escape + Tab/Shift+Tab handling through window listener pattern: implemented
  • Props preserved (position, variant, dismissible, lock_body_scroll, show_overlay): present in drawer.rs
  • JS/CSS drawer runtime files removed: done (public/app/vaul_drawer.js, public/app/vaul_drawer.css)
  • Registry drawer snapshot updated: done

Validation Done in This Pass:

  • cargo build: pass
  • cargo clippy -- -D warnings: pass

closes #30

@krishpranav krishpranav force-pushed the drawer-rust-port-and-demo-fixes branch from 4c7236a to 28bdeb7 Compare April 18, 2026 13:23
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.
@krishpranav krishpranav force-pushed the drawer-rust-port-and-demo-fixes branch from 28bdeb7 to 6310eae Compare April 18, 2026 13:25
Copy link
Copy Markdown
Contributor

@max-wells max-wells left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @krishpranav — really solid PR overall! Removing the JS/CSS runtime and moving everything into Rust is exactly the right direction for this project. The checklist in the description made review much easier too, thanks for that.

Three things to address before merge:


1. CSS — keep it as a standalone file

Rather than embedding the styles as a DRAWER_STYLE const in the .rs file, let's keep it as a standalone CSS file — easier to edit without a recompile and consistent with how we handle sonner.css.

Please:

  • Restore public/components/vaul_drawer.css with the CSS content
  • Remove the DRAWER_STYLE const from drawer.rs
  • Add these two lines to app/src/shell.rs (right after the sonner.css block):
<link rel="preload" href="/components/vaul_drawer.css" r#as="style" />
<link rel="stylesheet" href="/components/vaul_drawer.css" media="print" onload="this.media='all'" />

2. Event listener cleanup — raw Closure lifetime

Raw wasm_bindgen::Closure values must be explicitly kept alive — if they're dropped early, the event listener silently becomes a no-op. Without proper cleanup you'll also get stale listeners accumulating if the drawer unmounts and remounts.

For any window.add_event_listener_with_callback calls using a manual Closure, either:

  • Switch to window_event_listener(ev::keydown, ...) which you're already using elsewhere in this file (Leptos handles cleanup automatically), or
  • Store the Closure and register on_cleanup(move || { ... remove_event_listener ... }) to drop it properly

3. Minor — SCROLL_LOCK_TIMEOUT_MS type

This is typed as f64 but used as a duration. Should be u32, or just use Duration::from_millis(500) directly at the call site.


Once those are addressed this is good to merge!

@max-wells
Copy link
Copy Markdown
Contributor

@krishpranav — did a deeper pass comparing the Rust port line-by-line against the original vaul_drawer.js. The goal is a 100% behavioral match — everything the JS did, the Rust must do too. Here are the gaps I found:


1. dampenValue — drag resistance in the non-closing direction

The JS has a logarithmic damping function applied when the user drags against the closing direction (e.g. pulling a bottom drawer upward):

function dampenValue(v) {
  return 8 * (Math.log(v + 1) - 2);
}

Without this, dragging the wrong way will feel either stiff or uncapped. The Rust port needs an equivalent applied in the pointerMove path when isDraggingInClosingDirection is false.


2. shouldDrag — scroll detection with DOM walk

The JS walks up the DOM tree from the pointer target checking overflow and scrollTop to detect if the element (or any ancestor) is scrollable. It also has two guards:

  • A 500ms grace period after open (openTime) — no drag allowed immediately after the drawer opens
  • A lastTimeDragPrevented cooldown — if the user was scrolling recently, drag is suppressed for SCROLL_LOCK_TIMEOUT

Without this full logic, drag-to-dismiss will incorrectly fire when the user is trying to scroll content inside the drawer. This is probably the most important behavioral gap.


3. Wrapper scaling — the "app shrinks behind the drawer" effect

On open, JS scales the [data-vaul-drawer-wrapper] element to create the signature visual where the app content shrinks behind the drawer:

wrapper.style.transformOrigin = "top";
wrapper.style.transitionDuration = "0.5s";
wrapper.style.transitionTimingFunction = "cubic-bezier(0.32, 0.72, 0, 1)";
wrapper.style.borderRadius = `8px`;
wrapper.style.overflow = "hidden";
wrapper.style.transform = `scale(${scale}) translate3d(0, calc(env(safe-area-inset-top) + 14px), 0)`;
document.body.style.background = "black";

On close, all of these are reset. If this is missing, the bottom drawer loses its core visual identity entirely.


4. Scrollbar width compensation on body scroll lock

Before setting body overflow: hidden, the JS calculates the scrollbar width and compensates:

const scrollbarWidth = window.innerWidth - body.clientWidth;
if (scrollbarWidth > 0) {
  body.style.paddingRight = `${scrollbarWidth}px`;
}

Without this, locking the body scroll causes a visible layout shift as the scrollbar disappears. Must be reset on close.


5. previousActiveElement — restore focus on close

The JS saves document.activeElement when the drawer opens, and restores focus to it after the close animation completes:

// on open:
previousActiveElement = document.activeElement;

// on close (after timeout):
if (previousActiveElement && typeof previousActiveElement.focus === "function") {
  previousActiveElement.focus();
  previousActiveElement = null;
}

Without this, keyboard and screen reader users lose their position in the page after dismissing the drawer.


6. Overlay opacity during drag

During pointerMove, the JS dynamically sets the overlay opacity proportional to how far the drawer has been dragged toward dismissal:

const percentageDragged = Math.min(absDelta / drawerSize, 1);
const opacityValue = Math.max(0, 1 - percentageDragged);
overlay.style.transition = "none";
overlay.style.opacity = String(opacityValue);

Without this, the overlay snaps on/off instead of fading with the drag gesture.


7. pointercancel must be handled

The JS registers:

drawer.addEventListener("pointercancel", onPointerUp);

This handles cases where the browser cancels the pointer (system gesture interrupt, touch stolen by scroll, etc.). Without it, drag state gets stuck when a touch is cancelled mid-gesture.


8. selectstart prevention during drag

The JS prevents text selection while a drag is in progress:

drawer.addEventListener("selectstart", (event) => {
  if (isDragging && isAllowedToDrag) {
    event.preventDefault();
  }
});

Without this, dragging over text inside the drawer will select it instead of moving the drawer.


All 8 of these need to be in the Rust port before merge — the target is exact behavioral parity with the original JS. Let me know if you'd like pointers on the Rust/Leptos equivalents for any of these.

@max-wells
Copy link
Copy Markdown
Contributor

@krishpranav — one more pass, 6 additional items not covered in the previous comment:


9. fixDrawerPosition — scroll offset compensation on body lock

When lockBodyScroll is true, the JS calls fixDrawerPosition() which sets drawer.style.top to compensate for the current window scroll position. This prevents the drawer from visually jumping when overflow: hidden is applied to the body (which resets scroll to top). On close, drawer.style.top = "" must be reset. Without this, opening the drawer while the page is scrolled will cause a visible jump.


10. data-vaul-animate attribute lifecycle

The JS toggles this attribute at very specific moments:

  • On close start: immediately set data-vaul-animate="false" on both drawer and overlay (disables CSS transitions so the programmatic close transform takes over)
  • After close animation finishes (inside the setTimeout): set back to data-vaul-animate="true"
  • On open (inside requestAnimationFrame): for the floating variant, set overlay to data-vaul-animate="false" to suppress the overlay fade-in (it's already at opacity 1)

This attribute is what switches the drawer between CSS-animated and JS-controlled states. If toggled at the wrong time, animations will fight each other.


11. Wrapper interpolation DURING drag

This is separate from the open/close wrapper scaling (#3 in the previous comment). During pointerMove, the JS live-interpolates the wrapper's scale, border radius, and translate as you drag:

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)`;

The wrapper visually un-scales as you drag toward dismiss — the whole UI moves together. Without this, the drawer drags but the background stays frozen.


12. Wrapper restore on failed drag (pointerUp without dismiss)

When the user releases the drag but the threshold wasn't met (drawer stays open), the JS resets the wrapper back to its open state:

// in onPointerUp when shouldClose is false:
wrapper.style.borderRadius = `${BORDER_RADIUS}px`;
wrapper.style.transform = `scale(${scale}) translate3d(0, 14px, 0)`;
overlay.style.opacity = "1";

Without this, a failed drag attempt leaves the wrapper and overlay in a half-interpolated state.


13. Auto-focus on open — skip if only focusable element is the close button

On open, the JS focuses the first focusable element inside the drawer, but with one guard:

const isOnlyCloseButton =
  focusableElements.length === 1 &&
  focusableElements[0].getAttribute("data-name") === "DrawerClose";

if (!isOnlyCloseButton && focusableElements.length > 0) {
  focusableElements[0].focus();
}

If you auto-focus the close button immediately on open, it creates an awkward UX where pressing Enter instantly closes the drawer. This guard is intentional.


14. Full close cleanup — paddingRight and body[data-state] reset

On close (after the animation timeout), all body/scrollbar changes must be fully reverted:

body.style.paddingRight = "";       // restore scrollbar compensation
document.body.removeAttribute("data-state");  // or set back to "closed"
wrapper.style.overflow = "";
document.body.style.background = "";

The paddingRight reset is easy to miss since it's set conditionally (if (scrollbarWidth > 0)) but must always be cleared on close.


That should be the complete list across both comments. 14 items total — once all of these are in, the Rust port will be at full behavioral parity with the original JS.

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.
@max-wells
Copy link
Copy Markdown
Contributor

Great work @krishpranav — all 14 items are implemented and the port looks solid. Merging now, thanks!

@max-wells max-wells merged commit 8adce63 into rust-ui:main Apr 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Replace vaul_drawer.js + CSS with a pure Rust implementation using web_sys

2 participants