flams_web_utils/components/
anchors.rs1use ftml_dom::utils::css::inject_css;
2use leptos::web_sys::{DomRect, Element};
3use leptos::{context::Provider, html, prelude::*};
4
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#[component]
76pub fn Anchor(children: Children) -> impl IntoView {
77 inject_css("anchor", include_str!("./anchor.css"));
78
79 let anchor_ref = NodeRef::new();
80 let bar_ref = NodeRef::new();
81 let element_ids = RwSignal::new(Vec::<String>::new());
82 let active_id = RwSignal::new(None::<String>);
83
84 #[cfg(any(feature = "csr", feature = "hydrate"))]
85 {
86 use leptos::ev;
87 use std::cmp::Ordering;
88 use thaw_utils::{add_event_listener_with_bool, throttle};
89
90 struct LinkInfo {
91 top: f64,
92 id: String,
93 }
94
95 let offset_target: send_wrapper::SendWrapper<Option<OffsetTarget>> =
96 send_wrapper::SendWrapper::new(None);
97
98 let on_scroll = move || {
99 element_ids.with(|ids| {
100 let offset_target_top = if let Some(offset_target) = offset_target.as_ref() {
101 if let Some(rect) = offset_target.get_bounding_client_rect() {
102 rect.top()
103 } else {
104 return;
105 }
106 } else {
107 0.0
108 };
109
110 let mut links: Vec<LinkInfo> = vec![];
111 for id in ids {
112 if let Some(link_el) = document().get_element_by_id(id) {
113 let link_rect = link_el.get_bounding_client_rect();
114 links.push(LinkInfo {
115 top: link_rect.top() - offset_target_top,
116 id: id.clone(),
117 });
118 }
119 }
120 links.sort_by(|a, b| {
121 if a.top > b.top {
122 Ordering::Greater
123 } else {
124 Ordering::Less
125 }
126 });
127
128 let mut temp_link = None::<LinkInfo>;
129 for link in links {
130 if link.top >= 0.0 {
131 if link.top <= 12.0 {
132 temp_link = Some(link);
133 break;
134 } else if temp_link.is_some() {
135 break;
136 }
137 temp_link = None;
138 } else {
139 temp_link = Some(link);
140 }
141 }
142 active_id.set(temp_link.map(|link| link.id));
143 });
144 };
145 let cb = throttle(
146 move || {
147 on_scroll();
148 },
149 std::time::Duration::from_millis(200),
150 );
151 let scroll_handle = add_event_listener_with_bool(
152 document(),
153 ev::scroll,
154 move |_| {
155 cb();
156 },
157 true,
158 );
159 on_cleanup(move || {
160 scroll_handle.remove();
161 });
162 }
163
164 view! {
165 <div class="thaw-anchor" node_ref=anchor_ref>
166 <div class="thaw-anchor-rail">
167 <div
168 class="thaw-anchor-rail__bar"
169 class=(
170 "thaw-anchor-rail__bar--active",
171 move || active_id.with(Option::is_some),
172 )
173
174 node_ref=bar_ref
175 ></div>
176 </div>
177 <Provider value=AnchorInjection::new(
178 anchor_ref,
179 bar_ref,
180 element_ids,
181 active_id,
182 )>{children()}</Provider>
183 </div>
184 }
185}
186
187#[derive(Clone, Copy)]
188struct AnchorInjection {
189 anchor_ref: NodeRef<html::Div>,
190 bar_ref: NodeRef<html::Div>,
191 element_ids: RwSignal<Vec<String>>,
192 active_id: RwSignal<Option<String>>,
193}
194
195impl AnchorInjection {
196 pub fn expect_context() -> Self {
197 expect_context()
198 }
199
200 const fn new(
201 anchor_ref: NodeRef<html::Div>,
202 bar_ref: NodeRef<html::Div>,
203 element_ids: RwSignal<Vec<String>>,
204 active_id: RwSignal<Option<String>>,
205 ) -> Self {
206 Self {
207 anchor_ref,
208 bar_ref,
209 element_ids,
210 active_id,
211 }
212 }
213
214 pub fn scroll_into_view(id: &str) {
215 let Some(link_el) = document().get_element_by_id(id) else {
216 return;
217 };
218 link_el.scroll_into_view();
219 }
220
221 pub fn append_id(&self, id: String) {
222 self.element_ids.update(|ids| {
223 ids.push(id);
224 });
225 }
226
227 pub fn remove_id(&self, id: &String) {
228 self.element_ids.update(|ids| {
229 if let Some(index) = ids.iter().position(|item_id| item_id == id) {
230 ids.remove(index);
231 }
232 });
233 }
234
235 pub fn update_background_position(&self, title_rect: &DomRect) {
236 if let Some(anchor_el) = self.anchor_ref.get_untracked() {
237 let bar_el = self
238 .bar_ref
239 .get_untracked()
240 .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}