Skip to main content

dioxus_docs_kit/components/
docs_layout.rs

1use 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
14/// Which tab a freshly mounted layout should show.
15///
16/// Derived from the path rather than defaulting to `tabs[0]`, because the
17/// effect that syncs the tab does not run during SSR: a server-rendered
18/// API-reference URL would otherwise ship the Docs sidebar to crawlers and
19/// visibly flip tabs after hydration. Falls back to the first tab for paths the
20/// registry doesn't recognise (e.g. the docs index).
21fn 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/// Layout offset values computed by `DocsLayout` and consumed by child components
29/// (e.g. `DocsPageContent`) via context.
30///
31/// The values are determined by `show_header` and whether tabs exist, so that the
32/// TOC sidebar and heading scroll targets align with the actual header height.
33#[derive(Clone, Debug)]
34pub struct LayoutOffsets {
35    /// Tailwind sticky top class for sidebars/TOC (e.g. `"top-[6.5rem]"`, `"top-16"`, `"top-0"`).
36    pub sticky_top: &'static str,
37    /// Tailwind scroll-margin-top class for heading anchors (e.g. `"scroll-mt-[6.5rem]"`).
38    pub scroll_mt: &'static str,
39    /// Tailwind height calc for sidebar (e.g. `"h-[calc(100vh-6.5rem)]"`).
40    pub sidebar_height: &'static str,
41}
42
43#[derive(Clone, Copy)]
44pub struct CurrentTheme(pub Signal<String>);
45
46/// Density preset for the docs layout.
47///
48/// Consumers target the emitted class (`dk-variant-prose` or `dk-variant-reference`)
49/// in their own CSS to further tweak the look. The shipped `theme.css` applies
50/// width / type-scale differences out of the box.
51#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
52#[non_exhaustive]
53pub enum DocsVariant {
54    /// Default: wide margins, generous line-height, optimized for long-form prose.
55    #[default]
56    Prose,
57    /// Tight: narrower article column, denser type, better for API/reference docs.
58    Reference,
59}
60
61impl DocsVariant {
62    /// The CSS class appended to `dk-root` for this variant.
63    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/// Newtype wrapper for the drawer-open signal, so it can't collide with other
72/// `Signal<bool>` values in the context system.
73///
74/// Consumers can provide this before rendering `DocsLayout` to control
75/// the mobile drawer from a custom header.
76#[derive(Clone, Copy)]
77pub struct DrawerOpen(pub Signal<bool>);
78
79/// Newtype wrapper for the search-modal open signal, so it can't collide with
80/// other `Signal<bool>` values in the context system.
81///
82/// Provided by `DocsLayout`/`BlogLayout` (or [`use_docs_providers`](crate::use_docs_providers) /
83/// [`use_blog_providers`](crate::use_blog_providers)); consumers can provide it
84/// beforehand to control search from a custom header.
85#[derive(Clone, Copy)]
86pub struct SearchOpen(pub Signal<bool>);
87
88/// Newtype wrapper for the active tab-bar tab, provided by `DocsLayout`.
89#[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/// Documentation layout shell.
98///
99/// Renders the tab bar, sidebar, content area, search modal, and mobile drawer.
100/// The consumer wraps this in their own navbar via Dioxus route layouts.
101///
102/// # Context requirements
103///
104/// - `&'static DocsRegistry` — provided by consumer
105/// - `DocsContext` — provided by consumer
106///
107/// # Props
108///
109/// - `header`: Optional element to render inside the docs area header (e.g. branding + search button).
110///   If not provided, a default header with search button and hamburger is rendered.
111/// - `show_header`: Whether to render the internal header area (default header/custom header *and*
112///   tab bar). Defaults to `true`. Set to `false` when the consumer provides their own header
113///   and tab bar outside of `DocsLayout`.
114/// - `announcement_bar`: Optional element rendered at the very top, above `header`.
115/// - `sidebar_header`: Optional element rendered above the generated sidebar nav.
116/// - `sidebar_footer`: Optional element rendered below the generated sidebar nav
117///   (e.g. "Edit this page" links).
118/// - `footer`: Optional element rendered at the bottom of the layout (site-wide footer).
119/// - `variant`: Density preset (`Prose` default, `Reference` for denser API/reference docs).
120/// - `children`: The routed page content (from `Outlet` or explicit child).
121///
122/// # Stable public classes
123///
124/// `DocsLayout` tags its structural nodes with semver-stable `dk-*` classes so
125/// CSS overrides don't drift: `dk-root`, `dk-docs-root`, `dk-header`, `dk-shell`,
126/// `dk-sidebar`, `dk-main`, and slot wrappers like `dk-announcement-slot`.
127#[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 = &registry.nav;
141
142    // Check if consumer already provided context (lookups, not hooks)
143    let parent_search: Option<SearchOpen> = try_use_context();
144    let parent_drawer: Option<DrawerOpen> = try_use_context();
145
146    // Always create local fallback signals unconditionally
147    let local_search = use_signal(|| false);
148    let local_drawer = use_signal(|| false);
149
150    // Use consumer-provided context if available, otherwise local
151    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    // Always provide context for children (SearchModal, MobileDrawer, etc.)
155    use_context_provider(|| SearchOpen(search_open));
156    use_context_provider(|| DrawerOpen(drawer_open));
157
158    // Theme state: hooks must be called unconditionally
159    // `current_theme` only feeds the code-theme override below, which is gated on the
160    // `highlight` feature; the hook itself must still run to provide `CurrentTheme`.
161    #[cfg_attr(not(feature = "highlight"), allow(unused_variables))]
162    let current_theme = super::shared::use_theme_provider(registry.theme.clone());
163
164    // Resolve the code-block syntax theme. When a light/dark toggle is configured, the
165    // choice tracks the active `data-theme` so code backgrounds match the site toggle
166    // rather than the reader's OS `prefers-color-scheme`. Provided reactively so blocks
167    // restyle when the theme switches. (See `CodeThemeOverride` in dioxus-mdx.)
168    #[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    // Sync active tab from current path
191    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    // Keyboard shortcut: Cmd/Ctrl+K to toggle search
201    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            // Optional announcement bar (rendered above everything)
228            if let Some(bar) = announcement_bar {
229                div { class: "dk-announcement-slot", {bar} }
230            }
231
232            // Top area
233            if show_header {
234                div { class: "dk-header sticky top-0 z-50",
235                    // Header (consumer-provided or default)
236                    if let Some(hdr) = header {
237                        {hdr}
238                    } else {
239                        // Default minimal header
240                        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                    // Tab bar (below header)
256                    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            // Main docs content with sidebar
290            div { class: "dk-shell flex",
291                // Sidebar
292                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                // Main content area
305                div { class: "dk-main flex-1 min-w-0",
306                    {children}
307                }
308            }
309
310            // Optional site-wide footer
311            if let Some(ft) = footer {
312                div { class: "dk-footer-slot", {ft} }
313            }
314        }
315
316        // Overlays
317        MobileDrawer { open: drawer_open }
318        SearchModal {}
319    }
320}
321
322/// Reusable search button component for headers.
323#[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        // The regression: this used to seed tabs[0] ("Docs") and was only
386        // corrected by an effect, which never runs during SSR - so crawlers got
387        // the Docs sidebar on an API URL.
388        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        // e.g. the docs index, which has no owning group.
402        assert_eq!(initial_tab(registry(), ""), "Docs");
403        assert_eq!(initial_tab(registry(), "nope/nothing"), "Docs");
404    }
405}