Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions app_crates/registry/src/demos/demo_card_carousel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,6 @@ pub fn DemoCardCarousel() -> impl IntoView {
<CardDescription>"$685 per night"</CardDescription>
</CardContent>
</div>

<script type="module" src="/app_components/card_carousel.js"></script>
}
}

Expand Down
1 change: 1 addition & 0 deletions app_crates/registry/src/hooks/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
135 changes: 135 additions & 0 deletions app_crates/registry/src/hooks/use_card_carousel.rs
Original file line number Diff line number Diff line change
@@ -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<Option<Listeners>> = const { RefCell::new(None) };
}

struct Listeners {
_click: Closure<dyn FnMut(Event)>,
_scroll: Closure<dyn FnMut(Event)>,
}

/// 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<Listeners> {
let document = web_sys::window()?.document()?;
let target: EventTarget = document.unchecked_into();

let click_cb = Closure::wrap(Box::new(handle_click) as Box<dyn FnMut(Event)>);
let scroll_cb = Closure::wrap(Box::new(handle_scroll) as Box<dyn FnMut(Event)>);

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::<Element>() 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 <a> 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::<Element>().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::<Element>() 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::<Element>() 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<web_sys::Node>, disabled: bool) {
let Some(node) = node else { return };
let Ok(el) = node.dyn_into::<Element>() else { return };
if disabled {
let _ = el.set_attribute("aria-disabled", "true");
} else {
let _ = el.remove_attribute("aria-disabled");
}
}
6 changes: 6 additions & 0 deletions app_crates/registry/src/ui/card_carousel.rs
Original file line number Diff line number Diff line change
@@ -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"}
Expand All @@ -21,6 +24,9 @@ pub use components::*;

#[component]
pub fn CardCarouselTrack(children: Children) -> impl IntoView {
#[cfg(target_arch = "wasm32")]
use_card_carousel::init();

view! {
<div
data-name="CardCarouselTrack"
Expand Down
35 changes: 0 additions & 35 deletions public/app_components/card_carousel.js

This file was deleted.

2 changes: 0 additions & 2 deletions public/registry/styles/default/demo_card_carousel.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,6 @@ pub fn DemoCardCarousel() -> impl IntoView {
<CardDescription>"$685 per night"</CardDescription>
</CardContent>
</div>

<script type="module" src="/components/card_carousel.js"></script>
}
}

Expand Down
1 change: 0 additions & 1 deletion public/registry/tree.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down