dioxus_docs_kit/components/
docs_layout.rs1use dioxus::prelude::*;
2use dioxus_free_icons::Icon;
3use dioxus_free_icons::icons::ld_icons::LdMenu;
4
5use crate::DocsContext;
6use crate::registry::DocsRegistry;
7
8#[derive(Clone, Debug)]
14pub struct LayoutOffsets {
15 pub sticky_top: &'static str,
17 pub scroll_mt: &'static str,
19 pub sidebar_height: &'static str,
21}
22
23#[derive(Clone, Copy)]
24pub struct CurrentTheme(pub Signal<String>);
25
26#[derive(Clone, Copy)]
32pub struct DrawerOpen(pub Signal<bool>);
33
34use super::mobile_drawer::MobileDrawer;
35use super::search_modal::SearchModal;
36use super::sidebar::DocsSidebar;
37use super::theme_toggle::ThemeToggle;
38
39#[component]
58pub fn DocsLayout(
59 header: Option<Element>,
60 #[props(default = true)] show_header: bool,
61 children: Element,
62) -> Element {
63 let registry = use_context::<&'static DocsRegistry>();
64 let ctx = use_context::<DocsContext>();
65 let nav = ®istry.nav;
66
67 let parent_search: Option<Signal<bool>> = try_use_context();
69 let parent_drawer: Option<DrawerOpen> = try_use_context();
70
71 let local_search = use_signal(|| false);
73 let local_drawer = use_signal(|| false);
74
75 let mut search_open = parent_search.unwrap_or(local_search);
77 let mut drawer_open = parent_drawer.map(|d| d.0).unwrap_or(local_drawer);
78
79 use_context_provider(|| search_open);
81 use_context_provider(|| DrawerOpen(drawer_open));
82
83 let theme_default = registry
85 .theme
86 .as_ref()
87 .map(|t| t.default_theme.clone())
88 .unwrap_or_default();
89 let theme_storage_key = registry
90 .theme
91 .as_ref()
92 .map(|t| t.storage_key.clone())
93 .unwrap_or_default();
94 let has_theme = registry.theme.is_some();
95
96 let mut current_theme = use_signal(|| theme_default.clone());
97 use_context_provider(|| CurrentTheme(current_theme));
98
99 use_effect(move || {
101 if !has_theme {
102 return;
103 }
104 let key = theme_storage_key.clone();
105 let fallback = theme_default.clone();
106 spawn(async move {
107 let mut eval = document::eval(&format!(
108 r#"
109 let theme = null;
110 try {{ theme = localStorage.getItem('{key}'); }} catch(e) {{}}
111 theme = theme || '{fallback}';
112 document.documentElement.setAttribute('data-theme', theme);
113 dioxus.send(theme);
114 "#
115 ));
116 if let Ok(stored) = eval.recv::<String>().await {
117 current_theme.set(stored);
118 }
119 });
120 });
121
122 let mut active_tab = use_signal(|| nav.tabs.first().cloned().unwrap_or_default());
124 use_context_provider(|| active_tab);
125
126 let current_path = ctx.current_path;
128 let registry_for_effect = registry;
129 use_effect(move || {
130 let path = current_path();
131 if let Some(tab) = registry_for_effect.tab_for_path(&path) {
132 active_tab.set(tab);
133 }
134 });
135
136 use_effect(move || {
138 spawn(async move {
139 let mut eval = document::eval(
140 r#"
141 document.addEventListener('keydown', (e) => {
142 if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
143 e.preventDefault();
144 dioxus.send(true);
145 }
146 });
147 while (true) { await new Promise(r => setTimeout(r, 1000000)); }
148 "#,
149 );
150 loop {
151 if (eval.recv::<bool>().await).is_ok() {
152 search_open.toggle();
153 }
154 }
155 });
156 });
157
158 let has_tabs = nav.has_tabs();
159 let offsets = if !show_header {
160 LayoutOffsets {
161 sticky_top: "top-0",
162 scroll_mt: "scroll-mt-0",
163 sidebar_height: "h-screen",
164 }
165 } else if has_tabs {
166 LayoutOffsets {
167 sticky_top: "top-[6.5rem]",
168 scroll_mt: "scroll-mt-[6.5rem]",
169 sidebar_height: "h-[calc(100vh-6.5rem)]",
170 }
171 } else {
172 LayoutOffsets {
173 sticky_top: "top-16",
174 scroll_mt: "scroll-mt-16",
175 sidebar_height: "h-[calc(100vh-4rem)]",
176 }
177 };
178 use_context_provider(|| offsets.clone());
179
180 rsx! {
181 div { class: "min-h-screen bg-base-100",
182 if show_header {
184 div { class: "sticky top-0 z-50",
185 if let Some(hdr) = header {
187 {hdr}
188 } else {
189 div { class: "navbar bg-base-200 border-b border-base-300 px-4 lg:px-8",
191 div { class: "flex-1 gap-2",
192 button {
193 class: "btn btn-ghost btn-sm btn-square lg:hidden",
194 onclick: move |_| drawer_open.toggle(),
195 Icon { class: "size-5", icon: LdMenu }
196 }
197 }
198 div { class: "flex-none gap-1",
199 SearchButton { search_open }
200 ThemeToggle {}
201 }
202 }
203 }
204
205 if has_tabs {
207 div { class: "bg-base-200/80 backdrop-blur border-b border-base-300 px-4 lg:px-8",
208 div { class: "flex gap-6",
209 for tab in nav.tabs.iter() {
210 {
211 let is_active = *tab == active_tab();
212 let tab_clone = tab.clone();
213 let style = if is_active {
214 "text-primary border-b-2 border-primary font-medium"
215 } else {
216 "text-base-content/60 hover:text-base-content border-b-2 border-transparent"
217 };
218 rsx! {
219 button {
220 class: "px-1 py-2.5 text-sm transition-colors -mb-px {style}",
221 onclick: move |_| {
222 active_tab.set(tab_clone.clone());
223 let groups = nav.groups_for_tab(&tab_clone);
224 if let Some(first_page) = groups.first().and_then(|g| g.pages.first()) {
225 (ctx.navigate)(first_page.clone());
226 }
227 },
228 "{tab}"
229 }
230 }
231 }
232 }
233 }
234 }
235 }
236 }
237 }
238
239 div { class: "flex",
241 aside { class: "w-64 shrink-0 border-r border-base-300 bg-base-200/30 hidden lg:block",
243 div { class: "sticky {offsets.sticky_top} {offsets.sidebar_height} overflow-y-auto p-6",
244 DocsSidebar {}
245 }
246 }
247
248 div { class: "flex-1 min-w-0",
250 {children}
251 }
252 }
253 }
254
255 MobileDrawer { open: drawer_open }
257 SearchModal {}
258 }
259}
260
261#[component]
263pub fn SearchButton(search_open: Signal<bool>) -> Element {
264 use dioxus_free_icons::icons::ld_icons::LdSearch;
265
266 rsx! {
267 button {
268 class: "btn btn-ghost btn-sm gap-2",
269 onclick: move |_| search_open.set(true),
270 Icon { class: "size-4", icon: LdSearch }
271 span { class: "hidden sm:inline text-base-content/60 text-sm", "Search" }
272 kbd { class: "kbd kbd-xs hidden sm:inline-flex", "\u{2318}K" }
273 }
274 }
275}