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;
fn initial_tab(registry: &'static DocsRegistry, path: &str) -> String {
registry
.tab_for_path(path)
.or_else(|| registry.nav.tabs.first().cloned())
.unwrap_or_default()
}
#[derive(Clone, Debug)]
pub struct LayoutOffsets {
pub sticky_top: &'static str,
pub scroll_mt: &'static str,
pub sidebar_height: &'static str,
}
#[derive(Clone, Copy)]
pub struct CurrentTheme(pub Signal<String>);
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DocsVariant {
#[default]
Prose,
Reference,
}
impl DocsVariant {
pub fn class(self) -> &'static str {
match self {
DocsVariant::Prose => "dk-variant-prose",
DocsVariant::Reference => "dk-variant-reference",
}
}
}
#[derive(Clone, Copy)]
pub struct DrawerOpen(pub Signal<bool>);
#[derive(Clone, Copy)]
pub struct SearchOpen(pub Signal<bool>);
#[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;
#[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 = ®istry.nav;
let parent_search: Option<SearchOpen> = try_use_context();
let parent_drawer: Option<DrawerOpen> = try_use_context();
let local_search = use_signal(|| false);
let local_drawer = use_signal(|| false);
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);
use_context_provider(|| SearchOpen(search_open));
use_context_provider(|| DrawerOpen(drawer_open));
#[cfg_attr(not(feature = "highlight"), allow(unused_variables))]
let current_theme = super::shared::use_theme_provider(registry.theme.clone());
#[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));
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);
}
});
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",
if let Some(bar) = announcement_bar {
div { class: "dk-announcement-slot", {bar} }
}
if show_header {
div { class: "dk-header sticky top-0 z-50",
if let Some(hdr) = header {
{hdr}
} else {
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 {}
}
}
}
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}"
}
}
}
}
}
}
}
}
}
div { class: "dk-shell flex",
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} }
}
}
}
div { class: "dk-main flex-1 min-w-0",
{children}
}
}
if let Some(ft) = footer {
div { class: "dk-footer-slot", {ft} }
}
}
MobileDrawer { open: drawer_open }
SearchModal {}
}
}
#[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",
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";
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)]);
Box::leak(Box::new(
DocsConfig::new(NAV, map)
.with_openapi("api-reference", SPEC)
.build(),
))
}
#[test]
fn initial_tab_seeds_api_pages_to_their_own_tab() {
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() {
assert_eq!(initial_tab(registry(), ""), "Docs");
assert_eq!(initial_tab(registry(), "nope/nothing"), "Docs");
}
}