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 = ®istry.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}