Skip to main content

dioxus_docs_kit/components/
search_modal.rs

1use dioxus::prelude::*;
2use dioxus_mdx::HttpMethod;
3
4use super::search_shell::{SearchHit, SearchModalShell};
5use crate::DocsContext;
6use crate::registry::DocsRegistry;
7use crate::search::{SNIPPET_WINDOW, build_snippet, split_terms};
8
9/// Full-screen search modal triggered by Cmd/Ctrl+K or the search button.
10#[component]
11pub fn SearchModal() -> Element {
12    let ctx = use_context::<DocsContext>();
13    let registry = use_context::<&'static DocsRegistry>();
14
15    let search = use_callback(move |query: String| {
16        let terms = split_terms(&query);
17        registry
18            .search_docs(&query)
19            .into_iter()
20            .map(|entry| {
21                // Section hits deep-link via `path#anchor`; page-level hits use
22                // the bare path.
23                let target = if entry.anchor.is_empty() {
24                    entry.path.clone()
25                } else {
26                    format!("{}#{}", entry.path, entry.anchor)
27                };
28                // Section hits show the heading with the page title as context.
29                let (title, context) = if entry.heading.is_empty() {
30                    (entry.title.clone(), None)
31                } else {
32                    (entry.heading.clone(), Some(entry.title.clone()))
33                };
34                let snippet_src = if entry.body.is_empty() {
35                    &entry.description
36                } else {
37                    &entry.body
38                };
39                SearchHit {
40                    target,
41                    title,
42                    context,
43                    badge: entry.api_method.map(method_badge),
44                    meta: entry.breadcrumb.clone(),
45                    tags: Vec::new(),
46                    snippet: build_snippet(snippet_src, &terms, SNIPPET_WINDOW),
47                }
48            })
49            .collect()
50    });
51
52    let on_select = use_callback(move |target: String| {
53        // `target` is `path` or `path#anchor`.
54        let mut parts = target.splitn(2, '#');
55        let path = parts.next().unwrap_or_default().to_string();
56        let anchor = parts.next().unwrap_or_default().to_string();
57
58        (ctx.navigate)(path);
59
60        // Same-page selection is a navigate no-op, so scroll regardless.
61        #[cfg(target_arch = "wasm32")]
62        if !anchor.is_empty() {
63            scroll_to_anchor(anchor);
64        }
65        #[cfg(not(target_arch = "wasm32"))]
66        let _ = anchor;
67    });
68
69    rsx! {
70        SearchModalShell {
71            placeholder: "Search documentation...",
72            search,
73            on_select,
74        }
75    }
76}
77
78/// Scroll a freshly navigated page to a heading anchor.
79///
80/// The new page's DOM mounts *after* navigation returns, so retry over a few
81/// animation frames until the element exists (mirrors the TOC scroll JS).
82#[cfg(target_arch = "wasm32")]
83fn scroll_to_anchor(anchor: String) {
84    spawn(async move {
85        let js = format!(
86            r#"
87            (function() {{
88                const id = {};
89                let attempts = 0;
90                function tryScroll() {{
91                    const el = document.getElementById(id);
92                    if (el) {{
93                        el.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
94                        history.replaceState(null, '', '#' + id);
95                        return;
96                    }}
97                    if (attempts++ < 20) {{
98                        requestAnimationFrame(tryScroll);
99                    }}
100                }}
101                requestAnimationFrame(tryScroll);
102            }})();
103            "#,
104            serde_json::to_string(&anchor).unwrap_or_default()
105        );
106        let _ = document::eval(&js);
107    });
108}
109
110fn method_badge(method: HttpMethod) -> (&'static str, &'static str) {
111    match method {
112        HttpMethod::Get => ("GET", "badge-soft badge-success"),
113        HttpMethod::Post => ("POST", "badge-soft badge-primary"),
114        HttpMethod::Put => ("PUT", "badge-soft badge-warning"),
115        HttpMethod::Delete => ("DEL", "badge-soft badge-error"),
116        HttpMethod::Patch => ("PATCH", "badge-soft badge-info"),
117        _ => ("???", "badge-soft badge-ghost"),
118    }
119}