dioxus-docs-kit 0.7.0

Reusable documentation site shell for Dioxus applications
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
use dioxus::prelude::*;
#[cfg(feature = "highlight")]
use dioxus_code::CodeTheme;
use dioxus_free_icons::Icon;
use dioxus_free_icons::icons::ld_icons::LdMenu;
#[cfg(feature = "highlight")]
use dioxus_mdx::CodeThemeOverride;

use crate::DocsContext;
#[cfg(feature = "highlight")]
use crate::config::CodeThemeConfig;
use crate::registry::DocsRegistry;

/// Which tab a freshly mounted layout should show.
///
/// Derived from the path rather than defaulting to `tabs[0]`, because the
/// effect that syncs the tab does not run during SSR: a server-rendered
/// API-reference URL would otherwise ship the Docs sidebar to crawlers and
/// visibly flip tabs after hydration. Falls back to the first tab for paths the
/// registry doesn't recognise (e.g. the docs index).
fn initial_tab(registry: &'static DocsRegistry, path: &str) -> String {
    registry
        .tab_for_path(path)
        .or_else(|| registry.nav.tabs.first().cloned())
        .unwrap_or_default()
}

/// Layout offset values computed by `DocsLayout` and consumed by child components
/// (e.g. `DocsPageContent`) via context.
///
/// The values are determined by `show_header` and whether tabs exist, so that the
/// TOC sidebar and heading scroll targets align with the actual header height.
#[derive(Clone, Debug)]
pub struct LayoutOffsets {
    /// Tailwind sticky top class for sidebars/TOC (e.g. `"top-[6.5rem]"`, `"top-16"`, `"top-0"`).
    pub sticky_top: &'static str,
    /// Tailwind scroll-margin-top class for heading anchors (e.g. `"scroll-mt-[6.5rem]"`).
    pub scroll_mt: &'static str,
    /// Tailwind height calc for sidebar (e.g. `"h-[calc(100vh-6.5rem)]"`).
    pub sidebar_height: &'static str,
}

#[derive(Clone, Copy)]
pub struct CurrentTheme(pub Signal<String>);

/// Density preset for the docs layout.
///
/// Consumers target the emitted class (`dk-variant-prose` or `dk-variant-reference`)
/// in their own CSS to further tweak the look. The shipped `theme.css` applies
/// width / type-scale differences out of the box.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum DocsVariant {
    /// Default: wide margins, generous line-height, optimized for long-form prose.
    #[default]
    Prose,
    /// Tight: narrower article column, denser type, better for API/reference docs.
    Reference,
}

impl DocsVariant {
    /// The CSS class appended to `dk-root` for this variant.
    pub fn class(self) -> &'static str {
        match self {
            DocsVariant::Prose => "dk-variant-prose",
            DocsVariant::Reference => "dk-variant-reference",
        }
    }
}

/// Newtype wrapper for the drawer-open signal, so it can't collide with other
/// `Signal<bool>` values in the context system.
///
/// Consumers can provide this before rendering `DocsLayout` to control
/// the mobile drawer from a custom header.
#[derive(Clone, Copy)]
pub struct DrawerOpen(pub Signal<bool>);

/// Newtype wrapper for the search-modal open signal, so it can't collide with
/// other `Signal<bool>` values in the context system.
///
/// Provided by `DocsLayout`/`BlogLayout` (or [`use_docs_providers`](crate::use_docs_providers) /
/// [`use_blog_providers`](crate::use_blog_providers)); consumers can provide it
/// beforehand to control search from a custom header.
#[derive(Clone, Copy)]
pub struct SearchOpen(pub Signal<bool>);

/// Newtype wrapper for the active tab-bar tab, provided by `DocsLayout`.
#[derive(Clone, Copy)]
pub struct ActiveTab(pub Signal<String>);

use super::mobile_drawer::MobileDrawer;
use super::search_modal::SearchModal;
use super::sidebar::DocsSidebar;
use super::theme_toggle::ThemeToggle;

/// Documentation layout shell.
///
/// Renders the tab bar, sidebar, content area, search modal, and mobile drawer.
/// The consumer wraps this in their own navbar via Dioxus route layouts.
///
/// # Context requirements
///
/// - `&'static DocsRegistry` — provided by consumer
/// - `DocsContext` — provided by consumer
///
/// # Props
///
/// - `header`: Optional element to render inside the docs area header (e.g. branding + search button).
///   If not provided, a default header with search button and hamburger is rendered.
/// - `show_header`: Whether to render the internal header area (default header/custom header *and*
///   tab bar). Defaults to `true`. Set to `false` when the consumer provides their own header
///   and tab bar outside of `DocsLayout`.
/// - `announcement_bar`: Optional element rendered at the very top, above `header`.
/// - `sidebar_header`: Optional element rendered above the generated sidebar nav.
/// - `sidebar_footer`: Optional element rendered below the generated sidebar nav
///   (e.g. "Edit this page" links).
/// - `footer`: Optional element rendered at the bottom of the layout (site-wide footer).
/// - `variant`: Density preset (`Prose` default, `Reference` for denser API/reference docs).
/// - `children`: The routed page content (from `Outlet` or explicit child).
///
/// # Stable public classes
///
/// `DocsLayout` tags its structural nodes with semver-stable `dk-*` classes so
/// CSS overrides don't drift: `dk-root`, `dk-docs-root`, `dk-header`, `dk-shell`,
/// `dk-sidebar`, `dk-main`, and slot wrappers like `dk-announcement-slot`.
#[component]
pub fn DocsLayout(
    header: Option<Element>,
    #[props(default = true)] show_header: bool,
    announcement_bar: Option<Element>,
    sidebar_header: Option<Element>,
    sidebar_footer: Option<Element>,
    footer: Option<Element>,
    #[props(default)] variant: DocsVariant,
    children: Element,
) -> Element {
    let registry = use_context::<&'static DocsRegistry>();
    let ctx = use_context::<DocsContext>();
    let nav = &registry.nav;

    // Check if consumer already provided context (lookups, not hooks)
    let parent_search: Option<SearchOpen> = try_use_context();
    let parent_drawer: Option<DrawerOpen> = try_use_context();

    // Always create local fallback signals unconditionally
    let local_search = use_signal(|| false);
    let local_drawer = use_signal(|| false);

    // Use consumer-provided context if available, otherwise local
    let search_open = parent_search.map(|s| s.0).unwrap_or(local_search);
    let mut drawer_open = parent_drawer.map(|d| d.0).unwrap_or(local_drawer);

    // Always provide context for children (SearchModal, MobileDrawer, etc.)
    use_context_provider(|| SearchOpen(search_open));
    use_context_provider(|| DrawerOpen(drawer_open));

    // Theme state: hooks must be called unconditionally
    // `current_theme` only feeds the code-theme override below, which is gated on the
    // `highlight` feature; the hook itself must still run to provide `CurrentTheme`.
    #[cfg_attr(not(feature = "highlight"), allow(unused_variables))]
    let current_theme = super::shared::use_theme_provider(registry.theme.clone());

    // Resolve the code-block syntax theme. When a light/dark toggle is configured, the
    // choice tracks the active `data-theme` so code backgrounds match the site toggle
    // rather than the reader's OS `prefers-color-scheme`. Provided reactively so blocks
    // restyle when the theme switches. (See `CodeThemeOverride` in dioxus-mdx.)
    #[cfg(feature = "highlight")]
    {
        let code_theme_config = registry.code_theme;
        let toggle_dark = registry
            .theme
            .as_ref()
            .and_then(|t| t.toggle_themes.as_ref())
            .map(|(_, dark)| dark.clone());
        let code_theme = use_memo(move || match code_theme_config {
            CodeThemeConfig::Fixed(theme) => CodeTheme::fixed(theme),
            CodeThemeConfig::Adaptive { light, dark } => match &toggle_dark {
                Some(dark_name) if current_theme() == *dark_name => CodeTheme::fixed(dark),
                Some(_) => CodeTheme::fixed(light),
                None => CodeTheme::system(light, dark),
            },
        });
        use_context_provider(|| CodeThemeOverride(code_theme.into()));
    }

    let mut active_tab = use_signal(|| initial_tab(registry, &ctx.current_path.peek()));
    use_context_provider(|| ActiveTab(active_tab));

    // Sync active tab from current path
    let current_path = ctx.current_path;
    let registry_for_effect = registry;
    use_effect(move || {
        let path = current_path();
        if let Some(tab) = registry_for_effect.tab_for_path(&path) {
            active_tab.set(tab);
        }
    });

    // Keyboard shortcut: Cmd/Ctrl+K to toggle search
    super::shared::use_search_hotkey(search_open);

    let has_tabs = nav.has_tabs();
    let offsets = if !show_header {
        LayoutOffsets {
            sticky_top: "top-0",
            scroll_mt: "scroll-mt-0",
            sidebar_height: "h-screen",
        }
    } else if has_tabs {
        LayoutOffsets {
            sticky_top: "top-[6.5rem]",
            scroll_mt: "scroll-mt-[6.5rem]",
            sidebar_height: "h-[calc(100vh-6.5rem)]",
        }
    } else {
        LayoutOffsets {
            sticky_top: "top-16",
            scroll_mt: "scroll-mt-16",
            sidebar_height: "h-[calc(100vh-4rem)]",
        }
    };
    use_context_provider(|| offsets.clone());

    rsx! {
        div { class: "dk-root dk-docs-root {variant.class()} min-h-screen bg-base-100",
            // Optional announcement bar (rendered above everything)
            if let Some(bar) = announcement_bar {
                div { class: "dk-announcement-slot", {bar} }
            }

            // Top area
            if show_header {
                div { class: "dk-header sticky top-0 z-50",
                    // Header (consumer-provided or default)
                    if let Some(hdr) = header {
                        {hdr}
                    } else {
                        // Default minimal header
                        div { class: "navbar bg-base-200 border-b border-base-300 px-4 lg:px-8",
                            div { class: "flex-1 gap-2",
                                button {
                                    class: "btn btn-ghost btn-sm btn-square lg:hidden",
                                    onclick: move |_| drawer_open.toggle(),
                                    Icon { class: "size-5", icon: LdMenu }
                                }
                            }
                            div { class: "flex-none gap-1",
                                SearchButton { search_open }
                                ThemeToggle {}
                            }
                        }
                    }

                    // Tab bar (below header)
                    if has_tabs {
                        div { class: "dk-tabs bg-base-200/80 backdrop-blur border-b border-base-300 px-4 lg:px-8",
                            div { class: "flex gap-6",
                                for tab in nav.tabs.iter() {
                                    {
                                        let is_active = *tab == active_tab();
                                        let tab_clone = tab.clone();
                                        let style = if is_active {
                                            "dk-tab-active text-primary border-b-2 border-primary font-medium"
                                        } else {
                                            "text-base-content/60 hover:text-base-content border-b-2 border-transparent"
                                        };
                                        rsx! {
                                            button {
                                                class: "dk-tab px-1 py-2.5 text-sm transition-colors -mb-px {style}",
                                                onclick: move |_| {
                                                    active_tab.set(tab_clone.clone());
                                                    let groups = nav.groups_for_tab(&tab_clone);
                                                    if let Some(first_page) = groups.first().and_then(|g| g.pages.first()) {
                                                        (ctx.navigate)(first_page.clone());
                                                    }
                                                },
                                                "{tab}"
                                            }
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }

            // Main docs content with sidebar
            div { class: "dk-shell flex",
                // Sidebar
                aside { class: "dk-sidebar w-64 shrink-0 border-r border-base-300 bg-base-200/30 hidden lg:block",
                    div { class: "sticky {offsets.sticky_top} {offsets.sidebar_height} overflow-y-auto p-6 flex flex-col gap-6",
                        if let Some(sh) = sidebar_header {
                            div { class: "dk-sidebar-header-slot", {sh} }
                        }
                        DocsSidebar {}
                        if let Some(sf) = sidebar_footer {
                            div { class: "dk-sidebar-footer-slot mt-auto pt-4", {sf} }
                        }
                    }
                }

                // Main content area
                div { class: "dk-main flex-1 min-w-0",
                    {children}
                }
            }

            // Optional site-wide footer
            if let Some(ft) = footer {
                div { class: "dk-footer-slot", {ft} }
            }
        }

        // Overlays
        MobileDrawer { open: drawer_open }
        SearchModal {}
    }
}

/// Reusable search button component for headers.
#[component]
pub fn SearchButton(search_open: Signal<bool>) -> Element {
    use dioxus_free_icons::icons::ld_icons::LdSearch;

    rsx! {
        button {
            class: "dk-search-trigger btn btn-ghost btn-sm gap-2",
            r#type: "button",
            aria_label: "Search",
            aria_haspopup: "dialog",
            onclick: move |_| search_open.set(true),
            Icon { class: "size-4", icon: LdSearch }
            span { class: "hidden sm:inline text-base-content/60 text-sm", "Search" }
            kbd { class: "kbd kbd-xs hidden sm:inline-flex", "\u{2318}K" }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::initial_tab;
    use crate::config::DocsConfig;
    use crate::registry::DocsRegistry;
    use std::collections::HashMap;

    const NAV: &str = r#"{
        "tabs": ["Docs", "API Reference"],
        "groups": [
            { "group": "Getting Started", "tab": "Docs", "pages": ["getting-started/intro"] },
            { "group": "API Reference", "tab": "API Reference", "pages": [] }
        ]
    }"#;

    const INTRO: &str = "---\ntitle: Intro\n---\n\nWelcome.\n";

    #[cfg(feature = "openapi")]
    const SPEC: &str = r#"
openapi: "3.0.0"
info:
  title: Pets API
  version: "1.0.0"
paths:
  /pets:
    get:
      operationId: listPets
      summary: List pets
      responses:
        "200":
          description: OK
"#;

    fn registry() -> &'static DocsRegistry {
        let map = HashMap::from([("getting-started/intro", INTRO)]);
        let config = DocsConfig::new(NAV, map);
        #[cfg(feature = "openapi")]
        let config = config.with_openapi("api-reference", SPEC);
        Box::leak(Box::new(config.build()))
    }

    #[test]
    #[cfg(feature = "openapi")]
    fn initial_tab_seeds_api_pages_to_their_own_tab() {
        // The regression: this used to seed tabs[0] ("Docs") and was only
        // corrected by an effect, which never runs during SSR - so crawlers got
        // the Docs sidebar on an API URL.
        assert_eq!(
            initial_tab(registry(), "api-reference/list-pets"),
            "API Reference"
        );
    }

    #[test]
    fn initial_tab_seeds_doc_pages_to_the_docs_tab() {
        assert_eq!(initial_tab(registry(), "getting-started/intro"), "Docs");
    }

    #[test]
    fn initial_tab_falls_back_to_the_first_tab_for_unknown_paths() {
        // e.g. the docs index, which has no owning group.
        assert_eq!(initial_tab(registry(), ""), "Docs");
        assert_eq!(initial_tab(registry(), "nope/nothing"), "Docs");
    }
}