use leptos::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SearchResultItem {
pub url: String,
pub title: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub score: f32,
}
#[component]
pub fn SearchBox(
#[prop(default = "Search...".to_string())]
placeholder: String,
query: RwSignal<String>,
#[prop(default = false.into())]
loading: Signal<bool>,
) -> impl IntoView {
let input_ref = NodeRef::<leptos::html::Input>::new();
Effect::new(move |_| {
if let Some(input) = input_ref.get() {
let _ = input.focus();
}
});
view! {
<div class="typstify-search-box">
<input
node_ref=input_ref
type="text"
class="typstify-search-input"
placeholder=placeholder
prop:value=move || query.get()
on:input=move |ev| {
let value = event_target_value(&ev);
query.set(value);
}
/>
<Show when=move || loading.get()>
<span class="typstify-search-spinner" aria-label="Loading"></span>
</Show>
</div>
}
}
#[component]
pub fn SearchResults(
results: Signal<Vec<SearchResultItem>>,
#[prop(default = "".to_string().into())]
query: Signal<String>,
) -> impl IntoView {
view! {
<div class="typstify-search-results">
<Show
when=move || !results.get().is_empty()
fallback=move || {
let q = query.get();
if q.is_empty() {
view! { <div class="typstify-search-empty"></div> }.into_any()
} else {
view! {
<div class="typstify-search-no-results">"No results found for \"" {q} "\""</div>
}
.into_any()
}
}
>
<ul class="typstify-search-list">
<For
each=move || results.get()
key=|item| item.url.clone()
children=move |item| {
view! { <SearchResultItem item=item /> }
}
/>
</ul>
</Show>
</div>
}
}
#[component]
fn SearchResultItem(
item: SearchResultItem,
) -> impl IntoView {
let description = item.description.clone();
let has_description = description.is_some();
view! {
<li class="typstify-search-item">
<a href=item.url.clone() class="typstify-search-link">
<span class="typstify-search-title">{item.title.clone()}</span>
<Show when=move || has_description>
<span class="typstify-search-description">
{description.clone().unwrap_or_default()}
</span>
</Show>
</a>
</li>
}
}
#[component]
pub fn SearchModal(
open: RwSignal<bool>,
query: RwSignal<String>,
results: Signal<Vec<SearchResultItem>>,
#[prop(default = false.into())]
loading: Signal<bool>,
) -> impl IntoView {
let on_keydown = move |ev: web_sys::KeyboardEvent| {
if ev.key() == "Escape" {
open.set(false);
}
};
let on_overlay_click = move |_| {
open.set(false);
};
let on_content_click = move |ev: web_sys::MouseEvent| {
ev.stop_propagation();
};
view! {
<Show when=move || open.get()>
<div class="typstify-modal-overlay" on:click=on_overlay_click on:keydown=on_keydown>
<div class="typstify-modal-content" on:click=on_content_click>
<div class="typstify-modal-header">
<SearchBox query=query loading=loading />
<button
class="typstify-modal-close"
on:click=move |_| open.set(false)
aria-label="Close search"
>
"×"
</button>
</div>
<div class="typstify-modal-body">
<SearchResults results=results query=query.into() />
</div>
<div class="typstify-modal-footer">
<span class="typstify-modal-hint">"Press Esc to close"</span>
</div>
</div>
</div>
</Show>
}
}
#[component]
#[allow(clippy::unused_unit)]
pub fn SearchShortcut(
open: RwSignal<bool>,
) -> impl IntoView {
Effect::new(move |_| {
use wasm_bindgen::{JsCast, prelude::*};
let open = open;
let handler =
Closure::<dyn Fn(web_sys::KeyboardEvent)>::new(move |ev: web_sys::KeyboardEvent| {
if ev.key() == "k" && (ev.meta_key() || ev.ctrl_key()) {
ev.prevent_default();
open.set(true);
}
});
let window = web_sys::window().expect("no window");
let _ =
window.add_event_listener_with_callback("keydown", handler.as_ref().unchecked_ref());
handler.forget();
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_search_result_item_creation() {
let item = SearchResultItem {
url: "/test".to_string(),
title: "Test Page".to_string(),
description: Some("A test description".to_string()),
score: 10.5,
};
assert_eq!(item.url, "/test");
assert_eq!(item.title, "Test Page");
assert!(item.description.is_some());
}
#[test]
fn test_search_result_item_without_description() {
let item = SearchResultItem {
url: "/test".to_string(),
title: "Test".to_string(),
description: None,
score: 0.0,
};
assert!(item.description.is_none());
}
#[test]
fn test_search_result_serialization() {
let item = SearchResultItem {
url: "/test".to_string(),
title: "Test".to_string(),
description: None,
score: 5.0,
};
let json = serde_json::to_string(&item).unwrap();
assert!(json.contains("\"url\":\"/test\""));
assert!(json.contains("\"title\":\"Test\""));
}
}