Feat/back top (#169)

* feat: thaw_utils adds get_scroll_parent

* feat: add BackTop

* feat: BackTop scroll
This commit is contained in:
luoxiaozero 2024-04-16 23:00:01 +08:00 committed by GitHub
parent 95abde2b9d
commit 1b0f664dc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 351 additions and 82 deletions

View file

@ -49,6 +49,7 @@ fn TheRouter(is_routing: RwSignal<bool>) -> impl IntoView {
<Route path="/alert" view=AlertMdPage/> <Route path="/alert" view=AlertMdPage/>
<Route path="/auto-complete" view=AutoCompleteMdPage/> <Route path="/auto-complete" view=AutoCompleteMdPage/>
<Route path="/avatar" view=AvatarMdPage/> <Route path="/avatar" view=AvatarMdPage/>
<Route path="/back-top" view=BackTopMdPage/>
<Route path="/badge" view=BadgeMdPage/> <Route path="/badge" view=BadgeMdPage/>
<Route path="/breadcrumb" view=BreadcrumbMdPage/> <Route path="/breadcrumb" view=BreadcrumbMdPage/>
<Route path="/button" view=ButtonMdPage/> <Route path="/button" view=ButtonMdPage/>

View file

@ -208,6 +208,10 @@ pub(crate) fn gen_menu_data() -> Vec<MenuGroupOption> {
MenuGroupOption { MenuGroupOption {
label: "Navigation Components".into(), label: "Navigation Components".into(),
children: vec![ children: vec![
MenuItemOption {
value: "back-top".into(),
label: "Back Top".into(),
},
MenuItemOption { MenuItemOption {
value: "breadcrumb".into(), value: "breadcrumb".into(),
label: "Breadcrumb".into(), label: "Breadcrumb".into(),

View file

@ -0,0 +1,46 @@
# Back Top
BackTop will find its first scrollable ascendant element and listen scroll event on it.
```rust demo
view! {
<BackTop />
}
```
### Visibility height
```rust demo
view! {
<BackTop bottom=100 visibility_height=280>
<div style="width: 200px; text-align: center;">
"Visibility Height: 280px"
</div>
</BackTop>
}
```
### Change position
```rust demo
view! {
<BackTop right=40 bottom=160>
<div style="width: 200px; text-align: center;">
"Change Position"
</div>
</BackTop>
}
```
### BackTop Props
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| class | `OptionalProp<MaybeSignal<String>>` | `Default::default()` | Addtional classes for the back top element. |
| right | `MaybeSignal<i32>` | `40` | The width of BackTop from the right side of the page. |
| bottom | `MaybeSignal<i32>` | `40` | The height of BackTop from the bottom of the page. |
| bottom | `MaybeSignal<i32>` | `180` | BackTop's trigger scroll top. |
| children | `Option<Children>` | `None` | BackTop's content. |
<div style="height: 600px">
</div>

View file

@ -31,6 +31,7 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt
"AlertMdPage" => "../docs/alert/mod.md", "AlertMdPage" => "../docs/alert/mod.md",
"AutoCompleteMdPage" => "../docs/auto_complete/mod.md", "AutoCompleteMdPage" => "../docs/auto_complete/mod.md",
"AvatarMdPage" => "../docs/avatar/mod.md", "AvatarMdPage" => "../docs/avatar/mod.md",
"BackTopMdPage" => "../docs/back_top/mod.md",
"BadgeMdPage" => "../docs/badge/mod.md", "BadgeMdPage" => "../docs/badge/mod.md",
"BreadcrumbMdPage" => "../docs/breadcrumb/mod.md", "BreadcrumbMdPage" => "../docs/breadcrumb/mod.md",
"ButtonMdPage" => "../docs/button/mod.md", "ButtonMdPage" => "../docs/button/mod.md",

View file

@ -21,6 +21,8 @@ web-sys = { version = "0.3.69", features = [
"File", "File",
"FileList", "FileList",
"DataTransfer", "DataTransfer",
"ScrollToOptions",
"ScrollBehavior",
] } ] }
wasm-bindgen = "0.2.92" wasm-bindgen = "0.2.92"
icondata_core = "0.1.0" icondata_core = "0.1.0"

View file

@ -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);
}

126
thaw/src/back_top/mod.rs Normal file
View file

@ -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<MaybeSignal<String>>,
#[prop(default=40.into(), into)] right: MaybeSignal<i32>,
#[prop(default=40.into(), into)] bottom: MaybeSignal<i32>,
#[prop(default=180.into(), into)] visibility_height: MaybeSignal<i32>,
#[prop(optional)] children: Option<Children>,
) -> 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::<html::Div>::new();
let back_top_ref = NodeRef::<html::Div>::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::<Callback<()>>);
let scroll_handle = StoredValue::new(None::<EventListenerHandle>);
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! {
<div style="display: none" class="thaw-back-top-placeholder" ref=placeholder_ref>
<Teleport immediate=is_show_back_top>
<CSSTransition
node_ref=back_top_ref
name="fade-in-scale-up-transition"
appear=is_show_back_top.get_untracked()
show=is_show_back_top
let:display
>
<div
class=class_list!["thaw-back-top", class.map(| c | move || c.get())]
ref=back_top_ref
style=move || {
display.get().map(|d| d.to_string()).unwrap_or_else(|| style.get())
}
on:click=on_click
>
<OptionComp value=children let:children>
<Fallback slot>
<Icon icon=icondata_ai::AiVerticalAlignTopOutlined/>
</Fallback>
{children()}
</OptionComp>
</div>
</CSSTransition>
</Teleport>
</div>
}
}

View file

@ -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(),
}
}
}

View file

@ -1,6 +1,7 @@
mod alert; mod alert;
mod auto_complete; mod auto_complete;
mod avatar; mod avatar;
mod back_top;
mod badge; mod badge;
mod breadcrumb; mod breadcrumb;
mod button; mod button;
@ -46,6 +47,7 @@ mod upload;
pub use alert::*; pub use alert::*;
pub use auto_complete::*; pub use auto_complete::*;
pub use avatar::*; pub use avatar::*;
pub use back_top::*;
pub use badge::*; pub use badge::*;
pub use breadcrumb::*; pub use breadcrumb::*;
pub use button::*; pub use button::*;

View file

@ -3,10 +3,11 @@ mod common;
use self::common::CommonTheme; use self::common::CommonTheme;
use crate::{ use crate::{
mobile::{NavBarTheme, TabbarTheme}, mobile::{NavBarTheme, TabbarTheme},
AlertTheme, AutoCompleteTheme, AvatarTheme, BreadcrumbTheme, ButtonTheme, CalendarTheme, AlertTheme, AutoCompleteTheme, AvatarTheme, BackTopTheme, BreadcrumbTheme, ButtonTheme,
CollapseTheme, ColorPickerTheme, DatePickerTheme, InputTheme, MenuTheme, MessageTheme, CalendarTheme, CollapseTheme, ColorPickerTheme, DatePickerTheme, InputTheme, MenuTheme,
PopoverTheme, ProgressTheme, ScrollbarTheme, SelectTheme, SkeletionTheme, SliderTheme, MessageTheme, PopoverTheme, ProgressTheme, ScrollbarTheme, SelectTheme, SkeletionTheme,
SpinnerTheme, SwitchTheme, TableTheme, TagTheme, TimePickerTheme, TypographyTheme, UploadTheme, SliderTheme, SpinnerTheme, SwitchTheme, TableTheme, TagTheme, TimePickerTheme, TypographyTheme,
UploadTheme,
}; };
use leptos::*; use leptos::*;
@ -46,6 +47,7 @@ pub struct Theme {
pub popover: PopoverTheme, pub popover: PopoverTheme,
pub collapse: CollapseTheme, pub collapse: CollapseTheme,
pub scrollbar: ScrollbarTheme, pub scrollbar: ScrollbarTheme,
pub back_top: BackTopTheme,
} }
impl Theme { impl Theme {
@ -80,6 +82,7 @@ impl Theme {
popover: PopoverTheme::light(), popover: PopoverTheme::light(),
collapse: CollapseTheme::light(), collapse: CollapseTheme::light(),
scrollbar: ScrollbarTheme::light(), scrollbar: ScrollbarTheme::light(),
back_top: BackTopTheme::light(),
} }
} }
pub fn dark() -> Self { pub fn dark() -> Self {
@ -113,6 +116,7 @@ impl Theme {
popover: PopoverTheme::dark(), popover: PopoverTheme::dark(),
collapse: CollapseTheme::dark(), collapse: CollapseTheme::dark(),
scrollbar: ScrollbarTheme::dark(), scrollbar: ScrollbarTheme::dark(),
back_top: BackTopTheme::dark(),
} }
} }
} }

View file

@ -4,12 +4,10 @@ pub use get_placement_style::FollowerPlacement;
use crate::Teleport; use crate::Teleport;
use get_placement_style::{get_follower_placement_offset, FollowerPlacementOffset}; use get_placement_style::{get_follower_placement_offset, FollowerPlacementOffset};
use leptos::{ use leptos::{html::ElementDescriptor, leptos_dom::helpers::WindowListenerHandle, *};
html::{AnyElement, ElementDescriptor, ToHtmlElement}, use thaw_utils::{
leptos_dom::helpers::WindowListenerHandle, 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] #[slot]
pub struct Follower { pub struct Follower {
@ -74,26 +72,26 @@ pub fn Binder<El: ElementDescriptor + Clone + 'static>(
let resize_handle = store_value(None::<WindowListenerHandle>); let resize_handle = store_value(None::<WindowListenerHandle>);
let ensure_scroll_listener = move || { let ensure_scroll_listener = move || {
let mut cursor = target_ref.get_untracked().map(|target| target.into_any()); let Some(el) = target_ref.get_untracked().map(|target| target.into_any()) else {
let mut scrollable_element_vec = vec![]; return;
};
let mut handle_vec = vec![];
let mut cursor = get_scroll_parent(&el);
loop { loop {
cursor = get_scroll_parent(cursor); if let Some(el) = cursor.take() {
if let Some(cursor) = cursor.take() { cursor = get_scroll_parent(&el);
scrollable_element_vec.push(cursor);
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 { } else {
break; 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); scrollable_element_handle_vec.set_value(handle_vec);
}; };
@ -241,59 +239,3 @@ fn FollowerContainer<El: ElementDescriptor + Clone + 'static>(
view! { <Teleport element=children immediate=show/> } view! { <Teleport element=children immediate=show/> }
} }
fn get_scroll_parent(element: Option<HtmlElement<AnyElement>>) -> Option<HtmlElement<AnyElement>> {
let Some(element) = element else {
return None;
};
fn get_parent_element(element: HtmlElement<AnyElement>) -> Option<HtmlElement<AnyElement>> {
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<AnyElement>,
) -> 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))
}

View file

@ -0,0 +1,55 @@
use leptos::{
html::{AnyElement, ToHtmlElement},
*,
};
pub fn get_scroll_parent(element: &HtmlElement<AnyElement>) -> Option<HtmlElement<AnyElement>> {
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<AnyElement>) -> Option<HtmlElement<AnyElement>> {
if element.node_type() == 9 {
None
} else {
element.parent_element().map(|ele| ele.to_leptos_element())
}
}
fn get_overflow(parent_element: &HtmlElement<AnyElement>) -> 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))
}

View file

@ -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;

View file

@ -1,14 +1,14 @@
pub mod class_list; pub mod class_list;
mod dom;
mod event_listener; mod event_listener;
mod hooks; mod hooks;
mod mount_style;
mod optional_prop; mod optional_prop;
mod signals; mod signals;
mod time; mod time;
pub use dom::{get_scroll_parent, mount_style};
pub use event_listener::{add_event_listener, EventListenerHandle}; pub use event_listener::{add_event_listener, EventListenerHandle};
pub use hooks::{use_click_position, use_lock_html_scroll, use_next_frame, NextFrame}; 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 optional_prop::OptionalProp;
pub use signals::{ pub use signals::{
create_component_ref, ComponentRef, Model, OptionalMaybeSignal, SignalWatch, StoredMaybeSignal, create_component_ref, ComponentRef, Model, OptionalMaybeSignal, SignalWatch, StoredMaybeSignal,