diff --git a/Cargo.toml b/Cargo.toml index 435c35b..75d5779 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,6 +80,7 @@ wasm-bindgen-futures = "0.4" web-sys = { version = "0.3", default-features = false, features = [ "Clipboard", "console", + "CssStyleDeclaration", "Document", "DomRect", "DomTokenList", diff --git a/app/src/app.rs b/app/src/app.rs index 30d00e7..014e62c 100644 --- a/app/src/app.rs +++ b/app/src/app.rs @@ -17,6 +17,8 @@ use registry::blocks::sidenav08::Sidenav08Routes; use registry::blocks::sidenav09::Sidenav09Routes; use registry::blocks::sidenav10::Sidenav10Routes; use registry::blocks::sidenav11::Sidenav11Routes; +#[cfg(target_arch = "wasm32")] +use registry::hooks::scroll_lock; use registry::hooks::use_data_scrolled::DATA_SCROLL_TARGET; use registry::hooks::use_theme_mode::ThemeMode; use registry::ui::sonner::SonnerToaster; @@ -50,6 +52,10 @@ use crate::utils::page_transition::ScrollToTop; pub fn App() -> impl IntoView { provide_meta_context(); + // Register window.ScrollLock (Rust replacement for lock_scroll.js) + #[cfg(target_arch = "wasm32")] + scroll_lock::init(); + provide_toaster(); let theme_mode = ThemeMode::init(); diff --git a/app/src/shell.rs b/app/src/shell.rs index 8d8b076..5808cbc 100644 --- a/app/src/shell.rs +++ b/app/src/shell.rs @@ -140,7 +140,6 @@ 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/hooks/mod.rs b/app_crates/registry/src/hooks/mod.rs index a70215d..9cdc297 100644 --- a/app_crates/registry/src/hooks/mod.rs +++ b/app_crates/registry/src/hooks/mod.rs @@ -23,4 +23,5 @@ pub mod use_pagination; pub mod use_press_hold; pub mod use_random; pub mod use_theme_mode; +pub mod scroll_lock; pub mod use_virtual_scroll; diff --git a/app_crates/registry/src/hooks/scroll_lock.rs b/app_crates/registry/src/hooks/scroll_lock.rs new file mode 100644 index 0000000..a12a383 --- /dev/null +++ b/app_crates/registry/src/hooks/scroll_lock.rs @@ -0,0 +1,478 @@ +//! Scroll lock utility — pure Rust replacement for `lock_scroll.js`. +//! +//! Prevents scrolling on body and all scrollable containers when overlays +//! (Dialog, Sheet, Select, etc.) are open. Compensates scrollbar width to +//! prevent layout shift. +//! +//! # Setup +//! +//! Call [`init`] once at app startup (e.g. in `hydrate()`) to register +//! `window.ScrollLock` for JS interop. Components can then call +//! `window.ScrollLock.lock()` / `.unlock(delay)` / `.isLocked()` from +//! inline scripts. +//! +//! The Rust API ([`lock`], [`unlock`], [`is_locked`]) can also be called +//! directly from Leptos components without JS interop. + +use std::cell::RefCell; + +use wasm_bindgen::closure::Closure; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; + +/// Component data-names excluded from scroll locking (internal scrollable areas). +const EXCLUDED_DATA_NAMES: &[&str] = &[ + "ScrollArea", + "CommandList", + "SelectContent", + "MultiSelectContent", + "DropdownMenuContent", + "ContextMenuContent", +]; + +/// Data-names excluded when collecting fixed-position elements. +const FIXED_EXCLUDED: &[&str] = &[ + "DropdownMenuContent", + "MultiSelectContent", + "ContextMenuContent", +]; + +/// CSS selector for scrollable element candidates. +const SCROLLABLE_SELECTOR: &str = + r#"[style*="overflow"],[class*="overflow"],[class*="scroll"],main,aside,section,div"#; + +/// CSS selector for fixed-position element candidates. +const FIXED_SELECTOR: &str = + r#"[style*="fixed"],[class*="fixed"],header,nav,aside,[role="dialog"],[role="alertdialog"]"#; + +// ── State ────────────────────────────────────────────────────────── + +struct BodyStyles { + position: String, + top: String, + width: String, + overflow: String, + padding_right: String, +} + +struct ScrollableEntry { + element: web_sys::HtmlElement, + scroll_top: i32, + overflow: String, + overflow_y: String, + padding_right: String, +} + +struct FixedEntry { + element: web_sys::HtmlElement, + padding_right: String, +} + +struct State { + locked: bool, + window_scroll_y: f64, + body_styles: Option, + scrollable: Vec, + fixed: Vec, +} + +impl State { + const fn new() -> Self { + Self { + locked: false, + window_scroll_y: 0.0, + body_styles: None, + scrollable: Vec::new(), + fixed: Vec::new(), + } + } + + fn clear(&mut self) { + self.locked = false; + self.window_scroll_y = 0.0; + self.body_styles = None; + self.scrollable.clear(); + self.fixed.clear(); + } +} + +thread_local! { + static STATE: RefCell = const { RefCell::new(State::new()) }; +} + +// ── Helpers ──────────────────────────────────────────────────────── + +/// Check if an element (or any ancestor) is in the exclusion list. +fn is_excluded(el: &web_sys::Element) -> bool { + if let Some(name) = el.get_attribute("data-name") { + if EXCLUDED_DATA_NAMES.iter().any(|&n| n == name) { + return true; + } + } + for &name in EXCLUDED_DATA_NAMES { + let sel = format!(r#"[data-name="{name}"]"#); + if el.closest(&sel).ok().flatten().is_some() { + return true; + } + } + false +} + +/// Check if a fixed element is inside an excluded container. +fn is_fixed_excluded(el: &web_sys::Element) -> bool { + for &name in FIXED_EXCLUDED { + let sel = format!(r#"[data-name="{name}"]"#); + if el.closest(&sel).ok().flatten().is_some() { + return true; + } + } + false +} + +/// Set or remove an inline style property. +fn set_style(style: &web_sys::CssStyleDeclaration, prop: &str, val: &str) { + if val.is_empty() { + let _ = style.remove_property(prop); + } else { + let _ = style.set_property(prop, val); + } +} + +/// Parse a CSS pixel value (e.g. `"12px"`) to `f64`. Returns `0.0` on failure. +fn parse_px(s: &str) -> f64 { + s.trim_end_matches("px").parse::().unwrap_or(0.0) +} + +// ── Public API ───────────────────────────────────────────────────── + +/// Register `window.ScrollLock` for JS interop. +/// +/// Call once at app startup. Subsequent calls are no-ops. +pub fn init() { + let Some(window) = web_sys::window() else { return }; + let window_js: &JsValue = window.as_ref(); + + // Prevent double initialization + if js_sys::Reflect::get(window_js, &"ScrollLock".into()) + .ok() + .filter(|v| !v.is_undefined() && !v.is_null()) + .is_some() + { + return; + } + + let obj = js_sys::Object::new(); + let obj_js: &JsValue = obj.as_ref(); + + // lock() + let f = Closure::wrap(Box::new(lock) as Box); + let _ = js_sys::Reflect::set(obj_js, &"lock".into(), f.as_ref()); + f.forget(); + + // unlock(delay?) + let f = Closure::wrap(Box::new(|delay: JsValue| { + unlock(delay.as_f64().unwrap_or(0.0) as u32); + }) as Box); + let _ = js_sys::Reflect::set(obj_js, &"unlock".into(), f.as_ref()); + f.forget(); + + // isLocked() + let f = Closure::wrap(Box::new(is_locked) as Box bool>); + let _ = js_sys::Reflect::set(obj_js, &"isLocked".into(), f.as_ref()); + f.forget(); + + let _ = js_sys::Reflect::set(window_js, &"ScrollLock".into(), obj_js); +} + +/// Lock scrolling on body and all scrollable containers. +/// +/// Batches DOM reads before writes to minimise reflows. Compensates +/// scrollbar width on the body and fixed-position elements to prevent +/// layout shift. +pub fn lock() { + // Check and mark as locked (short borrow) + let proceed = STATE.with(|s| { + let mut s = s.borrow_mut(); + if s.locked { + return false; + } + s.locked = true; + true + }); + if !proceed { + return; + } + + let Some(window) = web_sys::window() else { return }; + let Some(document) = window.document() else { return }; + let Some(body) = document.body() else { return }; + + // ── READ PHASE ───────────────────────────────────────────── + + let window_scroll_y = window.scroll_y().unwrap_or(0.0); + let inner_width = window + .inner_width() + .ok() + .and_then(|w| w.as_f64()) + .unwrap_or(0.0); + let scrollbar_width = inner_width - body.client_width() as f64; + + // Store original body inline styles + let body_style = body.style(); + let original_body = BodyStyles { + position: body_style.get_property_value("position").unwrap_or_default(), + top: body_style.get_property_value("top").unwrap_or_default(), + width: body_style.get_property_value("width").unwrap_or_default(), + overflow: body_style.get_property_value("overflow").unwrap_or_default(), + padding_right: body_style + .get_property_value("padding-right") + .unwrap_or_default(), + }; + + // Collect scrollable elements ───────────────────────────── + + let body_js: &JsValue = body.as_ref(); + let doc_element = document.document_element(); + + struct SRead { + el: web_sys::HtmlElement, + scroll_top: i32, + overflow: String, + overflow_y: String, + padding_right: String, + computed_padding: f64, + el_scrollbar: i32, + } + + let mut s_reads: Vec = Vec::new(); + + if let Ok(nodes) = document.query_selector_all(SCROLLABLE_SELECTOR) { + for i in 0..nodes.length() { + let Some(node) = nodes.item(i) else { continue }; + let Ok(element) = node.dyn_into::() else { + continue; + }; + + // Skip body and documentElement + let el_js: &JsValue = element.as_ref(); + if el_js == body_js { + continue; + } + if let Some(ref de) = doc_element { + let de_js: &JsValue = de.as_ref(); + if el_js == de_js { + continue; + } + } + + // Skip excluded components + if is_excluded(&element) { + continue; + } + + // Check if actually scrollable (computed style) + let Some(cs) = window.get_computed_style(&element).ok().flatten() else { + continue; + }; + let ov = cs.get_property_value("overflow").unwrap_or_default(); + let ovy = cs.get_property_value("overflow-y").unwrap_or_default(); + let scrollable = matches!(ov.as_str(), "auto" | "scroll") + || matches!(ovy.as_str(), "auto" | "scroll"); + + if !scrollable || element.scroll_height() <= element.client_height() { + continue; + } + + // Cast to HtmlElement for offset_width and style access + let Ok(el) = element.dyn_into::() else { + continue; + }; + + let st = el.style(); + let cp = cs + .get_property_value("padding-right") + .ok() + .map(|p| parse_px(&p)) + .unwrap_or(0.0); + + s_reads.push(SRead { + scroll_top: el.scroll_top(), + overflow: st.get_property_value("overflow").unwrap_or_default(), + overflow_y: st.get_property_value("overflow-y").unwrap_or_default(), + padding_right: st.get_property_value("padding-right").unwrap_or_default(), + computed_padding: cp, + el_scrollbar: el.offset_width() - el.client_width(), + el, + }); + } + } + + // Collect fixed elements (only when scrollbar is visible) ─ + + struct FRead { + el: web_sys::HtmlElement, + original_pr: String, + computed_padding: f64, + } + + let mut f_reads: Vec = Vec::new(); + + if scrollbar_width > 0.0 { + if let Ok(nodes) = document.query_selector_all(FIXED_SELECTOR) { + for i in 0..nodes.length() { + let Some(node) = nodes.item(i) else { continue }; + let Ok(element) = node.dyn_into::() else { + continue; + }; + + let Some(cs) = window.get_computed_style(&element).ok().flatten() else { + continue; + }; + if cs.get_property_value("position").unwrap_or_default() != "fixed" { + continue; + } + if is_fixed_excluded(&element) { + continue; + } + + // Cast to HtmlElement for style access + let Ok(el) = element.dyn_into::() else { + continue; + }; + + let cp = cs + .get_property_value("padding-right") + .ok() + .map(|p| parse_px(&p)) + .unwrap_or(0.0); + + f_reads.push(FRead { + original_pr: el + .style() + .get_property_value("padding-right") + .unwrap_or_default(), + computed_padding: cp, + el, + }); + } + } + } + + // ── WRITE PHASE ──────────────────────────────────────────── + + // Lock body + let _ = body_style.set_property("position", "fixed"); + let _ = body_style.set_property("top", &format!("-{window_scroll_y}px")); + let _ = body_style.set_property("width", "100%"); + let _ = body_style.set_property("overflow", "hidden"); + + if scrollbar_width > 0.0 { + let _ = body_style.set_property("padding-right", &format!("{scrollbar_width}px")); + + // Compensate fixed-position elements + for fr in &f_reads { + let np = fr.computed_padding + scrollbar_width; + let _ = fr.el.style().set_property("padding-right", &format!("{np}px")); + } + } + + // Lock scrollable containers + for sr in &s_reads { + let _ = sr.el.style().set_property("overflow", "hidden"); + if sr.el_scrollbar > 0 { + let np = sr.computed_padding + sr.el_scrollbar as f64; + let _ = sr.el.style().set_property("padding-right", &format!("{np}px")); + } + } + + // ── STORE STATE ──────────────────────────────────────────── + + STATE.with(|state| { + let mut s = state.borrow_mut(); + s.window_scroll_y = window_scroll_y; + s.body_styles = Some(original_body); + s.scrollable = s_reads + .into_iter() + .map(|r| ScrollableEntry { + element: r.el, + scroll_top: r.scroll_top, + overflow: r.overflow, + overflow_y: r.overflow_y, + padding_right: r.padding_right, + }) + .collect(); + s.fixed = f_reads + .into_iter() + .map(|r| FixedEntry { + element: r.el, + padding_right: r.original_pr, + }) + .collect(); + }); +} + +/// Unlock scrolling, optionally after a delay in milliseconds. +/// +/// The delay is used by animated components (Sheet, Drawer) to keep +/// scroll locked during exit animations. +pub fn unlock(delay_ms: u32) { + let locked = STATE.with(|s| s.borrow().locked); + if !locked { + return; + } + + if delay_ms > 0 { + leptos::prelude::set_timeout( + perform_unlock, + std::time::Duration::from_millis(u64::from(delay_ms)), + ); + } else { + perform_unlock(); + } +} + +/// Check if scrolling is currently locked. +pub fn is_locked() -> bool { + STATE.with(|s| s.borrow().locked) +} + +// ── Internal ─────────────────────────────────────────────────────── + +fn perform_unlock() { + let Some(window) = web_sys::window() else { return }; + + STATE.with(|state| { + let mut s = state.borrow_mut(); + + // Restore body styles + if let Some(body) = window.document().and_then(|d| d.body()) { + if let Some(ref orig) = s.body_styles { + let st = body.style(); + set_style(&st, "position", &orig.position); + set_style(&st, "top", &orig.top); + set_style(&st, "width", &orig.width); + set_style(&st, "overflow", &orig.overflow); + set_style(&st, "padding-right", &orig.padding_right); + } + } + + // Restore window scroll position + window.scroll_to_with_x_and_y(0.0, s.window_scroll_y); + + // Restore scrollable containers + for entry in &s.scrollable { + let st = entry.element.style(); + set_style(&st, "overflow", &entry.overflow); + set_style(&st, "overflow-y", &entry.overflow_y); + set_style(&st, "padding-right", &entry.padding_right); + entry.element.set_scroll_top(entry.scroll_top); + } + + // Restore fixed-position elements + for entry in &s.fixed { + set_style(&entry.element.style(), "padding-right", &entry.padding_right); + } + + s.clear(); + }); +} diff --git a/app_crates/registry/src/ui/command.rs b/app_crates/registry/src/ui/command.rs index a22a796..cc3069a 100644 --- a/app_crates/registry/src/ui/command.rs +++ b/app_crates/registry/src/ui/command.rs @@ -188,8 +188,6 @@ pub fn CommandDialog(children: ChildrenFn, #[prop(into, optional)] class: String ); view! { - - {children()} diff --git a/app_crates/registry/src/ui/context_menu.rs b/app_crates/registry/src/ui/context_menu.rs index 11dfd41..f3f4dee 100644 --- a/app_crates/registry/src/ui/context_menu.rs +++ b/app_crates/registry/src/ui/context_menu.rs @@ -167,8 +167,6 @@ pub fn ContextMenuContent( let target_id_for_script = ctx.target_id.clone(); view! { - -
-
-
-
    -
    -
    -
    { - // Prevent multiple initializations - if (window.ScrollLock) { - return; - } - - class ScrollLock { - constructor() { - this.locked = false; - this.scrollableElements = []; - this.scrollPositions = new Map(); - this.originalStyles = new Map(); - this.fixedElements = []; - } - - /** - * Find all scrollable elements in the DOM (optimized) - * Uses more targeted selectors instead of querying all elements - */ - findScrollableElements() { - const scrollables = []; - - // More targeted query - only look for elements with overflow properties - const candidates = document.querySelectorAll( - '[style*="overflow"], [class*="overflow"], [class*="scroll"], main, aside, section, div', - ); - - // Batch all style reads first to minimize reflows - const elementsToCheck = []; - for (const el of candidates) { - // Skip the element itself or if it's inside these containers - const dataName = el.getAttribute("data-name"); - const isExcludedElement = - dataName === "ScrollArea" || - dataName === "CommandList" || - dataName === "SelectContent" || - dataName === "MultiSelectContent" || - dataName === "DropdownMenuContent" || - dataName === "ContextMenuContent"; - - if ( - el !== document.body && - el !== document.documentElement && - !isExcludedElement && - !el.closest('[data-name="ScrollArea"]') && - !el.closest('[data-name="CommandList"]') && - !el.closest('[data-name="SelectContent"]') && - !el.closest('[data-name="MultiSelectContent"]') && - !el.closest('[data-name="DropdownMenuContent"]') && - !el.closest('[data-name="ContextMenuContent"]') - ) { - elementsToCheck.push(el); - } - } - - // Now batch read all computed styles and dimensions - elementsToCheck.forEach((el) => { - const style = window.getComputedStyle(el); - const hasOverflow = - style.overflow === "auto" || - style.overflow === "scroll" || - style.overflowY === "auto" || - style.overflowY === "scroll"; - - // Only check scrollHeight if overflow is set - if (hasOverflow && el.scrollHeight > el.clientHeight) { - scrollables.push(el); - } - }); - - return scrollables; - } - - /** - * Lock scrolling on all scrollable elements (optimized) - * Batches all DOM reads before DOM writes to prevent forced reflows - */ - lock() { - if (this.locked) return; - - this.locked = true; - - // Find all scrollable elements - this.scrollableElements = this.findScrollableElements(); - - // ===== BATCH 1: READ PHASE - Read all layout properties first ===== - const windowScrollY = window.scrollY; - const scrollbarWidth = window.innerWidth - document.body.clientWidth; - - // Store window scroll position - this.scrollPositions.set("window", windowScrollY); - - // Store original body styles - this.originalStyles.set("body", { - position: document.body.style.position, - top: document.body.style.top, - width: document.body.style.width, - overflow: document.body.style.overflow, - paddingRight: document.body.style.paddingRight, - }); - - // Read all fixed-position elements and their padding (only if we have scrollbar) - if (scrollbarWidth > 0) { - // Use more targeted query for fixed elements - const fixedCandidates = document.querySelectorAll( - '[style*="fixed"], [class*="fixed"], header, nav, aside, [role="dialog"], [role="alertdialog"]', - ); - - this.fixedElements = Array.from(fixedCandidates).filter((el) => { - const style = window.getComputedStyle(el); - return ( - style.position === "fixed" && - !el.closest('[data-name="DropdownMenuContent"]') && - !el.closest('[data-name="MultiSelectContent"]') && - !el.closest('[data-name="ContextMenuContent"]') - ); - }); - - // Batch read all padding values - this.fixedElements.forEach((el) => { - const computedStyle = window.getComputedStyle(el); - const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0; - - this.originalStyles.set(el, { - paddingRight: el.style.paddingRight, - computedPadding: currentPadding, - }); - }); - } - - // Read scrollable elements info - const scrollableInfo = this.scrollableElements.map((el) => { - const scrollTop = el.scrollTop; - const elementScrollbarWidth = el.offsetWidth - el.clientWidth; - const computedStyle = window.getComputedStyle(el); - const currentPadding = Number.parseInt(computedStyle.paddingRight, 10) || 0; - - this.scrollPositions.set(el, scrollTop); - this.originalStyles.set(el, { - overflow: el.style.overflow, - overflowY: el.style.overflowY, - paddingRight: el.style.paddingRight, - }); - - return { el, elementScrollbarWidth, currentPadding }; - }); - - // ===== BATCH 2: WRITE PHASE - Apply all styles at once ===== - - // Apply body lock - document.body.style.position = "fixed"; - document.body.style.top = `-${windowScrollY}px`; - document.body.style.width = "100%"; - document.body.style.overflow = "hidden"; - - if (scrollbarWidth > 0) { - document.body.style.paddingRight = `${scrollbarWidth}px`; - - // Apply padding compensation to fixed elements - this.fixedElements.forEach((el) => { - const stored = this.originalStyles.get(el); - if (stored) { - el.style.paddingRight = `${stored.computedPadding + scrollbarWidth}px`; - } - }); - } - - // Lock all scrollable containers - scrollableInfo.forEach(({ el, elementScrollbarWidth, currentPadding }) => { - el.style.overflow = "hidden"; - - if (elementScrollbarWidth > 0) { - el.style.paddingRight = `${currentPadding + elementScrollbarWidth}px`; - } - }); - } - - /** - * Unlock scrolling on all elements (optimized) - * @param {number} delay - Delay in milliseconds before unlocking (for animations) - */ - unlock(delay = 0) { - if (!this.locked) return; - - const performUnlock = () => { - // Restore body scroll - const bodyStyles = this.originalStyles.get("body"); - if (bodyStyles) { - document.body.style.position = bodyStyles.position; - document.body.style.top = bodyStyles.top; - document.body.style.width = bodyStyles.width; - document.body.style.overflow = bodyStyles.overflow; - document.body.style.paddingRight = bodyStyles.paddingRight; - } - - // Restore window scroll position - const windowScrollY = this.scrollPositions.get("window") || 0; - window.scrollTo(0, windowScrollY); - - // Restore all scrollable containers - this.scrollableElements.forEach((el) => { - const originalStyles = this.originalStyles.get(el); - if (originalStyles) { - el.style.overflow = originalStyles.overflow; - el.style.overflowY = originalStyles.overflowY; - el.style.paddingRight = originalStyles.paddingRight; - } - - // Restore scroll position - const scrollPosition = this.scrollPositions.get(el) || 0; - el.scrollTop = scrollPosition; - }); - - // Restore fixed-position elements padding - this.fixedElements.forEach((el) => { - const styles = this.originalStyles.get(el); - if (styles && styles.paddingRight !== undefined) { - el.style.paddingRight = styles.paddingRight; - } - }); - - // Clear storage - this.scrollableElements = []; - this.fixedElements = []; - this.scrollPositions.clear(); - this.originalStyles.clear(); - this.locked = false; - }; - - if (delay > 0) { - setTimeout(performUnlock, delay); - } else { - performUnlock(); - } - } - - /** - * Check if scrolling is currently locked - */ - isLocked() { - return this.locked; - } - } - - // Export as singleton - window.ScrollLock = new ScrollLock(); -})();