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)]
52pub enum DocsVariant {
53 /// Default: wide margins, generous line-height, optimized for long-form prose.
54 #[default]
55 Prose,
56 /// Tight: narrower article column, denser type, better for API/reference docs.
57 Reference,
58}
59
60impl DocsVariant {
61 /// The CSS class appended to `dk-root` for this variant.
62 pub fn class(self) -> &'static str {
63 match self {
64 DocsVariant::Prose => "dk-variant-prose",
65 DocsVariant::Reference => "dk-variant-reference",
66 }
67 }
68}
69
70/// Newtype wrapper for the drawer-open signal, so it can't collide with other
71/// `Signal<bool>` values in the context system.
72///
73/// Consumers can provide this before rendering `DocsLayout` to control
74/// the mobile drawer from a custom header.
75#[derive(Clone, Copy)]
76pub struct DrawerOpen(pub Signal<bool>);
77
78/// Newtype wrapper for the search-modal open signal, so it can't collide with
79/// other `Signal<bool>` values in the context system.
80///
81/// Provided by `DocsLayout`/`BlogLayout` (or [`use_docs_providers`](crate::use_docs_providers) /
82/// [`use_blog_providers`](crate::use_blog_providers)); consumers can provide it
83/// beforehand to control search from a custom header.
84#[derive(Clone, Copy)]
85pub struct SearchOpen(pub Signal<bool>);
86
87/// Newtype wrapper for the active tab-bar tab, provided by `DocsLayout`.
88#[derive(Clone, Copy)]
89pub struct ActiveTab(pub Signal<String>);
90
91use super::mobile_drawer::MobileDrawer;
92use super::search_modal::SearchModal;
93use super::sidebar::DocsSidebar;
94use super::theme_toggle::ThemeToggle;
95
96/// Documentation layout shell.
97///
98/// Renders the tab bar, sidebar, content area, search modal, and mobile drawer.
99/// The consumer wraps this in their own navbar via Dioxus route layouts.
100///
101/// # Context requirements
102///
103/// - `&'static DocsRegistry` — provided by consumer
104/// - `DocsContext` — provided by consumer
105///
106/// # Props
107///
108/// - `header`: Optional element to render inside the docs area header (e.g. branding + search button).
109/// If not provided, a default header with search button and hamburger is rendered.
110/// - `show_header`: Whether to render the internal header area (default header/custom header *and*
111/// tab bar). Defaults to `true`. Set to `false` when the consumer provides their own header
112/// and tab bar outside of `DocsLayout`.
113/// - `announcement_bar`: Optional element rendered at the very top, above `header`.
114/// - `sidebar_header`: Optional element rendered above the generated sidebar nav.
115/// - `sidebar_footer`: Optional element rendered below the generated sidebar nav
116/// (e.g. "Edit this page" links).
117/// - `footer`: Optional element rendered at the bottom of the layout (site-wide footer).
118/// - `variant`: Density preset (`Prose` default, `Reference` for denser API/reference docs).
119/// - `children`: The routed page content (from `Outlet` or explicit child).
120///
121/// # Stable public classes
122///
123/// `DocsLayout` tags its structural nodes with semver-stable `dk-*` classes so
124/// CSS overrides don't drift: `dk-root`, `dk-docs-root`, `dk-header`, `dk-shell`,
125/// `dk-sidebar`, `dk-main`, and slot wrappers like `dk-announcement-slot`.
126#[component]
127pub fn DocsLayout(
128 header: Option<Element>,
129 #[props(default = true)] show_header: bool,
130 announcement_bar: Option<Element>,
131 sidebar_header: Option<Element>,
132 sidebar_footer: Option<Element>,
133 footer: Option<Element>,
134 #[props(default)] variant: DocsVariant,
135 children: Element,
136) -> Element {
137 let registry = use_context::<&'static DocsRegistry>();
138 let ctx = use_context::<DocsContext>();
139 let nav = ®istry.nav;
140
141 // Check if consumer already provided context (lookups, not hooks)
142 let parent_search: Option<SearchOpen> = try_use_context();
143 let parent_drawer: Option<DrawerOpen> = try_use_context();
144
145 // Always create local fallback signals unconditionally
146 let local_search = use_signal(|| false);
147 let local_drawer = use_signal(|| false);
148
149 // Use consumer-provided context if available, otherwise local
150 let search_open = parent_search.map(|s| s.0).unwrap_or(local_search);
151 let mut drawer_open = parent_drawer.map(|d| d.0).unwrap_or(local_drawer);
152
153 // Always provide context for children (SearchModal, MobileDrawer, etc.)
154 use_context_provider(|| SearchOpen(search_open));
155 use_context_provider(|| DrawerOpen(drawer_open));
156
157 // Theme state: hooks must be called unconditionally
158 // `current_theme` only feeds the code-theme override below, which is gated on the
159 // `highlight` feature; the hook itself must still run to provide `CurrentTheme`.
160 #[cfg_attr(not(feature = "highlight"), allow(unused_variables))]
161 let current_theme = super::shared::use_theme_provider(registry.theme.clone());
162
163 // Resolve the code-block syntax theme. When a light/dark toggle is configured, the
164 // choice tracks the active `data-theme` so code backgrounds match the site toggle
165 // rather than the reader's OS `prefers-color-scheme`. Provided reactively so blocks
166 // restyle when the theme switches. (See `CodeThemeOverride` in dioxus-mdx.)
167 #[cfg(feature = "highlight")]
168 {
169 let code_theme_config = registry.code_theme;
170 let toggle_dark = registry
171 .theme
172 .as_ref()
173 .and_then(|t| t.toggle_themes.as_ref())
174 .map(|(_, dark)| dark.clone());
175 let code_theme = use_memo(move || match code_theme_config {
176 CodeThemeConfig::Fixed(theme) => CodeTheme::fixed(theme),
177 CodeThemeConfig::Adaptive { light, dark } => match &toggle_dark {
178 Some(dark_name) if current_theme() == *dark_name => CodeTheme::fixed(dark),
179 Some(_) => CodeTheme::fixed(light),
180 None => CodeTheme::system(light, dark),
181 },
182 });
183 use_context_provider(|| CodeThemeOverride(code_theme.into()));
184 }
185
186 let mut active_tab = use_signal(|| initial_tab(registry, &ctx.current_path.peek()));
187 use_context_provider(|| ActiveTab(active_tab));
188
189 // Sync active tab from current path
190 let current_path = ctx.current_path;
191 let registry_for_effect = registry;
192 use_effect(move || {
193 let path = current_path();
194 if let Some(tab) = registry_for_effect.tab_for_path(&path) {
195 active_tab.set(tab);
196 }
197 });
198
199 // Keyboard shortcut: Cmd/Ctrl+K to toggle search
200 super::shared::use_search_hotkey(search_open);
201
202 let has_tabs = nav.has_tabs();
203 let offsets = if !show_header {
204 LayoutOffsets {
205 sticky_top: "top-0",
206 scroll_mt: "scroll-mt-0",
207 sidebar_height: "h-screen",
208 }
209 } else if has_tabs {
210 LayoutOffsets {
211 sticky_top: "top-[6.5rem]",
212 scroll_mt: "scroll-mt-[6.5rem]",
213 sidebar_height: "h-[calc(100vh-6.5rem)]",
214 }
215 } else {
216 LayoutOffsets {
217 sticky_top: "top-16",
218 scroll_mt: "scroll-mt-16",
219 sidebar_height: "h-[calc(100vh-4rem)]",
220 }
221 };
222 use_context_provider(|| offsets.clone());
223
224 rsx! {
225 div { class: "dk-root dk-docs-root {variant.class()} min-h-screen bg-base-100",
226 // Optional announcement bar (rendered above everything)
227 if let Some(bar) = announcement_bar {
228 div { class: "dk-announcement-slot", {bar} }
229 }
230
231 // Top area
232 if show_header {
233 div { class: "dk-header sticky top-0 z-50",
234 // Header (consumer-provided or default)
235 if let Some(hdr) = header {
236 {hdr}
237 } else {
238 // Default minimal header
239 div { class: "navbar bg-base-200 border-b border-base-300 px-4 lg:px-8",
240 div { class: "flex-1 gap-2",
241 button {
242 class: "btn btn-ghost btn-sm btn-square lg:hidden",
243 onclick: move |_| drawer_open.toggle(),
244 Icon { class: "size-5", icon: LdMenu }
245 }
246 }
247 div { class: "flex-none gap-1",
248 SearchButton { search_open }
249 ThemeToggle {}
250 }
251 }
252 }
253
254 // Tab bar (below header)
255 if has_tabs {
256 div { class: "dk-tabs bg-base-200/80 backdrop-blur border-b border-base-300 px-4 lg:px-8",
257 div { class: "flex gap-6",
258 for tab in nav.tabs.iter() {
259 {
260 let is_active = *tab == active_tab();
261 let tab_clone = tab.clone();
262 let style = if is_active {
263 "dk-tab-active text-primary border-b-2 border-primary font-medium"
264 } else {
265 "text-base-content/60 hover:text-base-content border-b-2 border-transparent"
266 };
267 rsx! {
268 button {
269 class: "dk-tab px-1 py-2.5 text-sm transition-colors -mb-px {style}",
270 onclick: move |_| {
271 active_tab.set(tab_clone.clone());
272 let groups = nav.groups_for_tab(&tab_clone);
273 if let Some(first_page) = groups.first().and_then(|g| g.pages.first()) {
274 (ctx.navigate)(first_page.clone());
275 }
276 },
277 "{tab}"
278 }
279 }
280 }
281 }
282 }
283 }
284 }
285 }
286 }
287
288 // Main docs content with sidebar
289 div { class: "dk-shell flex",
290 // Sidebar
291 aside { class: "dk-sidebar w-64 shrink-0 border-r border-base-300 bg-base-200/30 hidden lg:block",
292 div { class: "sticky {offsets.sticky_top} {offsets.sidebar_height} overflow-y-auto p-6 flex flex-col gap-6",
293 if let Some(sh) = sidebar_header {
294 div { class: "dk-sidebar-header-slot", {sh} }
295 }
296 DocsSidebar {}
297 if let Some(sf) = sidebar_footer {
298 div { class: "dk-sidebar-footer-slot mt-auto pt-4", {sf} }
299 }
300 }
301 }
302
303 // Main content area
304 div { class: "dk-main flex-1 min-w-0",
305 {children}
306 }
307 }
308
309 // Optional site-wide footer
310 if let Some(ft) = footer {
311 div { class: "dk-footer-slot", {ft} }
312 }
313 }
314
315 // Overlays
316 MobileDrawer { open: drawer_open }
317 SearchModal {}
318 }
319}
320
321/// Reusable search button component for headers.
322#[component]
323pub fn SearchButton(search_open: Signal<bool>) -> Element {
324 use dioxus_free_icons::icons::ld_icons::LdSearch;
325
326 rsx! {
327 button {
328 class: "dk-search-trigger btn btn-ghost btn-sm gap-2",
329 onclick: move |_| search_open.set(true),
330 Icon { class: "size-4", icon: LdSearch }
331 span { class: "hidden sm:inline text-base-content/60 text-sm", "Search" }
332 kbd { class: "kbd kbd-xs hidden sm:inline-flex", "\u{2318}K" }
333 }
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::initial_tab;
340 use crate::config::DocsConfig;
341 use crate::registry::DocsRegistry;
342 use std::collections::HashMap;
343
344 const NAV: &str = r#"{
345 "tabs": ["Docs", "API Reference"],
346 "groups": [
347 { "group": "Getting Started", "tab": "Docs", "pages": ["getting-started/intro"] },
348 { "group": "API Reference", "tab": "API Reference", "pages": [] }
349 ]
350 }"#;
351
352 const INTRO: &str = "---\ntitle: Intro\n---\n\nWelcome.\n";
353
354 const SPEC: &str = r#"
355openapi: "3.0.0"
356info:
357 title: Pets API
358 version: "1.0.0"
359paths:
360 /pets:
361 get:
362 operationId: listPets
363 summary: List pets
364 responses:
365 "200":
366 description: OK
367"#;
368
369 fn registry() -> &'static DocsRegistry {
370 let map = HashMap::from([("getting-started/intro", INTRO)]);
371 Box::leak(Box::new(
372 DocsConfig::new(NAV, map)
373 .with_openapi("api-reference", SPEC)
374 .build(),
375 ))
376 }
377
378 #[test]
379 fn initial_tab_seeds_api_pages_to_their_own_tab() {
380 // The regression: this used to seed tabs[0] ("Docs") and was only
381 // corrected by an effect, which never runs during SSR - so crawlers got
382 // the Docs sidebar on an API URL.
383 assert_eq!(
384 initial_tab(registry(), "api-reference/list-pets"),
385 "API Reference"
386 );
387 }
388
389 #[test]
390 fn initial_tab_seeds_doc_pages_to_the_docs_tab() {
391 assert_eq!(initial_tab(registry(), "getting-started/intro"), "Docs");
392 }
393
394 #[test]
395 fn initial_tab_falls_back_to_the_first_tab_for_unknown_paths() {
396 // e.g. the docs index, which has no owning group.
397 assert_eq!(initial_tab(registry(), ""), "Docs");
398 assert_eq!(initial_tab(registry(), "nope/nothing"), "Docs");
399 }
400}