diff --git a/demo/src/app.rs b/demo/src/app.rs index 4deb83b..7a3cc26 100644 --- a/demo/src/app.rs +++ b/demo/src/app.rs @@ -49,6 +49,7 @@ fn TheRouter(is_routing: RwSignal) -> impl IntoView { + diff --git a/demo/src/pages/components.rs b/demo/src/pages/components.rs index 3f6af57..3855a59 100644 --- a/demo/src/pages/components.rs +++ b/demo/src/pages/components.rs @@ -208,6 +208,10 @@ pub(crate) fn gen_menu_data() -> Vec { MenuGroupOption { label: "Navigation Components".into(), children: vec![ + MenuItemOption { + value: "back-top".into(), + label: "Back Top".into(), + }, MenuItemOption { value: "breadcrumb".into(), label: "Breadcrumb".into(), diff --git a/demo_markdown/docs/back_top/mod.md b/demo_markdown/docs/back_top/mod.md new file mode 100644 index 0000000..3778a9b --- /dev/null +++ b/demo_markdown/docs/back_top/mod.md @@ -0,0 +1,46 @@ +# Back Top + +BackTop will find its first scrollable ascendant element and listen scroll event on it. + +```rust demo +view! { + +} +``` + +### Visibility height + +```rust demo +view! { + +
+ "Visibility Height: 280px" +
+
+} +``` + +### Change position + +```rust demo +view! { + +
+ "Change Position" +
+
+} +``` + +### BackTop Props + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| class | `OptionalProp>` | `Default::default()` | Addtional classes for the back top element. | +| right | `MaybeSignal` | `40` | The width of BackTop from the right side of the page. | +| bottom | `MaybeSignal` | `40` | The height of BackTop from the bottom of the page. | +| bottom | `MaybeSignal` | `180` | BackTop's trigger scroll top. | +| children | `Option` | `None` | BackTop's content. | + +
+
diff --git a/demo_markdown/src/lib.rs b/demo_markdown/src/lib.rs index 78f378c..700eabd 100644 --- a/demo_markdown/src/lib.rs +++ b/demo_markdown/src/lib.rs @@ -31,6 +31,7 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt "AlertMdPage" => "../docs/alert/mod.md", "AutoCompleteMdPage" => "../docs/auto_complete/mod.md", "AvatarMdPage" => "../docs/avatar/mod.md", + "BackTopMdPage" => "../docs/back_top/mod.md", "BadgeMdPage" => "../docs/badge/mod.md", "BreadcrumbMdPage" => "../docs/breadcrumb/mod.md", "ButtonMdPage" => "../docs/button/mod.md", diff --git a/thaw/Cargo.toml b/thaw/Cargo.toml index 5944f32..d6757ed 100644 --- a/thaw/Cargo.toml +++ b/thaw/Cargo.toml @@ -21,6 +21,8 @@ web-sys = { version = "0.3.69", features = [ "File", "FileList", "DataTransfer", + "ScrollToOptions", + "ScrollBehavior", ] } wasm-bindgen = "0.2.92" icondata_core = "0.1.0" diff --git a/thaw/src/back_top/back-top.css b/thaw/src/back_top/back-top.css new file mode 100644 index 0000000..5bbc165 --- /dev/null +++ b/thaw/src/back_top/back-top.css @@ -0,0 +1,60 @@ +.thaw-back-top { + position: fixed; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.3s cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1), + background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 22px; + height: 44px; + min-width: 44px; + box-shadow: 0 2px 8px 0px rgba(0, 0, 0, 0.12); + background-color: var(--thaw-background-color); +} + +.thaw-back-top.fade-in-scale-up-transition-leave-active { + transform-origin: inherit; + transition: opacity 0.2s cubic-bezier(0.4, 0, 1, 1), + transform 0.2s cubic-bezier(0.4, 0, 1, 1); +} + +.thaw-back-top.fade-in-scale-up-transition-enter-active { + transform-origin: inherit; + transition: opacity 0.2s cubic-bezier(0, 0, 0.2, 1), + transform 0.2s cubic-bezier(0, 0, 0.2, 1); +} + +.thaw-back-top.fade-in-scale-up-transition-enter-from, +.thaw-back-top.fade-in-scale-up-transition-leave-to { + opacity: 0; + transform: scale(0.9); +} + +.thaw-back-top.fade-in-scale-up-transition-leave-from, +.thaw-back-top.fade-in-scale-up-transition-enter-to { + opacity: 1; + transform: scale(1); +} + +.thaw-back-top > svg { + font-size: 24px; + transition: color 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.thaw-back-top:hover { + box-shadow: 0 2px 12px 0px #0000002e; +} + +.thaw-back-top:hover > svg { + color: var(--thaw-icon-color-hover); +} + +.thaw-back-top:active { + box-shadow: 0 2px 12px 0px #0000002e; +} + +.thaw-back-top:active svg { + color: var(--thaw-icon-color-active); +} diff --git a/thaw/src/back_top/mod.rs b/thaw/src/back_top/mod.rs new file mode 100644 index 0000000..639b880 --- /dev/null +++ b/thaw/src/back_top/mod.rs @@ -0,0 +1,126 @@ +mod theme; + +pub use theme::BackTopTheme; + +use crate::{use_theme, Icon, Theme}; +use leptos::{html::ToHtmlElement, *}; +use thaw_components::{CSSTransition, Fallback, OptionComp, Teleport}; +use thaw_utils::{ + add_event_listener, class_list, get_scroll_parent, mount_style, EventListenerHandle, OptionalProp, +}; + +#[component] +pub fn BackTop( + #[prop(optional, into)] class: OptionalProp>, + #[prop(default=40.into(), into)] right: MaybeSignal, + #[prop(default=40.into(), into)] bottom: MaybeSignal, + #[prop(default=180.into(), into)] visibility_height: MaybeSignal, + #[prop(optional)] children: Option, +) -> impl IntoView { + mount_style("back-top", include_str!("./back-top.css")); + let theme = use_theme(Theme::light); + let style = Memo::new(move |_| { + let mut style = String::new(); + style.push_str(&format!("right: {}px;", right.get_untracked())); + style.push_str(&format!("bottom: {}px;", bottom.get_untracked())); + theme.with(|theme| { + style.push_str(&format!( + "--thaw-icon-color-hover: {};", + theme.common.color_primary_hover + )); + style.push_str(&format!( + "--thaw-icon-color-active: {};", + theme.common.color_primary_active + )); + style.push_str(&format!( + "--thaw-background-color: {};", + theme.back_top.background_color + )); + }); + style + }); + let placeholder_ref = NodeRef::::new(); + let back_top_ref = NodeRef::::new(); + let is_show_back_top = RwSignal::new(false); + let scroll_top = RwSignal::new(0); + + let _ = watch( + move || scroll_top.get(), + move |scroll_top, _, _| { + is_show_back_top.set(scroll_top > &visibility_height.get()); + }, + false, + ); + + let scroll_to_top = StoredValue::new(None::>); + let scroll_handle = StoredValue::new(None::); + + placeholder_ref.on_load(move |el| { + request_animation_frame(move || { + let scroll_el = get_scroll_parent(&el.into_any()) + .unwrap_or_else(|| document().document_element().unwrap().to_leptos_element()); + + { + let scroll_el = scroll_el.clone(); + scroll_to_top.set_value(Some(Callback::new(move |_| { + scroll_el.scroll_to_with_scroll_to_options( + web_sys::ScrollToOptions::new() + .top(0.0) + .behavior(web_sys::ScrollBehavior::Smooth), + ); + }))); + } + + let handle = add_event_listener(scroll_el.clone(), ev::scroll, move |_| { + scroll_top.set(scroll_el.scroll_top()); + }); + scroll_handle.set_value(Some(handle)); + }); + }); + + on_cleanup(move || { + scroll_handle.update_value(|handle| { + if let Some(handle) = handle.take() { + handle.remove(); + } + }); + }); + + let on_click = move |_| { + scroll_to_top.with_value(|scroll_to_top| { + if let Some(scroll_to_top) = scroll_to_top { + scroll_to_top.call(()); + } + }); + }; + + view! { + + } +} diff --git a/thaw/src/back_top/theme.rs b/thaw/src/back_top/theme.rs new file mode 100644 index 0000000..10cec49 --- /dev/null +++ b/thaw/src/back_top/theme.rs @@ -0,0 +1,21 @@ +use crate::theme::ThemeMethod; + +#[derive(Clone)] +pub struct BackTopTheme { + pub background_color: String, + +} + +impl ThemeMethod for BackTopTheme { + fn light() -> Self { + Self { + background_color: "#fff".into(), + } + } + + fn dark() -> Self { + Self { + background_color: "#48484e".into(), + } + } +} diff --git a/thaw/src/lib.rs b/thaw/src/lib.rs index 733c9ff..cd06dd6 100644 --- a/thaw/src/lib.rs +++ b/thaw/src/lib.rs @@ -1,6 +1,7 @@ mod alert; mod auto_complete; mod avatar; +mod back_top; mod badge; mod breadcrumb; mod button; @@ -46,6 +47,7 @@ mod upload; pub use alert::*; pub use auto_complete::*; pub use avatar::*; +pub use back_top::*; pub use badge::*; pub use breadcrumb::*; pub use button::*; diff --git a/thaw/src/theme/mod.rs b/thaw/src/theme/mod.rs index 9e42b82..602d857 100644 --- a/thaw/src/theme/mod.rs +++ b/thaw/src/theme/mod.rs @@ -3,10 +3,11 @@ mod common; use self::common::CommonTheme; use crate::{ mobile::{NavBarTheme, TabbarTheme}, - AlertTheme, AutoCompleteTheme, AvatarTheme, BreadcrumbTheme, ButtonTheme, CalendarTheme, - CollapseTheme, ColorPickerTheme, DatePickerTheme, InputTheme, MenuTheme, MessageTheme, - PopoverTheme, ProgressTheme, ScrollbarTheme, SelectTheme, SkeletionTheme, SliderTheme, - SpinnerTheme, SwitchTheme, TableTheme, TagTheme, TimePickerTheme, TypographyTheme, UploadTheme, + AlertTheme, AutoCompleteTheme, AvatarTheme, BackTopTheme, BreadcrumbTheme, ButtonTheme, + CalendarTheme, CollapseTheme, ColorPickerTheme, DatePickerTheme, InputTheme, MenuTheme, + MessageTheme, PopoverTheme, ProgressTheme, ScrollbarTheme, SelectTheme, SkeletionTheme, + SliderTheme, SpinnerTheme, SwitchTheme, TableTheme, TagTheme, TimePickerTheme, TypographyTheme, + UploadTheme, }; use leptos::*; @@ -46,6 +47,7 @@ pub struct Theme { pub popover: PopoverTheme, pub collapse: CollapseTheme, pub scrollbar: ScrollbarTheme, + pub back_top: BackTopTheme, } impl Theme { @@ -80,6 +82,7 @@ impl Theme { popover: PopoverTheme::light(), collapse: CollapseTheme::light(), scrollbar: ScrollbarTheme::light(), + back_top: BackTopTheme::light(), } } pub fn dark() -> Self { @@ -113,6 +116,7 @@ impl Theme { popover: PopoverTheme::dark(), collapse: CollapseTheme::dark(), scrollbar: ScrollbarTheme::dark(), + back_top: BackTopTheme::dark(), } } } diff --git a/thaw_components/src/binder/mod.rs b/thaw_components/src/binder/mod.rs index d19c720..fa5ed6d 100644 --- a/thaw_components/src/binder/mod.rs +++ b/thaw_components/src/binder/mod.rs @@ -4,12 +4,10 @@ pub use get_placement_style::FollowerPlacement; use crate::Teleport; use get_placement_style::{get_follower_placement_offset, FollowerPlacementOffset}; -use leptos::{ - html::{AnyElement, ElementDescriptor, ToHtmlElement}, - leptos_dom::helpers::WindowListenerHandle, - *, +use leptos::{html::ElementDescriptor, leptos_dom::helpers::WindowListenerHandle, *}; +use thaw_utils::{ + add_event_listener, get_scroll_parent, mount_style, with_hydration_off, EventListenerHandle, }; -use thaw_utils::{add_event_listener, mount_style, with_hydration_off, EventListenerHandle}; #[slot] pub struct Follower { @@ -74,26 +72,26 @@ pub fn Binder( let resize_handle = store_value(None::); let ensure_scroll_listener = move || { - let mut cursor = target_ref.get_untracked().map(|target| target.into_any()); - let mut scrollable_element_vec = vec![]; + let Some(el) = target_ref.get_untracked().map(|target| target.into_any()) else { + return; + }; + + let mut handle_vec = vec![]; + let mut cursor = get_scroll_parent(&el); loop { - cursor = get_scroll_parent(cursor); - if let Some(cursor) = cursor.take() { - scrollable_element_vec.push(cursor); + if let Some(el) = cursor.take() { + cursor = get_scroll_parent(&el); + + let handle = add_event_listener(el, ev::scroll, move |_| { + if let Some(scroll_listener) = scroll_listener.get_value() { + scroll_listener.call(()); + } + }); + handle_vec.push(handle); } else { break; } } - let handle_vec = scrollable_element_vec - .into_iter() - .map(|ele| { - add_event_listener(ele, ev::scroll, move |_| { - if let Some(scroll_listener) = scroll_listener.get_value() { - scroll_listener.call(()); - } - }) - }) - .collect(); scrollable_element_handle_vec.set_value(handle_vec); }; @@ -241,59 +239,3 @@ fn FollowerContainer( view! { } } - -fn get_scroll_parent(element: Option>) -> Option> { - let Some(element) = element else { - return None; - }; - - fn get_parent_element(element: HtmlElement) -> Option> { - if element.node_type() == 9 { - None - } else { - element.parent_element().map(|ele| ele.to_leptos_element()) - } - } - let Some(parent_element) = get_parent_element(element) else { - return None; - }; - - if parent_element.node_type() == 9 { - return Some(parent_element); - } - - if parent_element.node_type() == 1 { - fn get_overflow( - parent_element: &HtmlElement, - ) -> Option<(String, String, String)> { - let Ok(Some(css_style_declaration)) = window().get_computed_style(parent_element) - else { - return None; - }; - let Ok(overflow) = css_style_declaration.get_property_value("overflow") else { - return None; - }; - let Ok(overflow_x) = css_style_declaration.get_property_value("overflowX") else { - return None; - }; - let Ok(overflow_y) = css_style_declaration.get_property_value("overflowY") else { - return None; - }; - Some((overflow, overflow_x, overflow_y)) - } - if let Some((overflow, overflow_x, overflow_y)) = get_overflow(&parent_element) { - let overflow = format!("{overflow}{overflow_x}{overflow_y}"); - if overflow.contains("auto") { - return Some(parent_element); - } - if overflow.contains("scroll") { - return Some(parent_element); - } - if overflow.contains("overlay") { - return Some(parent_element); - } - } - } - - get_scroll_parent(Some(parent_element)) -} diff --git a/thaw_utils/src/dom/get_scroll_parent.rs b/thaw_utils/src/dom/get_scroll_parent.rs new file mode 100644 index 0000000..29dd9d6 --- /dev/null +++ b/thaw_utils/src/dom/get_scroll_parent.rs @@ -0,0 +1,55 @@ +use leptos::{ + html::{AnyElement, ToHtmlElement}, + *, +}; + +pub fn get_scroll_parent(element: &HtmlElement) -> Option> { + let Some(parent_element) = get_parent_element(element) else { + return None; + }; + + if parent_element.node_type() == 9 { + return Some(parent_element); + } + + if parent_element.node_type() == 1 { + if let Some((overflow, overflow_x, overflow_y)) = get_overflow(&parent_element) { + let overflow = format!("{overflow}{overflow_x}{overflow_y}"); + if overflow.contains("auto") { + return Some(parent_element); + } + if overflow.contains("scroll") { + return Some(parent_element); + } + if overflow.contains("overlay") { + return Some(parent_element); + } + } + } + + get_scroll_parent(&parent_element) +} + +fn get_parent_element(element: &HtmlElement) -> Option> { + if element.node_type() == 9 { + None + } else { + element.parent_element().map(|ele| ele.to_leptos_element()) + } +} + +fn get_overflow(parent_element: &HtmlElement) -> Option<(String, String, String)> { + let Ok(Some(css_style_declaration)) = window().get_computed_style(parent_element) else { + return None; + }; + let Ok(overflow) = css_style_declaration.get_property_value("overflow") else { + return None; + }; + let Ok(overflow_x) = css_style_declaration.get_property_value("overflowX") else { + return None; + }; + let Ok(overflow_y) = css_style_declaration.get_property_value("overflowY") else { + return None; + }; + Some((overflow, overflow_x, overflow_y)) +} diff --git a/thaw_utils/src/dom/mod.rs b/thaw_utils/src/dom/mod.rs new file mode 100644 index 0000000..d1fc0b6 --- /dev/null +++ b/thaw_utils/src/dom/mod.rs @@ -0,0 +1,5 @@ +mod get_scroll_parent; +mod mount_style; + +pub use get_scroll_parent::get_scroll_parent; +pub use mount_style::mount_style; diff --git a/thaw_utils/src/mount_style.rs b/thaw_utils/src/dom/mount_style.rs similarity index 100% rename from thaw_utils/src/mount_style.rs rename to thaw_utils/src/dom/mount_style.rs diff --git a/thaw_utils/src/lib.rs b/thaw_utils/src/lib.rs index 2edc3f5..80c8d29 100644 --- a/thaw_utils/src/lib.rs +++ b/thaw_utils/src/lib.rs @@ -1,14 +1,14 @@ pub mod class_list; +mod dom; mod event_listener; mod hooks; -mod mount_style; mod optional_prop; mod signals; mod time; +pub use dom::{get_scroll_parent, mount_style}; pub use event_listener::{add_event_listener, EventListenerHandle}; pub use hooks::{use_click_position, use_lock_html_scroll, use_next_frame, NextFrame}; -pub use mount_style::mount_style; pub use optional_prop::OptionalProp; pub use signals::{ create_component_ref, ComponentRef, Model, OptionalMaybeSignal, SignalWatch, StoredMaybeSignal,