mirror of
https://github.com/adoyle0/thaw.git
synced 2025-02-02 08:34:15 -05:00
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:
parent
1b0f664dc7
commit
f75b38f97d
15 changed files with 710 additions and 16 deletions
|
@ -47,6 +47,7 @@ fn TheRouter(is_routing: RwSignal<bool>) -> impl IntoView {
|
||||||
<Route path="/nav-bar" view=NavBarPage/>
|
<Route path="/nav-bar" view=NavBarPage/>
|
||||||
<Route path="/toast" view=ToastPage/>
|
<Route path="/toast" view=ToastPage/>
|
||||||
<Route path="/alert" view=AlertMdPage/>
|
<Route path="/alert" view=AlertMdPage/>
|
||||||
|
<Route path="/anchor" view=AnchorMdPage/>
|
||||||
<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="/back-top" view=BackTopMdPage/>
|
||||||
|
|
|
@ -33,10 +33,19 @@ pub fn ComponentsPage() -> impl IntoView {
|
||||||
width: 896px;
|
width: 896px;
|
||||||
margin: 0 auto;
|
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 {
|
.demo-md-table-box {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
@media screen and (max-width: 1200px) {
|
@media screen and (max-width: 1200px) {
|
||||||
|
.demo-components__toc,
|
||||||
.demo-components__sider {
|
.demo-components__sider {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
@ -56,7 +65,7 @@ pub fn ComponentsPage() -> impl IntoView {
|
||||||
|
|
||||||
</Menu>
|
</Menu>
|
||||||
</LayoutSider>
|
</LayoutSider>
|
||||||
<Layout content_style="padding: 8px 12px 28px;">
|
<Layout content_style="padding: 8px 12px 28px; display: flex;" class="doc-content">
|
||||||
<Outlet/>
|
<Outlet/>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
@ -208,6 +217,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: "anchor".into(),
|
||||||
|
label: "Anchor".into(),
|
||||||
|
},
|
||||||
MenuItemOption {
|
MenuItemOption {
|
||||||
value: "back-top".into(),
|
value: "back-top".into(),
|
||||||
label: "Back Top".into(),
|
label: "Back Top".into(),
|
||||||
|
|
|
@ -32,10 +32,19 @@ pub fn GuidePage() -> impl IntoView {
|
||||||
width: 896px;
|
width: 896px;
|
||||||
margin: 0 auto;
|
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 {
|
.demo-md-table-box {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
@media screen and (max-width: 1200px) {
|
@media screen and (max-width: 1200px) {
|
||||||
|
.demo-components__toc,
|
||||||
.demo-guide__sider {
|
.demo-guide__sider {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
@ -55,7 +64,7 @@ pub fn GuidePage() -> impl IntoView {
|
||||||
|
|
||||||
</Menu>
|
</Menu>
|
||||||
</LayoutSider>
|
</LayoutSider>
|
||||||
<Layout content_style="padding: 8px 12px 28px;">
|
<Layout content_style="padding: 8px 12px 28px; display: flex;" class="doc-content">
|
||||||
<Outlet/>
|
<Outlet/>
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
43
demo_markdown/docs/anchor/mod.md
Normal file
43
demo_markdown/docs/anchor/mod.md
Normal 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. |
|
|
@ -29,6 +29,7 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt
|
||||||
"TabbarMdPage" => "../docs/_mobile/tabbar/mod.md",
|
"TabbarMdPage" => "../docs/_mobile/tabbar/mod.md",
|
||||||
"ToastMdPage" => "../docs/_mobile/toast/mod.md",
|
"ToastMdPage" => "../docs/_mobile/toast/mod.md",
|
||||||
"AlertMdPage" => "../docs/alert/mod.md",
|
"AlertMdPage" => "../docs/alert/mod.md",
|
||||||
|
"AnchorMdPage" => "../docs/anchor/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",
|
"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 {
|
for (fn_name, file_str) in file_list {
|
||||||
let fn_name = Ident::new(fn_name, Span::call_site());
|
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,
|
Ok(body) => body,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
return quote!(compile_error!(#err)).into();
|
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
|
let demos: Vec<ItemFn> = demos
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
|
@ -105,10 +120,15 @@ pub fn include_md(_token_stream: proc_macro::TokenStream) -> proc_macro::TokenSt
|
||||||
pub fn #fn_name() -> impl IntoView {
|
pub fn #fn_name() -> impl IntoView {
|
||||||
#(#demos)*
|
#(#demos)*
|
||||||
|
|
||||||
|
#toc
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="demo-components__component">
|
<div class="demo-components__component">
|
||||||
#body
|
#body
|
||||||
</div>
|
</div>
|
||||||
|
<div class="demo-components__toc">
|
||||||
|
<Toc />
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,29 +1,35 @@
|
||||||
mod code_block;
|
mod code_block;
|
||||||
|
|
||||||
use comrak::{
|
use comrak::{
|
||||||
nodes::{AstNode, NodeValue},
|
nodes::{AstNode, LineColumn, NodeValue},
|
||||||
parse_document, Arena,
|
parse_document, Arena,
|
||||||
};
|
};
|
||||||
use proc_macro2::{Ident, Span, TokenStream};
|
use proc_macro2::{Ident, Span, TokenStream};
|
||||||
use quote::quote;
|
use quote::quote;
|
||||||
use syn::ItemMacro;
|
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 demos: Vec<String> = vec![];
|
||||||
|
let mut toc: Vec<(String, String)> = vec![];
|
||||||
|
|
||||||
let arena = Arena::new();
|
let arena = Arena::new();
|
||||||
let mut options = comrak::Options::default();
|
let mut options = comrak::Options::default();
|
||||||
options.extension.table = true;
|
options.extension.table = true;
|
||||||
|
|
||||||
let root = parse_document(&arena, &md_text, &options);
|
let root = parse_document(&arena, &md_text, &options);
|
||||||
let body = iter_nodes(root, &mut demos);
|
let body = iter_nodes(md_text, root, &mut demos, &mut toc);
|
||||||
Ok((body, demos))
|
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![];
|
let mut children = vec![];
|
||||||
for c in node.children() {
|
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 {
|
match &node.data.borrow().value {
|
||||||
NodeValue::Document => quote!(#(#children)*),
|
NodeValue::Document => quote!(#(#children)*),
|
||||||
|
@ -55,9 +61,15 @@ fn iter_nodes<'a>(node: &'a AstNode<'a>, demos: &mut Vec<String>) -> TokenStream
|
||||||
</p >
|
</p >
|
||||||
),
|
),
|
||||||
NodeValue::Heading(node_h) => {
|
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());
|
let h = Ident::new(&format!("h{}", node_h.level), Span::call_site());
|
||||||
quote!(
|
quote!(
|
||||||
<#h>
|
<#h id=#h_id>
|
||||||
#(#children)*
|
#(#children)*
|
||||||
</#h>
|
</#h>
|
||||||
)
|
)
|
||||||
|
@ -148,3 +160,40 @@ fn iter_nodes<'a>(node: &'a AstNode<'a>, demos: &mut Vec<String>) -> TokenStream
|
||||||
NodeValue::MultilineBlockQuote(_) => quote!("FootnoteReference todo!!!"),
|
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
|
||||||
|
}
|
||||||
|
|
77
thaw/src/anchor/anchor.css
Normal file
77
thaw/src/anchor/anchor.css
Normal 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);
|
||||||
|
}
|
83
thaw/src/anchor/anchor_link.rs
Normal file
83
thaw/src/anchor/anchor_link.rs
Normal 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
267
thaw/src/anchor/mod.rs
Normal 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
20
thaw/src/anchor/theme.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
mod alert;
|
mod alert;
|
||||||
|
mod anchor;
|
||||||
mod auto_complete;
|
mod auto_complete;
|
||||||
mod avatar;
|
mod avatar;
|
||||||
mod back_top;
|
mod back_top;
|
||||||
|
@ -45,6 +46,7 @@ mod typography;
|
||||||
mod upload;
|
mod upload;
|
||||||
|
|
||||||
pub use alert::*;
|
pub use alert::*;
|
||||||
|
pub use anchor::*;
|
||||||
pub use auto_complete::*;
|
pub use auto_complete::*;
|
||||||
pub use avatar::*;
|
pub use avatar::*;
|
||||||
pub use back_top::*;
|
pub use back_top::*;
|
||||||
|
|
|
@ -3,11 +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, BackTopTheme, BreadcrumbTheme, ButtonTheme,
|
AlertTheme, AnchorTheme, AutoCompleteTheme, AvatarTheme, BackTopTheme, BreadcrumbTheme,
|
||||||
CalendarTheme, CollapseTheme, ColorPickerTheme, DatePickerTheme, InputTheme, MenuTheme,
|
ButtonTheme, CalendarTheme, CollapseTheme, ColorPickerTheme, DatePickerTheme, InputTheme,
|
||||||
MessageTheme, PopoverTheme, ProgressTheme, ScrollbarTheme, SelectTheme, SkeletionTheme,
|
MenuTheme, MessageTheme, PopoverTheme, ProgressTheme, ScrollbarTheme, SelectTheme,
|
||||||
SliderTheme, SpinnerTheme, SwitchTheme, TableTheme, TagTheme, TimePickerTheme, TypographyTheme,
|
SkeletionTheme, SliderTheme, SpinnerTheme, SwitchTheme, TableTheme, TagTheme, TimePickerTheme,
|
||||||
UploadTheme,
|
TypographyTheme, UploadTheme,
|
||||||
};
|
};
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
|
|
||||||
|
@ -48,6 +48,7 @@ pub struct Theme {
|
||||||
pub collapse: CollapseTheme,
|
pub collapse: CollapseTheme,
|
||||||
pub scrollbar: ScrollbarTheme,
|
pub scrollbar: ScrollbarTheme,
|
||||||
pub back_top: BackTopTheme,
|
pub back_top: BackTopTheme,
|
||||||
|
pub anchor: AnchorTheme,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Theme {
|
impl Theme {
|
||||||
|
@ -83,6 +84,7 @@ impl Theme {
|
||||||
collapse: CollapseTheme::light(),
|
collapse: CollapseTheme::light(),
|
||||||
scrollbar: ScrollbarTheme::light(),
|
scrollbar: ScrollbarTheme::light(),
|
||||||
back_top: BackTopTheme::light(),
|
back_top: BackTopTheme::light(),
|
||||||
|
anchor: AnchorTheme::light(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn dark() -> Self {
|
pub fn dark() -> Self {
|
||||||
|
@ -117,6 +119,7 @@ impl Theme {
|
||||||
collapse: CollapseTheme::dark(),
|
collapse: CollapseTheme::dark(),
|
||||||
scrollbar: ScrollbarTheme::dark(),
|
scrollbar: ScrollbarTheme::dark(),
|
||||||
back_top: BackTopTheme::dark(),
|
back_top: BackTopTheme::dark(),
|
||||||
|
anchor: AnchorTheme::dark(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
use ::wasm_bindgen::{prelude::Closure, JsCast};
|
use ::wasm_bindgen::{prelude::Closure, JsCast};
|
||||||
use leptos::{html::AnyElement, *};
|
use leptos::{html::AnyElement, *};
|
||||||
|
use std::ops::Deref;
|
||||||
|
use web_sys::EventTarget;
|
||||||
|
|
||||||
pub fn add_event_listener<E: ev::EventDescriptor + 'static>(
|
pub fn add_event_listener<E: ev::EventDescriptor + 'static>(
|
||||||
target: HtmlElement<AnyElement>,
|
target: HtmlElement<AnyElement>,
|
||||||
|
@ -48,3 +50,73 @@ fn add_event_listener_untyped(
|
||||||
|
|
||||||
wel(target, Box::new(cb), event_name)
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -4,15 +4,19 @@ mod event_listener;
|
||||||
mod hooks;
|
mod hooks;
|
||||||
mod optional_prop;
|
mod optional_prop;
|
||||||
mod signals;
|
mod signals;
|
||||||
|
mod throttle;
|
||||||
mod time;
|
mod time;
|
||||||
|
|
||||||
pub use dom::{get_scroll_parent, mount_style};
|
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 hooks::{use_click_position, use_lock_html_scroll, use_next_frame, NextFrame};
|
||||||
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,
|
||||||
};
|
};
|
||||||
|
pub use throttle::throttle;
|
||||||
pub use time::now_date;
|
pub use time::now_date;
|
||||||
|
|
||||||
pub fn with_hydration_off<T>(f: impl FnOnce() -> T) -> T {
|
pub fn with_hydration_off<T>(f: impl FnOnce() -> T) -> T {
|
||||||
|
|
31
thaw_utils/src/throttle.rs
Normal file
31
thaw_utils/src/throttle.rs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue