From 50803c6b3cfc78e4047dbc8fb1bf0087ca8198a6 Mon Sep 17 00:00:00 2001 From: krishpranav Date: Mon, 11 May 2026 12:48:44 +0530 Subject: [PATCH] feat(card-carousel): replace card_carousel.js with Rust/web_sys Migrates the vanilla JS carousel controller to a pure Rust module, eliminating the } } diff --git a/app_crates/registry/src/hooks/mod.rs b/app_crates/registry/src/hooks/mod.rs index 92a6ab8..1354f50 100644 --- a/app_crates/registry/src/hooks/mod.rs +++ b/app_crates/registry/src/hooks/mod.rs @@ -1,4 +1,5 @@ pub mod use_breadcrumb; +pub mod use_card_carousel; pub mod use_can_scroll; pub mod use_can_scroll_vertical; pub mod use_cell_edit; diff --git a/app_crates/registry/src/hooks/use_card_carousel.rs b/app_crates/registry/src/hooks/use_card_carousel.rs new file mode 100644 index 0000000..1704be1 --- /dev/null +++ b/app_crates/registry/src/hooks/use_card_carousel.rs @@ -0,0 +1,135 @@ +use std::cell::RefCell; + +use wasm_bindgen::JsCast; +use wasm_bindgen::closure::Closure; +use web_sys::{Element, Event, EventTarget}; + +// Selectors mirror the data-name attributes set by the Leptos components. +const CAROUSEL_ROOT: &str = r#"[data-name="CardCarousel"]"#; +const CAROUSEL_TRACK: &str = r#"[data-name="CardCarouselTrack"]"#; +const CAROUSEL_NAV_BUTTON: &str = r#"[data-name="CardCarouselNavButton"]"#; +const CAROUSEL_INDICATOR: &str = r#"[data-name="CardCarouselIndicator"]"#; + +thread_local! { + static LISTENERS: RefCell> = const { RefCell::new(None) }; +} + +struct Listeners { + _click: Closure, + _scroll: Closure, +} + +/// Register delegated event listeners on `document` for all `CardCarousel` +/// instances on the page. +/// +/// Safe to call multiple times — subsequent calls are no-ops. +pub fn init() { + LISTENERS.with(|cell| { + if cell.borrow().is_some() { + return; + } + if let Some(listeners) = setup_listeners() { + *cell.borrow_mut() = Some(listeners); + } + }); +} + +// ── Setup ───────────────────────────────────────────────────────────────────── + +fn setup_listeners() -> Option { + let document = web_sys::window()?.document()?; + let target: EventTarget = document.unchecked_into(); + + let click_cb = Closure::wrap(Box::new(handle_click) as Box); + let scroll_cb = Closure::wrap(Box::new(handle_scroll) as Box); + + let _ = target.add_event_listener_with_callback("click", click_cb.as_ref().unchecked_ref()); + + // Capture phase so scroll events from the overflow-scroll track element + // bubble up to document before reaching their normal target. + let _ = target.add_event_listener_with_callback_and_bool( + "scroll", + scroll_cb.as_ref().unchecked_ref(), + true, + ); + + Some(Listeners { _click: click_cb, _scroll: scroll_cb }) +} + +// ── Click handler ───────────────────────────────────────────────────────────── + +fn handle_click(event: Event) { + let Some(target) = event.target() else { return }; + let Ok(el) = target.dyn_into::() else { return }; + + // Walk up from the actual click target to find the nav button. + let Some(btn) = el.closest(CAROUSEL_NAV_BUTTON).ok().flatten() else { return }; + + // Mirror the JS: stop navigation when the button is inside an tag. + event.stop_propagation(); + event.prevent_default(); + + let Some(root) = btn.closest(CAROUSEL_ROOT).ok().flatten() else { return }; + let Some(track) = root.query_selector(CAROUSEL_TRACK).ok().flatten() else { return }; + let Ok(buttons) = root.query_selector_all(CAROUSEL_NAV_BUTTON) else { return }; + + // buttons[0] = prev, buttons[1] = next (DOM order). + let is_prev = buttons + .item(0) + .and_then(|n| n.dyn_into::().ok()) + .is_some_and(|first| first == btn); + + let width = track.client_width(); + let delta = width * if is_prev { -1 } else { 1 }; + track.set_scroll_left(track.scroll_left() + delta); +} + +// ── Scroll handler ──────────────────────────────────────────────────────────── + +fn handle_scroll(event: Event) { + let Some(target) = event.target() else { return }; + let Ok(el) = target.dyn_into::() else { return }; + + // Only act when the scroll happened inside a CarouselTrack. + let Some(track) = el.closest(CAROUSEL_TRACK).ok().flatten() else { return }; + + let Some(root) = track.closest(CAROUSEL_ROOT).ok().flatten() else { return }; + let Ok(indicators) = root.query_selector_all(CAROUSEL_INDICATOR) else { return }; + let Ok(buttons) = root.query_selector_all(CAROUSEL_NAV_BUTTON) else { return }; + + let client_width = track.client_width(); + let index = if client_width > 0 { + (f64::from(track.scroll_left()) / f64::from(client_width)).round() as u32 + } else { + 0 + }; + + let count = indicators.length(); + + // Sync indicator dots: aria-current on the active slide's dot. + for i in 0..count { + let Some(node) = indicators.item(i) else { continue }; + let Ok(dot) = node.dyn_into::() else { continue }; + if i == index { + let _ = dot.set_attribute("aria-current", "true"); + } else { + let _ = dot.remove_attribute("aria-current"); + } + } + + // Sync nav button disabled state. + set_aria_disabled(buttons.item(0), index == 0); + set_aria_disabled(buttons.item(1), count > 0 && index >= count - 1); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn set_aria_disabled(node: Option, disabled: bool) { + let Some(node) = node else { return }; + let Ok(el) = node.dyn_into::() else { return }; + if disabled { + let _ = el.set_attribute("aria-disabled", "true"); + } else { + let _ = el.remove_attribute("aria-disabled"); + } +} diff --git a/app_crates/registry/src/ui/card_carousel.rs b/app_crates/registry/src/ui/card_carousel.rs index 6be26f6..71ca3e4 100644 --- a/app_crates/registry/src/ui/card_carousel.rs +++ b/app_crates/registry/src/ui/card_carousel.rs @@ -1,6 +1,9 @@ use leptos::prelude::*; use leptos_ui::{clx, void}; +#[cfg(target_arch = "wasm32")] +use crate::hooks::use_card_carousel; + mod components { use super::*; clx! {CardCarousel, div, "group rounded-[20px] overflow-hidden relative w-[320px] h-[320px] bg-gray-200"} @@ -21,6 +24,9 @@ pub use components::*; #[component] pub fn CardCarouselTrack(children: Children) -> impl IntoView { + #[cfg(target_arch = "wasm32")] + use_card_carousel::init(); + view! {
{ - const btn = e.target.closest('[data-name="CardCarouselNavButton"]'); - if (!btn) return; - - // Allows to wrap in a `a` tag and prevent the navigation when clicking NavButton. - e.stopPropagation(); - e.preventDefault(); - - const root = btn.closest('[data-name="CardCarousel"]'); - const carousel = root.querySelector('[data-name="CardCarouselTrack"]'); - const buttons = root.querySelectorAll('[data-name="CardCarouselNavButton"]'); - const isPrev = btn === buttons[0]; - - carousel.scrollBy({ left: carousel.clientWidth * (isPrev ? -1 : 1) }); -}); - -// * Event delegation for all carousel scroll events (using capture phase) -document.addEventListener( - "scroll", - (e) => { - const carousel = e.target.closest('[data-name="CardCarouselTrack"]'); - if (!carousel) return; - - const root = carousel.closest('[data-name="CardCarousel"]'); - const indicators = root.querySelectorAll('[data-name="CardCarouselIndicator"]'); - const buttons = root.querySelectorAll('[data-name="CardCarouselNavButton"]'); - - const index = Math.round(carousel.scrollLeft / carousel.clientWidth); - indicators.forEach((dot, i) => dot.toggleAttribute("aria-current", i === index)); - buttons[0].toggleAttribute("aria-disabled", index === 0); - buttons[1].toggleAttribute("aria-disabled", index === indicators.length - 1); - }, - true, -); diff --git a/public/registry/styles/default/demo_card_carousel.md b/public/registry/styles/default/demo_card_carousel.md index cba9af9..8e7b8b6 100644 --- a/public/registry/styles/default/demo_card_carousel.md +++ b/public/registry/styles/default/demo_card_carousel.md @@ -81,8 +81,6 @@ pub fn DemoCardCarousel() -> impl IntoView { "$685 per night"
- - } } diff --git a/public/registry/tree.md b/public/registry/tree.md index ffed3d6..731c559 100644 --- a/public/registry/tree.md +++ b/public/registry/tree.md @@ -347,7 +347,6 @@ Each dependency level is shown with progressive bullet points: * for components, ** card (ui) * demo_card_carousel (demos) ** cargo: icons/leptos - ** js: /components/card_carousel.js ** card (ui) ** card_carousel (ui) * demo_card_group (demos)