Feat/anchor (#170)

* feat: Add Anchor component

* feat: Anchor collection Link

* feat: Anchor scroll view

* feat: Demo adds Toc

* fix: GuideDemo Toc

* feat: Anchor scroll

* feat: Anchor offset target
This commit is contained in:
luoxiaozero 2024-04-19 14:42:30 +08:00 committed by GitHub
parent 1b0f664dc7
commit f75b38f97d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 710 additions and 16 deletions

View file

@ -47,6 +47,7 @@ fn TheRouter(is_routing: RwSignal<bool>) -> impl IntoView {
<Route path="/nav-bar" view=NavBarPage/>
<Route path="/toast" view=ToastPage/>
<Route path="/alert" view=AlertMdPage/>
<Route path="/anchor" view=AnchorMdPage/>
<Route path="/auto-complete" view=AutoCompleteMdPage/>
<Route path="/avatar" view=AvatarMdPage/>
<Route path="/back-top" view=BackTopMdPage/>

View file

@ -33,10 +33,19 @@ pub fn ComponentsPage() -> impl IntoView {
width: 896px;
margin: 0 auto;
}
.demo-components__toc {
width: 190px;
margin: 12px 2px 12px 12px;
}
.demo-components__toc > .thaw-anchor {
position: sticky;
top: 36px;
}
.demo-md-table-box {
overflow: auto;
}
@media screen and (max-width: 1200px) {
.demo-components__toc,
.demo-components__sider {
display: none;
}
@ -56,7 +65,7 @@ pub fn ComponentsPage() -> impl IntoView {
</Menu>
</LayoutSider>
<Layout content_style="padding: 8px 12px 28px;">
<Layout content_style="padding: 8px 12px 28px; display: flex;" class="doc-content">
<Outlet/>
</Layout>
</Layout>
@ -208,6 +217,10 @@ pub(crate) fn gen_menu_data() -> Vec<MenuGroupOption> {
MenuGroupOption {
label: "Navigation Components".into(),
children: vec![
MenuItemOption {
value: "anchor".into(),
label: "Anchor".into(),
},
MenuItemOption {
value: "back-top".into(),
label: "Back Top".into(),

View file

@ -32,10 +32,19 @@ pub fn GuidePage() -> impl IntoView {
width: 896px;
margin: 0 auto;
}
.demo-components__toc {
width: 190px;
margin: 12px 2px 12px 12px;
}
.demo-components__toc > .thaw-anchor {
position: sticky;
top: 36px;
}
.demo-md-table-box {
overflow: auto;
}
@media screen and (max-width: 1200px) {
.demo-components__toc,
.demo-guide__sider {
display: none;
}
@ -55,7 +64,7 @@ pub fn GuidePage() -> impl IntoView {
</Menu>
</LayoutSider>
<Layout content_style="padding: 8px 12px 28px;">
<Layout content_style="padding: 8px 12px 28px; display: flex;" class="doc-content">
<Outlet/>
</Layout>
</Layout>

View file

@ -0,0 +1,43 @@
# Anchor
```rust demo
view! {
<Anchor>
<AnchorLink title="Web API" href="#web">
<AnchorLink title="DOM" href="#dom"/>
<AnchorLink title="SVG" href="#svg"/>
<AnchorLink title="File API" href="#file"/>
</AnchorLink>
<AnchorLink title="Rust" href="#rust"/>
<AnchorLink title="Anchor Props" href="#anchor-props"/>
<AnchorLink title="AnchorLink Props" href="#anchorlink-props"/>
</Anchor>
}
```
```rust demo
view! {
<Anchor>
<AnchorLink title="Anchor" href="#anchor"/>
<AnchorLink title="Anchor Props" href="#anchor-props"/>
<AnchorLink title="AnchorLink Props" href="#anchorlink-props"/>
</Anchor>
}
```
### Anchor Props
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| class | `OptionalProp<MaybeSignal<String>>` | `Default::default()` | Additional classes for the anchor element. |
| offset_target | `Option<OffsetTarget>` | `None` | The element or selector used to calc offset of link elements. If you are not scrolling the entire document but only a part of it, you may need to set this. |
| children | `Children` | | Anchor's children. |
### AnchorLink Props
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| class | `OptionalProp<MaybeSignal<String>>` | `Default::default()` | Additional classes for the anchor link element. |
| title | `MaybeSignal<String>` | | The content of link. |
| href | `String` | | The target of link. |
| children | `Option<Children>` | `None` | AnchorLink's children. |

View file

@ -29,6 +29,7 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt
"TabbarMdPage" => "../docs/_mobile/tabbar/mod.md",
"ToastMdPage" => "../docs/_mobile/toast/mod.md",
"AlertMdPage" => "../docs/alert/mod.md",
"AnchorMdPage" => "../docs/anchor/mod.md",
"AutoCompleteMdPage" => "../docs/auto_complete/mod.md",
"AvatarMdPage" => "../docs/avatar/mod.md",
"BackTopMdPage" => "../docs/back_top/mod.md",
@ -77,13 +78,27 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt
for (fn_name, file_str) in file_list {
let fn_name = Ident::new(fn_name, Span::call_site());
let (body, demos) = match parse_markdown(file_str) {
let (body, demos, toc) = match parse_markdown(file_str) {
Ok(body) => body,
Err(err) => {
return quote!(compile_error!(#err)).into();
}
};
let toc = {
let links = toc
.into_iter()
.map(|h| format!(r##"<AnchorLink href="#{}" title="{}" />"##, h.0, h.1))
.collect::<Vec<_>>()
.join(" ");
let toc = format!(
r##"#[component] fn Toc() -> impl IntoView {{ view! {{ <Anchor offset_target=".doc-content">{}</Anchor> }} }}"##,
links
);
syn::parse_str::<ItemFn>(&toc)
.expect(&format!("Cannot be resolved as a function: \n {toc}"))
};
let demos: Vec<ItemFn> = demos
.into_iter()
.enumerate()
@ -105,10 +120,15 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt
pub fn #fn_name() -> impl IntoView {
#(#demos)*
#toc
view! {
<div class="demo-components__component">
#body
</div>
<div class="demo-components__toc">
<Toc />
</div>
}
}
});

View file

@ -1,29 +1,35 @@
mod code_block;
use comrak::{
nodes::{AstNode, NodeValue},
nodes::{AstNode, LineColumn, NodeValue},
parse_document, Arena,
};
use proc_macro2::{Ident, Span, TokenStream};
use quote::quote;
use syn::ItemMacro;
pub fn parse_markdown(md_text: &str) -> Result<(TokenStream, Vec<String>), String> {
pub fn parse_markdown(md_text: &str) -> Result<(TokenStream, Vec<String>, Vec<(String, String)>), String> {
let mut demos: Vec<String> = vec![];
let mut toc: Vec<(String, String)> = vec![];
let arena = Arena::new();
let mut options = comrak::Options::default();
options.extension.table = true;
let root = parse_document(&arena, &md_text, &options);
let body = iter_nodes(root, &mut demos);
Ok((body, demos))
let body = iter_nodes(md_text, root, &mut demos, &mut toc);
Ok((body, demos, toc))
}
fn iter_nodes<'a>(node: &'a AstNode<'a>, demos: &mut Vec<String>) -> TokenStream {
fn iter_nodes<'a>(
md_text: &str,
node: &'a AstNode<'a>,
demos: &mut Vec<String>,
toc: &mut Vec<(String, String)>,
) -> TokenStream {
let mut children = vec![];
for c in node.children() {
children.push(iter_nodes(c, demos));
children.push(iter_nodes(md_text, c, demos, toc));
}
match &node.data.borrow().value {
NodeValue::Document => quote!(#(#children)*),
@ -55,9 +61,15 @@ fn iter_nodes<'a>(node: &'a AstNode<'a>, demos: &mut Vec<String>) -> TokenStream
</p >
),
NodeValue::Heading(node_h) => {
let sourcepos = node.data.borrow().sourcepos;
let text = range_text(md_text, sourcepos.start.clone(), sourcepos.end.clone());
let level = node_h.level as usize + 1;
let text = text[level..].to_string();
let h_id = format!("{}", text.replace(' ', "-").to_ascii_lowercase());
toc.push((h_id.clone(), text));
let h = Ident::new(&format!("h{}", node_h.level), Span::call_site());
quote!(
<#h>
<#h id=#h_id>
#(#children)*
</#h>
)
@ -148,3 +160,40 @@ fn iter_nodes<'a>(node: &'a AstNode<'a>, demos: &mut Vec<String>) -> TokenStream
NodeValue::MultilineBlockQuote(_) => quote!("FootnoteReference todo!!!"),
}
}
fn range_text(text: &str, start: LineColumn, end: LineColumn) -> &str {
let LineColumn {
line: start_line,
column: start_col,
} = start;
let LineColumn {
line: end_line,
column: end_col,
} = end;
let mut lines = text.lines();
let mut current_line_num = 1;
let mut start_line_text = lines.next().unwrap_or("");
while current_line_num < start_line {
start_line_text = lines.next().unwrap_or("");
current_line_num += 1;
}
let start_index = start_col - 1;
let mut start_line_text = &start_line_text[start_index..];
let mut current_line_num = start_line + 1;
while current_line_num < end_line {
let next_line = lines.next().unwrap_or("");
start_line_text = &next_line;
current_line_num += 1;
}
let end_index = end_col;
if current_line_num == end_line {
start_line_text = &start_line_text[..end_index];
}
start_line_text
}

View file

@ -0,0 +1,77 @@
.thaw-anchor {
position: relative;
padding-left: 4px;
}
.thaw-anchor .thaw-anchor-link + .thaw-anchor-link,
.thaw-anchor .thaw-anchor-link > .thaw-anchor-link {
margin-top: 0.5em;
}
.thaw-anchor-background {
max-width: 0;
position: absolute;
left: 2px;
width: 100%;
background-color: var(--thaw-link-background-color);
transition: top 0.15s cubic-bezier(0.4, 0, 0.2, 1),
max-width 0.15s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.thaw-anchor-rail {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
border-radius: 2px;
overflow: hidden;
transition: background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background-color: var(--thaw-rail-background-color);
}
.thaw-anchor-rail__bar {
position: absolute;
left: 0;
width: 4px;
height: 21px;
transition: top 0.15s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.thaw-anchor-rail__bar.thaw-anchor-rail__bar--active {
background-color: var(--thaw-title-color-active);
}
.thaw-anchor-link {
padding: 0 0 0 16px;
position: relative;
line-height: 1.5;
font-size: 13px;
min-height: 1.5em;
display: flex;
flex-direction: column;
}
.thaw-anchor-link.thaw-anchor-link--active > .thaw-anchor-link__title {
color: var(--thaw-title-color-active);
}
.thaw-anchor-link__title {
outline: none;
max-width: 100%;
text-decoration: none;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
cursor: pointer;
display: inline-block;
padding-right: 16px;
color: inherit;
transition: color 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.thaw-anchor-link__title:hover {
color: var(--thaw-title-color-hover);
}

View file

@ -0,0 +1,83 @@
use crate::use_anchor;
use leptos::*;
use thaw_components::OptionComp;
use thaw_utils::{class_list, OptionalProp, StoredMaybeSignal};
#[component]
pub fn AnchorLink(
#[prop(optional, into)] class: OptionalProp<MaybeSignal<String>>,
#[prop(into)] title: MaybeSignal<String>,
#[prop(into)] href: String,
#[prop(optional)] children: Option<Children>,
) -> impl IntoView {
let anchor = use_anchor();
let title: StoredMaybeSignal<_> = title.into();
let title_ref = NodeRef::<html::A>::new();
let href_id = StoredValue::new(None::<String>);
let is_active = Memo::new(move |_| {
href_id.with_value(|href_id| {
if href_id.is_none() {
false
} else {
anchor.active_id.with(|active_id| active_id == href_id)
}
})
});
if !href.is_empty() {
if href.starts_with('#') {
let id = href[1..].to_string();
href_id.set_value(Some(id.clone()));
anchor.append_id(id);
on_cleanup(move || {
href_id.with_value(|id| {
if let Some(id) = id {
anchor.remove_id(id);
}
});
});
title_ref.on_load(move |title_el| {
let _ = watch(
move || is_active.get(),
move |is_active, _, _| {
if *is_active {
let title_rect = title_el.get_bounding_client_rect();
anchor.update_background_position(title_rect);
}
},
true,
);
});
}
}
let on_click = move |_| {
href_id.with_value(move |href_id| {
if let Some(href_id) = href_id {
anchor.scroll_into_view(href_id);
}
});
};
view! {
<div class=class_list![
"thaw-anchor-link", ("thaw-anchor-link--active", move || is_active.get()), class.map(| c
| move || c.get())
]>
<a
href=href
class="thaw-anchor-link__title"
on:click=on_click
ref=title_ref
title=move || title.get()
>
{move || title.get()}
</a>
<OptionComp value=children let:children>
{children()}
</OptionComp>
</div>
}
}

267
thaw/src/anchor/mod.rs Normal file
View file

@ -0,0 +1,267 @@
mod anchor_link;
mod theme;
pub use anchor_link::AnchorLink;
pub use theme::AnchorTheme;
use crate::{use_theme, Theme};
use leptos::*;
use std::cmp::Ordering;
use thaw_utils::{add_event_listener_with_bool, class_list, mount_style, throttle, OptionalProp};
use web_sys::{DomRect, Element};
#[component]
pub fn Anchor(
#[prop(optional, into)] class: OptionalProp<MaybeSignal<String>>,
#[prop(into, optional)] offset_target: Option<OffsetTarget>,
children: Children,
) -> impl IntoView {
mount_style("anchor", include_str!("./anchor.css"));
let theme = use_theme(Theme::light);
let css_vars = Memo::new(move |_| {
let mut css_vars = String::new();
theme.with(|theme| {
css_vars.push_str(&format!(
"--thaw-rail-background-color: {};",
theme.anchor.rail_background_color
));
css_vars.push_str(&format!(
"--thaw-title-color-hover: {};",
theme.common.color_primary_hover
));
css_vars.push_str(&format!(
"--thaw-title-color-active: {};",
theme.common.color_primary
));
css_vars.push_str(&format!(
"--thaw-link-background-color: {}1a;",
theme.common.color_primary
));
});
css_vars
});
let anchor_ref = NodeRef::new();
let background_ref = NodeRef::<html::Div>::new();
let bar_ref = NodeRef::new();
let element_ids = RwSignal::new(Vec::<String>::new());
let active_id = RwSignal::new(None::<String>);
let _ = watch(
move || active_id.get(),
move |id, _, _| {
if id.is_none() {
if let Some(background_el) = background_ref.get_untracked() {
let _ = background_el.style("max-width", "0");
}
}
},
false,
);
let on_scroll = move || {
element_ids.with(|ids| {
let offset_target_top = if let Some(offset_target) = offset_target.as_ref() {
if let Some(rect) = offset_target.get_bounding_client_rect() {
rect.top()
} else {
return;
}
} else {
0.0
};
let mut links: Vec<LinkInfo> = vec![];
for id in ids.iter() {
if let Some(link_el) = document().get_element_by_id(id) {
let link_rect = link_el.get_bounding_client_rect();
links.push(LinkInfo {
top: link_rect.top() - offset_target_top,
id: id.clone(),
});
}
}
links.sort_by(|a, b| {
if a.top > b.top {
Ordering::Greater
} else {
Ordering::Less
}
});
let mut temp_link = None::<LinkInfo>;
for link in links.into_iter() {
if link.top >= 0.0 {
if link.top <= 12.0 {
temp_link = Some(link);
break;
} else if temp_link.is_some() {
break;
} else {
temp_link = None;
}
} else {
temp_link = Some(link);
}
}
active_id.set(temp_link.map(|link| link.id));
});
};
let cb = throttle(
move || {
on_scroll();
},
std::time::Duration::from_millis(200),
);
let scroll_handle = add_event_listener_with_bool(
document(),
ev::scroll,
move |_| {
cb();
},
true,
);
on_cleanup(move || {
scroll_handle.remove();
});
view! {
<div
class=class_list!["thaw-anchor", class.map(| c | move || c.get())]
ref=anchor_ref
style=move || css_vars.get()
>
<div class="thaw-anchor-rail">
<div
class="thaw-anchor-rail__bar"
class=(
"thaw-anchor-rail__bar--active",
move || active_id.with(|id| id.is_some()),
)
ref=bar_ref
></div>
</div>
<div class="thaw-anchor-background" ref=background_ref></div>
<Provider value=AnchorInjection::new(
anchor_ref,
background_ref,
bar_ref,
element_ids,
active_id,
)>{children()}</Provider>
</div>
}
}
#[derive(Clone)]
pub(crate) struct AnchorInjection {
anchor_ref: NodeRef<html::Div>,
background_ref: NodeRef<html::Div>,
bar_ref: NodeRef<html::Div>,
element_ids: RwSignal<Vec<String>>,
pub active_id: RwSignal<Option<String>>,
}
impl Copy for AnchorInjection {}
impl AnchorInjection {
fn new(
anchor_ref: NodeRef<html::Div>,
background_ref: NodeRef<html::Div>,
bar_ref: NodeRef<html::Div>,
element_ids: RwSignal<Vec<String>>,
active_id: RwSignal<Option<String>>,
) -> Self {
Self {
anchor_ref,
background_ref,
bar_ref,
element_ids,
active_id,
}
}
pub fn scroll_into_view(&self, id: &String) {
let Some(link_el) = document().get_element_by_id(id) else {
return;
};
link_el.scroll_into_view();
}
pub fn append_id(&self, id: String) {
self.element_ids.update(|ids| {
ids.push(id);
});
}
pub fn remove_id(&self, id: &String) {
self.element_ids.update(|ids| {
if let Some(index) = ids.iter().position(|item_id| item_id == id) {
ids.remove(index);
}
});
}
pub fn update_background_position(&self, title_rect: DomRect) {
if let Some(anchor_el) = self.anchor_ref.get_untracked() {
let background_el = self.background_ref.get_untracked().unwrap();
let bar_el = self.bar_ref.get_untracked().unwrap();
let anchor_rect = anchor_el.get_bounding_client_rect();
let offset_top = title_rect.top() - anchor_rect.top();
let offset_left = title_rect.left() - anchor_rect.left();
let _ = background_el
.style("top", format!("{}px", offset_top))
.style("height", format!("{}px", title_rect.height()))
.style(
"max-width",
format!("{}px", title_rect.width() + offset_left),
);
let _ = bar_el
.style("top", format!("{}px", offset_top))
.style("height", format!("{}px", title_rect.height()));
}
}
}
pub(crate) fn use_anchor() -> AnchorInjection {
expect_context()
}
struct LinkInfo {
top: f64,
id: String,
}
pub enum OffsetTarget {
Selector(String),
Element(Element),
}
impl OffsetTarget {
fn get_bounding_client_rect(&self) -> Option<DomRect> {
match self {
OffsetTarget::Selector(selector) => {
let el = document().query_selector(selector).ok().flatten()?;
Some(el.get_bounding_client_rect())
}
OffsetTarget::Element(el) => Some(el.get_bounding_client_rect()),
}
}
}
impl From<&'static str> for OffsetTarget {
fn from(value: &'static str) -> Self {
Self::Selector(value.to_string())
}
}
impl From<String> for OffsetTarget {
fn from(value: String) -> Self {
Self::Selector(value)
}
}
impl From<Element> for OffsetTarget {
fn from(value: Element) -> Self {
Self::Element(value)
}
}

20
thaw/src/anchor/theme.rs Normal file
View file

@ -0,0 +1,20 @@
use crate::theme::ThemeMethod;
#[derive(Clone)]
pub struct AnchorTheme {
pub rail_background_color: String,
}
impl ThemeMethod for AnchorTheme {
fn light() -> Self {
Self {
rail_background_color: "#dbdbdf".into(),
}
}
fn dark() -> Self {
Self {
rail_background_color: "#ffffff33".into(),
}
}
}

View file

@ -1,4 +1,5 @@
mod alert;
mod anchor;
mod auto_complete;
mod avatar;
mod back_top;
@ -45,6 +46,7 @@ mod typography;
mod upload;
pub use alert::*;
pub use anchor::*;
pub use auto_complete::*;
pub use avatar::*;
pub use back_top::*;

View file

@ -3,11 +3,11 @@ mod common;
use self::common::CommonTheme;
use crate::{
mobile::{NavBarTheme, TabbarTheme},
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,
AlertTheme, AnchorTheme, 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::*;
@ -48,6 +48,7 @@ pub struct Theme {
pub collapse: CollapseTheme,
pub scrollbar: ScrollbarTheme,
pub back_top: BackTopTheme,
pub anchor: AnchorTheme,
}
impl Theme {
@ -83,6 +84,7 @@ impl Theme {
collapse: CollapseTheme::light(),
scrollbar: ScrollbarTheme::light(),
back_top: BackTopTheme::light(),
anchor: AnchorTheme::light(),
}
}
pub fn dark() -> Self {
@ -117,6 +119,7 @@ impl Theme {
collapse: CollapseTheme::dark(),
scrollbar: ScrollbarTheme::dark(),
back_top: BackTopTheme::dark(),
anchor: AnchorTheme::dark(),
}
}
}

View file

@ -1,5 +1,7 @@
use ::wasm_bindgen::{prelude::Closure, JsCast};
use leptos::{html::AnyElement, *};
use std::ops::Deref;
use web_sys::EventTarget;
pub fn add_event_listener<E: ev::EventDescriptor + 'static>(
target: HtmlElement<AnyElement>,
@ -48,3 +50,73 @@ fn add_event_listener_untyped(
wel(target, Box::new(cb), event_name)
}
pub fn add_event_listener_with_bool<E: ev::EventDescriptor + 'static>(
target: impl IntoEventTarget,
event: E,
cb: impl Fn(E::EventType) + 'static,
use_capture: bool,
) -> EventListenerHandle
where
E::EventType: JsCast,
{
add_event_listener_untyped_with_bool(
target.into_event_target(),
&event.name(),
move |e| cb(e.unchecked_into::<E::EventType>()),
use_capture,
)
}
fn add_event_listener_untyped_with_bool(
target: EventTarget,
event_name: &str,
cb: impl Fn(web_sys::Event) + 'static,
use_capture: bool,
) -> EventListenerHandle {
fn wel(
target: EventTarget,
cb: Box<dyn FnMut(web_sys::Event)>,
event_name: &str,
use_capture: bool,
) -> EventListenerHandle {
let cb = Closure::wrap(cb).into_js_value();
_ = target.add_event_listener_with_callback_and_bool(
event_name,
cb.unchecked_ref(),
use_capture,
);
let event_name = event_name.to_string();
EventListenerHandle(Box::new(move || {
_ = target.remove_event_listener_with_callback_and_bool(
&event_name,
cb.unchecked_ref(),
use_capture,
);
}))
}
wel(target, Box::new(cb), event_name, use_capture)
}
pub trait IntoEventTarget {
fn into_event_target(self) -> EventTarget;
}
impl IntoEventTarget for EventTarget {
fn into_event_target(self) -> EventTarget {
self
}
}
impl IntoEventTarget for web_sys::Document {
fn into_event_target(self) -> EventTarget {
self.into()
}
}
impl IntoEventTarget for HtmlElement<AnyElement> {
fn into_event_target(self) -> EventTarget {
self.deref().deref().deref().deref().clone()
}
}

View file

@ -4,15 +4,19 @@ mod event_listener;
mod hooks;
mod optional_prop;
mod signals;
mod throttle;
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, add_event_listener_with_bool, EventListenerHandle, IntoEventTarget,
};
pub use hooks::{use_click_position, use_lock_html_scroll, use_next_frame, NextFrame};
pub use optional_prop::OptionalProp;
pub use signals::{
create_component_ref, ComponentRef, Model, OptionalMaybeSignal, SignalWatch, StoredMaybeSignal,
};
pub use throttle::throttle;
pub use time::now_date;
pub fn with_hydration_off<T>(f: impl FnOnce() -> T) -> T {

View file

@ -0,0 +1,31 @@
use leptos::{leptos_dom::helpers::TimeoutHandle, *};
use std::time::Duration;
pub fn throttle(cb: impl Fn() + 'static, duration: Duration) -> impl Fn() -> () {
let cb = Callback::new(move |_| cb());
let timeout_handle = StoredValue::new(None::<TimeoutHandle>);
on_cleanup(move || {
timeout_handle.update_value(move |handle| {
if let Some(handle) = handle.take() {
handle.clear();
}
});
});
move || {
if timeout_handle.with_value(|handle| handle.is_some()) {
return;
}
let handle = set_timeout_with_handle(
move || {
cb.call(());
timeout_handle.update_value(move |handle| {
*handle = None;
});
},
duration,
)
.unwrap();
timeout_handle.set_value(Some(handle));
}
}