From be601dd50ab29bedda51ed145bfe494b3b0e17f1 Mon Sep 17 00:00:00 2001 From: Max Wells Date: Mon, 20 Apr 2026 10:18:54 +0700 Subject: [PATCH 1/4] fix(cache): never cache HTML responses as static assets, bump JS versions to bust poisoned cache add_cache_headers was applying long cache headers to SSR HTML fallback responses when static files were temporarily missing during rebuild, causing browsers to cache HTML as JS for up to 7 days. Guard added to skip caching on text/html responses. --- server/src/middlewares/cache_control.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/server/src/middlewares/cache_control.rs b/server/src/middlewares/cache_control.rs index f7bb5e5..eaeffcb 100644 --- a/server/src/middlewares/cache_control.rs +++ b/server/src/middlewares/cache_control.rs @@ -7,6 +7,13 @@ pub async fn add_cache_headers(req: Request, next: Next) -> Response { let path = req.uri().path().to_owned(); let mut response = next.run(req).await; + // Never cache HTML responses — this prevents browsers from caching SSR + // fallback HTML when a static file is temporarily missing (e.g. during rebuild). + let content_type = response.headers().get("content-type").and_then(|v| v.to_str().ok()).unwrap_or(""); + if content_type.contains("text/html") { + return response; + } + // Determine cache duration based on file type let cache_header = if is_immutable_asset(&path) { // Hashed assets (like Leptos outputs) can be cached for 1 year From f5e44b2fce5396aa32538a18487fb0428760acaa Mon Sep 17 00:00:00 2001 From: Max Wells Date: Mon, 20 Apr 2026 10:20:51 +0700 Subject: [PATCH 2/4] fix(reactive): use ArcRwSignal for DocsTocContext to prevent use-after-dispose panic --- app/src/domain/docs/routing/docs_layout.rs | 4 ++-- app/src/domain/markdown_ui/components/md_shared_with_toc.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/domain/docs/routing/docs_layout.rs b/app/src/domain/docs/routing/docs_layout.rs index 4195dac..3b79ac4 100644 --- a/app/src/domain/docs/routing/docs_layout.rs +++ b/app/src/domain/docs/routing/docs_layout.rs @@ -14,7 +14,7 @@ use crate::utils::page_transition::{PAGE_OUTLET, retrigger_page_fade}; #[derive(Clone)] pub struct DocsTocContext { - pub toc_items: RwSignal>, + pub toc_items: ArcRwSignal>, } #[component] @@ -23,7 +23,7 @@ pub fn DocsLayout() -> impl IntoView { let params_demo_name = ParamsUtils::extract(PARAM::NAME); let params_demo_name_arc = Arc::new(params_demo_name); - let toc_items = RwSignal::new(Vec::::new()); + let toc_items: ArcRwSignal> = RwSignal::new(Vec::::new()).into(); let toc_context = DocsTocContext { toc_items }; provide_context(toc_context.clone()); diff --git a/app/src/domain/markdown_ui/components/md_shared_with_toc.rs b/app/src/domain/markdown_ui/components/md_shared_with_toc.rs index e20485a..2df1ead 100644 --- a/app/src/domain/markdown_ui/components/md_shared_with_toc.rs +++ b/app/src/domain/markdown_ui/components/md_shared_with_toc.rs @@ -8,7 +8,7 @@ use crate::domain::markdown_ui::components::md_skeleton::MdSkeletonDemo; #[component] pub fn SharedDemoMdWithToc( md_path: &'static str, - #[prop(into)] toc_signal: WriteSignal>, + #[prop(into)] toc_signal: ArcWriteSignal>, ) -> impl IntoView { let md_file_resource = Resource::new(|| (), move |_| async move { read_md_file_typed(md_path.to_string()).await }); From 8cc591f0e9fa35dd49ba0638cfae7a8f38921eb2 Mon Sep 17 00:00:00 2001 From: krishpranav Date: Mon, 20 Apr 2026 23:30:16 +0530 Subject: [PATCH 3/4] sonner: replace JS layer with pure Rust/Leptos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove sonner.js, sonner.min.js, sonner.css, and lazy_load_sonner.js entirely. Toast state, auto-dismiss timers, stacking, and position logic now live in a RwSignal> provided via Leptos context. SonnerTrigger no longer reads data-* attributes on click — it calls show_toast() directly. SonnerToaster renders toasts as Leptos components with CSS-only enter/exit animations. No JS runtime dependency remains in this component. Fixes #29 --- app/src/app.rs | 3 +- .../components/resizable_wrapper.rs | 8 +- .../components/static_demo_wrapper.rs | 2 +- app/src/shell.rs | 7 - app_crates/registry/src/demos/demo_sonner.rs | 11 +- .../src/demos/demo_sonner_positions.rs | 1 + .../src/demos/demo_sonner_variants.rs | 45 +- app_crates/registry/src/ui/sonner.rs | 1124 ++++++++++++++++- e2e/tests/components/_base_page.ts | 17 +- e2e/tests/components/sonner.spec.ts | 37 +- public/app_components/lazy_load_sonner.js | 146 --- public/app_components/sonner.css | 288 ----- public/app_components/sonner.js | 1040 --------------- public/app_components/sonner.min.js | 1 - public/registry/styles/default/demo_sonner.md | 11 +- .../styles/default/demo_sonner_positions.md | 1 + .../styles/default/demo_sonner_variants.md | 47 +- public/registry/styles/default/sonner.md | 112 +- 18 files changed, 1220 insertions(+), 1681 deletions(-) delete mode 100644 public/app_components/lazy_load_sonner.js delete mode 100644 public/app_components/sonner.css delete mode 100644 public/app_components/sonner.js delete mode 100644 public/app_components/sonner.min.js diff --git a/app/src/app.rs b/app/src/app.rs index a756187..5ea7bf0 100644 --- a/app/src/app.rs +++ b/app/src/app.rs @@ -21,7 +21,7 @@ use registry::hooks::use_data_scrolled::DATA_SCROLL_TARGET; #[cfg(target_arch = "wasm32")] use registry::hooks::use_scroll_lock; use registry::hooks::use_theme_mode::ThemeMode; -use registry::ui::sonner::SonnerToaster; +use registry::ui::sonner::{SonnerToaster, provide_sonner}; use registry::ui::toast_custom::toaster::{Toaster, provide_toaster}; use crate::components::navigation::app_wrapper::AppWrapper; @@ -57,6 +57,7 @@ pub fn App() -> impl IntoView { use_scroll_lock::init(); provide_toaster(); + provide_sonner(); let theme_mode = ThemeMode::init(); diff --git a/app/src/domain/markdown_ui/components/resizable_wrapper.rs b/app/src/domain/markdown_ui/components/resizable_wrapper.rs index 72eaaa4..875f2dc 100644 --- a/app/src/domain/markdown_ui/components/resizable_wrapper.rs +++ b/app/src/domain/markdown_ui/components/resizable_wrapper.rs @@ -9,13 +9,17 @@ use crate::domain::markdown_ui::components::my_resizable::{ /* ========================================================== */ #[component] -pub fn ResizableWrapper(children: Children, #[prop(into, optional)] preview_class: String) -> impl IntoView { +pub fn ResizableWrapper( + children: Children, + #[prop(into, optional)] preview_class: String, + #[prop(into, optional)] demo_name: String, +) -> impl IntoView { let preview_classes = format!("flex justify-center items-center w-full min-h-[370px] {}", preview_class); view! { -
+
{children()}
diff --git a/app/src/domain/markdown_ui/components/static_demo_wrapper.rs b/app/src/domain/markdown_ui/components/static_demo_wrapper.rs index 1d9085f..7c0c750 100644 --- a/app/src/domain/markdown_ui/components/static_demo_wrapper.rs +++ b/app/src/domain/markdown_ui/components/static_demo_wrapper.rs @@ -140,7 +140,7 @@ pub fn StaticDemoWrapper(demo_type: MarkdownType, children: Children) -> impl In // TODO 🚑 Show does not work at the moment. Using display as shortfix solution.
- {children_view} + {children_view}
diff --git a/app/src/shell.rs b/app/src/shell.rs index 8b09e8b..82b3459 100644 --- a/app/src/shell.rs +++ b/app/src/shell.rs @@ -132,16 +132,9 @@ pub fn shell(options: LeptosOptions) -> impl IntoView { - // Preload and async load Sonner CSS (non-critical) - - - // Load scripts (async for non-blocking parallel download, executes as soon as ready) - // JSON-LD Structured Data for SEO (inlined at compile time — readable by AI crawlers) diff --git a/app_crates/registry/src/demos/demo_sonner.rs b/app_crates/registry/src/demos/demo_sonner.rs index 3dd703f..5c1d752 100644 --- a/app_crates/registry/src/demos/demo_sonner.rs +++ b/app_crates/registry/src/demos/demo_sonner.rs @@ -1,12 +1,15 @@ use leptos::prelude::*; -use crate::ui::sonner::SonnerTrigger; +use crate::ui::sonner::{SonnerToaster, SonnerTrigger}; #[component] pub fn DemoSonner() -> impl IntoView { view! { - - "Toast Me!" - + <> + + "Toast Me!" + + + } } diff --git a/app_crates/registry/src/demos/demo_sonner_positions.rs b/app_crates/registry/src/demos/demo_sonner_positions.rs index 9781d22..466a898 100644 --- a/app_crates/registry/src/demos/demo_sonner_positions.rs +++ b/app_crates/registry/src/demos/demo_sonner_positions.rs @@ -61,5 +61,6 @@ pub fn DemoSonnerPositions() -> impl IntoView { + } } diff --git a/app_crates/registry/src/demos/demo_sonner_variants.rs b/app_crates/registry/src/demos/demo_sonner_variants.rs index 0f71f44..202f666 100644 --- a/app_crates/registry/src/demos/demo_sonner_variants.rs +++ b/app_crates/registry/src/demos/demo_sonner_variants.rs @@ -1,30 +1,33 @@ use leptos::prelude::*; -use crate::ui::sonner::{SonnerTrigger, ToastType}; +use crate::ui::sonner::{SonnerToaster, SonnerTrigger, ToastType}; #[component] pub fn DemoSonnerVariants() -> impl IntoView { view! { -
- - "Default" - + <> +
+ + "Default" + - - "Success" - - - "Error" - - - "Warning" - - - "Info" - - - "Loading" - -
+ + "Success" + + + "Error" + + + "Warning" + + + "Info" + + + "Loading" + +
+ + } } diff --git a/app_crates/registry/src/ui/sonner.rs b/app_crates/registry/src/ui/sonner.rs index 49b331e..6f8189b 100644 --- a/app_crates/registry/src/ui/sonner.rs +++ b/app_crates/registry/src/ui/sonner.rs @@ -1,7 +1,251 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use leptos::leptos_dom::helpers::{TimeoutHandle, set_timeout_with_handle}; use leptos::prelude::*; use tw_merge::*; +use wasm_bindgen::JsCast; +use web_sys::{HtmlElement, PointerEvent}; -#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)] +const MAX_TOASTS: usize = 5; +const VISIBLE_TOASTS_AMOUNT: usize = 3; +const ENTER_DURATION_MS: u32 = 300; +const EXIT_DURATION_MS: u32 = 300; +const SWIPE_THRESHOLD: f64 = 45.0; +const SWIPE_VELOCITY_THRESHOLD: f64 = 0.11; +const DEFAULT_DURATION_MS: u32 = 5000; + +const SONNER_STYLE: &str = r#" +[data-name='SonnerList'] { + position: fixed; + z-index: 9999; + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--gap); + opacity: 1; + height: 200px; + width: 400px; +} + +[data-name='SonnerList'][data-direction='BottomUp'] { + --fold-multiplier: -1; +} + +[data-name='SonnerList'][data-direction='TopDown'] { + --fold-multiplier: 1; +} + +[data-name='SonnerList'][data-position='TopLeft'] { + top: 0.75rem; + left: 0.75rem; +} + +[data-name='SonnerList'][data-position='TopCenter'] { + top: 0.75rem; + left: 50%; + transform: translateX(-50%); +} + +[data-name='SonnerList'][data-position='TopRight'] { + top: 0.75rem; + right: 0.75rem; +} + +[data-name='SonnerList'][data-position='BottomLeft'] { + bottom: 0.75rem; + left: 0.75rem; +} + +[data-name='SonnerList'][data-position='BottomCenter'] { + bottom: 0.75rem; + left: 50%; + transform: translateX(-50%); +} + +[data-name='SonnerList'][data-position='BottomRight'] { + bottom: 0.75rem; + right: 0.75rem; +} + +[data-name='SonnerItem'] { + --y: translateY(0); + transform: var(--y); + transition: transform var(--stack-duration) var(--transition-easing), opacity var(--exit-duration) var(--transition-easing); +} + +[data-name='SonnerList'][data-direction='BottomUp'] [data-name='SonnerItem'][data-mounted='false'] { + --y: translateY(100%); + opacity: 0; +} + +[data-name='SonnerList'][data-direction='TopDown'] [data-name='SonnerItem'][data-mounted='false'] { + --y: translateY(-100%); + opacity: 0; +} + +[data-name='SonnerItem'][data-mounted='true'] { + --y: translateY(0); + opacity: 1; +} + +[data-name='SonnerList'] [data-name='SonnerItem'][data-mounted='true'][data-entering='true'] { + --y: translateY(0); + transform: var(--y); + opacity: 1; +} + +[data-name='SonnerItem'][data-visible='false'] { + opacity: 0; + pointer-events: none; +} + +[data-name='SonnerItem'][data-hidden='true'] { + display: none; +} + +[data-name='SonnerItem'][data-mounted='true'][data-expanded='false'] { + --y: translateY(calc(var(--fold-multiplier) * var(--index) * var(--stack-spacing))); + transform: var(--y) scale(calc(1 - var(--index) * var(--scale-factor))); + z-index: var(--z-index); +} + +[data-name='SonnerList'][data-expanded='true'] [data-name='SonnerItem'][data-mounted='true'] { + --y: translateY(calc(var(--fold-multiplier) * var(--index) * var(--expand-spacing))); + transform: var(--y) scale(1); + z-index: var(--z-index); +} + +[data-name='SonnerItem'][data-expanded='true']::after { + content: ''; + position: absolute; + left: 0; + height: calc(var(--gap) + 1px); + bottom: 100%; + width: 100%; +} + +[data-name='SonnerItem'][data-removed='true'][data-front='true'][data-swipe-out='false'] { + --y: translateY(calc(var(--fold-multiplier) * -100%)); + opacity: 0; +} + +[data-name='SonnerList'][data-expanded='true'] [data-name='SonnerItem'][data-removed='true'][data-front='false'][data-swipe-out='false'] { + --y: translateY(calc(var(--fold-multiplier) * var(--index) * var(--expand-spacing) + var(--fold-multiplier) * -100%)); + opacity: 0; +} + +[data-name='SonnerList'][data-expanded='false'] [data-name='SonnerItem'][data-removed='true'][data-front='false'][data-swipe-out='false'] { + --y: translateY(40%); + opacity: 0; + transition: transform 500ms var(--transition-easing), opacity 200ms; +} + +[data-name='SonnerItem'][data-swiping='true'] { + transition: none !important; + cursor: grabbing; + user-select: none; +} + +[data-name='SonnerItem'][data-swiping='true'][data-mounted='true'] { + transform: var(--y) + scale(calc(1 - var(--index) * var(--scale-factor))) + translateX(var(--swipe-amount-x, 0px)) + translateY(var(--swipe-amount-y, 0px)); +} + +[data-name='SonnerItem'][data-swipe-out='true'][data-swipe-direction='Right'] { + animation: swipe-out-right var(--exit-duration) ease-out forwards; +} + +[data-name='SonnerItem'][data-swipe-out='true'][data-swipe-direction='Left'] { + animation: swipe-out-left var(--exit-duration) ease-out forwards; +} + +[data-name='SonnerItem'][data-swipe-out='true'][data-swipe-direction='Up'] { + animation: swipe-out-up var(--exit-duration) ease-out forwards; +} + +[data-name='SonnerItem'][data-swipe-out='true'][data-swipe-direction='Down'] { + animation: swipe-out-down var(--exit-duration) ease-out forwards; +} + +[data-duration-progress] { + transition: transform linear; +} + +[data-name='SonnerList'][data-expanded='true'] [data-name='SonnerItem'] [data-duration-progress] { + animation-play-state: paused !important; +} + +@keyframes sonner-progress { + from { transform: scaleX(1); } + to { transform: scaleX(0); } +} + +[data-name='SonnerItem'][data-variant='Loading'] [data-duration-track] { + display: none; +} + +[data-icon] svg { + width: 1rem; + height: 1rem; +} + +[data-close-button] svg { + width: 0.75rem; + height: 0.75rem; +} + +@keyframes swipe-out-right { + from { + transform: var(--y) translateX(var(--swipe-amount-x, 0px)); + opacity: 1; + } + to { + transform: var(--y) translateX(calc(100% + 100px)); + opacity: 0; + } +} + +@keyframes swipe-out-left { + from { + transform: var(--y) translateX(var(--swipe-amount-x, 0px)); + opacity: 1; + } + to { + transform: var(--y) translateX(calc(-100% - 100px)); + opacity: 0; + } +} + +@keyframes swipe-out-up { + from { + transform: var(--y) translateY(var(--swipe-amount-y, 0px)); + opacity: 1; + } + to { + transform: var(--y) translateY(calc(-100% - 100px)); + opacity: 0; + } +} + +@keyframes swipe-out-down { + from { + transform: var(--y) translateY(var(--swipe-amount-y, 0px)); + opacity: 1; + } + to { + transform: var(--y) translateY(calc(100% + 100px)); + opacity: 0; + } +} +"#; + +#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Hash)] pub enum ToastType { #[default] Default, @@ -12,15 +256,15 @@ pub enum ToastType { Loading, } -#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)] +#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Hash)] pub enum SonnerPosition { TopLeft, TopCenter, TopRight, + BottomLeft, + BottomCenter, #[default] BottomRight, - BottomCenter, - BottomLeft, } #[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)] @@ -30,13 +274,147 @@ pub enum SonnerDirection { BottomUp, } +#[derive(Clone)] +struct SonnerToast { + id: u64, + variant: ToastType, + title: String, + description: Option, + duration_ms: u32, + position: SonnerPosition, + mounted: bool, + entering: bool, + removed: bool, + swipe_out: bool, + swiping: bool, + swipe_direction: Option<&'static str>, +} + +#[derive(Clone)] +struct SonnerContext { + toasts: RwSignal>, + next_id: RwSignal, + expanded_position: RwSignal>, + timers: Arc>>, +} + +struct SonnerTimer { + timeout: Option, + remaining_ms: u32, + started_at_ms: f64, +} + +#[derive(Clone)] +pub struct ToastApi { + ctx: SonnerContext, +} + +pub struct ToastBuilder { + ctx: SonnerContext, + variant: ToastType, + title: String, + description: Option, + duration_ms: u32, + position: SonnerPosition, +} + +#[derive(Clone, Copy)] +struct RenderMeta { + index: usize, + z_index: usize, + front: bool, + visible: bool, + hidden: bool, +} + +impl ToastApi { + pub fn success(self, title: impl Into) { + self.message(title).variant(ToastType::Success).push(); + } + + pub fn error(self, title: impl Into) { + self.message(title).variant(ToastType::Error).push(); + } + + pub fn warning(self, title: impl Into) { + self.message(title).variant(ToastType::Warning).push(); + } + + pub fn info(self, title: impl Into) { + self.message(title).variant(ToastType::Info).push(); + } + + pub fn loading(self, title: impl Into) -> u64 { + self.message(title).variant(ToastType::Loading).duration(60_000).push() + } + + pub fn with_description(self, title: impl Into, description: impl Into) { + self.message(title).description(description).push(); + } + + pub fn message(self, title: impl Into) -> ToastBuilder { + ToastBuilder { + ctx: self.ctx, + variant: ToastType::Default, + title: title.into(), + description: None, + duration_ms: DEFAULT_DURATION_MS, + position: SonnerPosition::BottomRight, + } + } + + pub fn dismiss(self, toast_id: u64) { + dismiss_toast(&self.ctx, toast_id, false, None); + } + + pub fn update_to_success(self, toast_id: u64, title: impl Into, description: Option) { + update_toast(&self.ctx, toast_id, ToastType::Success, title.into(), description, Some(DEFAULT_DURATION_MS)); + } +} + +impl ToastBuilder { + pub fn variant(mut self, variant: ToastType) -> Self { + self.variant = variant; + self + } + + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn duration(mut self, duration_ms: u32) -> Self { + self.duration_ms = duration_ms; + self + } + + pub fn position(mut self, position: SonnerPosition) -> Self { + self.position = position; + self + } + + pub fn push(self) -> u64 { + push_toast(&self.ctx, self.variant, self.title, self.description, self.duration_ms, self.position) + } +} + +pub fn provide_sonner() { + if use_context::().is_none() { + provide_context(new_sonner_context()); + } +} + +pub fn show_toast() -> ToastApi { + ToastApi { ctx: expect_context::() } +} + #[component] pub fn SonnerTrigger( children: Children, #[prop(into, optional)] class: String, #[prop(optional, default = ToastType::default())] variant: ToastType, #[prop(into)] title: String, - #[prop(into)] description: String, + #[prop(into, optional)] description: String, #[prop(into, optional)] position: String, ) -> impl IntoView { let variant_classes = match variant { @@ -54,8 +432,9 @@ pub fn SonnerTrigger( class ); - // Only set position attribute if not empty - let position_attr = if position.is_empty() { None } else { Some(position) }; + let click_title = title.clone(); + let click_description = if description.is_empty() { None } else { Some(description.clone()) }; + let click_position = parse_position(position.as_str()).unwrap_or_default(); view! { @@ -77,55 +473,423 @@ pub fn SonnerContainer( #[prop(into, optional)] class: String, #[prop(optional, default = SonnerPosition::default())] position: SonnerPosition, ) -> impl IntoView { - let merged_class = tw_merge!("toast__container fixed z-50", class); - + let merged_class = tw_merge!("fixed z-[9999]", class); view! { -
- {children()} -
+
{children()}
} } #[component] pub fn SonnerList( - children: Children, - #[prop(into, optional)] class: String, #[prop(optional, default = SonnerPosition::default())] position: SonnerPosition, #[prop(optional, default = SonnerDirection::default())] direction: SonnerDirection, - #[prop(into, default = "false".to_string())] expanded: String, - #[prop(into, optional)] style: String, ) -> impl IntoView { - // pointer-events-none: container doesn't block clicks when empty - // [&>*]:pointer-events-auto: toast items still receive clicks - let merged_class = tw_merge!( - "flex relative flex-col opacity-100 gap-[15px] h-[100px] w-[400px] pointer-events-none [&>*]:pointer-events-auto", - class - ); + let ctx = expect_context::(); + let ctx_mouseenter = ctx.clone(); + let ctx_mousemove = ctx.clone(); + let ctx_mouseleave = ctx.clone(); + let ctx_focusin = ctx.clone(); + let ctx_focusout = ctx.clone(); + let position_toasts = + move || ctx.toasts.get().into_iter().filter(|toast| toast.position == position).collect::>(); + let position_toast_ids = move || position_toasts().into_iter().map(|toast| toast.id).collect::>(); + + let expanded = move || ctx.expanded_position.get() == Some(position); view! {
    - {children()} + + +
} } #[component] -pub fn SonnerToaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView { - // Auto-derive direction from position - let direction = match position { - SonnerPosition::TopLeft | SonnerPosition::TopCenter | SonnerPosition::TopRight => SonnerDirection::TopDown, - _ => SonnerDirection::BottomUp, +fn SonnerItem(toast_id: u64, position: SonnerPosition, expanded: Signal) -> impl IntoView { + let ctx = expect_context::(); + let swipe_amount_x = RwSignal::new(0.0_f64); + let swipe_amount_y = RwSignal::new(0.0_f64); + let pointer_start = RwSignal::new(None::<(f64, f64)>); + let drag_start = RwSignal::new(0.0_f64); + let swipe_axis = RwSignal::new(None::); + + let ctx_pointer_down = ctx.clone(); + let on_pointer_down = move |event: PointerEvent| { + if is_toast_removed(&ctx_pointer_down, toast_id) { + return; + } + + if let Some(target) = event.target() + && let Ok(element) = target.dyn_into::() + { + let _ = element.set_pointer_capture(event.pointer_id()); + } + + pointer_start.set(Some((f64::from(event.client_x()), f64::from(event.client_y())))); + drag_start.set(js_sys::Date::now()); + swipe_axis.set(None); + set_swiping(&ctx_pointer_down, toast_id, true); }; + let on_pointer_move = move |event: PointerEvent| { + let Some((start_x, start_y)) = pointer_start.get() else { return }; + let delta_x = f64::from(event.client_x()) - start_x; + let delta_y = f64::from(event.client_y()) - start_y; + + if swipe_axis.get().is_none() && (delta_x.abs() > 1.0 || delta_y.abs() > 1.0) { + let axis = if delta_x.abs() > delta_y.abs() { 'x' } else { 'y' }; + swipe_axis.set(Some(axis)); + } + + let mut x = 0.0_f64; + let mut y = 0.0_f64; + + match swipe_axis.get() { + Some('x') => { + x = delta_x; + } + Some('y') => { + if allowed_vertical_delta(position, delta_y) { + y = delta_y; + } else { + y = delta_y * dampening(delta_y); + } + } + _ => {} + } + + swipe_amount_x.set(x); + swipe_amount_y.set(y); + }; + + let ctx_pointer_up = ctx.clone(); + let on_pointer_up = move |_| { + let Some(axis) = swipe_axis.get() else { + pointer_start.set(None); + set_swiping(&ctx_pointer_up, toast_id, false); + return; + }; + + let elapsed = (js_sys::Date::now() - drag_start.get()).max(1.0); + let amount = if axis == 'x' { swipe_amount_x.get().abs() } else { swipe_amount_y.get().abs() }; + let velocity = amount / elapsed; + + if amount >= SWIPE_THRESHOLD || velocity > SWIPE_VELOCITY_THRESHOLD { + let direction = if axis == 'x' { + if swipe_amount_x.get() >= 0.0 { "Right" } else { "Left" } + } else if swipe_amount_y.get() >= 0.0 { + "Down" + } else { + "Up" + }; + + dismiss_toast(&ctx_pointer_up, toast_id, true, Some(direction)); + } else { + set_swiping(&ctx_pointer_up, toast_id, false); + } + + pointer_start.set(None); + swipe_axis.set(None); + swipe_amount_x.set(0.0); + swipe_amount_y.set(0.0); + }; + + let ctx_pointer_cancel = ctx.clone(); + let on_pointer_cancel = move |_| { + let Some(axis) = swipe_axis.get() else { + pointer_start.set(None); + set_swiping(&ctx_pointer_cancel, toast_id, false); + return; + }; + + let elapsed = (js_sys::Date::now() - drag_start.get()).max(1.0); + let amount = if axis == 'x' { swipe_amount_x.get().abs() } else { swipe_amount_y.get().abs() }; + let velocity = amount / elapsed; + + if amount >= SWIPE_THRESHOLD || velocity > SWIPE_VELOCITY_THRESHOLD { + let direction = if axis == 'x' { + if swipe_amount_x.get() >= 0.0 { "Right" } else { "Left" } + } else if swipe_amount_y.get() >= 0.0 { + "Down" + } else { + "Up" + }; + + dismiss_toast(&ctx_pointer_cancel, toast_id, true, Some(direction)); + } else { + set_swiping(&ctx_pointer_cancel, toast_id, false); + } + + pointer_start.set(None); + swipe_axis.set(None); + swipe_amount_x.set(0.0); + swipe_amount_y.set(0.0); + }; + + let y_position = if is_top_position(position) { "Top" } else { "Bottom" }; + let x_position = if matches!(position, SonnerPosition::TopLeft | SonnerPosition::BottomLeft) { + "Left" + } else if matches!(position, SonnerPosition::TopCenter | SonnerPosition::BottomCenter) { + "Center" + } else { + "Right" + }; + + let toast_state = Signal::derive({ + let ctx = ctx.clone(); + move || toast_snapshot(&ctx, toast_id) + }); + let render_meta_ctx = ctx.clone(); + let render_meta = Signal::derive({ + move || { + let active_ids = render_meta_ctx + .toasts + .get() + .into_iter() + .filter(|toast| toast.position == position && !toast.removed) + .map(|toast| toast.id) + .collect::>(); + render_meta(&active_ids, toast_id) + } + }); + let merged_class = move || { + let variant_class = match toast_state.get().map(|toast| toast.variant).unwrap_or(ToastType::Default) { + ToastType::Default => "bg-background text-foreground border-border", + ToastType::Success => "bg-success-light text-success-dark border-success", + ToastType::Error => "bg-destructive-light text-destructive-dark border-destructive", + ToastType::Warning => "bg-warning-light text-warning-dark border-warning", + ToastType::Info => "bg-info-light text-info-dark border-info", + ToastType::Loading => "bg-background text-foreground border-border", + }; + + tw_merge!( + "p-5 shadow-lg border rounded-lg cursor-grab absolute w-full max-w-96 touch-none flex items-start gap-3", + variant_class, + if is_top_position(position) { "top-0" } else { "bottom-0" } + ) + }; + + view! { +
  • +
    svg]:animate-spin" + } else { + "flex items-center justify-center w-5 h-5 shrink-0 mr-3" + }> + {move || toast_icon(toast_state.get().map(|toast| toast.variant).unwrap_or(ToastType::Default))} +
    + +
    +
    +

    + {move || toast_state.get().map(|toast| toast.title).unwrap_or_default()} +

    + + + +
    + + +

    + {move || toast_state.get().and_then(|toast| toast.description).unwrap_or_default()} +

    +
    +
    + +
    +
    +
    +
  • + } +} + +#[component] +fn CloseIcon() -> impl IntoView { + view! { + + + + } +} + +#[component] +fn SuccessIcon() -> impl IntoView { + view! { + + + + } +} + +#[component] +fn ErrorIcon() -> impl IntoView { + view! { + + + + } +} + +#[component] +fn WarningIcon() -> impl IntoView { + view! { + + + + } +} + +#[component] +fn InfoIcon() -> impl IntoView { + view! { + + + + } +} + +#[component] +fn LoadingIcon() -> impl IntoView { + view! { + + + + } +} + +fn toast_icon(variant: ToastType) -> AnyView { + match variant { + ToastType::Success => view! { }.into_any(), + ToastType::Error => view! { }.into_any(), + ToastType::Warning => view! { }.into_any(), + ToastType::Info => view! { }.into_any(), + ToastType::Loading => view! { }.into_any(), + ToastType::Default => view! { }.into_any(), + } +} + +#[component] +pub fn SonnerToaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView { + if use_context::().is_none() { + provide_context(new_sonner_context()); + } + let direction = direction_from_position(position); let container_class = match position { SonnerPosition::TopLeft => "left-6 top-6", SonnerPosition::TopRight => "right-6 top-6", @@ -136,10 +900,300 @@ pub fn SonnerToaster(#[prop(default = SonnerPosition::default())] position: Sonn }; view! { + - - "" - + } } + +fn new_sonner_context() -> SonnerContext { + SonnerContext { + toasts: RwSignal::new(Vec::new()), + next_id: RwSignal::new(1), + expanded_position: RwSignal::new(None), + timers: Arc::new(Mutex::new(HashMap::new())), + } +} + +fn push_toast( + ctx: &SonnerContext, + variant: ToastType, + title: String, + description: Option, + duration_ms: u32, + position: SonnerPosition, +) -> u64 { + let id = ctx.next_id.get_untracked(); + ctx.next_id.set(id.saturating_add(1)); + + let oldest = { + let toasts = ctx.toasts.get_untracked(); + let mut active = toasts + .iter() + .filter(|toast| toast.position == position && !toast.removed) + .map(|toast| toast.id) + .collect::>(); + if active.len() >= MAX_TOASTS { active.pop() } else { None } + }; + + if let Some(oldest_id) = oldest { + dismiss_toast(ctx, oldest_id, false, None); + } + + let toast = SonnerToast { + id, + variant, + title, + description, + duration_ms, + position, + mounted: false, + entering: true, + removed: false, + swipe_out: false, + swiping: false, + swipe_direction: None, + }; + + ctx.toasts.update(|toasts| { + toasts.insert(0, toast); + }); + + let mount_ctx = ctx.clone(); + set_timeout( + move || { + mount_ctx.toasts.update(|toasts| { + if let Some(toast) = toasts.iter_mut().find(|toast| toast.id == id) { + toast.mounted = true; + } + }); + }, + Duration::from_millis(16), + ); + + let entering_ctx = ctx.clone(); + set_timeout( + move || { + entering_ctx.toasts.update(|toasts| { + if let Some(toast) = toasts.iter_mut().find(|toast| toast.id == id) { + toast.entering = false; + } + }); + }, + Duration::from_millis(u64::from(ENTER_DURATION_MS)), + ); + + if variant != ToastType::Loading { + schedule_timer(ctx, id, duration_ms); + } + + id +} + +fn update_toast( + ctx: &SonnerContext, + id: u64, + variant: ToastType, + title: String, + description: Option, + duration_ms: Option, +) { + ctx.toasts.update(|toasts| { + if let Some(toast) = toasts.iter_mut().find(|toast| toast.id == id) { + toast.variant = variant; + toast.title = title; + toast.description = description; + if let Some(duration) = duration_ms { + toast.duration_ms = duration; + } + } + }); + + if variant == ToastType::Loading { + cancel_timer(ctx, id); + } else if let Some(duration) = duration_ms { + schedule_timer(ctx, id, duration); + } +} + +fn dismiss_toast(ctx: &SonnerContext, id: u64, swipe_out: bool, swipe_direction: Option<&'static str>) { + cancel_timer(ctx, id); + + ctx.toasts.update(|toasts| { + if let Some(toast) = toasts.iter_mut().find(|toast| toast.id == id) { + if toast.removed { + return; + } + toast.removed = true; + toast.swipe_out = swipe_out; + toast.swipe_direction = swipe_direction; + toast.swiping = false; + } + }); + + let ctx = ctx.clone(); + set_timeout( + move || { + ctx.toasts.update(|toasts| { + if let Some(index) = toasts.iter().position(|toast| toast.id == id) { + toasts.remove(index); + } + }); + cancel_timer(&ctx, id); + }, + Duration::from_millis(u64::from(EXIT_DURATION_MS)), + ); +} + +fn render_meta(active_ids: &[u64], toast_id: u64) -> RenderMeta { + if let Some(index) = active_ids.iter().position(|id| *id == toast_id) { + let z_index = active_ids.len().saturating_sub(index); + let from_end = index; + return RenderMeta { + index, + z_index, + front: index == 0, + visible: from_end < VISIBLE_TOASTS_AMOUNT, + hidden: from_end >= MAX_TOASTS, + }; + } + + RenderMeta { index: active_ids.len(), z_index: 1, front: false, visible: false, hidden: true } +} + +fn toast_snapshot(ctx: &SonnerContext, toast_id: u64) -> Option { + ctx.toasts.get().into_iter().find(|toast| toast.id == toast_id) +} + +fn bool_attr(value: bool) -> &'static str { + if value { "true" } else { "false" } +} + +fn parse_position(value: &str) -> Option { + match value { + "TopLeft" => Some(SonnerPosition::TopLeft), + "TopCenter" => Some(SonnerPosition::TopCenter), + "TopRight" => Some(SonnerPosition::TopRight), + "BottomLeft" => Some(SonnerPosition::BottomLeft), + "BottomCenter" => Some(SonnerPosition::BottomCenter), + "BottomRight" => Some(SonnerPosition::BottomRight), + _ => None, + } +} + +fn direction_from_position(position: SonnerPosition) -> SonnerDirection { + match position { + SonnerPosition::TopLeft | SonnerPosition::TopCenter | SonnerPosition::TopRight => SonnerDirection::TopDown, + _ => SonnerDirection::BottomUp, + } +} + +fn is_top_position(position: SonnerPosition) -> bool { + matches!(position, SonnerPosition::TopLeft | SonnerPosition::TopCenter | SonnerPosition::TopRight) +} + +fn allowed_vertical_delta(position: SonnerPosition, delta_y: f64) -> bool { + if is_top_position(position) { delta_y < 0.0 } else { delta_y > 0.0 } +} + +fn dampening(delta: f64) -> f64 { + 1.0 / (1.5 + delta.abs() / 20.0) +} + +fn set_swiping(ctx: &SonnerContext, id: u64, swiping: bool) { + ctx.toasts.update(|toasts| { + if let Some(toast) = toasts.iter_mut().find(|toast| toast.id == id) { + toast.swiping = swiping; + if !swiping { + toast.swipe_direction = None; + } + } + }); +} + +fn is_toast_removed(ctx: &SonnerContext, id: u64) -> bool { + ctx.toasts.get_untracked().iter().any(|toast| toast.id == id && toast.removed) +} + +fn pause_position_timers(ctx: &SonnerContext, position: SonnerPosition) { + let toast_ids = ctx + .toasts + .get_untracked() + .into_iter() + .filter(|toast| toast.position == position && !toast.removed && toast.variant != ToastType::Loading) + .map(|toast| toast.id) + .collect::>(); + + for id in toast_ids { + pause_timer(ctx, id); + } +} + +fn resume_position_timers(ctx: &SonnerContext, position: SonnerPosition) { + let toast_ids = ctx + .toasts + .get_untracked() + .into_iter() + .filter(|toast| toast.position == position && !toast.removed && toast.variant != ToastType::Loading) + .map(|toast| toast.id) + .collect::>(); + + for id in toast_ids { + resume_timer(ctx, id); + } +} + +fn schedule_timer(ctx: &SonnerContext, id: u64, duration_ms: u32) { + cancel_timer(ctx, id); + + let dismiss_ctx = ctx.clone(); + if let Ok(timeout) = set_timeout_with_handle( + move || dismiss_toast(&dismiss_ctx, id, false, None), + Duration::from_millis(u64::from(duration_ms)), + ) && let Ok(mut timers) = ctx.timers.lock() + { + timers.insert( + id, + SonnerTimer { timeout: Some(timeout), remaining_ms: duration_ms, started_at_ms: js_sys::Date::now() }, + ); + } +} + +fn pause_timer(ctx: &SonnerContext, id: u64) { + let now = js_sys::Date::now(); + let Ok(mut timers) = ctx.timers.lock() else { return }; + let Some(timer) = timers.get_mut(&id) else { return }; + + if let Some(timeout) = timer.timeout.take() { + timeout.clear(); + } + + let elapsed = (now - timer.started_at_ms).max(0.0) as u32; + timer.remaining_ms = timer.remaining_ms.saturating_sub(elapsed); +} + +fn resume_timer(ctx: &SonnerContext, id: u64) { + let remaining = { + let Ok(timers) = ctx.timers.lock() else { return }; + let Some(timer) = timers.get(&id) else { return }; + if timer.timeout.is_some() { + return; + } + timer.remaining_ms + }; + if remaining == 0 { + dismiss_toast(ctx, id, false, None); + return; + } + schedule_timer(ctx, id, remaining); +} + +fn cancel_timer(ctx: &SonnerContext, id: u64) { + if let Ok(mut timers) = ctx.timers.lock() + && let Some(mut timer) = timers.remove(&id) + && let Some(timeout) = timer.timeout.take() + { + timeout.clear(); + } +} diff --git a/e2e/tests/components/_base_page.ts b/e2e/tests/components/_base_page.ts index a74c957..9c08676 100644 --- a/e2e/tests/components/_base_page.ts +++ b/e2e/tests/components/_base_page.ts @@ -6,16 +6,23 @@ import { Locator, Page, expect } from "@playwright/test"; export abstract class BasePage { readonly page: Page; - /** The demo preview container - all component tests should scope within this */ - readonly preview: Locator; - /** Component name used in URL path (e.g., "button", "dialog") */ protected abstract readonly componentName: string; + /** Optional demo name used to scope tests to a specific preview on the docs page */ + protected readonly demoName?: string; + constructor(page: Page) { this.page = page; - // Demo components are rendered inside Preview container - this.preview = page.locator('[data-name="Preview"]').first(); + } + + /** The demo preview container - all component tests should scope within this */ + get preview(): Locator { + if (this.demoName) { + return this.page.locator(`[data-name="Preview"][data-demo-name="${this.demoName}"]`).first(); + } + + return this.page.locator('[data-name="Preview"]').first(); } /** diff --git a/e2e/tests/components/sonner.spec.ts b/e2e/tests/components/sonner.spec.ts index b6447d6..af67eeb 100644 --- a/e2e/tests/components/sonner.spec.ts +++ b/e2e/tests/components/sonner.spec.ts @@ -3,6 +3,7 @@ import { BasePage } from "./_base_page"; class SonnerPage extends BasePage { protected readonly componentName = "sonner"; + protected readonly demoName = "demo_sonner"; // Sonner elements readonly triggerButton: Locator; @@ -27,7 +28,7 @@ class SonnerPage extends BasePage { class SonnerPositionsPage extends BasePage { protected readonly componentName = "sonner"; - protected readonly demoName = "positions"; + protected readonly demoName = "demo_sonner_positions"; // Trigger buttons readonly topLeftTrigger: Locator; @@ -1094,7 +1095,7 @@ test.describe("Sonner Positions Page", () => { expect(toastCount).toBeGreaterThanOrEqual(2); // Hover over the toaster to trigger expansion - await ui.bottomRightToaster.hover(); + await ui.bottomRightToaster.dispatchEvent("mouseenter"); await page.waitForTimeout(400); // Wait for expansion animation // Get positions AFTER hover (expanded state) @@ -1810,7 +1811,7 @@ test.describe("Sonner Positions Page", () => { await page.waitForTimeout(400); // Hover to expand - await ui.bottomRightToaster.hover(); + await ui.bottomRightToaster.dispatchEvent("mouseenter"); await page.waitForTimeout(400); const toasts = ui.bottomRightToaster.locator('[data-sonner-toast="true"]'); @@ -1861,7 +1862,7 @@ test.describe("Sonner Positions Page", () => { await page.waitForTimeout(400); // Hover to expand - await ui.bottomRightToaster.hover(); + await ui.bottomRightToaster.dispatchEvent("mouseenter"); await page.waitForTimeout(400); const toasts = ui.bottomRightToaster.locator('[data-sonner-toast="true"]'); @@ -2648,7 +2649,7 @@ test.describe("Sonner Positions Page", () => { await page.waitForTimeout(400); // Hover over the toaster to pause - await ui.bottomRightToaster.hover(); + await ui.bottomRightToaster.dispatchEvent("mouseenter"); await page.waitForTimeout(100); const toast = ui.bottomRightToaster.locator('[data-sonner-toast="true"]').first(); @@ -2704,7 +2705,7 @@ test.describe("Sonner Positions Page", () => { await page.waitForTimeout(400); // Hover to pause - await ui.bottomRightToaster.hover(); + await ui.bottomRightToaster.dispatchEvent("mouseenter"); await page.waitForTimeout(200); const toast = ui.bottomRightToaster.locator('[data-sonner-toast="true"]').first(); @@ -2716,7 +2717,7 @@ test.describe("Sonner Positions Page", () => { ); // Move mouse away to resume - await page.mouse.move(0, 0); + await ui.bottomRightToaster.dispatchEvent("mouseleave"); await page.waitForTimeout(800); // Get scale after resuming @@ -3115,7 +3116,7 @@ test.describe("Sonner Positions Page", () => { await page.waitForTimeout(500); // Hover over toaster - await ui.bottomRightToaster.hover(); + await ui.bottomRightToaster.dispatchEvent("mouseenter"); await page.waitForTimeout(100); const frontToast = ui.bottomRightToaster.locator('[data-sonner-toast="true"]').first(); @@ -4905,7 +4906,7 @@ test.describe("Sonner Edge Cases", () => { class SonnerVariantsPage extends BasePage { protected readonly componentName = "sonner"; - protected readonly demoName = "variants"; + protected readonly demoName = "demo_sonner_variants"; readonly defaultTrigger: Locator; readonly successTrigger: Locator; @@ -4919,13 +4920,13 @@ test.describe("Sonner Edge Cases", () => { super(page); // Use exact: true to avoid matching "Bottom Right (Default)" - this.defaultTrigger = page.getByRole("button", { name: "Default", exact: true }); - this.successTrigger = page.getByRole("button", { name: "Success", exact: true }); - this.errorTrigger = page.getByRole("button", { name: "Error", exact: true }); - this.warningTrigger = page.getByRole("button", { name: "Warning", exact: true }); - this.infoTrigger = page.getByRole("button", { name: "Info", exact: true }); - this.loadingTrigger = page.getByRole("button", { name: "Loading", exact: true }); - this.toaster = page.locator('[data-sonner-toaster="true"]'); + this.defaultTrigger = this.preview.getByRole("button", { name: "Default", exact: true }); + this.successTrigger = this.preview.getByRole("button", { name: "Success", exact: true }); + this.errorTrigger = this.preview.getByRole("button", { name: "Error", exact: true }); + this.warningTrigger = this.preview.getByRole("button", { name: "Warning", exact: true }); + this.infoTrigger = this.preview.getByRole("button", { name: "Info", exact: true }); + this.loadingTrigger = this.preview.getByRole("button", { name: "Loading", exact: true }); + this.toaster = this.preview.locator('[data-sonner-toaster="true"]'); } } @@ -5596,8 +5597,6 @@ test.describe("Sonner Edge Cases", () => { test("sonner toast should appear after SPA navigation", async ({ page }) => { const ui = new SonnerPage(page); await ui.gotoViaSpa(); - // Wait for lazy-loaded sonner.js to finish loading before triggering - await page.waitForFunction(() => (window as any).LazySonner?.loaded === true, { timeout: 5000 }); await ui.triggerToast(); await expect(page.locator('[data-sonner-toaster]').first()).toBeVisible(); @@ -5606,8 +5605,6 @@ test.describe("Sonner Edge Cases", () => { test("sonner toast message should be visible after SPA navigation", async ({ page }) => { const ui = new SonnerPage(page); await ui.gotoViaSpa(); - // Wait for lazy-loaded sonner.js to finish loading before triggering - await page.waitForFunction(() => (window as any).LazySonner?.loaded === true, { timeout: 5000 }); await ui.triggerToast(); await expect(page.locator('[data-sonner-toast="true"]').first()).toBeVisible(); diff --git a/public/app_components/lazy_load_sonner.js b/public/app_components/lazy_load_sonner.js deleted file mode 100644 index f66f18e..0000000 --- a/public/app_components/lazy_load_sonner.js +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Lazy Sonner Loader - * Loads Sonner toast library only when needed (on-demand) - * Improves initial page load performance by deferring non-critical scripts - */ - -(() => { - // Prevent multiple initializations - if (window.LazySonner) { - return; - } - - class LazySonner { - constructor() { - this.loaded = false; - this.loading = false; - this.loadPromise = null; - this.pendingToasts = []; - } - - /** - * Load Sonner script dynamically - * @returns {Promise} Resolves when script is loaded - */ - load() { - // Return existing promise if already loading - if (this.loadPromise) { - return this.loadPromise; - } - - // Return resolved promise if already loaded - if (this.loaded) { - return Promise.resolve(); - } - - this.loading = true; - - this.loadPromise = new Promise((resolve, reject) => { - const script = document.createElement("script"); - script.type = "module"; - script.async = true; - script.src = "/app_components/sonner.js"; - - script.onload = () => { - this.loaded = true; - this.loading = false; - console.debug("Sonner loaded successfully"); - resolve(); - }; - - script.onerror = () => { - this.loading = false; - this.loadPromise = null; - console.error("Failed to load Sonner"); - reject(new Error("Failed to load Sonner")); - }; - - document.head.appendChild(script); - }); - - return this.loadPromise; - } - - /** - * Setup observers to detect when Sonner triggers appear - */ - observeTriggers() { - // Check for existing triggers on page - const checkAndLoad = () => { - const triggers = document.querySelectorAll('[data-name="SonnerTrigger"]'); - const wrapper = document.querySelector('[data-name="SonnerList"]'); - - if ((triggers.length > 0 || wrapper) && !this.loaded && !this.loading) { - console.debug("Sonner elements detected - loading script"); - this.load(); - } - }; - - // Initial check - checkAndLoad(); - - // Watch for dynamically added triggers (SPA navigation) - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.type === "childList") { - mutation.addedNodes.forEach((node) => { - if (node.nodeType === Node.ELEMENT_NODE) { - const hasTrigger = node?.querySelector?.('[data-name="SonnerTrigger"]'); - const hasWrapper = node?.querySelector?.('[data-name="SonnerList"]'); - const isTrigger = - node.getAttribute && node.getAttribute("data-name") === "SonnerTrigger"; - const isWrapper = - node.getAttribute && node.getAttribute("data-name") === "SonnerList"; - - if (hasTrigger || hasWrapper || isTrigger || isWrapper) { - if (!this.loaded && !this.loading) { - console.debug("Sonner elements detected via observer - loading script"); - this.load(); - } - } - } - }); - } - }); - }); - - // Start observing - observer.observe(document.body, { - childList: true, - subtree: true, - }); - } - - /** - * Preload Sonner on user interaction (optional optimization) - */ - preloadOnInteraction() { - const events = ["mouseover", "touchstart", "keydown"]; - const preload = () => { - if (!this.loaded && !this.loading) { - this.load(); - } - // Remove listeners after first interaction - events.forEach((event) => { - document.removeEventListener(event, preload, { once: true }); - }); - }; - - // Add listeners for first interaction - events.forEach((event) => { - document.addEventListener(event, preload, { once: true }); - }); - } - } - - // Export as singleton - const lazySonner = new LazySonner(); - window.LazySonner = lazySonner; - - // Start observing for Sonner elements - lazySonner.observeTriggers(); - - // Optional: Preload on first user interaction for better UX - // Comment out if you want strictly on-demand loading - lazySonner.preloadOnInteraction(); -})(); diff --git a/public/app_components/sonner.css b/public/app_components/sonner.css deleted file mode 100644 index f2f23ac..0000000 --- a/public/app_components/sonner.css +++ /dev/null @@ -1,288 +0,0 @@ -/* ========================================================== */ -/* TOAST REMOVAL ANIMATIONS */ -/* ========================================================== */ - -/* Front toast (most recent) - slides up/down and fades out */ -[data-name="SonnerItem"][data-removed="true"][data-front="true"][data-swipe-out="false"] { - --y: translateY(calc(var(--fold-multiplier) * -100%)); - opacity: 0; -} - -/* Non-front toasts when expanded - slide with offset */ -[data-name="SonnerList"][data-expanded="true"] [data-name="SonnerItem"][data-removed="true"][data-front="false"][data-swipe-out="false"] { - --y: translateY(calc(var(--fold-multiplier) * var(--index) * var(--expand-spacing) + var(--fold-multiplier) * -100%)); - opacity: 0; -} - -/* Non-front toasts when collapsed - gentle slide down with slower transition */ -[data-name="SonnerList"][data-expanded="false"] [data-name="SonnerItem"][data-removed="true"][data-front="false"][data-swipe-out="false"] { - --y: translateY(40%); - opacity: 0; - transition: transform 500ms var(--transition-easing), opacity 200ms; -} - -/* ========================================================== */ -/* INITIAL/MOUNTED STATE */ -/* ========================================================== */ - -/* Initial state - hidden and off-screen before mounted */ -/* BottomUp: start below screen (translateY(100%)) */ -[data-name="SonnerList"][data-direction="BottomUp"] [data-name="SonnerItem"][data-mounted="false"] { - --y: translateY(100%); - opacity: 0; -} - -/* TopDown: start above screen (translateY(-100%)) */ -[data-name="SonnerList"][data-direction="TopDown"] [data-name="SonnerItem"][data-mounted="false"] { - --y: translateY(-100%); - opacity: 0; -} - -/* Mounted state - slide to on-screen position with fade in */ -[data-name="SonnerItem"][data-mounted="true"] { - --y: translateY(0); - opacity: 1; -} - -/* Entry animation override - ensure entry animation completes before stacking applies */ -/* This rule has higher specificity to override the collapsed stacking rule during entry */ -[data-name="SonnerList"] [data-name="SonnerItem"][data-mounted="true"][data-entering="true"] { - --y: translateY(0); - transform: var(--y); - opacity: 1; -} - -/* ========================================================== */ -/* FOLD DIRECTION & BASE */ -/* ========================================================== */ - -/* Fold direction CSS custom properties */ -[data-name="SonnerList"][data-direction="BottomUp"] { - --fold-multiplier: -1; -} - -[data-name="SonnerList"][data-direction="TopDown"] { - --fold-multiplier: 1; -} - -[data-name="SonnerList"] { - /* Base container styles */ - position: fixed; - z-index: 9999; /* Must be above all other UI elements like headers (z-60) */ - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 15px; - opacity: 1; - height: 100px; - width: 400px; -} - -/* Position-specific styles */ -[data-name="SonnerList"][data-position="TopLeft"] { - top: 0.75rem; - left: 0.75rem; -} - -[data-name="SonnerList"][data-position="TopCenter"] { - top: 0.75rem; - left: 50%; - transform: translateX(-50%); -} - -[data-name="SonnerList"][data-position="TopRight"] { - top: 0.75rem; - right: 0.75rem; -} - -[data-name="SonnerList"][data-position="BottomLeft"] { - bottom: 0.75rem; - left: 0.75rem; -} - -[data-name="SonnerList"][data-position="BottomCenter"] { - bottom: 0.75rem; - left: 50%; - transform: translateX(-50%); -} - -[data-name="SonnerList"][data-position="BottomRight"] { - bottom: 0.75rem; - right: 0.75rem; -} - -[data-name="SonnerItem"] { - --y: translateY(0); - /* Base transform using CSS variable */ - transform: var(--y); - /* Unified transition properties for all toast animations */ - transition: transform var(--stack-duration) var(--transition-easing), - opacity var(--exit-duration) var(--transition-easing); -} - -/* ========================================================== */ -/* VISIBILITY & LIMITS */ -/* ========================================================== */ - -/* CSS-based toast limit - hide toasts beyond visible limit */ -/* This is now controlled by JS via data-hidden attribute to properly handle - the transition period when old toasts are being dismissed */ -[data-name="SonnerItem"][data-hidden="true"] { - display: none; -} - -/* Hide toasts beyond visible limit (controlled by JS) */ -[data-name="SonnerItem"][data-visible="false"] { - opacity: 0; - pointer-events: none; -} - -/* ========================================================== */ -/* STACKING & EXPANSION */ -/* ========================================================== */ - -/* Default collapsed state - stacked with scaling */ -[data-name="SonnerItem"][data-mounted="true"][data-expanded="false"] { - --y: translateY(calc(var(--fold-multiplier) * var(--index) * var(--stack-spacing))); - transform: var(--y) scale(calc(1 - var(--index) * var(--scale-factor))); - z-index: var(--z-index); -} - -/* Expanded state - expanded spacing with no scaling */ -[data-name="SonnerList"][data-expanded="true"] [data-name="SonnerItem"][data-mounted="true"] { - --y: translateY(calc(var(--fold-multiplier) * var(--index) * var(--expand-spacing))); - transform: var(--y) scale(1); - z-index: var(--z-index); -} - -/* Gap spacer - extends hover area between toasts when expanded */ -[data-name="SonnerItem"][data-expanded="true"]::after { - content: ''; - position: absolute; - left: 0; - height: calc(var(--gap) + 1px); - bottom: 100%; - width: 100%; -} - -/* ========================================================== */ -/* TOAST TYPES & COLORS */ -/* ========================================================== */ - -/* Icon SVG sizing */ -[data-icon] svg { - width: 1rem; - height: 1rem; -} - -/* Loading spinner uses Tailwind's animate-spin class */ - -/* ========================================================== */ -/* CLOSE BUTTON */ -/* ========================================================== */ - -/* Close button SVG sizing */ -[data-close-button] svg { - width: 0.75rem; - height: 0.75rem; -} - - - - -/* Fallback transition for progress bar (duration set dynamically in JS) */ -[data-duration-progress] { - transition: transform linear; -} - -/* Pause animation when toast list is expanded */ -[data-name="SonnerList"][data-expanded="true"] [data-name="SonnerItem"] [data-duration-progress] { - animation-play-state: paused !important; -} - -/* Hide duration track for loading toasts */ -[data-name="SonnerItem"][data-variant="Loading"] [data-duration-track] { - display: none; -} - -/* ========================================================== */ -/* SWIPE TO DISMISS */ -/* ========================================================== */ - -/* Swiping state - disable transitions and apply manual transform */ -[data-name="SonnerItem"][data-swiping="true"] { - transition: none !important; - cursor: grabbing; - user-select: none; -} - -/* Apply swipe transform during dragging */ -[data-name="SonnerItem"][data-swiping="true"][data-mounted="true"] { - transform: var(--y) - scale(calc(1 - var(--index) * var(--scale-factor))) - translateX(var(--swipe-amount-x, 0px)) - translateY(var(--swipe-amount-y, 0px)); -} - -/* Swipe out animations for each direction */ -[data-name="SonnerItem"][data-swipe-out="true"][data-swipe-direction="Right"] { - animation: swipe-out-right var(--exit-duration) ease-out forwards; -} - -[data-name="SonnerItem"][data-swipe-out="true"][data-swipe-direction="Left"] { - animation: swipe-out-left var(--exit-duration) ease-out forwards; -} - -[data-name="SonnerItem"][data-swipe-out="true"][data-swipe-direction="Up"] { - animation: swipe-out-up var(--exit-duration) ease-out forwards; -} - -[data-name="SonnerItem"][data-swipe-out="true"][data-swipe-direction="Down"] { - animation: swipe-out-down var(--exit-duration) ease-out forwards; -} - -@keyframes swipe-out-right { - from { - transform: var(--y) translateX(var(--swipe-amount-x, 0px)); - opacity: 1; - } - to { - transform: var(--y) translateX(calc(100% + 100px)); - opacity: 0; - } -} - -@keyframes swipe-out-left { - from { - transform: var(--y) translateX(var(--swipe-amount-x, 0px)); - opacity: 1; - } - to { - transform: var(--y) translateX(calc(-100% - 100px)); - opacity: 0; - } -} - -@keyframes swipe-out-up { - from { - transform: var(--y) translateY(var(--swipe-amount-y, 0px)); - opacity: 1; - } - to { - transform: var(--y) translateY(calc(-100% - 100px)); - opacity: 0; - } -} - -@keyframes swipe-out-down { - from { - transform: var(--y) translateY(var(--swipe-amount-y, 0px)); - opacity: 1; - } - to { - transform: var(--y) translateY(calc(100% + 100px)); - opacity: 0; - } -} diff --git a/public/app_components/sonner.js b/public/app_components/sonner.js deleted file mode 100644 index 11b511f..0000000 --- a/public/app_components/sonner.js +++ /dev/null @@ -1,1040 +0,0 @@ -// Toast props -const TOAST_PROPS = { - MAX_TOASTS: "--max-toasts", -}; - -const TOAST_DATA_NAMES = { - LIST: "SonnerList", - ITEM: "SonnerItem", - TRIGGER: "SonnerTrigger", -}; - -// Timer start mode -const TIMER_START = { - ON_ANIMATION_END: "on_animation_end", - IMMEDIATELY: "immediately", -}; - -// Swipe configuration -const SWIPE_THRESHOLD = 45; // pixels -const SWIPE_VELOCITY_THRESHOLD = 0.11; // pixels per millisecond - -// Visible toasts amount (Sonner default) -const VISIBLE_TOASTS_AMOUNT = 3; - -// Default toast container styles (CSS variables) -const DEFAULT_TOAST_STYLES = { - "--max-toasts": "5", - "--dismiss-delay": "5000ms", - "--enter-duration": "300ms", - "--exit-duration": "300ms", - "--stack-duration": "300ms", - "--stack-spacing": "20px", - "--expand-spacing": "110px", - "--gap": "15px", - "--scale-factor": "0.05", - "--transition-easing": "ease-out", - "--stack-easing": "ease-in-out", -}; - -// Toast type icons (SVG) -const TOAST_ICONS = { - success: ` - - `, - error: ` - - `, - warning: ` - - `, - info: ` - - `, - loading: ` - - `, -}; - -// Variant styling classes (Tailwind) -const VARIANT_CLASSES = { - Default: "bg-background text-foreground border-border", - Success: "bg-success-light text-success-dark border-success", - Error: "bg-destructive-light text-destructive-dark border-destructive", - Warning: "bg-warning-light text-warning-dark border-warning", - Info: "bg-info-light text-info-dark border-info", - Loading: "bg-background text-foreground border-border", -}; - -// Action button variant classes -const ACTION_BUTTON_CLASSES = { - Success: "!bg-success !text-white hover:!bg-success/90 !border-success/90", - Error: "!bg-destructive !text-white hover:!bg-destructive/90 !border-destructive/90", -}; - -// Close button icon -const CLOSE_ICON = ` - -`; - -// Filled checkmark icon (for successful promise toasts) -const CHECK_FILLED_ICON = ` - -`; - -// Filled error icon (for failed promise toasts) -const ERROR_FILLED_ICON = ` - -`; - -/* ========================================================== */ -/* ✨ TOAST STATE ✨ */ -/* ========================================================== */ - -// Toast state management - tracks toasts per position in arrays -const toastState = new Map(); -let toastIdCounter = 0; - -// Get or create toast array for a position -function getToastArray(toastsWrapper) { - if (!toastState.has(toastsWrapper)) { - toastState.set(toastsWrapper, []); - } - return toastState.get(toastsWrapper); -} - -// Add toast to state array -function addToastToState(toastsWrapper, toastElement) { - const toasts = getToastArray(toastsWrapper); - const toastId = toastIdCounter++; - - toastElement._toastId = toastId; - toasts.push({ - id: toastId, - element: toastElement, - }); - - return toastId; -} - -// Remove toast from state array -function removeToastFromState(toastsWrapper, toastElement) { - const toasts = getToastArray(toastsWrapper); - const index = toasts.findIndex((t) => t.element === toastElement); - - if (index !== -1) { - toasts.splice(index, 1); - } -} - -/* ========================================================== */ -/* ✨ UTILITY FUNCTIONS ✨ */ -/* ========================================================== */ - -// Get CSS custom property value as integer (used 6+ times) -function getCSSValue(element, property) { - return Number.parseInt(getComputedStyle(element).getPropertyValue(property), 10); -} - -// Filter active toasts (not removed) (used 3 times) -function getActiveToasts(toasts) { - return toasts.filter((toast) => toast.element.dataset.removed !== "true"); -} - -// Helper function to get toast content from trigger element -function getContentFromTrigger(trigger) { - const title = trigger.dataset.toastTitle; - const description = trigger.dataset.toastDescription; - if (!title) return null; - - const content = { title }; - if (description) content.description = description; - return content; -} - -// Extract y-position (Top/Bottom) and x-position (Left/Center/Right) from position string -// e.g., "TopLeft" → { y: "Top", x: "Left" }, "BottomCenter" → { y: "Bottom", x: "Center" } -function parsePosition(position) { - if (!position) return { y: "Bottom", x: "Right" }; // default - - const y = position.startsWith("Top") ? "Top" : "Bottom"; - - let x = "Right"; // default - if (position.includes("Left")) x = "Left"; - else if (position.includes("Center")) x = "Center"; - - return { y, x }; -} - -/* ========================================================== */ -/* ✨ INITIALIZATION ✨ */ -/* ========================================================== */ - -// Get toaster by position or return default (first one) -function getToasterByPosition(position) { - if (position) { - const toaster = document.querySelector(`[data-name="SonnerList"][data-position="${position}"]`); - if (toaster) return toaster; - } - // Fallback to first toaster - return document.querySelector('[data-name="SonnerList"]'); -} - -// Initialize a single toaster wrapper -function initializeToaster(toastsWrapper) { - // Check if wrapper already initialized - if (toastsWrapper.hasAttribute("data-sonner-initialized")) { - return; - } - - // Mark wrapper as initialized - toastsWrapper.setAttribute("data-sonner-initialized", "true"); - - // Apply default styles to toast container - Object.entries(DEFAULT_TOAST_STYLES).forEach(([property, value]) => { - toastsWrapper.style.setProperty(property, value); - }); - - // Setup expanded state tracking - setupExpandedState(toastsWrapper); -} - -// Initialize Sonner toast functionality for SPA-compatible operation -function initializeSonner() { - const toastsWrappers = document.querySelectorAll('[data-name="SonnerList"]'); - - // Early return if no toasters exist - if (toastsWrappers.length === 0) { - return; - } - - console.debug("Initializing Sonner toast functionality"); - - // Initialize all toasters - toastsWrappers.forEach((wrapper) => { - initializeToaster(wrapper); - }); -} - -// Use event delegation for trigger clicks - this handles dynamically added triggers -// and avoids timing issues with Leptos hydration -let delegationSetup = false; -function setupTriggerDelegation() { - if (delegationSetup) return; - delegationSetup = true; - - document.addEventListener("click", (event) => { - // Find if click target is a SonnerTrigger or inside one - const trigger = event.target.closest('[data-name="SonnerTrigger"]'); - if (!trigger) return; - - const variant = trigger.dataset.variant || "Default"; - const content = getContentFromTrigger(trigger); - const position = trigger.dataset.toastPosition; - - // Find the toaster - initialize it if not already done - const toastsWrapper = getToasterByPosition(position); - if (toastsWrapper) { - // Ensure toaster is initialized - initializeToaster(toastsWrapper); - createNewToast(toastsWrapper, variant, content ? { content } : {}); - } - }); -} - -// Initialize immediately if DOM is already loaded -initializeSonner(); -setupTriggerDelegation(); - -// Set up MutationObserver to watch for Sonner toasters being added (for SPA navigation) -const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - if (mutation.type === "childList") { - mutation.addedNodes.forEach((node) => { - // Check if added node contains Sonner toasters or is a toaster itself - if (node.nodeType === Node.ELEMENT_NODE) { - const hasToastWrapper = node?.querySelector?.('[data-name="SonnerList"]'); - const isToastWrapper = - node.getAttribute && node.getAttribute("data-name") === "SonnerList"; - - if (hasToastWrapper || isToastWrapper) { - console.debug("Sonner toaster detected via MutationObserver - initializing"); - initializeSonner(); - } - } - }); - } - }); -}); - -// Start observing DOM changes -observer.observe(document.body, { - childList: true, - subtree: true, -}); - -/* ========================================================== */ -/* ✨ FUNCTIONS ✨ */ -/* ========================================================== */ - -// Get default swipe directions based on toast position -function getSwipeDirections(position) { - // For now, support right and down for BottomRight position - if (position === "BottomRight") { - return ["right", "down"]; - } - return ["right", "left", "up", "down"]; -} - -// Calculate dampening factor for wrong-direction swipes -function getDampening(delta) { - const factor = Math.abs(delta) / 20; - return 1 / (1.5 + factor); -} - -// Toast element creation utility -function createToastElement(variant = "Default", options = {}) { - const toastItem = document.createElement("li"); - const content = options.content || { title: "Notification" }; - // Use lowercase for icon lookup - const icon = TOAST_ICONS[variant.toLowerCase()]; - - toastItem.dataset.name = TOAST_DATA_NAMES.ITEM; - toastItem.dataset.sonnerToast = "true"; - toastItem.dataset.variant = variant; - - // Apply variant-specific Tailwind classes - // Note: positioning (top-0/bottom-0) is added later based on toaster position - const variantClasses = VARIANT_CLASSES[variant] || VARIANT_CLASSES.Default; - // Note: Don't use Tailwind's transform/translate classes here - we use CSS custom properties for animations - toastItem.className = `p-5 shadow-lg border rounded-lg cursor-grab absolute w-full max-w-96 touch-none flex items-start gap-3 ${variantClasses}`; - toastItem.dataset.mounted = "false"; - toastItem.dataset.entering = "true"; // Prevent stacking transforms during entry animation - toastItem.dataset.expanded = "false"; - toastItem.dataset.visible = "true"; - toastItem.dataset.swiping = "false"; - toastItem.dataset.removed = "false"; - toastItem.dataset.swipeOut = "false"; - toastItem.dataset.front = "false"; - - // Set initial inline CSS variables (will be updated by updateToastStyles) - toastItem.style.setProperty("--index", "0"); - toastItem.style.setProperty("--toasts-before", "0"); - toastItem.style.setProperty("--z-index", "1"); - - // Build HTML with optional icon - const iconClasses = - variant === "Loading" - ? "flex items-center justify-center w-5 h-5 shrink-0 mr-3 [&>svg]:animate-spin" - : "flex items-center justify-center w-5 h-5 shrink-0 mr-3"; - const iconHtml = icon ? `
    ${icon}
    ` : ""; - const closeButtonHtml = - variant !== "Loading" - ? `` - : ""; - - // Build action buttons HTML - const actionsHtml = options.actions?.length - ? `
    - ${options.actions - .map( - (action, index) => - ``, - ) - .join("")} -
    ` - : ""; - - // Build description HTML (optional) - const descriptionHtml = content.description - ? `

    ${content.description}

    ` - : ""; - - toastItem.innerHTML = ` - ${iconHtml} -
    -
    -

    ${content.title}

    - ${closeButtonHtml} -
    - ${descriptionHtml} - ${actionsHtml} -
    -
    -
    -
    - `; - - // Add close button click handler if not loading - if (variant !== "Loading") { - const closeButton = toastItem.querySelector("[data-close-button]"); - closeButton.addEventListener("click", (e) => { - e.stopPropagation(); // Prevent triggering swipe events - dismissToast(toastItem); - }); - } - - // Add action button click handlers and apply variant-specific classes - if (options.actions?.length) { - const actionButtons = toastItem.querySelectorAll("[data-action-button]"); - const actionButtonClasses = ACTION_BUTTON_CLASSES[variant] || ""; - - actionButtons.forEach((button, index) => { - // Apply variant-specific action button styling - if (actionButtonClasses) { - button.className += ` ${actionButtonClasses}`; - } - - button.addEventListener("click", (e) => { - e.stopPropagation(); // Prevent triggering swipe events - const action = options.actions[index]; - if (action.onClick) { - action.onClick(); - } - // Auto-dismiss toast after action unless specified otherwise - if (action.dismissOnClick !== false) { - dismissToast(toastItem); - } - }); - }); - } - - return toastItem; -} - -// Update front status for all toasts in the wrapper (array-based, like Sonner) -function updateFrontStatus(toastsWrapper) { - if (!toastsWrapper) return; - - const toasts = getToastArray(toastsWrapper); - - // Filter out toasts that are being removed - const activeToasts = toasts.filter( - (toast) => - toast.element.dataset.removed !== "true" && - toast.element.dataset.swipeOut !== "true", - ); - - // Mark the last toast (most recent, highest index) as front - activeToasts.forEach((toast, index) => { - toast.element.dataset.front = index === activeToasts.length - 1 ? "true" : "false"; - }); - - // Update visible state - updateVisibleToasts(toastsWrapper); - - // Update inline CSS variables (index, z-index) - updateToastStyles(toastsWrapper); -} - -// Update visible toasts based on VISIBLE_TOASTS_AMOUNT -function updateVisibleToasts(toastsWrapper) { - const toasts = getToastArray(toastsWrapper); - const activeToasts = getActiveToasts(toasts); - const maxToasts = getCSSValue(toastsWrapper, TOAST_PROPS.MAX_TOASTS) || 5; - - // Set data-visible and data-hidden based on position from end (most recent toasts) - activeToasts.forEach((toast, index) => { - const fromEnd = activeToasts.length - 1 - index; - toast.element.dataset.visible = fromEnd < VISIBLE_TOASTS_AMOUNT ? "true" : "false"; - // Hide toasts beyond max limit (use JS instead of CSS nth-last-child to handle removed toasts) - toast.element.dataset.hidden = fromEnd >= maxToasts ? "true" : "false"; - }); -} - -// Update inline CSS variables for all toasts (index, z-index, etc.) -function updateToastStyles(toastsWrapper) { - const toasts = getToastArray(toastsWrapper); - const activeToasts = getActiveToasts(toasts); - - // Update styles for each toast based on position - activeToasts.forEach((toast, arrayIndex) => { - // In Sonner: index 0 = front (most recent), higher index = older - // Our array: last item = most recent, first item = oldest - // So we need to reverse: index = length - 1 - arrayIndex - const index = activeToasts.length - 1 - arrayIndex; - const zIndex = activeToasts.length - index; - - // Set inline CSS variables - toast.element.style.setProperty("--index", index.toString()); - toast.element.style.setProperty("--toasts-before", index.toString()); - toast.element.style.setProperty("--z-index", zIndex.toString()); - }); -} - -// Setup expanded state tracking for hover/focus -function setupExpandedState(toastsWrapper) { - let isExpanded = false; - - const setExpanded = (expanded) => { - if (isExpanded === expanded) return; - isExpanded = expanded; - - // Update wrapper attribute - toastsWrapper.dataset.expanded = expanded ? "true" : "false"; - - // Update all toasts in this wrapper with transforms - const toasts = getToastArray(toastsWrapper); - const activeToasts = getActiveToasts(toasts); - const gap = getCSSValue(toastsWrapper, "--gap") || 15; - - // Calculate cumulative offset for each toast (based on heights) - let cumulativeOffset = 0; - const offsets = []; - - // Process from newest (last) to oldest (first) - newest has offset 0 - for (let i = activeToasts.length - 1; i >= 0; i--) { - offsets[i] = cumulativeOffset; - const height = activeToasts[i].element.getBoundingClientRect().height; - cumulativeOffset += height + gap; - } - - activeToasts.forEach((toast, arrayIndex) => { - const el = toast.element; - el.dataset.expanded = expanded ? "true" : "false"; - - // Skip transform updates for toasts still in entry animation - // This allows CSS to handle the entry animation without JS override - if (el.dataset.mounted === "false" || el.dataset.entering === "true") { - return; - } - - // Get lift direction from toast's data attribute - const lift = el.style.getPropertyValue("--lift") || "-1"; - const liftValue = Number.parseFloat(lift); - - // Sonner index: 0 = front (newest), higher = older - const index = activeToasts.length - 1 - arrayIndex; - const offset = offsets[arrayIndex]; - - if (expanded) { - // Expanded: spread toasts using offset and lift direction - const translateY = liftValue * offset; - el.style.transform = `translateY(${translateY}px)`; - el.style.height = "auto"; - } else { - // Collapsed: stack toasts with scale effect - if (index === 0) { - // Front toast - no transform - el.style.transform = "translateY(0)"; - el.style.height = "auto"; - } else { - // Background toasts - stack with gap and scale - const stackGap = getCSSValue(toastsWrapper, "--stack-spacing") || 10; - const scaleFactor = getCSSValue(toastsWrapper, "--scale-factor") || 0.05; - const translateY = liftValue * stackGap * index; - const scale = 1 - scaleFactor * index; - el.style.transform = `translateY(${translateY}px) scale(${scale})`; - } - } - }); - }; - - toastsWrapper.addEventListener("mouseenter", () => setExpanded(true)); - toastsWrapper.addEventListener("mousemove", () => setExpanded(true)); - toastsWrapper.addEventListener("mouseleave", () => setExpanded(false)); - toastsWrapper.addEventListener("focus", () => setExpanded(true), true); - toastsWrapper.addEventListener("blur", () => setExpanded(false), true); -} - -// Dismiss toast utility -function dismissToast(toast) { - if (toast.dataset.removed === "true" || toast.dataset.swipeOut === "true") { - return; // Already dismissing - } - - // Cleanup timer if it exists - if (toast._cleanupTimer) { - toast._cleanupTimer(); - } - - // Mark as removed (not swipe out) - toast.dataset.removed = "true"; - toast.dataset.swipeOut = "false"; - - const toastsWrapper = toast.parentNode; - - // Update front status for all toasts - updateFrontStatus(toastsWrapper); - - // Wait for transition to complete - const exitDuration = getCSSValue(toastsWrapper, "--exit-duration"); - - setTimeout(() => { - // Remove from state array - removeToastFromState(toastsWrapper, toast); - - // Remove from DOM - if (toast.parentNode) { - toast.parentNode.removeChild(toast); - } - }, exitDuration || 200); -} - -// Setup swipe-to-dismiss functionality -function setupSwipeToDismiss(toast) { - let pointerStart = null; - let swipeDirection = null; - const position = "BottomRight"; // Get from data attribute if needed - const swipeDirections = getSwipeDirections(position); - let dragStartTime = null; - - const handlePointerDown = (event) => { - // Ignore right-click - if (event.button === 2) return; - - // Don't allow swiping during dismissal - if (toast.dataset.removed === "true" || toast.dataset.swipeOut === "true") { - return; - } - - // Set pointer capture for smooth dragging outside element - event.target.setPointerCapture(event.pointerId); - - dragStartTime = new Date(); - pointerStart = { x: event.clientX, y: event.clientY }; - toast.dataset.swiping = "true"; - }; - - const handlePointerMove = (event) => { - if (!pointerStart) return; - - // Don't track if user is selecting text - const isHighlighted = window.getSelection()?.toString().length > 0; - if (isHighlighted) return; - - const xDelta = event.clientX - pointerStart.x; - const yDelta = event.clientY - pointerStart.y; - - // Determine swipe direction if not locked - if (!swipeDirection && (Math.abs(xDelta) > 1 || Math.abs(yDelta) > 1)) { - swipeDirection = Math.abs(xDelta) > Math.abs(yDelta) ? "x" : "y"; - } - - const swipeAmount = { x: 0, y: 0 }; - - // Apply swipe in the locked direction - if (swipeDirection === "y") { - if (swipeDirections.includes("up") || swipeDirections.includes("down")) { - if ( - (swipeDirections.includes("up") && yDelta < 0) || - (swipeDirections.includes("down") && yDelta > 0) - ) { - swipeAmount.y = yDelta; - } else { - // Dampened movement for wrong direction - const dampenedDelta = yDelta * getDampening(yDelta); - swipeAmount.y = - Math.abs(dampenedDelta) < Math.abs(yDelta) ? dampenedDelta : yDelta; - } - } - } else if (swipeDirection === "x") { - if (swipeDirections.includes("left") || swipeDirections.includes("right")) { - if ( - (swipeDirections.includes("left") && xDelta < 0) || - (swipeDirections.includes("right") && xDelta > 0) - ) { - swipeAmount.x = xDelta; - } else { - // Dampened movement for wrong direction - const dampenedDelta = xDelta * getDampening(xDelta); - swipeAmount.x = - Math.abs(dampenedDelta) < Math.abs(xDelta) ? dampenedDelta : xDelta; - } - } - } - - // Apply CSS custom properties for transform - toast.style.setProperty("--swipe-amount-x", `${swipeAmount.x}px`); - toast.style.setProperty("--swipe-amount-y", `${swipeAmount.y}px`); - }; - - const handlePointerUp = () => { - if (!pointerStart) return; - - const swipeAmountX = Number.parseFloat( - toast.style.getPropertyValue("--swipe-amount-x").replace("px", "") || 0, - ); - const swipeAmountY = Number.parseFloat( - toast.style.getPropertyValue("--swipe-amount-y").replace("px", "") || 0, - ); - - const timeTaken = Date.now() - dragStartTime.getTime(); - const swipeAmount = swipeDirection === "x" ? swipeAmountX : swipeAmountY; - const velocity = Math.abs(swipeAmount) / timeTaken; - - // Check if swipe threshold met (distance or velocity) - if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > SWIPE_VELOCITY_THRESHOLD) { - // Determine swipe-out direction - let swipeOutDirection; - if (swipeDirection === "x") { - swipeOutDirection = swipeAmountX > 0 ? "right" : "left"; - } else { - swipeOutDirection = swipeAmountY > 0 ? "down" : "up"; - } - - // Cleanup timer if it exists - if (toast._cleanupTimer) { - toast._cleanupTimer(); - } - - // Set swipe-out attributes - toast.dataset.swipeOut = "true"; - toast.dataset.removed = "true"; - // Convert to PascalCase (capitalize first letter) - toast.dataset.swipeDirection = - swipeOutDirection.charAt(0).toUpperCase() + swipeOutDirection.slice(1); - toast.dataset.swiping = "false"; - - const toastsWrapper = toast.parentNode; - - // Update front status for all toasts - updateFrontStatus(toastsWrapper); - - // Wait for animation to complete before removing - const exitDuration = getCSSValue(toastsWrapper, "--exit-duration"); - - setTimeout(() => { - // Remove from state array - removeToastFromState(toastsWrapper, toast); - - // Remove from DOM - if (toast.parentNode) { - toast.parentNode.removeChild(toast); - } - }, exitDuration || 200); - } else { - // Reset swipe - spring back to original position - toast.style.setProperty("--swipe-amount-x", "0px"); - toast.style.setProperty("--swipe-amount-y", "0px"); - toast.dataset.swiping = "false"; - } - - // Reset swipe state - pointerStart = null; - swipeDirection = null; - dragStartTime = null; - }; - - toast.addEventListener("pointerdown", handlePointerDown); - toast.addEventListener("pointermove", handlePointerMove); - toast.addEventListener("pointerup", handlePointerUp); - toast.addEventListener("pointercancel", handlePointerUp); -} - -// Mount toast - trigger enter animation via data-mounted -function mountToast(toast) { - // data-entering="true" is already set in createToastElement to prevent - // stacking transforms from overriding entry animation - - // Set mounted to true using requestAnimationFrame to trigger CSS transition - requestAnimationFrame(() => { - requestAnimationFrame(() => { - toast.dataset.mounted = "true"; - - // Update front status after entrance duration - const toastsWrapper = toast.parentNode; - if (toastsWrapper) { - const enterDuration = getCSSValue(toastsWrapper, "--enter-duration"); - - setTimeout(() => { - // Remove entering flag after animation completes - toast.dataset.entering = "false"; - - if (toast.parentNode) { - updateFrontStatus(toast.parentNode); - } - }, enterDuration || 300); - } - }); - }); -} - -// Setup pause on hover timer functionality -function setupPauseOnHover( - toast, - toastsWrapper, - startMode = TIMER_START.ON_ANIMATION_END, - customDuration = null, -) { - const duration = customDuration || getCSSValue(toastsWrapper, "--dismiss-delay"); - - // Skip timer setup for loading toasts - if (toast.dataset.variant === "Loading") return; - - const progressBar = toast.querySelector("[data-duration-progress]"); - if (!progressBar) return; - - // Clean up existing timer if present - if (toast._cleanupTimer) { - toast._cleanupTimer(); - } - - let startTime = null; - let remainingTime = duration; - let timerId = null; - let isPaused = false; - - const startTimer = () => { - if (isPaused) return; - - startTime = Date.now(); - - // Animate progress bar - ensure we start from scaleX(1) - // First, reset to initial state without transition - progressBar.style.transition = "none"; - progressBar.style.transform = "scaleX(1)"; - - // Force reflow to ensure the initial state is applied - progressBar.offsetHeight; - - // Now apply the transition and animate to scaleX(0) - progressBar.style.transition = `transform ${remainingTime}ms linear`; - progressBar.style.transform = "scaleX(0)"; - - // Set timer to auto-dismiss - timerId = setTimeout(() => { - dismissToast(toast); - }, remainingTime); - }; - - const pauseTimer = () => { - if (isPaused) return; - isPaused = true; - - clearTimeout(timerId); - - // Calculate remaining time - const elapsed = Date.now() - startTime; - remainingTime = Math.max(0, remainingTime - elapsed); - - // Pause progress bar animation by capturing current scale - const computedStyle = window.getComputedStyle(progressBar); - const matrix = new DOMMatrix(computedStyle.transform); - const currentScale = matrix.a; // scaleX value - - progressBar.style.transition = "none"; - progressBar.style.transform = `scaleX(${currentScale})`; - }; - - const resumeTimer = () => { - if (!isPaused) return; - isPaused = false; - - // Force a reflow to ensure the paused state is applied - progressBar.offsetHeight; - - startTimer(); - }; - - // Listen for hover events on the toast list - toastsWrapper.addEventListener("mouseenter", pauseTimer); - toastsWrapper.addEventListener("mouseleave", resumeTimer); - - // Start timer based on mode - if (startMode === TIMER_START.IMMEDIATELY || toast.dataset.mounted === "true") { - startTimer(); - } else { - // Start the timer after mount completes (entrance duration) - const enterDuration = getCSSValue(toastsWrapper, "--enter-duration"); - - setTimeout(() => { - if (toast.dataset.removed !== "true") { - startTimer(); - } - }, enterDuration || 300); - } - - // Store cleanup function on toast element - toast._cleanupTimer = () => { - clearTimeout(timerId); - toastsWrapper.removeEventListener("mouseenter", pauseTimer); - toastsWrapper.removeEventListener("mouseleave", resumeTimer); - }; -} - -// Simple toast creation - CSS handles everything else -function createNewToast(toastsWrapper, variant = "Default", options = {}) { - // Check if single-mode is enabled (from options or container attribute) - const isSingleMode = - options.singleMode !== undefined - ? options.singleMode - : toastsWrapper.getAttribute("data-single-mode") === "true"; - - // Get max toasts from CSS custom property - const maxToasts = getCSSValue(toastsWrapper, TOAST_PROPS.MAX_TOASTS); - - // Get toasts from state array instead of DOM - const toasts = getToastArray(toastsWrapper); - - // Count only active toasts (not removed) - const activeToasts = getActiveToasts(toasts); - - // If single-mode, dismiss all existing toasts - if (isSingleMode && activeToasts.length > 0) { - activeToasts.forEach((toast) => { - dismissToast(toast.element); - }); - } - // Otherwise, remove oldest if at limit - else if (activeToasts.length >= maxToasts) { - const oldestToast = activeToasts[0]; - if (oldestToast) dismissToast(oldestToast.element); - } - - // Create and add new toast - const newToast = createToastElement(variant, options); - - // Set position attributes from toaster (critical for expansion direction) - const toasterPosition = toastsWrapper.getAttribute("data-position"); - const { y, x } = parsePosition(toasterPosition); - newToast.dataset.yPosition = y; - newToast.dataset.xPosition = x; - - // Add positioning class based on y-position - newToast.classList.add(y === "Top" ? "top-0" : "bottom-0"); - - // Set --lift CSS variable for expansion direction - // Top positions: --lift: 1 (expand downward into page) - // Bottom positions: --lift: -1 (expand upward into page) - newToast.style.setProperty("--lift", y === "Top" ? "1" : "-1"); - - // Prepend toast (newest first in DOM, like original Sonner) - toastsWrapper.prepend(newToast); - - // Add to state array - addToastToState(toastsWrapper, newToast); - - // Mount toast - triggers enter animation - mountToast(newToast); - - // Setup swipe-to-dismiss - setupSwipeToDismiss(newToast); - - // Setup pause on hover with timer - setupPauseOnHover(newToast, toastsWrapper); - - // Update front status after adding - updateFrontStatus(toastsWrapper); - - // Return toast element for promise toasts - return newToast; -} - -// Update toast content and variant (for promise toasts) -function updateToast(toast, newVariant, newContent, customIcon = null, options = {}) { - const toastsWrapper = toast.parentNode; - - // Update variant and classes - toast.dataset.variant = newVariant; - - // Update Tailwind classes for new variant - const variantClasses = VARIANT_CLASSES[newVariant] || VARIANT_CLASSES.Default; - toast.className = - toast.className.replace(/bg-\S+|text-\S+|border-\S+/g, "").trim() + - ` ${variantClasses}`; - - // Update icon (use custom icon if provided, otherwise use default for variant) - const iconContainer = toast.querySelector("[data-icon]"); - const newIcon = customIcon || TOAST_ICONS[newVariant.toLowerCase()]; - if (iconContainer && newIcon) { - iconContainer.innerHTML = newIcon; - } - - // Update title - const titleElement = toast.querySelector("h3"); - if (titleElement && newContent.title) { - titleElement.textContent = newContent.title; - } - - // Update description - const descriptionElement = toast.querySelector("p"); - if (newContent.description) { - if (descriptionElement) { - descriptionElement.textContent = newContent.description; - } else { - // Add description if it didn't exist - const titleContainer = toast.querySelector(".flex-1"); - const descriptionHtml = `

    ${newContent.description}

    `; - titleContainer.insertAdjacentHTML("beforeend", descriptionHtml); - } - } else if (descriptionElement) { - // Remove description if new content doesn't have one - descriptionElement.remove(); - } - - // Add close button if it wasn't there (loading toasts don't have close buttons) - if (newVariant !== "Loading" && !toast.querySelector("[data-close-button]")) { - const titleContainer = toast.querySelector(".flex-1 .flex"); - const closeButtonHtml = ``; - titleContainer.insertAdjacentHTML("beforeend", closeButtonHtml); - - const closeButton = toast.querySelector("[data-close-button]"); - closeButton.addEventListener("click", (e) => { - e.stopPropagation(); - dismissToast(toast); - }); - } - - // Handle duration track for promise toasts - const durationTrack = toast.querySelector("[data-duration-track]"); - if (options.hideProgressBar && durationTrack) { - durationTrack.style.display = "none"; - } - - // Setup timer for non-loading, non-promise toasts - if (newVariant !== "Loading" && toastsWrapper && !options.hideProgressBar) { - setupPauseOnHover(toast, toastsWrapper, TIMER_START.IMMEDIATELY); - } -} - -// Promise toast - shows loading state and updates based on promise result -function toastPromise(toastsWrapper, promise, messages) { - // Create loading toast - const toast = createNewToast(toastsWrapper, "Loading", { - content: { - title: messages.loading, - }, - }); - - // Handle promise resolution - promise - .then((data) => { - // Update to default state with filled checkmark icon - const successMessage = - typeof messages.success === "function" - ? messages.success(data) - : messages.success; - - updateToast( - toast, - "Default", - { - title: successMessage, - }, - CHECK_FILLED_ICON, - { - hideProgressBar: true, - }, - ); - }) - .catch((err) => { - // Update to error state with filled error icon - const errorMessage = - typeof messages.error === "function" ? messages.error(err) : messages.error; - - updateToast( - toast, - "Error", - { - title: errorMessage, - }, - ERROR_FILLED_ICON, - { - hideProgressBar: true, - }, - ); - }); - - return toast; -} diff --git a/public/app_components/sonner.min.js b/public/app_components/sonner.min.js deleted file mode 100644 index af1dccb..0000000 --- a/public/app_components/sonner.min.js +++ /dev/null @@ -1 +0,0 @@ -const TOAST_PROPS={MAX_TOASTS:"--max-toasts"},TOAST_DATA_NAMES={LIST:"SonnerList",ITEM:"SonnerItem",TRIGGER:"SonnerTrigger"},TIMER_START={ON_ANIMATION_END:"on_animation_end",IMMEDIATELY:"immediately"},SWIPE_THRESHOLD=45,SWIPE_VELOCITY_THRESHOLD=.11,VISIBLE_TOASTS_AMOUNT=3,DEFAULT_TOAST_STYLES={"--max-toasts":"5","--dismiss-delay":"5000ms","--enter-duration":"300ms","--exit-duration":"300ms","--stack-duration":"300ms","--stack-spacing":"20px","--expand-spacing":"110px","--gap":"15px","--scale-factor":"0.05","--transition-easing":"ease-out","--stack-easing":"ease-in-out"},TOAST_ICONS={success:'\n \n ',error:'\n \n ',warning:'\n \n ',info:'\n \n ',loading:'\n \n '},VARIANT_CLASSES={Default:"bg-background text-foreground border-border",Success:"bg-success-light text-success-dark border-success",Error:"bg-destructive-light text-destructive-dark border-destructive",Warning:"bg-warning-light text-warning-dark border-warning",Info:"bg-info-light text-info-dark border-info",Loading:"bg-background text-foreground border-border"},ACTION_BUTTON_CLASSES={Success:"!bg-success !text-white hover:!bg-success/90 !border-success/90",Error:"!bg-destructive !text-white hover:!bg-destructive/90 !border-destructive/90"},CLOSE_ICON='\n \n',CHECK_FILLED_ICON='\n \n',ERROR_FILLED_ICON='\n \n',toastState=new Map;let toastIdCounter=0;function getToastArray(e){return toastState.has(e)||toastState.set(e,[]),toastState.get(e)}function addToastToState(e,t){const n=getToastArray(e),o=toastIdCounter++;return t._toastId=o,n.push({id:o,element:t}),o}function removeToastFromState(e,t){const n=getToastArray(e),o=n.findIndex(e=>e.element===t);-1!==o&&n.splice(o,1)}function getCSSValue(e,t){return Number.parseInt(getComputedStyle(e).getPropertyValue(t),10)}function getActiveToasts(e){return e.filter(e=>"true"!==e.element.dataset.removed)}function getContentFromTrigger(e){const t=e.dataset.toastTitle,n=e.dataset.toastDescription;if(!t)return null;const o={title:t};return n&&(o.description=n),o}function initializeSonner(){const e=document.querySelector('[data-name="SonnerList"]'),t=document.querySelectorAll('[data-name="SonnerTrigger"]');e&&0!==t.length&&(e.hasAttribute("data-sonner-initialized")||(e.setAttribute("data-sonner-initialized","true"),console.debug("Initializing Sonner toast functionality"),Object.entries(DEFAULT_TOAST_STYLES).forEach(([t,n])=>{e.style.setProperty(t,n)}),setupExpandedState(e),t.forEach(t=>{t.hasAttribute("data-sonner-initialized")||(t.setAttribute("data-sonner-initialized","true"),t.addEventListener("click",()=>{const n=t.dataset.variant||"Default",o=getContentFromTrigger(t);createNewToast(e,n,o?{content:o}:{})}))})))}initializeSonner();const observer=new MutationObserver(e=>{e.forEach(e=>{"childList"===e.type&&e.addedNodes.forEach(e=>{if(e.nodeType===Node.ELEMENT_NODE){const t=e?.querySelector?.('[data-name="SonnerList"]'),n=e?.querySelector?.('[data-name="SonnerTrigger"]'),o=e.getAttribute&&"SonnerList"===e.getAttribute("data-name"),r=e.getAttribute&&"SonnerTrigger"===e.getAttribute("data-name");(t||n||o||r)&&(console.debug("Sonner elements detected via MutationObserver - initializing"),initializeSonner())}})})});function getSwipeDirections(e){return"BottomRight"===e?["right","down"]:["right","left","up","down"]}function getDampening(e){return 1/(1.5+Math.abs(e)/20)}function createToastElement(e="Default",t={}){const n=document.createElement("li"),o=t.content||{title:"Notification"},r=TOAST_ICONS[e.toLowerCase()];n.dataset.name=TOAST_DATA_NAMES.ITEM,n.dataset.variant=e;const s=VARIANT_CLASSES[e]||VARIANT_CLASSES.Default;n.className=`p-5 shadow-lg border rounded-lg cursor-grab absolute bottom-0 transform translate-y-0 w-full max-w-96 touch-none flex items-start gap-3 ${s}`,n.dataset.mounted="false",n.dataset.expanded="false",n.dataset.visible="true",n.dataset.swiping="false",n.dataset.removed="false",n.dataset.swipeOut="false",n.dataset.front="false",n.style.setProperty("--index","0"),n.style.setProperty("--toasts-before","0"),n.style.setProperty("--z-index","1");const a=r?`
    svg]:animate-spin":"flex items-center justify-center w-5 h-5 shrink-0 mr-3"}">${r}
    `:"",i="Loading"!==e?``:"",l=t.actions?.length?`
    \n ${t.actions.map((e,t)=>``).join("")}\n
    `:"",d=o.description?`

    ${o.description}

    `:"";if(n.innerHTML=`\n ${a}\n
    \n
    \n

    ${o.title}

    \n ${i}\n
    \n ${d}\n ${l}\n
    \n
    \n
    \n
    \n `,"Loading"!==e){n.querySelector("[data-close-button]").addEventListener("click",e=>{e.stopPropagation(),dismissToast(n)})}if(t.actions?.length){const o=n.querySelectorAll("[data-action-button]"),r=ACTION_BUTTON_CLASSES[e]||"";o.forEach((e,o)=>{r&&(e.className+=` ${r}`),e.addEventListener("click",e=>{e.stopPropagation();const r=t.actions[o];r.onClick&&r.onClick(),!1!==r.dismissOnClick&&dismissToast(n)})})}return n}function updateFrontStatus(e){if(!e)return;const t=getToastArray(e).filter(e=>"true"!==e.element.dataset.removed&&"true"!==e.element.dataset.swipeOut);t.forEach((e,n)=>{e.element.dataset.front=n===t.length-1?"true":"false"}),updateVisibleToasts(e),updateToastStyles(e)}function updateVisibleToasts(e){const t=getActiveToasts(getToastArray(e));t.forEach((e,n)=>{const o=t.length-1-n;e.element.dataset.visible=o<3?"true":"false"})}function updateToastStyles(e){const t=getActiveToasts(getToastArray(e));t.forEach((e,n)=>{const o=t.length-1-n,r=t.length-o;e.element.style.setProperty("--index",o.toString()),e.element.style.setProperty("--toasts-before",o.toString()),e.element.style.setProperty("--z-index",r.toString())})}function setupExpandedState(e){let t=!1;const n=n=>{if(t===n)return;t=n,e.dataset.expanded=n?"true":"false";getToastArray(e).forEach(e=>{e.element.dataset.expanded=n?"true":"false"})};e.addEventListener("mouseenter",()=>n(!0)),e.addEventListener("mousemove",()=>n(!0)),e.addEventListener("mouseleave",()=>n(!1)),e.addEventListener("focus",()=>n(!0),!0),e.addEventListener("blur",()=>n(!1),!0)}function dismissToast(e){if("true"===e.dataset.removed||"true"===e.dataset.swipeOut)return;e._cleanupTimer&&e._cleanupTimer(),e.dataset.removed="true",e.dataset.swipeOut="false";const t=e.parentNode;updateFrontStatus(t);const n=getCSSValue(t,"--exit-duration");setTimeout(()=>{removeToastFromState(t,e),e.parentNode&&e.parentNode.removeChild(e)},n||200)}function setupSwipeToDismiss(e){let t=null,n=null;const o=getSwipeDirections("BottomRight");let r=null;const s=()=>{if(!t)return;const o=Number.parseFloat(e.style.getPropertyValue("--swipe-amount-x").replace("px","")||0),s=Number.parseFloat(e.style.getPropertyValue("--swipe-amount-y").replace("px","")||0),a=Date.now()-r.getTime(),i="x"===n?o:s,l=Math.abs(i)/a;if(Math.abs(i)>=45||l>.11){let t;t="x"===n?o>0?"right":"left":s>0?"down":"up",e._cleanupTimer&&e._cleanupTimer(),e.dataset.swipeOut="true",e.dataset.removed="true",e.dataset.swipeDirection=t.charAt(0).toUpperCase()+t.slice(1),e.dataset.swiping="false";const r=e.parentNode;updateFrontStatus(r);const a=getCSSValue(r,"--exit-duration");setTimeout(()=>{removeToastFromState(r,e),e.parentNode&&e.parentNode.removeChild(e)},a||200)}else e.style.setProperty("--swipe-amount-x","0px"),e.style.setProperty("--swipe-amount-y","0px"),e.dataset.swiping="false";t=null,n=null,r=null};e.addEventListener("pointerdown",n=>{2!==n.button&&"true"!==e.dataset.removed&&"true"!==e.dataset.swipeOut&&(n.target.setPointerCapture(n.pointerId),r=new Date,t={x:n.clientX,y:n.clientY},e.dataset.swiping="true")}),e.addEventListener("pointermove",r=>{if(!t)return;if(window.getSelection()?.toString().length>0)return;const s=r.clientX-t.x,a=r.clientY-t.y;!n&&(Math.abs(s)>1||Math.abs(a)>1)&&(n=Math.abs(s)>Math.abs(a)?"x":"y");const i={x:0,y:0};if("y"===n){if(o.includes("up")||o.includes("down"))if(o.includes("up")&&a<0||o.includes("down")&&a>0)i.y=a;else{const e=a*getDampening(a);i.y=Math.abs(e)0)i.x=s;else{const e=s*getDampening(s);i.x=Math.abs(e){requestAnimationFrame(()=>{e.dataset.mounted="true";const t=e.parentNode;if(t){const n=getCSSValue(t,"--enter-duration");setTimeout(()=>{e.parentNode&&updateFrontStatus(e.parentNode)},n||300)}})})}function setupPauseOnHover(e,t,n=TIMER_START.ON_ANIMATION_END,o=null){const r=o||getCSSValue(t,"--dismiss-delay");if("Loading"===e.dataset.variant)return;const s=e.querySelector("[data-duration-progress]");if(!s)return;e._cleanupTimer&&e._cleanupTimer();let a=null,i=r,l=null,d=!1;const u=()=>{d||(a=Date.now(),s.style.transition=`transform ${i}ms linear`,s.style.transform="scaleX(0)",l=setTimeout(()=>{dismissToast(e)},i))},c=()=>{if(d)return;d=!0,clearTimeout(l);const e=Date.now()-a;i=Math.max(0,i-e);const t=window.getComputedStyle(s),n=new DOMMatrix(t.transform).a;s.style.transition="none",s.style.transform=`scaleX(${n})`},p=()=>{d&&(d=!1,s.offsetHeight,u())};if(t.addEventListener("mouseenter",c),t.addEventListener("mouseleave",p),n===TIMER_START.IMMEDIATELY||"true"===e.dataset.mounted)u();else{const n=getCSSValue(t,"--enter-duration");setTimeout(()=>{"true"!==e.dataset.removed&&u()},n||300)}e._cleanupTimer=()=>{clearTimeout(l),t.removeEventListener("mouseenter",c),t.removeEventListener("mouseleave",p)}}function createNewToast(e,t="Default",n={}){const o=void 0!==n.singleMode?n.singleMode:"true"===e.getAttribute("data-single-mode"),r=getCSSValue(e,TOAST_PROPS.MAX_TOASTS),s=getActiveToasts(getToastArray(e));if(o&&s.length>0)s.forEach(e=>{dismissToast(e.element)});else if(s.length>=r){const e=s[0];e&&dismissToast(e.element)}const a=createToastElement(t,n);return e.appendChild(a),addToastToState(e,a),mountToast(a),setupSwipeToDismiss(a),setupPauseOnHover(a,e),updateFrontStatus(e),a}function updateToast(e,t,n,o=null,r={}){const s=e.parentNode;e.dataset.variant=t;const a=VARIANT_CLASSES[t]||VARIANT_CLASSES.Default;e.className=e.className.replace(/bg-\S+|text-\S+|border-\S+/g,"").trim()+` ${a}`;const i=e.querySelector("[data-icon]"),l=o||TOAST_ICONS[t.toLowerCase()];i&&l&&(i.innerHTML=l);const d=e.querySelector("h3");d&&n.title&&(d.textContent=n.title);const u=e.querySelector("p");if(n.description)if(u)u.textContent=n.description;else{const t=e.querySelector(".flex-1"),o=`

    ${n.description}

    `;t.insertAdjacentHTML("beforeend",o)}else u&&u.remove();if("Loading"!==t&&!e.querySelector("[data-close-button]")){const t=e.querySelector(".flex-1 .flex"),n=``;t.insertAdjacentHTML("beforeend",n);e.querySelector("[data-close-button]").addEventListener("click",t=>{t.stopPropagation(),dismissToast(e)})}const c=e.querySelector("[data-duration-track]");r.hideProgressBar&&c&&(c.style.display="none"),"Loading"!==t&&s&&!r.hideProgressBar&&setupPauseOnHover(e,s,TIMER_START.IMMEDIATELY)}function toastPromise(e,t,n){const o=createNewToast(e,"Loading",{content:{title:n.loading}});return t.then(e=>{const t="function"==typeof n.success?n.success(e):n.success;updateToast(o,"Default",{title:t},CHECK_FILLED_ICON,{hideProgressBar:!0})}).catch(e=>{const t="function"==typeof n.error?n.error(e):n.error;updateToast(o,"Error",{title:t},ERROR_FILLED_ICON,{hideProgressBar:!0})}),o}observer.observe(document.body,{childList:!0,subtree:!0}); \ No newline at end of file diff --git a/public/registry/styles/default/demo_sonner.md b/public/registry/styles/default/demo_sonner.md index 980e022..9bc1d45 100644 --- a/public/registry/styles/default/demo_sonner.md +++ b/public/registry/styles/default/demo_sonner.md @@ -25,14 +25,17 @@ ui add demo_sonner ```rust use leptos::prelude::*; -use crate::components::ui::sonner::SonnerTrigger; +use crate::components::ui::sonner::{SonnerToaster, SonnerTrigger}; #[component] pub fn DemoSonner() -> impl IntoView { view! { - - "Toast Me!" - + <> + + "Toast Me!" + + + } } ``` diff --git a/public/registry/styles/default/demo_sonner_positions.md b/public/registry/styles/default/demo_sonner_positions.md index 1cc3eb4..7f04201 100644 --- a/public/registry/styles/default/demo_sonner_positions.md +++ b/public/registry/styles/default/demo_sonner_positions.md @@ -86,6 +86,7 @@ pub fn DemoSonnerPositions() -> impl IntoView { + } } ``` diff --git a/public/registry/styles/default/demo_sonner_variants.md b/public/registry/styles/default/demo_sonner_variants.md index 263d529..fdff48c 100644 --- a/public/registry/styles/default/demo_sonner_variants.md +++ b/public/registry/styles/default/demo_sonner_variants.md @@ -25,32 +25,35 @@ ui add demo_sonner_variants ```rust use leptos::prelude::*; -use crate::components::ui::sonner::{SonnerTrigger, ToastType}; +use crate::components::ui::sonner::{SonnerToaster, SonnerTrigger, ToastType}; #[component] pub fn DemoSonnerVariants() -> impl IntoView { view! { -
    - - "Default" - - - - "Success" - - - "Error" - - - "Warning" - - - "Info" - - - "Loading" - -
    + <> +
    + + "Default" + + + + "Success" + + + "Error" + + + "Warning" + + + "Info" + + + "Loading" + +
    + + } } ``` diff --git a/public/registry/styles/default/sonner.md b/public/registry/styles/default/sonner.md index b37d8d8..f3debcd 100644 --- a/public/registry/styles/default/sonner.md +++ b/public/registry/styles/default/sonner.md @@ -11,11 +11,11 @@ tags: [] # Sonner -This component demo demonstrates practical implementation patterns and provides a concrete usage example for LLMs to understand the code structure and functionality. +Pure Rust/Leptos toast system with global context, trigger helpers, reactive rendering, auto-dismiss, stacking, and swipe-to-dismiss. ## Installation -To add this component demo in your app, run: +To add this component in your app, run: ```bash # cargo install ui-cli --force @@ -28,7 +28,7 @@ ui add sonner use leptos::prelude::*; use tw_merge::*; -#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)] +#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Hash)] pub enum ToastType { #[default] Default, @@ -39,22 +39,25 @@ pub enum ToastType { Loading, } -#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)] +#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display, Hash)] pub enum SonnerPosition { TopLeft, TopCenter, TopRight, + BottomLeft, + BottomCenter, #[default] BottomRight, - BottomCenter, - BottomLeft, } -#[derive(Clone, Copy, PartialEq, Eq, Default, strum::Display)] -pub enum SonnerDirection { - TopDown, - #[default] - BottomUp, +pub fn provide_sonner() { + if use_context::().is_none() { + provide_context(new_sonner_context()); + } +} + +pub fn show_toast() -> ToastApi { + ToastApi { ctx: expect_context::() } } #[component] @@ -63,7 +66,7 @@ pub fn SonnerTrigger( #[prop(into, optional)] class: String, #[prop(optional, default = ToastType::default())] variant: ToastType, #[prop(into)] title: String, - #[prop(into)] description: String, + #[prop(into, optional)] description: String, #[prop(into, optional)] position: String, ) -> impl IntoView { let variant_classes = match variant { @@ -81,92 +84,33 @@ pub fn SonnerTrigger( class ); - // Only set position attribute if not empty - let position_attr = if position.is_empty() { None } else { Some(position) }; - view! { } } -#[component] -pub fn SonnerContainer( - children: Children, - #[prop(into, optional)] class: String, - #[prop(optional, default = SonnerPosition::default())] position: SonnerPosition, -) -> impl IntoView { - let merged_class = tw_merge!("toast__container fixed z-50", class); - - view! { -
    - {children()} -
    - } -} - -#[component] -pub fn SonnerList( - children: Children, - #[prop(into, optional)] class: String, - #[prop(optional, default = SonnerPosition::default())] position: SonnerPosition, - #[prop(optional, default = SonnerDirection::default())] direction: SonnerDirection, - #[prop(into, default = "false".to_string())] expanded: String, - #[prop(into, optional)] style: String, -) -> impl IntoView { - // pointer-events-none: container doesn't block clicks when empty - // [&>*]:pointer-events-auto: toast items still receive clicks - let merged_class = tw_merge!( - "flex relative flex-col opacity-100 gap-[15px] h-[100px] w-[400px] pointer-events-none [&>*]:pointer-events-auto", - class - ); - - view! { -
      - {children()} -
    - } -} - #[component] pub fn SonnerToaster(#[prop(default = SonnerPosition::default())] position: SonnerPosition) -> impl IntoView { - // Auto-derive direction from position - let direction = match position { - SonnerPosition::TopLeft | SonnerPosition::TopCenter | SonnerPosition::TopRight => SonnerDirection::TopDown, - _ => SonnerDirection::BottomUp, - }; - - let container_class = match position { - SonnerPosition::TopLeft => "left-6 top-6", - SonnerPosition::TopRight => "right-6 top-6", - SonnerPosition::TopCenter => "left-1/2 -translate-x-1/2 top-6", - SonnerPosition::BottomCenter => "left-1/2 -translate-x-1/2 bottom-6", - SonnerPosition::BottomLeft => "left-6 bottom-6", - SonnerPosition::BottomRight => "right-6 bottom-6", - }; + if use_context::().is_none() { + provide_context(new_sonner_context()); + } view! { - - - "" - + + + } } From d35d843a928236836d8dbdf3540e6e320183d619 Mon Sep 17 00:00:00 2001 From: krishpranav Date: Wed, 22 Apr 2026 18:59:22 +0530 Subject: [PATCH 4/4] test(drawer): add parity page --- app/src/domain/tests/drawer_tofix.rs | 64 ++--- app/src/domain/tests/mod.rs | 1 + app/src/domain/tests/page_to_fix.rs | 414 ++++++++++++++++++++++++++- 3 files changed, 426 insertions(+), 53 deletions(-) diff --git a/app/src/domain/tests/drawer_tofix.rs b/app/src/domain/tests/drawer_tofix.rs index 92349fc..eec51ae 100644 --- a/app/src/domain/tests/drawer_tofix.rs +++ b/app/src/domain/tests/drawer_tofix.rs @@ -1,13 +1,15 @@ use std::time::Duration; -use leptos::{ev, html, leptos_dom::helpers::window_event_listener, prelude::*}; +use leptos::leptos_dom::helpers::window_event_listener; +use leptos::prelude::*; +use leptos::{ev, html}; use leptos_ui::clx; +use registry::ui::button::{Button, ButtonSize, ButtonVariant}; use tw_merge::*; -use wasm_bindgen::{JsCast, closure::Closure}; +use wasm_bindgen::JsCast; +use wasm_bindgen::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; @@ -341,9 +343,7 @@ pub fn DrawerContent( 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) - { + if !is_allowed_to_drag.get() && !should_drag(event.target(), &drawer, open_time, last_time_drag_prevented) { return; } @@ -351,9 +351,7 @@ pub fn DrawerContent( if closing_direction { let abs_delta = delta.abs(); - let _ = drawer - .style() - .set_property("transform", &drag_transform(delta, closing_direction, position)); + 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()); @@ -372,9 +370,7 @@ pub fn DrawerContent( 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 _ = drawer.style().set_property("transform", &drag_transform(delta, closing_direction, position)); } } }; @@ -388,11 +384,8 @@ pub fn DrawerContent( DrawerPosition::Right => "right-0 top-0 bottom-0 max-w-[96vw] rounded-l-[10px]", }; - let merged_class = tw_merge!( - "flex flex-col pt-3 pb-6 px-6 fixed z-210 bg-background hidden outline-none", - position_class, - class - ); + let merged_class = + tw_merge!("flex flex-col pt-3 pb-6 px-6 fixed z-210 bg-background hidden outline-none", position_class, class); let class_ctx = ctx.clone(); let content_animate_ctx = ctx.clone(); @@ -534,12 +527,7 @@ fn should_close_from_drag(delta: f64, velocity: f64, drawer_size: f64, position: } } -fn finish_pointer_drag( - ctx: &DrawerContext, - event: PointerEvent, - position: DrawerPosition, - drag_state: DragState, -) { +fn finish_pointer_drag(ctx: &DrawerContext, event: PointerEvent, position: DrawerPosition, drag_state: DragState) { if !drag_state.is_dragging.get() || !ctx.dismissible.get() { return; } @@ -552,9 +540,7 @@ fn finish_pointer_drag( 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}")); + let _ = drawer.style().set_property("transition", &format!("transform 0.5s {TRANSITION_EASING}")); if let Some(wrapper) = query_drawer_wrapper() { let _ = wrapper.style().set_property( @@ -564,9 +550,7 @@ fn finish_pointer_drag( } if let Some(overlay) = &overlay { - let _ = overlay - .style() - .set_property("transition", &format!("opacity 0.5s {TRANSITION_EASING}")); + let _ = overlay.style().set_property("transition", &format!("opacity 0.5s {TRANSITION_EASING}")); } let size = drag_state.drawer_size.get_untracked().max(1.0); @@ -647,8 +631,7 @@ fn should_drag( } 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\"])"; + 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(); @@ -682,10 +665,7 @@ fn focus_first_drawer_element(ctx: &DrawerContext) { } let is_only_close_button = focusable.len() == 1 - && focusable - .first() - .and_then(|element| element.get_attribute("data-name")) - .as_deref() + && focusable.first().and_then(|element| element.get_attribute("data-name")).as_deref() == Some("DrawerClose"); if is_only_close_button { @@ -718,8 +698,8 @@ fn fix_drawer_position(drawer: &HtmlElement) { 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 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 { @@ -861,14 +841,10 @@ fn close_drawer_dom(ctx: &DrawerContext, position: DrawerPosition, variant: Draw 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("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("transition", &format!("opacity 0.5s {TRANSITION_EASING}")); let _ = overlay.style().set_property("opacity", "0"); reset_wrapper_styles(); diff --git a/app/src/domain/tests/mod.rs b/app/src/domain/tests/mod.rs index aa76ce2..c165c04 100644 --- a/app/src/domain/tests/mod.rs +++ b/app/src/domain/tests/mod.rs @@ -1,2 +1,3 @@ +pub mod drawer_tofix; pub mod page_test; pub mod page_to_fix; diff --git a/app/src/domain/tests/page_to_fix.rs b/app/src/domain/tests/page_to_fix.rs index 5e6d626..76bfb81 100644 --- a/app/src/domain/tests/page_to_fix.rs +++ b/app/src/domain/tests/page_to_fix.rs @@ -1,19 +1,415 @@ +use icons::{FileText, Lock, TriangleAlert, X}; use leptos::prelude::*; -use registry::demos::demo_carousel::DemoCarousel; +use registry::ui::button::Button; +use registry::ui::dialog::{ + Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, + DialogTrigger, +}; +use registry::ui::direction_provider::{Direction, DirectionProvider}; +use registry::ui::input::Input; +use registry::ui::label::Label; +use registry::ui::textarea::Textarea; + +use crate::domain::tests::drawer_tofix::{ + Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHandle, DrawerHeader, + DrawerPosition, DrawerTitle, DrawerTrigger, DrawerVariant, +}; #[component] pub fn PageToFix() -> impl IntoView { view! { -
    -

    "To Fix"

    - - "→ Test" - +
    +
    +
    +

    "Drawer Parity Test Page"

    +

    + "This page isolates the Rust drawer port parked in " + "domain/tests/drawer_tofix.rs" + " so behavior can be matched against the original JS implementation before the public drawer is touched again." +

    + +
    + +
    + + + + + + + + + + + + + + + + + + + -
    -

    "Carousel"

    - + + + + + + + + + + + + + + + + + + + +
    } } + +#[component] +fn DrawerDemoCard(title: &'static str, description: &'static str, children: Children) -> impl IntoView { + view! { +
    +
    +

    {title}

    +

    {description}

    +
    +
    {children()}
    +
    + } +} + +#[component] +fn DemoDrawerToFix() -> impl IntoView { + view! { + + "Open Drawer" + + + + + + "Drawer Title" + "Drag down to close or click outside." + + + "Close" + + + + } +} + +#[component] +fn DemoDrawerDialogToFix() -> impl IntoView { + view! { +
    +
    + + "Subscribe" + + + + + "Subscribe" + "Get the latest updates delivered to your inbox." + + + + "Cancel" + + + + + +
    + + +
    + } +} + +#[component] +fn DemoDrawerFamilyToFix() -> impl IntoView { + view! { + + "Open Drawer" + + +
    +

    "Options"

    + +
    + +
    + + + + + +
    +
    +
    + } +} + +#[component] +fn DemoDrawerFocusToFix() -> impl IntoView { + view! { + + "Open Drawer" + + + + + + + "Focus Drawer" + + "Test keyboard navigation: Press Tab to cycle through elements, Shift+Tab to go back, and Escape to close." + + + +
    +
    + + +
    + +
    + + +
    + +
    + +