dioxus_docs_kit/components/
search_modal.rs1use 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
9fn 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 let target = if entry.anchor.is_empty() {
24 entry.path.clone()
25 } else {
26 format!("{}#{}", entry.path, entry.anchor)
27 };
28 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#[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 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 #[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#[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 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 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 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}