adui_dioxus/components/
anchor.rs

1//! Anchor component - navigation links for scrolling within a page.
2//!
3//! Ported from Ant Design's Anchor component.
4
5#![allow(unpredictable_function_pointer_comparisons)]
6
7use crate::components::affix::Affix;
8use dioxus::prelude::*;
9
10/// Direction of the anchor navigation.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum AnchorDirection {
13    /// Vertical anchor navigation (default).
14    #[default]
15    Vertical,
16    /// Horizontal anchor navigation.
17    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/// Data model for a single anchor link item.
30#[derive(Clone, PartialEq)]
31pub struct AnchorLinkItem {
32    /// Unique key for the item.
33    pub key: String,
34    /// The target anchor href (e.g., "#section-1").
35    pub href: String,
36    /// The display title for the link.
37    pub title: String,
38    /// Optional target attribute for the anchor element.
39    pub target: Option<String>,
40    /// Nested child items (only for vertical direction).
41    pub children: Option<Vec<AnchorLinkItem>>,
42}
43
44impl AnchorLinkItem {
45    /// Create a new anchor link item.
46    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    /// Create an anchor link item with children.
57    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/// Context for Anchor component shared with AnchorLink children.
74#[derive(Clone, Copy)]
75#[allow(dead_code)]
76struct AnchorContext {
77    /// Currently active link href.
78    active_link: Signal<Option<String>>,
79    /// Direction of the anchor.
80    direction: AnchorDirection,
81}
82
83/// Props for the Anchor component.
84#[derive(Props, Clone, PartialEq)]
85#[allow(clippy::fn_address_comparisons)]
86pub struct AnchorProps {
87    /// List of anchor link items.
88    pub items: Vec<AnchorLinkItem>,
89
90    /// Whether to use Affix to fix the anchor when scrolling.
91    /// Default is true.
92    #[props(default = true)]
93    pub affix: bool,
94
95    /// Offset from the top of the viewport when affixed (in pixels).
96    #[props(optional)]
97    pub offset_top: Option<f64>,
98
99    /// Bounding distance from the anchor when calculating active state (in pixels).
100    /// Default is 5.
101    #[props(default = 5.0)]
102    pub bounds: f64,
103
104    /// Scroll offset from the top when clicking an anchor (in pixels).
105    /// If not set, equals `offset_top`.
106    #[props(optional)]
107    pub target_offset: Option<f64>,
108
109    /// Direction of the anchor navigation.
110    #[props(default)]
111    pub direction: AnchorDirection,
112
113    /// Whether to replace the current history entry when clicking an anchor.
114    #[props(default)]
115    pub replace: bool,
116
117    /// Show ink indicator in fixed mode.
118    #[props(default)]
119    pub show_ink_in_fixed: bool,
120
121    /// Callback when the active link changes.
122    #[props(optional)]
123    pub on_change: Option<EventHandler<String>>,
124
125    /// Callback when an anchor link is clicked.
126    #[props(optional)]
127    pub on_click: Option<EventHandler<AnchorClickInfo>>,
128
129    /// Custom function to determine the current active anchor.
130    /// Note: Function pointer comparison may not be meaningful.
131    #[props(optional)]
132    pub get_current_anchor: Option<fn(String) -> String>,
133
134    /// Additional CSS class for the anchor wrapper.
135    #[props(optional)]
136    pub class: Option<String>,
137
138    /// Additional inline styles for the anchor wrapper.
139    #[props(optional)]
140    pub style: Option<String>,
141}
142
143/// Information passed to the on_click callback.
144#[derive(Clone, Debug)]
145pub struct AnchorClickInfo {
146    pub href: String,
147    pub title: String,
148}
149
150/// Anchor component - provides navigation links that scroll to sections within a page.
151///
152/// # Example
153///
154/// ```rust,ignore
155/// use adui_dioxus::{Anchor, AnchorLinkItem};
156///
157/// rsx! {
158///     Anchor {
159///         items: vec![
160///             AnchorLinkItem::new("1", "#section-1", "Section 1"),
161///             AnchorLinkItem::new("2", "#section-2", "Section 2"),
162///         ],
163///     }
164/// }
165/// ```
166#[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    // Calculate effective target offset
189    let effective_target_offset = target_offset.unwrap_or(offset_top.unwrap_or(0.0));
190
191    // Provide context
192    use_context_provider(|| AnchorContext {
193        active_link,
194        direction,
195    });
196
197    // Collect all hrefs from items recursively
198    let all_hrefs = collect_hrefs(&items);
199
200    // Silence warnings for non-wasm targets
201    let _ = (
202        &animating,
203        &registered_links,
204        &bounds,
205        &effective_target_offset,
206        &on_change,
207        &get_current_anchor,
208        &all_hrefs,
209    );
210
211    // Set up scroll listener to update active link
212    #[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                    // Apply custom getCurrentAnchor if provided
254                    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    // Build class names
277    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    // Build style
290    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                // Ink indicator
308                AnchorInk {
309                    active_link: current_active.clone(),
310                    direction: Some(direction),
311                }
312
313                // Render items
314                {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/// Props for the ink indicator.
352#[derive(Props, Clone, PartialEq)]
353struct AnchorInkProps {
354    active_link: Option<String>,
355    #[props(optional)]
356    direction: Option<AnchorDirection>,
357}
358
359/// Ink indicator component for the anchor.
360#[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    // The ink position would be calculated in WASM based on the active link element
379    // For now, we just show/hide the ink
380    rsx! {
381        span { class: "{class_attr}" }
382    }
383}
384
385/// Props for individual anchor link.
386#[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/// Individual anchor link component.
402#[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                    // Call user callback
446                    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                    // Handle scroll to target
454                    #[cfg(target_arch = "wasm32")]
455                    {
456                        handle_anchor_click(&href_for_click, replace_for_click);
457                    }
458
459                    // Silence warning for non-wasm targets
460                    #[cfg(not(target_arch = "wasm32"))]
461                    let _ = replace_for_click;
462
463                    // Prevent default for internal anchors
464                    if href_for_click.starts_with('#') {
465                        evt.prevent_default();
466                    }
467                },
468                "{title}"
469            }
470
471            // Render nested children (only for vertical direction)
472            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                        // For nested items, we'd need to check if they're active too
481                        // This is simplified for MVP
482
483                        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
503/// Collect all hrefs from anchor items recursively.
504fn 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/// Get the currently active anchor based on scroll position.
516#[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        // Extract the ID from href (e.g., "#section-1" -> "section-1")
529        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                // Check if element is within the threshold
535                if top <= offset_top + bounds {
536                    sections.push((href.clone(), top));
537                }
538            }
539        }
540    }
541
542    // Return the section closest to the top
543    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/// Handle anchor click - scroll to target and update history.
554#[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    // Check if it's an internal anchor
569    if let Some(id) = href.strip_prefix('#') {
570        if let Some(element) = document.get_element_by_id(id) {
571            // Scroll to element
572            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            // Update history
578            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}