dioxus_docs_kit/components/
docs_layout.rs1use dioxus::prelude::*;
2#[cfg(feature = "highlight")]
3use dioxus_code::CodeTheme;
4use dioxus_free_icons::Icon;
5use dioxus_free_icons::icons::ld_icons::LdMenu;
6#[cfg(feature = "highlight")]
7use dioxus_mdx::CodeThemeOverride;
8
9use crate::DocsContext;
10#[cfg(feature = "highlight")]
11use crate::config::CodeThemeConfig;
12use crate::registry::DocsRegistry;
13
14fn initial_tab(registry: &'static DocsRegistry, path: &str) -> String {
22 registry
23 .tab_for_path(path)
24 .or_else(|| registry.nav.tabs.first().cloned())
25 .unwrap_or_default()
26}
27
28#[derive(Clone, Debug)]
34pub struct LayoutOffsets {
35 pub sticky_top: &'static str,
37 pub scroll_mt: &'static str,
39 pub sidebar_height: &'static str,
41}
42
43#[derive(Clone, Copy)]
44pub struct CurrentTheme(pub Signal<String>);
45
46#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
52#[non_exhaustive]
53pub enum DocsVariant {
54 #[default]
56 Prose,
57 Reference,
59}
60
61impl DocsVariant {
62 pub fn class(self) -> &'static str {
64 match self {
65 DocsVariant::Prose => "dk-variant-prose",
66 DocsVariant::Reference => "dk-variant-reference",
67 }
68 }
69}
70
71#[derive(Clone, Copy)]
77pub struct DrawerOpen(pub Signal<bool>);
78
79#[derive(Clone, Copy)]
86pub struct SearchOpen(pub Signal<bool>);
87
88#[derive(Clone, Copy)]
90pub struct ActiveTab(pub Signal<String>);
91
92use super::mobile_drawer::MobileDrawer;
93use super::search_modal::SearchModal;
94use super::sidebar::DocsSidebar;
95use super::theme_toggle::ThemeToggle;
96
97#[component]
128pub fn DocsLayout(
129 header: Option<Element>,
130 #[props(default = true)] show_header: bool,
131 announcement_bar: Option<Element>,
132 sidebar_header: Option<Element>,
133 sidebar_footer: Option<Element>,
134 footer: Option<Element>,
135 #[props(default)] variant: DocsVariant,
136 children: Element,
137) -> Element {
138 let registry = use_context::<&'static DocsRegistry>();
139 let ctx = use_context::<DocsContext>();
140 let nav = ®istry.nav;
141
142 let parent_search: Option<SearchOpen> = try_use_context();
144 let parent_drawer: Option<DrawerOpen> = try_use_context();
145
146 let local_search = use_signal(|| false);
148 let local_drawer = use_signal(|| false);
149
150 let search_open = parent_search.map(|s| s.0).unwrap_or(local_search);
152 let mut drawer_open = parent_drawer.map(|d| d.0).unwrap_or(local_drawer);
153
154 use_context_provider(|| SearchOpen(search_open));
156 use_context_provider(|| DrawerOpen(drawer_open));
157
158 #[cfg_attr(not(feature = "highlight"), allow(unused_variables))]
162 let current_theme = super::shared::use_theme_provider(registry.theme.clone());
163
164 #[cfg(feature = "highlight")]
169 {
170 let code_theme_config = registry.code_theme;
171 let toggle_dark = registry
172 .theme
173 .as_ref()
174 .and_then(|t| t.toggle_themes.as_ref())
175 .map(|(_, dark)| dark.clone());
176 let code_theme = use_memo(move || match code_theme_config {
177 CodeThemeConfig::Fixed(theme) => CodeTheme::fixed(theme),
178 CodeThemeConfig::Adaptive { light, dark } => match &toggle_dark {
179 Some(dark_name) if current_theme() == *dark_name => CodeTheme::fixed(dark),
180 Some(_) => CodeTheme::fixed(light),
181 None => CodeTheme::system(light, dark),
182 },
183 });
184 use_context_provider(|| CodeThemeOverride(code_theme.into()));
185 }
186
187 let mut active_tab = use_signal(|| initial_tab(registry, &ctx.current_path.peek()));
188 use_context_provider(|| ActiveTab(active_tab));
189
190 let current_path = ctx.current_path;
192 let registry_for_effect = registry;
193 use_effect(move || {
194 let path = current_path();
195 if let Some(tab) = registry_for_effect.tab_for_path(&path) {
196 active_tab.set(tab);
197 }
198 });
199
200 super::shared::use_search_hotkey(search_open);
202
203 let has_tabs = nav.has_tabs();
204 let offsets = if !show_header {
205 LayoutOffsets {
206 sticky_top: "top-0",
207 scroll_mt: "scroll-mt-0",
208 sidebar_height: "h-screen",
209 }
210 } else if has_tabs {
211 LayoutOffsets {
212 sticky_top: "top-[6.5rem]",
213 scroll_mt: "scroll-mt-[6.5rem]",
214 sidebar_height: "h-[calc(100vh-6.5rem)]",
215 }
216 } else {
217 LayoutOffsets {
218 sticky_top: "top-16",
219 scroll_mt: "scroll-mt-16",
220 sidebar_height: "h-[calc(100vh-4rem)]",
221 }
222 };
223 use_context_provider(|| offsets.clone());
224
225 rsx! {
226 div { class: "dk-root dk-docs-root {variant.class()} min-h-screen bg-base-100",
227 if let Some(bar) = announcement_bar {
229 div { class: "dk-announcement-slot", {bar} }
230 }
231
232 if show_header {
234 div { class: "dk-header sticky top-0 z-50",
235 if let Some(hdr) = header {
237 {hdr}
238 } else {
239 div { class: "navbar bg-base-200 border-b border-base-300 px-4 lg:px-8",
241 div { class: "flex-1 gap-2",
242 button {
243 class: "btn btn-ghost btn-sm btn-square lg:hidden",
244 onclick: move |_| drawer_open.toggle(),
245 Icon { class: "size-5", icon: LdMenu }
246 }
247 }
248 div { class: "flex-none gap-1",
249 SearchButton { search_open }
250 ThemeToggle {}
251 }
252 }
253 }
254
255 if has_tabs {
257 div { class: "dk-tabs bg-base-200/80 backdrop-blur border-b border-base-300 px-4 lg:px-8",
258 div { class: "flex gap-6",
259 for tab in nav.tabs.iter() {
260 {
261 let is_active = *tab == active_tab();
262 let tab_clone = tab.clone();
263 let style = if is_active {
264 "dk-tab-active text-primary border-b-2 border-primary font-medium"
265 } else {
266 "text-base-content/60 hover:text-base-content border-b-2 border-transparent"
267 };
268 rsx! {
269 button {
270 class: "dk-tab px-1 py-2.5 text-sm transition-colors -mb-px {style}",
271 onclick: move |_| {
272 active_tab.set(tab_clone.clone());
273 let groups = nav.groups_for_tab(&tab_clone);
274 if let Some(first_page) = groups.first().and_then(|g| g.pages.first()) {
275 (ctx.navigate)(first_page.clone());
276 }
277 },
278 "{tab}"
279 }
280 }
281 }
282 }
283 }
284 }
285 }
286 }
287 }
288
289 div { class: "dk-shell flex",
291 aside { class: "dk-sidebar w-64 shrink-0 border-r border-base-300 bg-base-200/30 hidden lg:block",
293 div { class: "sticky {offsets.sticky_top} {offsets.sidebar_height} overflow-y-auto p-6 flex flex-col gap-6",
294 if let Some(sh) = sidebar_header {
295 div { class: "dk-sidebar-header-slot", {sh} }
296 }
297 DocsSidebar {}
298 if let Some(sf) = sidebar_footer {
299 div { class: "dk-sidebar-footer-slot mt-auto pt-4", {sf} }
300 }
301 }
302 }
303
304 div { class: "dk-main flex-1 min-w-0",
306 {children}
307 }
308 }
309
310 if let Some(ft) = footer {
312 div { class: "dk-footer-slot", {ft} }
313 }
314 }
315
316 MobileDrawer { open: drawer_open }
318 SearchModal {}
319 }
320}
321
322#[component]
324pub fn SearchButton(search_open: Signal<bool>) -> Element {
325 use dioxus_free_icons::icons::ld_icons::LdSearch;
326
327 rsx! {
328 button {
329 class: "dk-search-trigger btn btn-ghost btn-sm gap-2",
330 r#type: "button",
331 aria_label: "Search",
332 aria_haspopup: "dialog",
333 onclick: move |_| search_open.set(true),
334 Icon { class: "size-4", icon: LdSearch }
335 span { class: "hidden sm:inline text-base-content/60 text-sm", "Search" }
336 kbd { class: "kbd kbd-xs hidden sm:inline-flex", "\u{2318}K" }
337 }
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::initial_tab;
344 use crate::config::DocsConfig;
345 use crate::registry::DocsRegistry;
346 use std::collections::HashMap;
347
348 const NAV: &str = r#"{
349 "tabs": ["Docs", "API Reference"],
350 "groups": [
351 { "group": "Getting Started", "tab": "Docs", "pages": ["getting-started/intro"] },
352 { "group": "API Reference", "tab": "API Reference", "pages": [] }
353 ]
354 }"#;
355
356 const INTRO: &str = "---\ntitle: Intro\n---\n\nWelcome.\n";
357
358 #[cfg(feature = "openapi")]
359 const SPEC: &str = r#"
360openapi: "3.0.0"
361info:
362 title: Pets API
363 version: "1.0.0"
364paths:
365 /pets:
366 get:
367 operationId: listPets
368 summary: List pets
369 responses:
370 "200":
371 description: OK
372"#;
373
374 fn registry() -> &'static DocsRegistry {
375 let map = HashMap::from([("getting-started/intro", INTRO)]);
376 let config = DocsConfig::new(NAV, map);
377 #[cfg(feature = "openapi")]
378 let config = config.with_openapi("api-reference", SPEC);
379 Box::leak(Box::new(config.build()))
380 }
381
382 #[test]
383 #[cfg(feature = "openapi")]
384 fn initial_tab_seeds_api_pages_to_their_own_tab() {
385 assert_eq!(
389 initial_tab(registry(), "api-reference/list-pets"),
390 "API Reference"
391 );
392 }
393
394 #[test]
395 fn initial_tab_seeds_doc_pages_to_the_docs_tab() {
396 assert_eq!(initial_tab(registry(), "getting-started/intro"), "Docs");
397 }
398
399 #[test]
400 fn initial_tab_falls_back_to_the_first_tab_for_unknown_paths() {
401 assert_eq!(initial_tab(registry(), ""), "Docs");
403 assert_eq!(initial_tab(registry(), "nope/nothing"), "Docs");
404 }
405}