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 id: "dk-docs-search",
80 placeholder: "Search documentation...",
81 search,
82 on_select,
83 }
84 }
85}
86
87#[cfg(target_arch = "wasm32")]
92fn scroll_to_anchor(anchor: String) {
93 spawn(async move {
94 let js = format!(
95 r#"
96 (function() {{
97 const id = {};
98 let attempts = 0;
99 function tryScroll() {{
100 const el = document.getElementById(id);
101 if (el) {{
102 el.scrollIntoView({{ behavior: 'smooth', block: 'start' }});
103 history.replaceState(null, '', '#' + id);
104 return;
105 }}
106 if (attempts++ < 20) {{
107 requestAnimationFrame(tryScroll);
108 }}
109 }}
110 requestAnimationFrame(tryScroll);
111 }})();
112 "#,
113 serde_json::to_string(&anchor).unwrap_or_default()
114 );
115 let _ = document::eval(&js);
116 });
117}
118
119fn method_badge(method: HttpMethod) -> (&'static str, &'static str) {
120 match method {
121 HttpMethod::Get => ("GET", "badge-soft badge-success"),
122 HttpMethod::Post => ("POST", "badge-soft badge-primary"),
123 HttpMethod::Put => ("PUT", "badge-soft badge-warning"),
124 HttpMethod::Delete => ("DEL", "badge-soft badge-error"),
125 HttpMethod::Patch => ("PATCH", "badge-soft badge-info"),
126 _ => ("???", "badge-soft badge-ghost"),
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use crate::config::DocsConfig;
134 use std::collections::HashMap;
135
136 fn wide_registry(pages: usize) -> &'static DocsRegistry {
141 let mut nav_pages: Vec<String> = Vec::new();
142 let mut map: HashMap<&'static str, &'static str> = HashMap::new();
143
144 for i in 0..pages {
145 let path: &'static str = Box::leak(format!("g/page-{i}").into_boxed_str());
146 let body: &'static str = Box::leak(
147 format!("---\ntitle: Widget {i}\n---\n\nThe widget keyword appears here.\n")
148 .into_boxed_str(),
149 );
150 nav_pages.push(format!("\"{path}\""));
151 map.insert(path, body);
152 }
153
154 let nav: &'static str = Box::leak(
155 format!(
156 r#"{{ "groups": [ {{ "group": "G", "pages": [{}] }} ] }}"#,
157 nav_pages.join(",")
158 )
159 .into_boxed_str(),
160 );
161
162 Box::leak(Box::new(DocsConfig::new(nav, map).build()))
163 }
164
165 #[test]
166 fn docs_hits_caps_rendered_results() {
167 let registry = wide_registry(40);
168 assert!(
170 registry.search_docs("widget").len() > 25,
171 "fixture should match more than the cap"
172 );
173 assert_eq!(docs_hits(registry, "widget", 25).len(), 25);
174 }
175
176 #[test]
177 fn docs_hits_returns_every_match_below_the_cap() {
178 let registry = wide_registry(3);
179 assert_eq!(docs_hits(registry, "widget", 25).len(), 3);
180 }
181
182 #[test]
183 fn docs_hits_fully_builds_every_returned_row() {
184 let registry = wide_registry(40);
185 let hits = docs_hits(registry, "widget", 5);
186 assert_eq!(hits.len(), 5);
187 assert!(hits.iter().all(|h| !h.snippet.is_empty()));
191 }
192
193 #[test]
194 fn docs_hits_empty_query_yields_nothing() {
195 let registry = wide_registry(3);
196 assert!(docs_hits(registry, "", 25).is_empty());
197 }
198}