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::{MAX_RESULTS, SNIPPET_WINDOW, build_snippet, split_terms};
8
9/// Rank `query` against the docs index and build at most `limit` rendered hits.
10///
11/// The cap is applied *before* the map: the query re-runs on every keystroke,
12/// and each hit costs a snippet scan plus a mounted component, so building hits
13/// the modal cannot show is the dominant cost of a broad query.
14fn docs_hits(registry: &'static DocsRegistry, query: &str, limit: usize) -> Vec<SearchHit> {
15    let terms = split_terms(query);
16    registry
17        .search_docs(query)
18        .into_iter()
19        .take(limit)
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/// Full-screen search modal triggered by Cmd/Ctrl+K or the search button.
53#[component]
54pub fn SearchModal() -> Element {
55    let ctx = use_context::<DocsContext>();
56    let registry = use_context::<&'static DocsRegistry>();
57
58    let search = use_callback(move |query: String| docs_hits(registry, &query, MAX_RESULTS));
59
60    let on_select = use_callback(move |target: String| {
61        // `target` is `path` or `path#anchor`.
62        let mut parts = target.splitn(2, '#');
63        let path = parts.next().unwrap_or_default().to_string();
64        let anchor = parts.next().unwrap_or_default().to_string();
65
66        (ctx.navigate)(path);
67
68        // Same-page selection is a navigate no-op, so scroll regardless.
69        #[cfg(target_arch = "wasm32")]
70        if !anchor.is_empty() {
71            scroll_to_anchor(anchor);
72        }
73        #[cfg(not(target_arch = "wasm32"))]
74        let _ = anchor;
75    });
76
77    rsx! {
78        SearchModalShell {
79            placeholder: "Search documentation...",
80            search,
81            on_select,
82        }
83    }
84}
85
86/// Scroll a freshly navigated page to a heading anchor.
87///
88/// The new page's DOM mounts *after* navigation returns, so retry over a few
89/// animation frames until the element exists (mirrors the TOC scroll JS).
90#[cfg(target_arch = "wasm32")]
91fn scroll_to_anchor(anchor: String) {
92    spawn(async move {
93        let js = format!(
94            r#"
95            (function() {{
96                const id = {};
97                let attempts = 0;
98                function tryScroll() {{
99                    const el = document.getElementById(id);
100                    if (el) {{
101                        el.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
102                        history.replaceState(null, '', '#' + id);
103                        return;
104                    }}
105                    if (attempts++ < 20) {{
106                        requestAnimationFrame(tryScroll);
107                    }}
108                }}
109                requestAnimationFrame(tryScroll);
110            }})();
111            "#,
112            serde_json::to_string(&anchor).unwrap_or_default()
113        );
114        let _ = document::eval(&js);
115    });
116}
117
118fn method_badge(method: HttpMethod) -> (&'static str, &'static str) {
119    match method {
120        HttpMethod::Get => ("GET", "badge-soft badge-success"),
121        HttpMethod::Post => ("POST", "badge-soft badge-primary"),
122        HttpMethod::Put => ("PUT", "badge-soft badge-warning"),
123        HttpMethod::Delete => ("DEL", "badge-soft badge-error"),
124        HttpMethod::Patch => ("PATCH", "badge-soft badge-info"),
125        _ => ("???", "badge-soft badge-ghost"),
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use crate::config::DocsConfig;
133    use std::collections::HashMap;
134
135    /// Registry with `pages` pages that all match the query "widget".
136    ///
137    /// Leaks, which is fine in a test and is what lets us hand `docs_hits` the
138    /// `&'static DocsRegistry` the component would get from context.
139    fn wide_registry(pages: usize) -> &'static DocsRegistry {
140        let mut nav_pages: Vec<String> = Vec::new();
141        let mut map: HashMap<&'static str, &'static str> = HashMap::new();
142
143        for i in 0..pages {
144            let path: &'static str = Box::leak(format!("g/page-{i}").into_boxed_str());
145            let body: &'static str = Box::leak(
146                format!("---\ntitle: Widget {i}\n---\n\nThe widget keyword appears here.\n")
147                    .into_boxed_str(),
148            );
149            nav_pages.push(format!("\"{path}\""));
150            map.insert(path, body);
151        }
152
153        let nav: &'static str = Box::leak(
154            format!(
155                r#"{{ "groups": [ {{ "group": "G", "pages": [{}] }} ] }}"#,
156                nav_pages.join(",")
157            )
158            .into_boxed_str(),
159        );
160
161        Box::leak(Box::new(DocsConfig::new(nav, map).build()))
162    }
163
164    #[test]
165    fn docs_hits_caps_rendered_results() {
166        let registry = wide_registry(40);
167        // The fixture has to exceed the cap or the test proves nothing.
168        assert!(
169            registry.search_docs("widget").len() > 25,
170            "fixture should match more than the cap"
171        );
172        assert_eq!(docs_hits(registry, "widget", 25).len(), 25);
173    }
174
175    #[test]
176    fn docs_hits_returns_every_match_below_the_cap() {
177        let registry = wide_registry(3);
178        assert_eq!(docs_hits(registry, "widget", 25).len(), 3);
179    }
180
181    #[test]
182    fn docs_hits_fully_builds_every_returned_row() {
183        let registry = wide_registry(40);
184        let hits = docs_hits(registry, "widget", 5);
185        assert_eq!(hits.len(), 5);
186        // The cap must not truncate work the rendered rows still need. (That
187        // the *dropped* rows skip snippet building is not observable from the
188        // return value — it is a property of applying `take` before the map.)
189        assert!(hits.iter().all(|h| !h.snippet.is_empty()));
190    }
191
192    #[test]
193    fn docs_hits_empty_query_yields_nothing() {
194        let registry = wide_registry(3);
195        assert!(docs_hits(registry, "", 25).is_empty());
196    }
197}