1#![allow(unpredictable_function_pointer_comparisons)]
6
7use crate::components::affix::Affix;
8use dioxus::prelude::*;
9
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum AnchorDirection {
13 #[default]
15 Vertical,
16 Horizontal,
18}
19
20impl AnchorDirection {
21 fn as_class(&self) -> &'static str {
22 match self {
23 AnchorDirection::Vertical => "adui-anchor-vertical",
24 AnchorDirection::Horizontal => "adui-anchor-horizontal",
25 }
26 }
27}
28
29#[derive(Clone, PartialEq)]
31pub struct AnchorLinkItem {
32 pub key: String,
34 pub href: String,
36 pub title: String,
38 pub target: Option<String>,
40 pub children: Option<Vec<AnchorLinkItem>>,
42}
43
44impl AnchorLinkItem {
45 pub fn new(key: impl Into<String>, href: impl Into<String>, title: impl Into<String>) -> Self {
47 Self {
48 key: key.into(),
49 href: href.into(),
50 title: title.into(),
51 target: None,
52 children: None,
53 }
54 }
55
56 pub fn with_children(
58 key: impl Into<String>,
59 href: impl Into<String>,
60 title: impl Into<String>,
61 children: Vec<AnchorLinkItem>,
62 ) -> Self {
63 Self {
64 key: key.into(),
65 href: href.into(),
66 title: title.into(),
67 target: None,
68 children: Some(children),
69 }
70 }
71}
72
73#[derive(Clone, Copy)]
75#[allow(dead_code)]
76struct AnchorContext {
77 active_link: Signal<Option<String>>,
79 direction: AnchorDirection,
81}
82
83#[derive(Props, Clone, PartialEq)]
85#[allow(clippy::fn_address_comparisons)]
86pub struct AnchorProps {
87 pub items: Vec<AnchorLinkItem>,
89
90 #[props(default = true)]
93 pub affix: bool,
94
95 #[props(optional)]
97 pub offset_top: Option<f64>,
98
99 #[props(default = 5.0)]
102 pub bounds: f64,
103
104 #[props(optional)]
107 pub target_offset: Option<f64>,
108
109 #[props(default)]
111 pub direction: AnchorDirection,
112
113 #[props(default)]
115 pub replace: bool,
116
117 #[props(default)]
119 pub show_ink_in_fixed: bool,
120
121 #[props(optional)]
123 pub on_change: Option<EventHandler<String>>,
124
125 #[props(optional)]
127 pub on_click: Option<EventHandler<AnchorClickInfo>>,
128
129 #[props(optional)]
132 pub get_current_anchor: Option<fn(String) -> String>,
133
134 #[props(optional)]
136 pub class: Option<String>,
137
138 #[props(optional)]
140 pub style: Option<String>,
141}
142
143#[derive(Clone, Debug)]
145pub struct AnchorClickInfo {
146 pub href: String,
147 pub title: String,
148}
149
150#[component]
167pub fn Anchor(props: AnchorProps) -> Element {
168 let AnchorProps {
169 items,
170 affix,
171 offset_top,
172 bounds,
173 target_offset,
174 direction,
175 replace,
176 show_ink_in_fixed,
177 on_change,
178 on_click,
179 get_current_anchor,
180 class,
181 style,
182 } = props;
183
184 let active_link: Signal<Option<String>> = use_signal(|| None);
185 let animating: Signal<bool> = use_signal(|| false);
186 let registered_links: Signal<Vec<String>> = use_signal(Vec::new);
187
188 let effective_target_offset = target_offset.unwrap_or(offset_top.unwrap_or(0.0));
190
191 use_context_provider(|| AnchorContext {
193 active_link,
194 direction,
195 });
196
197 let all_hrefs = collect_hrefs(&items);
199
200 let _ = (
202 &animating,
203 ®istered_links,
204 &bounds,
205 &effective_target_offset,
206 &on_change,
207 &get_current_anchor,
208 &all_hrefs,
209 );
210
211 #[cfg(target_arch = "wasm32")]
213 {
214 let items_for_effect = items.clone();
215 let target_offset_val = effective_target_offset;
216 let bounds_val = bounds;
217 let on_change_cb = on_change;
218 let get_current_anchor_fn = get_current_anchor;
219 let mut active_link_signal = active_link;
220 let animating_signal = animating;
221
222 use_effect(move || {
223 use wasm_bindgen::{JsCast, closure::Closure};
224
225 let window = match web_sys::window() {
226 Some(w) => w,
227 None => return,
228 };
229
230 let items_clone = items_for_effect.clone();
231
232 let handler = Closure::<dyn FnMut(web_sys::Event)>::wrap(Box::new(
233 move |_evt: web_sys::Event| {
234 if *animating_signal.read() {
235 return;
236 }
237
238 let Some(window) = web_sys::window() else {
239 return;
240 };
241 let Some(document) = window.document() else {
242 return;
243 };
244
245 let hrefs = collect_hrefs(&items_clone);
246 let current = get_internal_current_anchor(
247 &document,
248 &hrefs,
249 target_offset_val,
250 bounds_val,
251 );
252
253 let final_link = if let Some(custom_fn) = get_current_anchor_fn {
255 custom_fn(current.clone())
256 } else {
257 current
258 };
259
260 let prev = active_link_signal.read().clone();
261 if prev.as_deref() != Some(&final_link) && !final_link.is_empty() {
262 active_link_signal.set(Some(final_link.clone()));
263 if let Some(cb) = on_change_cb {
264 cb.call(final_link);
265 }
266 }
267 },
268 ));
269
270 let _ =
271 window.add_event_listener_with_callback("scroll", handler.as_ref().unchecked_ref());
272 handler.forget();
273 });
274 }
275
276 let mut class_list = vec![
278 "adui-anchor-wrapper".to_string(),
279 direction.as_class().to_string(),
280 ];
281 if !affix && !show_ink_in_fixed {
282 class_list.push("adui-anchor-fixed".into());
283 }
284 if let Some(extra) = class {
285 class_list.push(extra);
286 }
287 let class_attr = class_list.join(" ");
288
289 let style_attr = {
291 let max_height = if let Some(top) = offset_top {
292 format!("max-height: calc(100vh - {}px);", top)
293 } else {
294 "max-height: 100vh;".to_string()
295 };
296 let extra = style.unwrap_or_default();
297 format!("{} {}", max_height, extra)
298 };
299
300 let current_active = active_link.read().clone();
301 let on_click_cb = on_click;
302 let replace_flag = replace;
303
304 let anchor_content = rsx! {
305 div { class: "{class_attr}", style: "{style_attr}",
306 div { class: "adui-anchor",
307 AnchorInk {
309 active_link: current_active.clone(),
310 direction: Some(direction),
311 }
312
313 {items.iter().map(|item| {
315 let href = item.href.clone();
316 let title = item.title.clone();
317 let target = item.target.clone();
318 let nested = item.children.clone();
319 let key = item.key.clone();
320 let is_active = current_active.as_ref() == Some(&href);
321
322 rsx! {
323 AnchorLink {
324 key: "{key}",
325 href: href,
326 title: title,
327 target: target,
328 nested_items: nested,
329 active: is_active,
330 direction: direction,
331 replace: replace_flag,
332 on_click: on_click_cb,
333 }
334 }
335 })}
336 }
337 }
338 };
339
340 if affix {
341 rsx! {
342 Affix { offset_top: offset_top.unwrap_or(0.0),
343 {anchor_content}
344 }
345 }
346 } else {
347 anchor_content
348 }
349}
350
351#[derive(Props, Clone, PartialEq)]
353struct AnchorInkProps {
354 active_link: Option<String>,
355 #[props(optional)]
356 direction: Option<AnchorDirection>,
357}
358
359#[component]
361fn AnchorInk(props: AnchorInkProps) -> Element {
362 let AnchorInkProps {
363 active_link,
364 direction: _direction,
365 } = props;
366
367 let visible = active_link.is_some();
368
369 let class_attr = format!(
370 "adui-anchor-ink{}",
371 if visible {
372 " adui-anchor-ink-visible"
373 } else {
374 ""
375 }
376 );
377
378 rsx! {
381 span { class: "{class_attr}" }
382 }
383}
384
385#[derive(Props, Clone, PartialEq)]
387struct AnchorLinkProps {
388 href: String,
389 title: String,
390 #[props(optional)]
391 target: Option<String>,
392 #[props(optional)]
393 nested_items: Option<Vec<AnchorLinkItem>>,
394 active: bool,
395 direction: AnchorDirection,
396 replace: bool,
397 #[props(optional)]
398 on_click: Option<EventHandler<AnchorClickInfo>>,
399}
400
401#[component]
403fn AnchorLink(props: AnchorLinkProps) -> Element {
404 let AnchorLinkProps {
405 href,
406 title,
407 target,
408 nested_items,
409 active,
410 direction,
411 replace,
412 on_click,
413 } = props;
414
415 let link_class = format!(
416 "adui-anchor-link{}",
417 if active {
418 " adui-anchor-link-active"
419 } else {
420 ""
421 }
422 );
423
424 let title_class = format!(
425 "adui-anchor-link-title{}",
426 if active {
427 " adui-anchor-link-title-active"
428 } else {
429 ""
430 }
431 );
432
433 let href_for_click = href.clone();
434 let title_for_click = title.clone();
435 let replace_for_click = replace;
436
437 rsx! {
438 div { class: "{link_class}",
439 a {
440 class: "{title_class}",
441 href: "{href}",
442 target: target.as_deref().unwrap_or(""),
443 title: "{title}",
444 onclick: move |evt| {
445 if let Some(cb) = on_click {
447 cb.call(AnchorClickInfo {
448 href: href_for_click.clone(),
449 title: title_for_click.clone(),
450 });
451 }
452
453 #[cfg(target_arch = "wasm32")]
455 {
456 handle_anchor_click(&href_for_click, replace_for_click);
457 }
458
459 #[cfg(not(target_arch = "wasm32"))]
461 let _ = replace_for_click;
462
463 if href_for_click.starts_with('#') {
465 evt.prevent_default();
466 }
467 },
468 "{title}"
469 }
470
471 if matches!(direction, AnchorDirection::Vertical) {
473 if let Some(nested) = nested_items {
474 {nested.iter().map(|child| {
475 let child_href = child.href.clone();
476 let child_title = child.title.clone();
477 let child_target = child.target.clone();
478 let child_nested = child.children.clone();
479 let child_key = child.key.clone();
480 rsx! {
484 AnchorLink {
485 key: "{child_key}",
486 href: child_href,
487 title: child_title,
488 target: child_target,
489 nested_items: child_nested,
490 active: false,
491 direction: direction,
492 replace: replace,
493 on_click: on_click,
494 }
495 }
496 })}
497 }
498 }
499 }
500 }
501}
502
503fn collect_hrefs(items: &[AnchorLinkItem]) -> Vec<String> {
505 let mut hrefs = Vec::new();
506 for item in items {
507 hrefs.push(item.href.clone());
508 if let Some(children) = &item.children {
509 hrefs.extend(collect_hrefs(children));
510 }
511 }
512 hrefs
513}
514
515#[cfg(target_arch = "wasm32")]
517fn get_internal_current_anchor(
518 document: &web_sys::Document,
519 hrefs: &[String],
520 offset_top: f64,
521 bounds: f64,
522) -> String {
523 use wasm_bindgen::JsCast;
524
525 let mut sections: Vec<(String, f64)> = Vec::new();
526
527 for href in hrefs {
528 if let Some(id) = href.strip_prefix('#') {
530 if let Some(element) = document.get_element_by_id(id) {
531 let rect = element.get_bounding_client_rect();
532 let top = rect.top();
533
534 if top <= offset_top + bounds {
536 sections.push((href.clone(), top));
537 }
538 }
539 }
540 }
541
542 if let Some((href, _)) = sections
544 .iter()
545 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
546 {
547 href.clone()
548 } else {
549 String::new()
550 }
551}
552
553#[cfg(target_arch = "wasm32")]
555fn handle_anchor_click(href: &str, replace: bool) {
556 use wasm_bindgen::JsCast;
557
558 let window = match web_sys::window() {
559 Some(w) => w,
560 None => return,
561 };
562
563 let document = match window.document() {
564 Some(d) => d,
565 None => return,
566 };
567
568 if let Some(id) = href.strip_prefix('#') {
570 if let Some(element) = document.get_element_by_id(id) {
571 let options = web_sys::ScrollIntoViewOptions::new();
573 options.set_behavior(web_sys::ScrollBehavior::Smooth);
574 options.set_block(web_sys::ScrollLogicalPosition::Start);
575 element.scroll_into_view_with_scroll_into_view_options(&options);
576
577 let history = window.history().ok();
579 if let Some(h) = history {
580 if replace {
581 let _ = h.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(href));
582 } else {
583 let _ = h.push_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(href));
584 }
585 }
586 }
587 }
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 #[test]
595 fn anchor_direction_classes_are_correct() {
596 assert_eq!(AnchorDirection::Vertical.as_class(), "adui-anchor-vertical");
597 assert_eq!(
598 AnchorDirection::Horizontal.as_class(),
599 "adui-anchor-horizontal"
600 );
601 }
602
603 #[test]
604 fn anchor_link_item_creation() {
605 let item = AnchorLinkItem::new("key1", "#section", "Section Title");
606 assert_eq!(item.key, "key1");
607 assert_eq!(item.href, "#section");
608 assert_eq!(item.title, "Section Title");
609 assert!(item.children.is_none());
610 }
611
612 #[test]
613 fn anchor_link_item_with_children() {
614 let children = vec![AnchorLinkItem::new("child1", "#child", "Child Section")];
615 let item =
616 AnchorLinkItem::with_children("parent", "#parent", "Parent Section", children.clone());
617 assert_eq!(item.children.unwrap().len(), 1);
618 }
619
620 #[test]
621 fn collect_hrefs_works_recursively() {
622 let items = vec![
623 AnchorLinkItem::new("1", "#section-1", "Section 1"),
624 AnchorLinkItem::with_children(
625 "2",
626 "#section-2",
627 "Section 2",
628 vec![AnchorLinkItem::new("2-1", "#section-2-1", "Section 2.1")],
629 ),
630 ];
631
632 let hrefs = collect_hrefs(&items);
633 assert_eq!(hrefs.len(), 3);
634 assert!(hrefs.contains(&"#section-1".to_string()));
635 assert!(hrefs.contains(&"#section-2".to_string()));
636 assert!(hrefs.contains(&"#section-2-1".to_string()));
637 }
638}