Skip to main content

dioxus_docs_kit/components/
search_modal.rs

1use dioxus::prelude::*;
2use dioxus_free_icons::Icon;
3use dioxus_free_icons::icons::ld_icons::{LdSearch, LdX};
4use dioxus_mdx::HttpMethod;
5
6use crate::DocsContext;
7use crate::registry::DocsRegistry;
8
9/// Full-screen search modal triggered by Cmd/Ctrl+K or the search button.
10#[component]
11pub fn SearchModal() -> Element {
12    let mut search_open = use_context::<Signal<bool>>();
13    let mut query = use_signal(String::new);
14    let ctx = use_context::<DocsContext>();
15    let registry = use_context::<&'static DocsRegistry>();
16
17    let results = use_memo(move || registry.search_docs(&query()));
18
19    let on_keydown = move |e: KeyboardEvent| {
20        if e.key() == Key::Enter {
21            let results = results.read();
22            if let Some(entry) = results.first() {
23                (ctx.navigate)(entry.path.clone());
24                search_open.set(false);
25                query.set(String::new());
26            }
27        } else if e.key() == Key::Escape {
28            search_open.set(false);
29            query.set(String::new());
30        }
31    };
32
33    if !search_open() {
34        return rsx! {};
35    }
36
37    rsx! {
38        // Backdrop
39        div {
40            class: "fixed inset-0 z-[100] bg-black/50 flex items-start justify-center pt-[15vh]",
41            onclick: move |_| {
42                search_open.set(false);
43                query.set(String::new());
44            },
45
46            // Modal container
47            div {
48                class: "bg-base-200 rounded-xl w-full max-w-lg mx-4 border border-base-300 shadow-2xl overflow-hidden",
49                onclick: move |e| e.stop_propagation(),
50
51                // Search input row
52                div { class: "flex items-center gap-3 px-4 py-3 border-b border-base-300",
53                    Icon { class: "size-5 text-base-content/50 shrink-0", icon: LdSearch }
54                    input {
55                        class: "flex-1 bg-transparent outline-none text-base placeholder:text-base-content/40",
56                        placeholder: "Search documentation...",
57                        autofocus: true,
58                        value: "{query}",
59                        oninput: move |e| query.set(e.value()),
60                        onkeydown: on_keydown,
61                    }
62                    button {
63                        class: "btn btn-ghost btn-xs btn-square",
64                        onclick: move |_| {
65                            search_open.set(false);
66                            query.set(String::new());
67                        },
68                        Icon { class: "size-4", icon: LdX }
69                    }
70                }
71
72                // Results list
73                div { class: "max-h-80 overflow-y-auto",
74                    if query().trim().is_empty() {
75                        div { class: "px-4 py-8 text-center text-base-content/50 text-sm",
76                            "Type to search..."
77                        }
78                    } else if results.read().is_empty() {
79                        div { class: "px-4 py-8 text-center text-base-content/50 text-sm",
80                            "No results for \"{query}\""
81                        }
82                    } else {
83                        for entry in results.read().iter() {
84                            {
85                                let path = entry.path.clone();
86                                let title = entry.title.clone();
87                                let breadcrumb = entry.breadcrumb.clone();
88                                let api_method = entry.api_method;
89                                rsx! {
90                                    SearchResultItem {
91                                        path,
92                                        title,
93                                        breadcrumb,
94                                        api_method,
95                                        search_open,
96                                        query,
97                                    }
98                                }
99                            }
100                        }
101                    }
102                }
103
104                // Footer
105                div { class: "px-4 py-2 border-t border-base-300 text-xs text-base-content/40 flex justify-between",
106                    span { "Esc to close" }
107                    span { "Enter to navigate" }
108                }
109            }
110        }
111    }
112}
113
114#[component]
115fn SearchResultItem(
116    path: String,
117    title: String,
118    breadcrumb: String,
119    api_method: Option<HttpMethod>,
120    mut search_open: Signal<bool>,
121    mut query: Signal<String>,
122) -> Element {
123    let ctx = use_context::<DocsContext>();
124    let path_for_click = path.clone();
125
126    rsx! {
127        button {
128            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",
129            onclick: move |_| {
130                (ctx.navigate)(path_for_click.clone());
131                search_open.set(false);
132                query.set(String::new());
133            },
134            div { class: "flex-1 min-w-0",
135                div { class: "flex items-center gap-2",
136                    if let Some(method) = api_method {
137                        {
138                            let (label, color) = match method {
139                                HttpMethod::Get => ("GET", "badge-success"),
140                                HttpMethod::Post => ("POST", "badge-primary"),
141                                HttpMethod::Put => ("PUT", "badge-warning"),
142                                HttpMethod::Delete => ("DEL", "badge-error"),
143                                HttpMethod::Patch => ("PATCH", "badge-info"),
144                                _ => ("???", "badge-ghost"),
145                            };
146                            rsx! {
147                                span { class: "badge badge-xs font-mono {color}", "{label}" }
148                            }
149                        }
150                    }
151                    span { class: "font-medium text-sm truncate", "{title}" }
152                }
153                span { class: "text-xs text-base-content/50 truncate block mt-0.5",
154                    "{breadcrumb}"
155                }
156            }
157        }
158    }
159}