diff --git a/.changeset/soft-sloths-draw.md b/.changeset/soft-sloths-draw.md new file mode 100644 index 000000000..5851d0b1f --- /dev/null +++ b/.changeset/soft-sloths-draw.md @@ -0,0 +1,6 @@ +--- +'fumadocs-openapi': patch +'fumadocs-ui': patch +--- + +Expose `--fd-tocnav-height` CSS variable diff --git a/packages/openapi/src/ui/index.tsx b/packages/openapi/src/ui/index.tsx index a17d188ee..de22176e5 100644 --- a/packages/openapi/src/ui/index.tsx +++ b/packages/openapi/src/ui/index.tsx @@ -66,13 +66,13 @@ export function API({ children, ...props }: HTMLAttributes) {
) { + const { toc } = usePageStyles(); + return (
) { } as object } > - {props.children} +
+ {props.children} +
); } diff --git a/packages/ui/src/components/registry.ts b/packages/ui/src/components/registry.ts index 20f5122ec..0b7fd290c 100644 --- a/packages/ui/src/components/registry.ts +++ b/packages/ui/src/components/registry.ts @@ -6,6 +6,7 @@ const contextsMap = { '../contexts/search.tsx': 'fumadocs-ui/provider', '../contexts/tree.tsx': 'fumadocs-ui/provider', '../contexts/i18n.tsx': 'fumadocs-ui/provider', + '../contexts/layout.tsx': 'fumadocs-ui/provider', }; export const registry: Registry = { diff --git a/packages/ui/src/contexts/layout.tsx b/packages/ui/src/contexts/layout.tsx new file mode 100644 index 000000000..43e6d2784 --- /dev/null +++ b/packages/ui/src/contexts/layout.tsx @@ -0,0 +1,30 @@ +'use client'; +import { createContext, type ReactNode, useContext } from 'react'; + +export interface PageStyles { + tocNav?: string; + toc?: string; + page?: string; + article?: string; +} + +/** + * applied styles to different layout components in `Page` from layouts + */ +const StylesContext = createContext({ + tocNav: 'xl:hidden', + toc: 'max-xl:hidden', +}); + +export function usePageStyles() { + return useContext(StylesContext); +} + +export function StylesProvider({ + children, + ...value +}: PageStyles & { children: ReactNode }) { + return ( + {children} + ); +} diff --git a/packages/ui/src/contexts/sidebar.tsx b/packages/ui/src/contexts/sidebar.tsx index c7df8fda7..c495b0128 100644 --- a/packages/ui/src/contexts/sidebar.tsx +++ b/packages/ui/src/contexts/sidebar.tsx @@ -4,8 +4,8 @@ import { useState, useMemo, useRef, - type MutableRefObject, type ReactNode, + type RefObject, } from 'react'; import { usePathname } from 'next/navigation'; import { SidebarProvider as BaseProvider } from 'fumadocs-core/sidebar'; @@ -20,7 +20,7 @@ interface SidebarContext { /** * When set to false, don't close the sidebar when navigate to another page */ - closeOnRedirect: MutableRefObject; + closeOnRedirect: RefObject; } const SidebarContext = createContext(undefined); diff --git a/packages/ui/src/contexts/tree.tsx b/packages/ui/src/contexts/tree.tsx index b3a68913e..81d0d1510 100644 --- a/packages/ui/src/contexts/tree.tsx +++ b/packages/ui/src/contexts/tree.tsx @@ -1,20 +1,14 @@ 'use client'; import type { PageTree } from 'fumadocs-core/server'; import { usePathname } from 'next/navigation'; -import { - createContext, - useContext, - type ReactNode, - useMemo, - useRef, -} from 'react'; +import { createContext, useContext, type ReactNode, useMemo } from 'react'; import { searchPath } from 'fumadocs-core/breadcrumb'; interface TreeContextType { root: PageTree.Root | PageTree.Folder; } -const TreeContext = createContext(undefined); +const TreeContext = createContext(null); const PathContext = createContext([]); export function TreeContextProvider({ @@ -32,8 +26,6 @@ export function TreeContextProvider({ const root = (path.findLast((item) => item.type === 'folder' && item.root) ?? tree) as PageTree.Root; - const pathnameRef = useRef(pathname); - pathnameRef.current = pathname; return ( ({ root }), [root])}> diff --git a/packages/ui/src/layouts/docs.client.tsx b/packages/ui/src/layouts/docs.client.tsx index 919258b92..9f243f33b 100644 --- a/packages/ui/src/layouts/docs.client.tsx +++ b/packages/ui/src/layouts/docs.client.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ChevronDown } from 'lucide-react'; +import { ChevronDown, Menu, X } from 'lucide-react'; import { type ButtonHTMLAttributes, type HTMLAttributes, @@ -23,23 +23,8 @@ import { import { cva } from 'class-variance-authority'; import { buttonVariants } from '@/components/ui/button'; import { useSidebar } from '@/contexts/sidebar'; - -export function LayoutBody(props: HTMLAttributes) { - const { collapsed } = useSidebar(); - - return ( -
- {props.children} -
- ); -} +import { useNav } from '@/components/layout/nav'; +import { SidebarTrigger } from 'fumadocs-core/sidebar'; const itemVariants = cva( 'flex flex-row items-center gap-2 rounded-md px-3 py-2.5 text-fd-muted-foreground transition-colors duration-100 [overflow-wrap:anywhere] hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none md:px-2 md:py-1.5 [&_svg]:size-4', @@ -69,11 +54,51 @@ export function LinksMenu({ items, ...props }: LinksMenuProps) { ); } +export function Navbar(props: HTMLAttributes) { + const { open } = useSidebar(); + const { isTransparent } = useNav(); + + return ( +
+ {props.children} +
+ ); +} + +export function NavbarSidebarTrigger( + props: ButtonHTMLAttributes, +) { + const { open } = useSidebar(); + + return ( + + {open ? : } + + ); +} + interface MenuItemProps extends HTMLAttributes { item: LinkItemType; } -export function MenuItem({ item, ...props }: MenuItemProps) { +function MenuItem({ item, ...props }: MenuItemProps) { if (item.type === 'custom') return (
diff --git a/packages/ui/src/layouts/docs.tsx b/packages/ui/src/layouts/docs.tsx index 169bc1c9a..fc27554d3 100644 --- a/packages/ui/src/layouts/docs.tsx +++ b/packages/ui/src/layouts/docs.tsx @@ -27,11 +27,10 @@ import { LanguageToggle, LanguageToggleText, } from '@/components/layout/language-toggle'; -import { LayoutBody, LinksMenu } from '@/layouts/docs.client'; +import { LinksMenu, Navbar, NavbarSidebarTrigger } from '@/layouts/docs.client'; import { TreeContextProvider } from '@/contexts/tree'; import { NavProvider, Title } from '@/components/layout/nav'; import { ThemeToggle } from '@/components/layout/theme-toggle'; -import { Navbar, NavbarSidebarTrigger } from '@/layouts/docs/navbar'; import { LargeSearchToggle, SearchToggle, @@ -39,9 +38,11 @@ import { import { SearchOnly } from '@/contexts/search'; import { getSidebarTabsFromOptions, + layoutVariables, SidebarLinkItem, type SidebarOptions, } from '@/layouts/docs/shared'; +import { type PageStyles, StylesProvider } from '@/contexts/layout'; export interface DocsLayoutProps extends BaseLayoutProps { tree: PageTree.Root; @@ -76,13 +77,24 @@ export function DocsLayout({ if (props.tree === undefined) notFound(); const tabs = getSidebarTabsFromOptions(tabOptions, props.tree) ?? []; + const variables = cn( + '[--fd-tocnav-height:36px] md:[--fd-sidebar-width:260px] xl:[--fd-toc-width:260px] xl:[--fd-tocnav-height:0px]', + !navReplace && navEnabled + ? '[--fd-nav-height:3.5rem] md:[--fd-nav-height:0px]' + : undefined, + ); + + const pageStyles: PageStyles = { + tocNav: cn('xl:hidden'), + toc: cn('max-xl:hidden'), + }; return ( {replaceOrDefault( { enabled: navEnabled, component: navReplace }, - + <div className="flex flex-1 flex-row items-center gap-1"> {nav.children} @@ -94,16 +106,18 @@ export function DocsLayout({ </Navbar>, nav, )} - <LayoutBody + <main id="nd-docs-layout" {...props.containerProps} className={cn( - 'flex flex-1 flex-row md:[--fd-sidebar-width:260px] xl:[--fd-toc-width:260px] [&_#nd-toc]:max-xl:hidden [&_#nd-tocnav]:xl:hidden', - !navReplace && navEnabled - ? '[--fd-nav-height:3.5rem] md:[--fd-nav-height:0px]' - : null, + 'flex flex-1 flex-row pe-[var(--fd-layout-offset)]', + variables, props.containerProps?.className, )} + style={{ + ...layoutVariables, + ...props.containerProps?.style, + }} > {collapsible ? ( <SidebarCollapseTrigger className="fixed bottom-3 start-2 z-40 transition-opacity data-[collapsed=false]:pointer-events-none data-[collapsed=false]:opacity-0 max-md:hidden" /> @@ -112,7 +126,10 @@ export function DocsLayout({ { enabled: sidebarEnabled, component: sidebarReplace }, <Aside {...sidebar} - className="md:flex-1 md:data-[collapsed=true]:flex-initial" + className={cn( + 'md:ps-[var(--fd-layout-offset)]', + sidebar.className, + )} > <SidebarHeader> <SidebarHeaderItems {...nav} links={links} /> @@ -151,8 +168,8 @@ export function DocsLayout({ tabs, }, )} - {props.children} - </LayoutBody> + <StylesProvider {...pageStyles}>{props.children}</StylesProvider> + </main> </NavProvider> </TreeContextProvider> ); diff --git a/packages/ui/src/layouts/docs/navbar.tsx b/packages/ui/src/layouts/docs/navbar.tsx deleted file mode 100644 index 422cc68ce..000000000 --- a/packages/ui/src/layouts/docs/navbar.tsx +++ /dev/null @@ -1,47 +0,0 @@ -'use client'; -import { useSidebar } from '@/contexts/sidebar'; -import { type ButtonHTMLAttributes, type HTMLAttributes } from 'react'; -import { useNav } from '@/components/layout/nav'; -import { cn } from '@/utils/cn'; -import { SidebarTrigger } from 'fumadocs-core/sidebar'; -import { buttonVariants } from '@/components/ui/button'; -import { Menu, X } from 'lucide-react'; - -export function Navbar(props: HTMLAttributes<HTMLElement>) { - const { open } = useSidebar(); - const { isTransparent } = useNav(); - - return ( - <header - {...props} - className={cn( - 'sticky top-[var(--fd-banner-height)] z-30 flex flex-row items-center border-b border-fd-foreground/10 px-4 backdrop-blur-lg transition-colors', - (!isTransparent || open) && 'bg-fd-background/80', - props.className, - )} - > - {props.children} - </header> - ); -} - -export function NavbarSidebarTrigger( - props: ButtonHTMLAttributes<HTMLButtonElement>, -) { - const { open } = useSidebar(); - - return ( - <SidebarTrigger - {...props} - className={cn( - buttonVariants({ - color: 'ghost', - size: 'icon', - }), - props.className, - )} - > - {open ? <X /> : <Menu />} - </SidebarTrigger> - ); -} diff --git a/packages/ui/src/layouts/docs/shared.tsx b/packages/ui/src/layouts/docs/shared.tsx index d9062cef9..d7e94093a 100644 --- a/packages/ui/src/layouts/docs/shared.tsx +++ b/packages/ui/src/layouts/docs/shared.tsx @@ -14,6 +14,10 @@ import { getSidebarTabs, type TabOptions } from '@/utils/get-sidebar-tabs'; import type { FC, ReactNode } from 'react'; import type { Option } from '@/components/layout/root-toggle'; +export const layoutVariables = { + '--fd-layout-offset': 'max(calc(50vw - var(--fd-layout-width) / 2), 0px)', +}; + export interface SidebarOptions extends SidebarProps { enabled: boolean; component: ReactNode; diff --git a/packages/ui/src/layouts/home.tsx b/packages/ui/src/layouts/home.tsx index 53266df44..52c6a0479 100644 --- a/packages/ui/src/layouts/home.tsx +++ b/packages/ui/src/layouts/home.tsx @@ -35,30 +35,26 @@ import { MenuLinkItem } from '@/layouts/home/menu'; export type HomeLayoutProps = BaseLayoutProps & HTMLAttributes<HTMLElement>; -export function HomeLayout({ - nav: { transparentMode, enableSearch = true, ...nav } = {}, - links = [], - githubUrl, - i18n = false, - disableThemeSwitch, - ...props -}: HomeLayoutProps): ReactNode { +export function HomeLayout(props: HomeLayoutProps) { + const { + nav, + links, + githubUrl, + i18n: _i18n, + disableThemeSwitch: _disableThemeSwitch, + ...rest + } = props; + const finalLinks = getLinks(links, githubUrl); - const navItems = finalLinks.filter((item) => - ['nav', 'all'].includes(item.on ?? 'all'), - ); - const menuItems = finalLinks.filter((item) => - ['menu', 'all'].includes(item.on ?? 'all'), - ); return ( - <NavProvider transparentMode={transparentMode}> + <NavProvider transparentMode={nav?.transparentMode}> <main id="nd-home-layout" - {...props} + {...rest} className={cn( 'flex flex-1 flex-col pt-[var(--fd-nav-height)] [--fd-nav-height:56px]', - props.className, + rest.className, )} > {replaceOrDefault( @@ -71,85 +67,10 @@ export function HomeLayout({ maskImage: 'linear-gradient(to bottom,white,transparent)', }} /> - <Navbar> - <Title title={nav.title} url={nav.url} /> - {nav.children} - <NavigationMenuList className="flex flex-row items-center gap-2 max-sm:hidden"> - {navItems - .filter((item) => !isSecondary(item)) - .map((item, i) => ( - <NavbarLinkItem key={i} item={item} className="text-sm" /> - ))} - </NavigationMenuList> - <div className="flex flex-1 flex-row items-center justify-end lg:gap-1.5"> - {enableSearch ? ( - <SearchOnly> - <SearchToggle className="lg:hidden" /> - <LargeSearchToggle className="w-full max-w-[240px] max-lg:hidden" /> - </SearchOnly> - ) : null} - {!disableThemeSwitch ? ( - <ThemeToggle className="max-lg:hidden" /> - ) : null} - {i18n ? ( - <LanguageToggle className="-me-1.5 max-lg:hidden"> - <Languages className="size-5" /> - </LanguageToggle> - ) : null} - {navItems.filter(isSecondary).map((item, i) => ( - <NavbarLinkItem - key={i} - item={item} - className="-me-1.5 list-none max-lg:hidden" - /> - ))} - <NavigationMenuItem className="list-none lg:hidden"> - <NavigationMenuTrigger - className={cn( - buttonVariants({ - size: 'icon', - color: 'ghost', - }), - 'group -me-2', - )} - > - <ChevronDown className="size-3 transition-transform duration-300 group-data-[state=open]:rotate-180" /> - </NavigationMenuTrigger> - <NavigationMenuContent className="flex flex-col p-4 sm:flex-row sm:items-center sm:justify-end"> - {menuItems - .filter((item) => !isSecondary(item)) - .map((item, i) => ( - <MenuLinkItem - key={i} - item={item} - className="sm:hidden" - /> - ))} - <div className="-ms-1.5 flex flex-row items-center gap-1.5 max-sm:mt-2"> - {menuItems.filter(isSecondary).map((item, i) => ( - <MenuLinkItem key={i} item={item} className="-me-1.5" /> - ))} - <div role="separator" className="flex-1" /> - {i18n ? ( - <LanguageToggle> - <Languages className="size-5" /> - <LanguageToggleText /> - <ChevronDown className="size-3 text-fd-muted-foreground" /> - </LanguageToggle> - ) : null} - {!disableThemeSwitch ? <ThemeToggle /> : null} - </div> - </NavigationMenuContent> - </NavigationMenuItem> - </div> - </Navbar> + <Header finalLinks={finalLinks} {...props} /> </>, { items: finalLinks, - i18n, - enableSearch, - disableThemeSwitch, - ...nav, }, )} {props.children} @@ -158,6 +79,91 @@ export function HomeLayout({ ); } +function Header({ + nav: { enableSearch = true, ...nav } = {}, + i18n = false, + finalLinks, + disableThemeSwitch, +}: BaseLayoutProps & { + finalLinks: LinkItemType[]; +}) { + const navItems = finalLinks.filter((item) => + ['nav', 'all'].includes(item.on ?? 'all'), + ); + const menuItems = finalLinks.filter((item) => + ['menu', 'all'].includes(item.on ?? 'all'), + ); + + return ( + <Navbar> + <Title title={nav.title} url={nav.url} /> + {nav.children} + <NavigationMenuList className="flex flex-row items-center gap-2 max-sm:hidden"> + {navItems + .filter((item) => !isSecondary(item)) + .map((item, i) => ( + <NavbarLinkItem key={i} item={item} className="text-sm" /> + ))} + </NavigationMenuList> + <div className="flex flex-1 flex-row items-center justify-end lg:gap-1.5"> + {enableSearch ? ( + <SearchOnly> + <SearchToggle className="lg:hidden" /> + <LargeSearchToggle className="w-full max-w-[240px] max-lg:hidden" /> + </SearchOnly> + ) : null} + {!disableThemeSwitch ? <ThemeToggle className="max-lg:hidden" /> : null} + {i18n ? ( + <LanguageToggle className="-me-1.5 max-lg:hidden"> + <Languages className="size-5" /> + </LanguageToggle> + ) : null} + {navItems.filter(isSecondary).map((item, i) => ( + <NavbarLinkItem + key={i} + item={item} + className="-me-1.5 list-none max-lg:hidden" + /> + ))} + <NavigationMenuItem className="list-none lg:hidden"> + <NavigationMenuTrigger + className={cn( + buttonVariants({ + size: 'icon', + color: 'ghost', + }), + 'group -me-2', + )} + > + <ChevronDown className="size-3 transition-transform duration-300 group-data-[state=open]:rotate-180" /> + </NavigationMenuTrigger> + <NavigationMenuContent className="flex flex-col p-4 sm:flex-row sm:items-center sm:justify-end"> + {menuItems + .filter((item) => !isSecondary(item)) + .map((item, i) => ( + <MenuLinkItem key={i} item={item} className="sm:hidden" /> + ))} + <div className="-ms-1.5 flex flex-row items-center gap-1.5 max-sm:mt-2"> + {menuItems.filter(isSecondary).map((item, i) => ( + <MenuLinkItem key={i} item={item} className="-me-1.5" /> + ))} + <div role="separator" className="flex-1" /> + {i18n ? ( + <LanguageToggle> + <Languages className="size-5" /> + <LanguageToggleText /> + <ChevronDown className="size-3 text-fd-muted-foreground" /> + </LanguageToggle> + ) : null} + {!disableThemeSwitch ? <ThemeToggle /> : null} + </div> + </NavigationMenuContent> + </NavigationMenuItem> + </div> + </Navbar> + ); +} + function NavbarLinkItem({ item, ...props @@ -168,37 +174,36 @@ function NavbarLinkItem({ if (item.type === 'custom') return <div {...props}>{item.children}</div>; if (item.type === 'menu') { + const children = item.items.map((child, j) => { + if (child.type === 'custom') return <div key={j}>{child.children}</div>; + + const { banner, footer, ...rest } = child.menu ?? {}; + + return ( + <NavbarMenuItem key={j} href={child.url} {...rest}> + {banner ?? + (child.icon ? ( + <div className="w-fit rounded-md border bg-fd-muted p-1 [&_svg]:size-4"> + {child.icon} + </div> + ) : null)} + <p className="-mb-1 text-sm font-medium">{child.text}</p> + {child.description ? ( + <p className="text-[13px] text-fd-muted-foreground"> + {child.description} + </p> + ) : null} + {footer} + </NavbarMenuItem> + ); + }); + return ( <NavbarMenu> <NavbarMenuTrigger {...props}> {item.url ? <Link href={item.url}>{item.text}</Link> : item.text} </NavbarMenuTrigger> - <NavbarMenuContent> - {item.items.map((child, j) => { - if (child.type === 'custom') - return <div key={j}>{child.children}</div>; - - const { banner, footer, ...rest } = child.menu ?? {}; - - return ( - <NavbarMenuItem key={j} href={child.url} {...rest}> - {banner ?? - (child.icon ? ( - <div className="w-fit rounded-md border bg-fd-muted p-1 [&_svg]:size-4"> - {child.icon} - </div> - ) : null)} - <p className="-mb-1 text-sm font-medium">{child.text}</p> - {child.description ? ( - <p className="text-[13px] text-fd-muted-foreground"> - {child.description} - </p> - ) : null} - {footer} - </NavbarMenuItem> - ); - })} - </NavbarMenuContent> + <NavbarMenuContent>{children}</NavbarMenuContent> </NavbarMenu> ); } diff --git a/packages/ui/src/layouts/notebook.client.tsx b/packages/ui/src/layouts/notebook.client.tsx index cff26ef41..096131cdf 100644 --- a/packages/ui/src/layouts/notebook.client.tsx +++ b/packages/ui/src/layouts/notebook.client.tsx @@ -7,27 +7,7 @@ import { SidebarTrigger } from 'fumadocs-core/sidebar'; import { buttonVariants } from '@/components/ui/button'; import { Menu, X } from 'lucide-react'; -export function LayoutBody(props: HTMLAttributes<HTMLElement>) { - return ( - <main - id="nd-docs-layout" - {...props} - className={cn('flex w-full flex-1 flex-row', props.className)} - style={ - { - ...props.style, - '--fd-layout-offset': - 'max(calc(50vw - var(--fd-layout-width) / 2), 0px)', - paddingInlineEnd: 'var(--fd-layout-offset)', - } as object - } - > - {props.children} - </main> - ); -} - -export function SubNavbar(props: HTMLAttributes<HTMLElement>) { +export function Navbar(props: HTMLAttributes<HTMLElement>) { const { open, collapsed } = useSidebar(); const { isTransparent } = useNav(); @@ -36,7 +16,7 @@ export function SubNavbar(props: HTMLAttributes<HTMLElement>) { id="nd-subnav" {...props} className={cn( - 'fixed inset-x-0 top-[var(--fd-banner-height)] z-10 h-14 backdrop-blur-lg transition-colors', + 'fixed inset-x-0 top-[var(--fd-banner-height)] z-10 h-14 pe-[var(--fd-layout-offset)] backdrop-blur-lg transition-colors', (!isTransparent || open) && 'bg-fd-background/80', props.className, )} @@ -45,7 +25,6 @@ export function SubNavbar(props: HTMLAttributes<HTMLElement>) { paddingInlineStart: collapsed ? 'calc(var(--fd-layout-offset))' : 'calc(var(--fd-layout-offset) + var(--fd-sidebar-width))', - paddingInlineEnd: 'var(--fd-layout-offset)', } as object } > diff --git a/packages/ui/src/layouts/notebook.tsx b/packages/ui/src/layouts/notebook.tsx index 0108387a9..0cb32509e 100644 --- a/packages/ui/src/layouts/notebook.tsx +++ b/packages/ui/src/layouts/notebook.tsx @@ -36,11 +36,13 @@ import { } from '@/components/ui/popover'; import { getSidebarTabsFromOptions, + layoutVariables, SidebarLinkItem, type SidebarOptions, } from '@/layouts/docs/shared'; import type { PageTree } from 'fumadocs-core/server'; -import { LayoutBody, SubNavbar, NavbarSidebarTrigger } from './notebook.client'; +import { Navbar, NavbarSidebarTrigger } from './notebook.client'; +import { type PageStyles, StylesProvider } from '@/contexts/layout'; export interface DocsLayoutProps extends BaseLayoutProps { tree: PageTree.Root; @@ -68,16 +70,31 @@ export function DocsLayout({ if (props.tree === undefined) notFound(); const tabs = getSidebarTabsFromOptions(tabOptions, props.tree) ?? []; + const variables = cn( + '[--fd-nav-height:3.5rem] [--fd-tocnav-height:36px] md:[--fd-sidebar-width:260px] xl:[--fd-toc-width:260px] xl:[--fd-tocnav-height:0px]', + ); + + const pageStyles: PageStyles = { + tocNav: cn('lg:px-4 xl:hidden'), + toc: cn('max-xl:hidden'), + page: cn('mt-[var(--fd-nav-height)]'), + }; return ( <TreeContextProvider tree={props.tree}> <NavProvider transparentMode={transparentMode}> - <LayoutBody + <main + id="nd-docs-layout" {...props.containerProps} className={cn( - '[--fd-nav-height:3.5rem] md:[--fd-sidebar-width:260px] lg:[--fd-toc-width:260px] [&_#nd-page]:mt-[var(--fd-nav-height)] [&_#nd-toc]:max-lg:hidden [&_#nd-tocnav]:lg:hidden', + 'flex w-full flex-1 flex-row pe-[var(--fd-layout-offset)]', + variables, props.containerProps?.className, )} + style={{ + ...layoutVariables, + ...props.containerProps?.style, + }} > <Aside {...sidebar} @@ -116,8 +133,8 @@ export function DocsLayout({ i18n={i18n} sidebarCollapsible={sidebarCollapsible} /> - {props.children} - </LayoutBody> + <StylesProvider {...pageStyles}>{props.children}</StylesProvider> + </main> </NavProvider> </TreeContextProvider> ); @@ -135,7 +152,7 @@ function DocsNavbar({ links: LinkItemType[]; }) { return ( - <SubNavbar> + <Navbar> {sidebarCollapsible ? ( <SidebarCollapseTrigger className="-ms-1.5 text-fd-muted-foreground data-[collapsed=false]:hidden max-md:hidden" /> ) : null} @@ -182,7 +199,7 @@ function DocsNavbar({ </LanguageToggle> ) : null} <ThemeToggle className="p-0 max-md:hidden" /> - </SubNavbar> + </Navbar> ); } diff --git a/packages/ui/src/page.client.tsx b/packages/ui/src/page.client.tsx index 25ffb00d4..efe5644ad 100644 --- a/packages/ui/src/page.client.tsx +++ b/packages/ui/src/page.client.tsx @@ -21,18 +21,22 @@ import { type BreadcrumbOptions, getBreadcrumbItemsFromPath, } from 'fumadocs-core/breadcrumb'; +import { usePageStyles } from '@/contexts/layout'; -export function PageHeader(props: HTMLAttributes<HTMLDivElement>) { +export function TocNav(props: HTMLAttributes<HTMLDivElement>) { const { open } = useSidebar(); + const { tocNav } = usePageStyles(); const { isTransparent } = useNav(); return ( <header + id="nd-tocnav" {...props} className={cn( 'sticky top-fd-layout-top z-10 flex flex-row items-center border-b border-fd-foreground/10 text-sm backdrop-blur-md transition-colors', !isTransparent && 'bg-fd-background/80', open && 'opacity-0', + tocNav, props.className, )} style={ @@ -48,6 +52,37 @@ export function PageHeader(props: HTMLAttributes<HTMLDivElement>) { ); } +export function PageBody(props: HTMLAttributes<HTMLDivElement>) { + const { page } = usePageStyles(); + + return ( + <div + id="nd-page" + {...props} + className={cn('flex w-full min-w-0 flex-col', page, props.className)} + > + {props.children} + </div> + ); +} + +export function PageArticle(props: HTMLAttributes<HTMLElement>) { + const { article } = usePageStyles(); + + return ( + <article + {...props} + className={cn( + 'flex w-full flex-1 flex-col gap-6 px-4 pt-8 md:pt-12 lg:px-8 xl:mx-auto', + article, + props.className, + )} + > + {props.children} + </article> + ); +} + export function LastUpdate(props: { date: Date }) { const { text } = useI18n(); const [date, setDate] = useState(''); diff --git a/packages/ui/src/page.tsx b/packages/ui/src/page.tsx index 6f0710586..6e1f6873e 100644 --- a/packages/ui/src/page.tsx +++ b/packages/ui/src/page.tsx @@ -14,9 +14,11 @@ import { Footer, type FooterProps, LastUpdate, - PageHeader, + TocNav, Breadcrumb, type BreadcrumbProps, + PageBody, + PageArticle, } from './page.client'; import { Toc, @@ -139,26 +141,21 @@ export function DocsPage({ tocPopoverOptions.header !== undefined || tocPopoverOptions.footer !== undefined; - const fullWidth = full && !tocEnabled; - return ( <AnchorProvider toc={toc} single={tocOptions.single}> - <div - id="nd-page" + <PageBody {...props.container} - className={cn( - 'flex w-full min-w-0 flex-col md:transition-[max-width]', - props.container?.className, - )} + className={cn(props.container?.className)} style={ { - '--fd-toc-width': fullWidth ? '0px' : undefined, + '--fd-tocnav-height': !tocPopoverEnabled ? '0px' : undefined, + ...props.container?.style, } as object } > {replaceOrDefault( { enabled: tocPopoverEnabled, component: tocPopoverReplace }, - <PageHeader id="nd-tocnav"> + <TocNav> <TocPopover> <TocPopoverTrigger className="size-full" items={toc} /> <TocPopoverContent> @@ -171,17 +168,16 @@ export function DocsPage({ {tocPopoverOptions.footer} </TocPopoverContent> </TocPopover> - </PageHeader>, + </TocNav>, { items: toc, ...tocPopoverOptions, }, )} - <article + <PageArticle {...props.article} className={cn( - 'mx-auto flex w-full flex-1 flex-col gap-6 px-4 pt-8 max-xl:mx-0 md:pt-12 lg:px-8', - fullWidth ? 'max-w-[1120px]' : 'max-w-[860px]', + full || !tocEnabled ? 'max-w-[1120px]' : 'max-w-[860px]', props.article?.className, )} > @@ -206,30 +202,27 @@ export function DocsPage({ props.footer, <Footer items={props.footer?.items} />, )} - </article> - </div> + </PageArticle> + </PageBody> {replaceOrDefault( { enabled: tocEnabled, component: tocReplace }, - <Toc id="nd-toc"> - <div className="flex h-full w-[var(--fd-toc-width)] max-w-full flex-col gap-3 pe-2"> - {tocOptions.header} - <h3 className="-ms-0.5 inline-flex items-center gap-1.5 text-sm text-fd-muted-foreground"> - <Text className="size-4" /> - <I18nLabel label="toc" /> - </h3> - {tocOptions.style === 'clerk' ? ( - <ClerkTOCItems items={toc} /> - ) : ( - <TOCItems items={toc} /> - )} - {tocOptions.footer} - </div> + <Toc> + {tocOptions.header} + <h3 className="-ms-0.5 inline-flex items-center gap-1.5 text-sm text-fd-muted-foreground"> + <Text className="size-4" /> + <I18nLabel label="toc" /> + </h3> + {tocOptions.style === 'clerk' ? ( + <ClerkTOCItems items={toc} /> + ) : ( + <TOCItems items={toc} /> + )} + {tocOptions.footer} </Toc>, { items: toc, ...tocOptions, }, - <div role="none" className="flex-1" />, )} </AnchorProvider> ); @@ -343,13 +336,15 @@ export function DocsCategory({ from: LoaderOutput<LoaderConfig>; tree?: PageTree.Root; }) { - const tree = - forcedTree ?? - (from._i18n + let tree = forcedTree; + + if (!tree) { + tree = from._i18n ? (from as LoaderOutput<LoaderConfig & { i18n: true }>).pageTree[ page.locale ?? from._i18n.defaultLanguage ] - : from.pageTree); + : from.pageTree; + } const parent = findParent(tree, page); if (!parent) return null; diff --git a/packages/ui/src/provider.tsx b/packages/ui/src/provider.tsx index 4e016e753..f7be64738 100644 --- a/packages/ui/src/provider.tsx +++ b/packages/ui/src/provider.tsx @@ -98,3 +98,8 @@ export { useTreeContext, TreeContextProvider, } from './contexts/tree'; +export { + StylesProvider, + usePageStyles, + type PageStyles, +} from './contexts/layout';