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/// Layout offset values computed by `DocsLayout` and consumed by child components
15/// (e.g. `DocsPageContent`) via context.
16///
17/// The values are determined by `show_header` and whether tabs exist, so that the
18/// TOC sidebar and heading scroll targets align with the actual header height.
19#[derive(Clone, Debug)]
20pub struct LayoutOffsets {
21    /// Tailwind sticky top class for sidebars/TOC (e.g. `"top-[6.5rem]"`, `"top-16"`, `"top-0"`).
22    pub sticky_top: &'static str,
23    /// Tailwind scroll-margin-top class for heading anchors (e.g. `"scroll-mt-[6.5rem]"`).
24    pub scroll_mt: &'static str,
25    /// Tailwind height calc for sidebar (e.g. `"h-[calc(100vh-6.5rem)]"`).
26    pub sidebar_height: &'static str,
27}
28
29#[derive(Clone, Copy)]
30pub struct CurrentTheme(pub Signal<String>);
31
32/// Density preset for the docs layout.
33///
34/// Consumers target the emitted class (`dk-variant-prose` or `dk-variant-reference`)
35/// in their own CSS to further tweak the look. The shipped `theme.css` applies
36/// width / type-scale differences out of the box.
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub enum DocsVariant {
39    /// Default: wide margins, generous line-height, optimized for long-form prose.
40    #[default]
41    Prose,
42    /// Tight: narrower article column, denser type, better for API/reference docs.
43    Reference,
44}
45
46impl DocsVariant {
47    /// The CSS class appended to `dk-root` for this variant.
48    pub fn class(self) -> &'static str {
49        match self {
50            DocsVariant::Prose => "dk-variant-prose",
51            DocsVariant::Reference => "dk-variant-reference",
52        }
53    }
54}
55
56/// Newtype wrapper for the drawer-open signal, so it can't collide with other
57/// `Signal<bool>` values in the context system.
58///
59/// Consumers can provide this before rendering `DocsLayout` to control
60/// the mobile drawer from a custom header.
61#[derive(Clone, Copy)]
62pub struct DrawerOpen(pub Signal<bool>);
63
64/// Newtype wrapper for the search-modal open signal, so it can't collide with
65/// other `Signal<bool>` values in the context system.
66///
67/// Provided by `DocsLayout`/`BlogLayout` (or [`use_docs_providers`](crate::use_docs_providers) /
68/// [`use_blog_providers`](crate::use_blog_providers)); consumers can provide it
69/// beforehand to control search from a custom header.
70#[derive(Clone, Copy)]
71pub struct SearchOpen(pub Signal<bool>);
72
73/// Newtype wrapper for the active tab-bar tab, provided by `DocsLayout`.
74#[derive(Clone, Copy)]
75pub struct ActiveTab(pub Signal<String>);
76
77use super::mobile_drawer::MobileDrawer;
78use super::search_modal::SearchModal;
79use super::sidebar::DocsSidebar;
80use super::theme_toggle::ThemeToggle;
81
82/// Documentation layout shell.
83///
84/// Renders the tab bar, sidebar, content area, search modal, and mobile drawer.
85/// The consumer wraps this in their own navbar via Dioxus route layouts.
86///
87/// # Context requirements
88///
89/// - `&'static DocsRegistry` — provided by consumer
90/// - `DocsContext` — provided by consumer
91///
92/// # Props
93///
94/// - `header`: Optional element to render inside the docs area header (e.g. branding + search button).
95///   If not provided, a default header with search button and hamburger is rendered.
96/// - `show_header`: Whether to render the internal header area (default header/custom header *and*
97///   tab bar). Defaults to `true`. Set to `false` when the consumer provides their own header
98///   and tab bar outside of `DocsLayout`.
99/// - `announcement_bar`: Optional element rendered at the very top, above `header`.
100/// - `sidebar_header`: Optional element rendered above the generated sidebar nav.
101/// - `sidebar_footer`: Optional element rendered below the generated sidebar nav
102///   (e.g. "Edit this page" links).
103/// - `footer`: Optional element rendered at the bottom of the layout (site-wide footer).
104/// - `variant`: Density preset (`Prose` default, `Reference` for denser API/reference docs).
105/// - `children`: The routed page content (from `Outlet` or explicit child).
106///
107/// # Stable public classes
108///
109/// `DocsLayout` tags its structural nodes with semver-stable `dk-*` classes so
110/// CSS overrides don't drift: `dk-root`, `dk-docs-root`, `dk-header`, `dk-shell`,
111/// `dk-sidebar`, `dk-main`, and slot wrappers like `dk-announcement-slot`.
112#[component]
113pub fn DocsLayout(
114    header: Option<Element>,
115    #[props(default = true)] show_header: bool,
116    announcement_bar: Option<Element>,
117    sidebar_header: Option<Element>,
118    sidebar_footer: Option<Element>,
119    footer: Option<Element>,
120    #[props(default)] variant: DocsVariant,
121    children: Element,
122) -> Element {
123    let registry = use_context::<&'static DocsRegistry>();
124    let ctx = use_context::<DocsContext>();
125    let nav = &registry.nav;
126
127    // Check if consumer already provided context (lookups, not hooks)
128    let parent_search: Option<SearchOpen> = try_use_context();
129    let parent_drawer: Option<DrawerOpen> = try_use_context();
130
131    // Always create local fallback signals unconditionally
132    let local_search = use_signal(|| false);
133    let local_drawer = use_signal(|| false);
134
135    // Use consumer-provided context if available, otherwise local
136    let search_open = parent_search.map(|s| s.0).unwrap_or(local_search);
137    let mut drawer_open = parent_drawer.map(|d| d.0).unwrap_or(local_drawer);
138
139    // Always provide context for children (SearchModal, MobileDrawer, etc.)
140    use_context_provider(|| SearchOpen(search_open));
141    use_context_provider(|| DrawerOpen(drawer_open));
142
143    // Theme state: hooks must be called unconditionally
144    // `current_theme` only feeds the code-theme override below, which is gated on the
145    // `highlight` feature; the hook itself must still run to provide `CurrentTheme`.
146    #[cfg_attr(not(feature = "highlight"), allow(unused_variables))]
147    let current_theme = super::shared::use_theme_provider(registry.theme.clone());
148
149    // Resolve the code-block syntax theme. When a light/dark toggle is configured, the
150    // choice tracks the active `data-theme` so code backgrounds match the site toggle
151    // rather than the reader's OS `prefers-color-scheme`. Provided reactively so blocks
152    // restyle when the theme switches. (See `CodeThemeOverride` in dioxus-mdx.)
153    #[cfg(feature = "highlight")]
154    {
155        let code_theme_config = registry.code_theme;
156        let toggle_dark = registry
157            .theme
158            .as_ref()
159            .and_then(|t| t.toggle_themes.as_ref())
160            .map(|(_, dark)| dark.clone());
161        let code_theme = use_memo(move || match code_theme_config {
162            CodeThemeConfig::Fixed(theme) => CodeTheme::fixed(theme),
163            CodeThemeConfig::Adaptive { light, dark } => match &toggle_dark {
164                Some(dark_name) if current_theme() == *dark_name => CodeTheme::fixed(dark),
165                Some(_) => CodeTheme::fixed(light),
166                None => CodeTheme::system(light, dark),
167            },
168        });
169        use_context_provider(|| CodeThemeOverride(code_theme.into()));
170    }
171
172    // Active tab state
173    let mut active_tab = use_signal(|| nav.tabs.first().cloned().unwrap_or_default());
174    use_context_provider(|| ActiveTab(active_tab));
175
176    // Sync active tab from current path
177    let current_path = ctx.current_path;
178    let registry_for_effect = registry;
179    use_effect(move || {
180        let path = current_path();
181        if let Some(tab) = registry_for_effect.tab_for_path(&path) {
182            active_tab.set(tab);
183        }
184    });
185
186    // Keyboard shortcut: Cmd/Ctrl+K to toggle search
187    super::shared::use_search_hotkey(search_open);
188
189    let has_tabs = nav.has_tabs();
190    let offsets = if !show_header {
191        LayoutOffsets {
192            sticky_top: "top-0",
193            scroll_mt: "scroll-mt-0",
194            sidebar_height: "h-screen",
195        }
196    } else if has_tabs {
197        LayoutOffsets {
198            sticky_top: "top-[6.5rem]",
199            scroll_mt: "scroll-mt-[6.5rem]",
200            sidebar_height: "h-[calc(100vh-6.5rem)]",
201        }
202    } else {
203        LayoutOffsets {
204            sticky_top: "top-16",
205            scroll_mt: "scroll-mt-16",
206            sidebar_height: "h-[calc(100vh-4rem)]",
207        }
208    };
209    use_context_provider(|| offsets.clone());
210
211    rsx! {
212        div { class: "dk-root dk-docs-root {variant.class()} min-h-screen bg-base-100",
213            // Optional announcement bar (rendered above everything)
214            if let Some(bar) = announcement_bar {
215                div { class: "dk-announcement-slot", {bar} }
216            }
217
218            // Top area
219            if show_header {
220                div { class: "dk-header sticky top-0 z-50",
221                    // Header (consumer-provided or default)
222                    if let Some(hdr) = header {
223                        {hdr}
224                    } else {
225                        // Default minimal header
226                        div { class: "navbar bg-base-200 border-b border-base-300 px-4 lg:px-8",
227                            div { class: "flex-1 gap-2",
228                                button {
229                                    class: "btn btn-ghost btn-sm btn-square lg:hidden",
230                                    onclick: move |_| drawer_open.toggle(),
231                                    Icon { class: "size-5", icon: LdMenu }
232                                }
233                            }
234                            div { class: "flex-none gap-1",
235                                SearchButton { search_open }
236                                ThemeToggle {}
237                            }
238                        }
239                    }
240
241                    // Tab bar (below header)
242                    if has_tabs {
243                        div { class: "dk-tabs bg-base-200/80 backdrop-blur border-b border-base-300 px-4 lg:px-8",
244                            div { class: "flex gap-6",
245                                for tab in nav.tabs.iter() {
246                                    {
247                                        let is_active = *tab == active_tab();
248                                        let tab_clone = tab.clone();
249                                        let style = if is_active {
250                                            "dk-tab-active text-primary border-b-2 border-primary font-medium"
251                                        } else {
252                                            "text-base-content/60 hover:text-base-content border-b-2 border-transparent"
253                                        };
254                                        rsx! {
255                                            button {
256                                                class: "dk-tab px-1 py-2.5 text-sm transition-colors -mb-px {style}",
257                                                onclick: move |_| {
258                                                    active_tab.set(tab_clone.clone());
259                                                    let groups = nav.groups_for_tab(&tab_clone);
260                                                    if let Some(first_page) = groups.first().and_then(|g| g.pages.first()) {
261                                                        (ctx.navigate)(first_page.clone());
262                                                    }
263                                                },
264                                                "{tab}"
265                                            }
266                                        }
267                                    }
268                                }
269                            }
270                        }
271                    }
272                }
273            }
274
275            // Main docs content with sidebar
276            div { class: "dk-shell flex",
277                // Sidebar
278                aside { class: "dk-sidebar w-64 shrink-0 border-r border-base-300 bg-base-200/30 hidden lg:block",
279                    div { class: "sticky {offsets.sticky_top} {offsets.sidebar_height} overflow-y-auto p-6 flex flex-col gap-6",
280                        if let Some(sh) = sidebar_header {
281                            div { class: "dk-sidebar-header-slot", {sh} }
282                        }
283                        DocsSidebar {}
284                        if let Some(sf) = sidebar_footer {
285                            div { class: "dk-sidebar-footer-slot mt-auto pt-4", {sf} }
286                        }
287                    }
288                }
289
290                // Main content area
291                div { class: "dk-main flex-1 min-w-0",
292                    {children}
293                }
294            }
295
296            // Optional site-wide footer
297            if let Some(ft) = footer {
298                div { class: "dk-footer-slot", {ft} }
299            }
300        }
301
302        // Overlays
303        MobileDrawer { open: drawer_open }
304        SearchModal {}
305    }
306}
307
308/// Reusable search button component for headers.
309#[component]
310pub fn SearchButton(search_open: Signal<bool>) -> Element {
311    use dioxus_free_icons::icons::ld_icons::LdSearch;
312
313    rsx! {
314        button {
315            class: "dk-search-trigger btn btn-ghost btn-sm gap-2",
316            onclick: move |_| search_open.set(true),
317            Icon { class: "size-4", icon: LdSearch }
318            span { class: "hidden sm:inline text-base-content/60 text-sm", "Search" }
319            kbd { class: "kbd kbd-xs hidden sm:inline-flex", "\u{2318}K" }
320        }
321    }
322}