flams_web_utils/components/
anchors.rs1use leptos::{context::Provider, html, prelude::*};
2use leptos::web_sys::{DomRect, Element};
3
4use crate::inject_css;
5use super::Header;
6
7#[component]
8pub fn AnchorLink(
9 header:Header,
10 #[prop(into)]
12 href: String,
13 #[prop(optional)] children: Option<Children>,
14) -> impl IntoView {
15 let anchor = AnchorInjection::expect_context();
16 let title_ref = NodeRef::<html::A>::new();
17 let href_id = StoredValue::new(None::<String>);
18 let is_active = Memo::new(move |_| {
19 href_id.with_value(|href_id| {
20 if href_id.is_none() {
21 false
22 } else {
23 anchor.active_id.with(|active_id| active_id == href_id)
24 }
25 })
26 });
27
28 if !href.is_empty() && href.starts_with('#') {
29 let id = href[1..].to_string();
30 href_id.set_value(Some(id.clone()));
31 anchor.append_id(id);
32
33 on_cleanup(move || {
34 href_id.with_value(|id| {
35 if let Some(id) = id {
36 anchor.remove_id(id);
37 }
38 });
39 });
40
41 Effect::new(move |_| {
42 let Some(title_el) = title_ref.get() else {
43 return;
44 };
45
46 if is_active.get() {
47 let title_rect = title_el.get_bounding_client_rect();
48 anchor.update_background_position(&title_rect);
49 }
50 });
51 }
52 let on_click = move |_| {
53 href_id.with_value(move |href_id| {
54 if let Some(href_id) = href_id {
55 AnchorInjection::scroll_into_view(href_id);
56 }
57 });
58 };
59
60 view! {
61 <div class="thaw-anchor-link" class:thaw-anchor-link--active= move || is_active.get()>
62 <a
63 href=href
64 class="thaw-anchor-link__title"
65 on:click=on_click
66 node_ref=title_ref
67 >
68 {(header.children)()}
69 </a>
70 {children.map(|c| c())}
71 </div>
72 }
73}
74
75
76#[component]
77pub fn Anchor(
78 children: Children,
79) -> impl IntoView {
80 inject_css("anchor",include_str!("./anchor.css"));
81
82 let anchor_ref = NodeRef::new();
83 let bar_ref = NodeRef::new();
84 let element_ids = RwSignal::new(Vec::<String>::new());
85 let active_id = RwSignal::new(None::<String>);
86
87 #[cfg(any(feature = "csr", feature = "hydrate"))]
88 {
89 use leptos::ev;
90 use std::cmp::Ordering;
91 use thaw_utils::{add_event_listener_with_bool, throttle};
92
93 struct LinkInfo {
94 top: f64,
95 id: String,
96 }
97
98 let offset_target : send_wrapper::SendWrapper<Option<OffsetTarget>> = send_wrapper::SendWrapper::new(None);
99
100 let on_scroll = move || {
101 element_ids.with(|ids| {
102 let offset_target_top = if let Some(offset_target) = offset_target.as_ref() {
103 if let Some(rect) = offset_target.get_bounding_client_rect() {
104 rect.top()
105 } else {
106 return;
107 }
108 } else {
109 0.0
110 };
111
112 let mut links: Vec<LinkInfo> = vec![];
113 for id in ids {
114 if let Some(link_el) = document().get_element_by_id(id) {
115 let link_rect = link_el.get_bounding_client_rect();
116 links.push(LinkInfo {
117 top: link_rect.top() - offset_target_top,
118 id: id.clone(),
119 });
120 }
121 }
122 links.sort_by(|a, b| {
123 if a.top > b.top {
124 Ordering::Greater
125 } else {
126 Ordering::Less
127 }
128 });
129
130 let mut temp_link = None::<LinkInfo>;
131 for link in links {
132 if link.top >= 0.0 {
133 if link.top <= 12.0 {
134 temp_link = Some(link);
135 break;
136 } else if temp_link.is_some() {
137 break;
138 }
139 temp_link = None;
140 } else {
141 temp_link = Some(link);
142 }
143 }
144 active_id.set(temp_link.map(|link| link.id));
145 });
146 };
147 let cb = throttle(
148 move || {
149 on_scroll();
150 },
151 std::time::Duration::from_millis(200),
152 );
153 let scroll_handle = add_event_listener_with_bool(
154 document(),
155 ev::scroll,
156 move |_| {
157 cb();
158 },
159 true,
160 );
161 on_cleanup(move || {
162 scroll_handle.remove();
163 });
164 }
165
166 view! {
167 <div class="thaw-anchor" node_ref=anchor_ref>
168 <div class="thaw-anchor-rail">
169 <div
170 class="thaw-anchor-rail__bar"
171 class=(
172 "thaw-anchor-rail__bar--active",
173 move || active_id.with(Option::is_some),
174 )
175
176 node_ref=bar_ref
177 ></div>
178 </div>
179 <Provider value=AnchorInjection::new(
180 anchor_ref,
181 bar_ref,
182 element_ids,
183 active_id,
184 )>{children()}</Provider>
185 </div>
186 }
187}
188
189#[derive(Clone,Copy)]
190struct AnchorInjection {
191 anchor_ref: NodeRef<html::Div>,
192 bar_ref: NodeRef<html::Div>,
193 element_ids: RwSignal<Vec<String>>,
194 active_id: RwSignal<Option<String>>,
195}
196
197
198impl AnchorInjection {
199 pub fn expect_context() -> Self {
200 expect_context()
201 }
202
203 const fn new(
204 anchor_ref: NodeRef<html::Div>,
205 bar_ref: NodeRef<html::Div>,
206 element_ids: RwSignal<Vec<String>>,
207 active_id: RwSignal<Option<String>>,
208 ) -> Self {
209 Self {
210 anchor_ref,
211 bar_ref,
212 element_ids,
213 active_id,
214 }
215 }
216
217 pub fn scroll_into_view(id: &str) {
218 let Some(link_el) = document().get_element_by_id(id) else {
219 return;
220 };
221 link_el.scroll_into_view();
222 }
223
224 pub fn append_id(&self, id: String) {
225 self.element_ids.update(|ids| {
226 ids.push(id);
227 });
228 }
229
230 pub fn remove_id(&self, id: &String) {
231 self.element_ids.update(|ids| {
232 if let Some(index) = ids.iter().position(|item_id| item_id == id) {
233 ids.remove(index);
234 }
235 });
236 }
237
238 pub fn update_background_position(&self, title_rect: &DomRect) {
239 if let Some(anchor_el) = self.anchor_ref.get_untracked() {
240 let bar_el = self.bar_ref.get_untracked().expect("This should not happen");
241 let anchor_rect = anchor_el.get_bounding_client_rect();
242
243 let offset_top = title_rect.top() - anchor_rect.top();
244 bar_el.style(("top", format!("{offset_top}px")));
247 bar_el.style(("height", format!("{}px", title_rect.height())));
248 }
249 }
250}
251
252pub enum OffsetTarget {
253 Selector(String),
254 Element(Element),
255}
256
257#[cfg(any(feature = "csr", feature = "hydrate"))]
258impl OffsetTarget {
259 fn get_bounding_client_rect(&self) -> Option<DomRect> {
260 match self {
261 Self::Selector(selector) => {
262 let el = document().query_selector(selector).ok().flatten()?;
263 Some(el.get_bounding_client_rect())
264 }
265 Self::Element(el) => Some(el.get_bounding_client_rect()),
266 }
267 }
268}
269
270impl From<&'static str> for OffsetTarget {
271 fn from(value: &'static str) -> Self {
272 Self::Selector(value.to_string())
273 }
274}
275
276impl From<String> for OffsetTarget {
277 fn from(value: String) -> Self {
278 Self::Selector(value)
279 }
280}
281
282impl From<Element> for OffsetTarget {
283 fn from(value: Element) -> Self {
284 Self::Element(value)
285 }
286}