typstify_ui/
search.rs

1//! Search components for the Typstify frontend.
2//!
3//! Provides SearchBox, SearchResults, and SearchModal Leptos components.
4
5use leptos::prelude::*;
6use serde::{Deserialize, Serialize};
7
8/// A single search result item.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct SearchResultItem {
11    /// Result URL.
12    pub url: String,
13
14    /// Result title.
15    pub title: String,
16
17    /// Result description/snippet.
18    #[serde(default)]
19    pub description: Option<String>,
20
21    /// Relevance score.
22    #[serde(default)]
23    pub score: f32,
24}
25
26/// Search box input component.
27///
28/// Provides a text input with debounced search callback.
29#[component]
30pub fn SearchBox(
31    /// Placeholder text for the input.
32    #[prop(default = "Search...".to_string())]
33    placeholder: String,
34    /// Signal to track the current query.
35    query: RwSignal<String>,
36    /// Whether search is loading.
37    #[prop(default = false.into())]
38    loading: Signal<bool>,
39) -> impl IntoView {
40    let input_ref = NodeRef::<leptos::html::Input>::new();
41
42    // Focus input on mount
43    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/// Search results list component.
70#[component]
71pub fn SearchResults(
72    /// The search results to display.
73    results: Signal<Vec<SearchResultItem>>,
74    /// The current search query (for highlighting).
75    #[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/// Individual search result item component.
111#[component]
112fn SearchResultItem(
113    /// The result item to display.
114    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/// Search modal component with keyboard shortcuts.
134#[component]
135pub fn SearchModal(
136    /// Whether the modal is open.
137    open: RwSignal<bool>,
138    /// The search query.
139    query: RwSignal<String>,
140    /// The search results.
141    results: Signal<Vec<SearchResultItem>>,
142    /// Whether search is loading.
143    #[prop(default = false.into())]
144    loading: Signal<bool>,
145) -> impl IntoView {
146    // Close on Escape key
147    let on_keydown = move |ev: web_sys::KeyboardEvent| {
148        if ev.key() == "Escape" {
149            open.set(false);
150        }
151    };
152
153    // Close when clicking overlay
154    let on_overlay_click = move |_| {
155        open.set(false);
156    };
157
158    // Prevent closing when clicking modal content
159    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/// Hook to set up global keyboard shortcut for search modal.
190///
191/// Opens the modal when Cmd/Ctrl + K is pressed.
192#[component]
193#[allow(clippy::unused_unit)]
194pub fn SearchShortcut(
195    /// Signal to control modal open state.
196    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                // Cmd+K (Mac) or Ctrl+K (Windows/Linux)
205                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        // Leak the closure to keep it alive
216        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}