Skip to main content

dioxus_docs_kit/components/blog/
search_modal.rs

1use dioxus::prelude::*;
2use dioxus_free_icons::Icon;
3use dioxus_free_icons::icons::ld_icons::{LdSearch, LdX};
4
5use crate::BlogContext;
6use crate::blog::registry::BlogRegistry;
7
8/// Blog search modal triggered by Cmd/Ctrl+K or the search button.
9#[component]
10pub fn BlogSearchModal() -> Element {
11    let mut search_open = use_context::<Signal<bool>>();
12    let mut query = use_signal(String::new);
13    let ctx = use_context::<BlogContext>();
14    let registry = use_context::<&'static BlogRegistry>();
15
16    let results = use_memo(move || registry.search_posts(&query()));
17
18    let on_keydown = move |e: KeyboardEvent| {
19        if e.key() == Key::Enter {
20            let results = results.read();
21            if let Some(entry) = results.first() {
22                (ctx.navigate)(entry.slug.clone());
23                search_open.set(false);
24                query.set(String::new());
25            }
26        } else if e.key() == Key::Escape {
27            search_open.set(false);
28            query.set(String::new());
29        }
30    };
31
32    if !search_open() {
33        return rsx! {};
34    }
35
36    rsx! {
37        div {
38            class: "fixed inset-0 z-[100] bg-black/50 flex items-start justify-center pt-[15vh]",
39            onclick: move |_| {
40                search_open.set(false);
41                query.set(String::new());
42            },
43
44            div {
45                class: "bg-base-200 rounded-xl w-full max-w-lg mx-4 border border-base-300 shadow-2xl overflow-hidden",
46                onclick: move |e| e.stop_propagation(),
47
48                div { class: "flex items-center gap-3 px-4 py-3 border-b border-base-300",
49                    Icon { class: "size-5 text-base-content/50 shrink-0", icon: LdSearch }
50                    input {
51                        class: "flex-1 bg-transparent outline-none text-base placeholder:text-base-content/40",
52                        placeholder: "Search posts...",
53                        autofocus: true,
54                        value: "{query}",
55                        oninput: move |e| query.set(e.value()),
56                        onkeydown: on_keydown,
57                    }
58                    button {
59                        class: "btn btn-ghost btn-xs btn-square",
60                        onclick: move |_| {
61                            search_open.set(false);
62                            query.set(String::new());
63                        },
64                        Icon { class: "size-4", icon: LdX }
65                    }
66                }
67
68                div { class: "max-h-80 overflow-y-auto",
69                    if query().trim().is_empty() {
70                        div { class: "px-4 py-8 text-center text-base-content/50 text-sm",
71                            "Type to search..."
72                        }
73                    } else if results.read().is_empty() {
74                        div { class: "px-4 py-8 text-center text-base-content/50 text-sm",
75                            "No results for \"{query}\""
76                        }
77                    } else {
78                        for entry in results.read().iter() {
79                            {
80                                let slug = entry.slug.clone();
81                                let title = entry.title.clone();
82                                let date = entry.date.clone();
83                                let tags = entry.tags.clone();
84                                rsx! {
85                                    BlogSearchResultItem {
86                                        slug,
87                                        title,
88                                        date,
89                                        tags,
90                                        search_open,
91                                        query,
92                                    }
93                                }
94                            }
95                        }
96                    }
97                }
98
99                div { class: "px-4 py-2 border-t border-base-300 text-xs text-base-content/40 flex justify-between",
100                    span { "Esc to close" }
101                    span { "Enter to navigate" }
102                }
103            }
104        }
105    }
106}
107
108#[component]
109fn BlogSearchResultItem(
110    slug: String,
111    title: String,
112    date: String,
113    tags: Vec<String>,
114    mut search_open: Signal<bool>,
115    mut query: Signal<String>,
116) -> Element {
117    let ctx = use_context::<BlogContext>();
118    let slug_for_click = slug.clone();
119
120    rsx! {
121        button {
122            class: "w-full text-left px-4 py-3 hover:bg-base-300/50 transition-colors flex items-center gap-3 border-b border-base-300/50 last:border-b-0",
123            onclick: move |_| {
124                (ctx.navigate)(slug_for_click.clone());
125                search_open.set(false);
126                query.set(String::new());
127            },
128            div { class: "flex-1 min-w-0",
129                div { class: "flex items-center gap-2",
130                    span { class: "font-medium text-sm truncate", "{title}" }
131                }
132                div { class: "flex items-center gap-2 mt-0.5",
133                    span { class: "text-xs text-base-content/50", "{date}" }
134                    for tag in tags.iter() {
135                        span { class: "badge badge-xs badge-outline badge-primary", "{tag}" }
136                    }
137                }
138            }
139        }
140    }
141}