1use leptos::prelude::*;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct SearchResultItem {
11 pub url: String,
13
14 pub title: String,
16
17 #[serde(default)]
19 pub description: Option<String>,
20
21 #[serde(default)]
23 pub score: f32,
24}
25
26#[component]
30pub fn SearchBox(
31 #[prop(default = "Search...".to_string())]
33 placeholder: String,
34 query: RwSignal<String>,
36 #[prop(default = false.into())]
38 loading: Signal<bool>,
39) -> impl IntoView {
40 let input_ref = NodeRef::<leptos::html::Input>::new();
41
42 Effect::new(move |_| {
44 if let Some(input) = input_ref.get() {
45 let _ = input.focus();
46 }
47 });
48
49 view! {
50 <div class="typstify-search-box">
51 <input
52 node_ref=input_ref
53 type="text"
54 class="typstify-search-input"
55 placeholder=placeholder
56 prop:value=move || query.get()
57 on:input=move |ev| {
58 let value = event_target_value(&ev);
59 query.set(value);
60 }
61 />
62 <Show when=move || loading.get()>
63 <span class="typstify-search-spinner" aria-label="Loading"></span>
64 </Show>
65 </div>
66 }
67}
68
69#[component]
71pub fn SearchResults(
72 results: Signal<Vec<SearchResultItem>>,
74 #[prop(default = "".to_string().into())]
76 query: Signal<String>,
77) -> impl IntoView {
78 view! {
79 <div class="typstify-search-results">
80 <Show
81 when=move || !results.get().is_empty()
82 fallback=move || {
83 let q = query.get();
84 if q.is_empty() {
85 view! { <div class="typstify-search-empty"></div> }.into_any()
86 } else {
87 view! {
88 <div class="typstify-search-no-results">"No results found for \"" {q} "\""</div>
89 }
90 .into_any()
91 }
92 }
93 >
94
95 <ul class="typstify-search-list">
96 <For
97 each=move || results.get()
98 key=|item| item.url.clone()
99 children=move |item| {
100 view! { <SearchResultItem item=item /> }
101 }
102 />
103
104 </ul>
105 </Show>
106 </div>
107 }
108}
109
110#[component]
112fn SearchResultItem(
113 item: SearchResultItem,
115) -> impl IntoView {
116 let description = item.description.clone();
117 let has_description = description.is_some();
118
119 view! {
120 <li class="typstify-search-item">
121 <a href=item.url.clone() class="typstify-search-link">
122 <span class="typstify-search-title">{item.title.clone()}</span>
123 <Show when=move || has_description>
124 <span class="typstify-search-description">
125 {description.clone().unwrap_or_default()}
126 </span>
127 </Show>
128 </a>
129 </li>
130 }
131}
132
133#[component]
135pub fn SearchModal(
136 open: RwSignal<bool>,
138 query: RwSignal<String>,
140 results: Signal<Vec<SearchResultItem>>,
142 #[prop(default = false.into())]
144 loading: Signal<bool>,
145) -> impl IntoView {
146 let on_keydown = move |ev: web_sys::KeyboardEvent| {
148 if ev.key() == "Escape" {
149 open.set(false);
150 }
151 };
152
153 let on_overlay_click = move |_| {
155 open.set(false);
156 };
157
158 let on_content_click = move |ev: web_sys::MouseEvent| {
160 ev.stop_propagation();
161 };
162
163 view! {
164 <Show when=move || open.get()>
165 <div class="typstify-modal-overlay" on:click=on_overlay_click on:keydown=on_keydown>
166 <div class="typstify-modal-content" on:click=on_content_click>
167 <div class="typstify-modal-header">
168 <SearchBox query=query loading=loading />
169 <button
170 class="typstify-modal-close"
171 on:click=move |_| open.set(false)
172 aria-label="Close search"
173 >
174 "×"
175 </button>
176 </div>
177 <div class="typstify-modal-body">
178 <SearchResults results=results query=query.into() />
179 </div>
180 <div class="typstify-modal-footer">
181 <span class="typstify-modal-hint">"Press Esc to close"</span>
182 </div>
183 </div>
184 </div>
185 </Show>
186 }
187}
188
189#[component]
193#[allow(clippy::unused_unit)]
194pub fn SearchShortcut(
195 open: RwSignal<bool>,
197) -> impl IntoView {
198 Effect::new(move |_| {
199 use wasm_bindgen::{JsCast, prelude::*};
200
201 let open = open;
202 let handler =
203 Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(move |ev: web_sys::KeyboardEvent| {
204 if ev.key() == "k" && (ev.meta_key() || ev.ctrl_key()) {
206 ev.prevent_default();
207 open.set(true);
208 }
209 });
210
211 let window = web_sys::window().expect("no window");
212 let _ =
213 window.add_event_listener_with_callback("keydown", handler.as_ref().unchecked_ref());
214
215 handler.forget();
217 });
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn test_search_result_item_creation() {
226 let item = SearchResultItem {
227 url: "/test".to_string(),
228 title: "Test Page".to_string(),
229 description: Some("A test description".to_string()),
230 score: 10.5,
231 };
232
233 assert_eq!(item.url, "/test");
234 assert_eq!(item.title, "Test Page");
235 assert!(item.description.is_some());
236 }
237
238 #[test]
239 fn test_search_result_item_without_description() {
240 let item = SearchResultItem {
241 url: "/test".to_string(),
242 title: "Test".to_string(),
243 description: None,
244 score: 0.0,
245 };
246
247 assert!(item.description.is_none());
248 }
249
250 #[test]
251 fn test_search_result_serialization() {
252 let item = SearchResultItem {
253 url: "/test".to_string(),
254 title: "Test".to_string(),
255 description: None,
256 score: 5.0,
257 };
258
259 let json = serde_json::to_string(&item).unwrap();
260 assert!(json.contains("\"url\":\"/test\""));
261 assert!(json.contains("\"title\":\"Test\""));
262 }
263}