UI: Introduce --fd-nav-height CSS variable for custom navbar

This commit is contained in:
fuma-nama
2024-10-08 19:15:59 +08:00
parent 139361399c
commit 64f0653310
15 changed files with 295 additions and 207 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-ui': minor
---
Introduce `--fd-nav-height` CSS variable for custom navbar
+36 -24
View File
@@ -36,20 +36,47 @@ See [Root Provider](/docs/ui/blocks/root-provider#theme-provider) to learn more.
### RTL Layout
RTL (Right-to-left) layout is supported. To enable RTL, pass the `dir` prop to body.
RTL (Right-to-left) layout is supported.
To enable RTL, set the `dir` prop to `rtl` in body and root provider (required for Radix UI).
```tsx
<body dir="rtl"></body>
import { RootProvider } from 'fumadocs-ui/provider';
import type { ReactNode } from 'react';
export default function RootLayout({
children,
}: {
children: ReactNode;
}): ReactNode {
return (
<html lang="en" suppressHydrationWarning>
<body dir="rtl">
<RootProvider dir="rtl">{children}</RootProvider>
</body>
</html>
);
}
```
Also pass the same prop to root provider to configure Radix UI RTL layout.
## CSS Variables
```tsx
import { RootProvider } from '@fumadocs-ui/provider';
Fumadocs UI provides some CSS variables for customising the layout.
<RootProvider dir="rtl"></RootProvider>;
### Navbar Height
When the default navbar is replaced, the layout may overlap with your new navbar.
You should set the `--fd-nav-height` variable to the height of your navbar.
```css
:root {
--fd-nav-height: 40px !important;
}
```
> You can use it with CSS media queries.
### Layout Width
Customise the max width of docs layout with CSS Variables.
@@ -113,7 +140,9 @@ We use the [Tailwind CSS Typography](https://tailwindcss.com/docs/typography-plu
The Tailwind CSS plugin overrides the default configuration for the Typography plugin. You can use the `prose` utility directly.
```tsx
<div className="prose">...</div>
<div className="prose">
<h1>Good Heading</h1>
</div>
```
### Colors
@@ -135,20 +164,3 @@ export default {
],
};
```
### Layout Width
Customise the max width of docs layout with Tailwind CSS plugin.
```js
import { createPreset } from 'fumadocs-ui/tailwind-plugin';
/** @type {import('tailwindcss').Config} */
export default {
presets: [
createPreset({
layoutWidth: '1400px',
}),
],
};
```
+15 -4
View File
@@ -1,4 +1,15 @@
import { toRuntime } from "fumadocs-mdx"
import * as docs_0 from "./index.mdx?collection=docs&hash=hash"
import * as docs_1 from "./folder/test.mdx?collection=docs&hash=hash"
export const docs = [toRuntime("doc", docs_0, {"path":"index.mdx","absolutePath":"/Users/xred/dev/fumadocs/packages/mdx/test/fixtures/index.mdx"}),toRuntime("doc", docs_1, {"path":"folder/test.mdx","absolutePath":"/Users/xred/dev/fumadocs/packages/mdx/test/fixtures/folder/test.mdx"})];
import { toRuntime } from 'fumadocs-mdx';
import * as docs_0 from './index.mdx?collection=docs&hash=hash';
import * as docs_1 from './folder/test.mdx?collection=docs&hash=hash';
export const docs = [
toRuntime('doc', docs_0, {
path: 'index.mdx',
absolutePath:
'/Users/xred/dev/fumadocs/packages/mdx/test/fixtures/index.mdx',
}),
toRuntime('doc', docs_1, {
path: 'folder/test.mdx',
absolutePath:
'/Users/xred/dev/fumadocs/packages/mdx/test/fixtures/folder/test.mdx',
}),
];
+2 -1
View File
@@ -87,7 +87,8 @@ export function APIPlayground({
);
const headers = new Headers();
if (bodyType !== 'form-data') headers.append('Content-Type', 'application/json');
if (bodyType !== 'form-data')
headers.append('Content-Type', 'application/json');
if (input.authorization) {
headers.append('Authorization', input.authorization);
+168 -120
View File
@@ -2,7 +2,7 @@ import Link from 'fumadocs-core/link';
import { ChevronDown } from 'lucide-react';
import { usePathname } from 'next/navigation';
import { cva } from 'class-variance-authority';
import { type ReactNode, useState } from 'react';
import { HTMLAttributes, type ReactNode, useState } from 'react';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import { cn } from '@/utils/cn';
import { isActive } from '@/utils/shared';
@@ -19,19 +19,8 @@ import {
} from '@/components/ui/popover';
import { itemVariants } from '@/components/layout/variants';
const linkItemVariants = cva(
'-m-2 inline-flex items-center gap-1 p-2 text-fd-muted-foreground transition-colors [&_svg]:size-4',
{
variants: {
active: {
true: 'text-fd-primary',
false: 'hover:text-fd-accent-foreground',
},
},
defaultVariants: {
active: false,
},
},
const navItemVariants = cva(
'-m-2 inline-flex items-center gap-1 p-2 text-fd-muted-foreground transition-colors hover:text-fd-accent-foreground data-[active=true]:!text-fd-primary [&_svg]:size-4',
);
interface BaseItem {
@@ -54,86 +43,78 @@ interface BaseLinkItem extends BaseItem {
external?: boolean;
}
interface MainItem extends BaseLinkItem {
type?: 'main';
icon?: ReactNode;
text: ReactNode;
}
interface IconItem extends BaseLinkItem {
type: 'icon';
/**
* `aria-label` of icon button
*/
label?: string;
icon: ReactNode;
text: ReactNode;
/**
* @defaultValue true
*/
secondary?: boolean;
}
interface ButtonItem extends BaseLinkItem {
type: 'button';
icon?: ReactNode;
text: ReactNode;
/**
* @defaultValue false
*/
secondary?: boolean;
}
interface MenuItem extends BaseItem {
type: 'menu';
icon?: ReactNode;
text: ReactNode;
items: LinkItemType[];
/**
* @defaultValue false
*/
secondary?: boolean;
}
interface CustomItem extends BaseItem {
type: 'custom';
/**
* @defaultValue false
*/
secondary?: boolean;
children: ReactNode;
}
export type LinkItemType =
| (BaseLinkItem & {
type?: 'main';
icon?: ReactNode;
text: ReactNode;
})
| (BaseLinkItem & {
type: 'icon';
/**
* `aria-label` of icon button
*/
label?: string;
icon: ReactNode;
text: ReactNode;
/**
* @defaultValue true
*/
secondary?: boolean;
})
| (BaseLinkItem & {
type: 'button';
icon?: ReactNode;
text: ReactNode;
/**
* @defaultValue false
*/
secondary?: boolean;
})
| (BaseItem & {
type: 'menu';
icon?: ReactNode;
text: ReactNode;
items: LinkItemType[];
/**
* @defaultValue false
*/
secondary?: boolean;
})
| (BaseItem & {
type: 'custom';
/**
* @defaultValue false
*/
secondary?: boolean;
children: ReactNode;
});
| MainItem
| IconItem
| ButtonItem
| MenuItem
| CustomItem;
interface LinkItemProps extends React.HTMLAttributes<HTMLElement> {
item: LinkItemType;
on?: 'menu' | 'nav';
}
export function LinkItem({
export function MenuItem({
item,
on = 'nav',
className,
...props
}: LinkItemProps): React.ReactNode {
const pathname = usePathname();
if (item.on && item.on !== 'all' && item.on !== on) return null;
if (item.type === 'custom')
return <div className={cn('grid', className)}>{item.children}</div>;
if (item.type === 'menu' && on === 'nav') {
return (
<LinksMenu
items={item.items.map((child, i) => (
<LinkItem key={i} item={child} on="menu" />
))}
className={cn(linkItemVariants({ className }))}
{...props}
>
{item.icon}
{item.text}
<ChevronDown className="ms-auto !size-3.5" />
</LinksMenu>
<div className={cn('grid', className)} {...props}>
{item.children}
</div>
);
}
if (item.type === 'menu') {
return (
@@ -149,7 +130,7 @@ export function LinkItem({
<CollapsibleContent>
<div className="ms-2 flex flex-col border-s py-2 ps-2">
{item.items.map((child, i) => (
<LinkItem key={i} item={child} on="menu" />
<MenuItem key={i} item={child} />
))}
</div>
</CollapsibleContent>
@@ -158,62 +139,129 @@ export function LinkItem({
}
if (item.type === 'button') {
return <ButtonItem item={item} />;
}
return (
<BaseLinkItem
item={item}
className={cn(itemVariants(), className)}
{...props}
>
{item.icon}
{item.text}
</BaseLinkItem>
);
}
export function LinkItem({
item,
className,
...props
}: LinkItemProps): React.ReactNode {
if (item.type === 'custom')
return (
<Link
href={item.url}
external={item.external}
className={cn(
buttonVariants({ color: 'secondary' }),
'gap-1.5 [&_svg]:size-4',
className,
)}
<div className={cn('grid', className)} {...props}>
{item.children}
</div>
);
if (item.type === 'menu') {
return (
<LinksMenu
items={item.items.map((child, i) => (
<MenuItem key={i} item={child} />
))}
className={cn(navItemVariants({ className }))}
{...props}
>
{item.icon}
{item.text}
</Link>
<ChevronDown className="ms-auto !size-3.5" />
</LinksMenu>
);
}
if (item.type === 'button') {
return <ButtonItem item={item} {...props} />;
}
if (item.type === 'icon') {
return <IconItem item={item} {...props} />;
}
return (
<BaseLinkItem
item={item}
className={cn(navItemVariants(), className)}
{...props}
>
{item.text}
</BaseLinkItem>
);
}
function BaseLinkItem({
item,
...props
}: { item: BaseLinkItem } & HTMLAttributes<HTMLAnchorElement>): ReactNode {
const pathname = usePathname();
const activeType = item.active ?? 'url';
const active =
activeType !== 'none' &&
isActive(item.url, pathname, activeType === 'nested-url');
if (item.type === 'icon' && on === 'nav') {
return (
<Link
aria-label={item.label}
href={item.url}
external={item.external}
className={cn(
buttonVariants({
size: 'icon',
color: 'ghost',
className,
}),
)}
{...props}
>
{item.icon}
</Link>
);
}
return (
<Link
href={item.url}
external={item.external}
className={cn(
on === 'nav'
? linkItemVariants({
active,
})
: itemVariants({ active }),
className,
)}
data-active={active}
{...props}
>
{on === 'menu' ? item.icon : null}
{props.children}
</Link>
);
}
export function IconItem({
item,
...props
}: { item: IconItem } & HTMLAttributes<HTMLAnchorElement>): ReactNode {
return (
<Link
aria-label={item.label}
href={item.url}
external={item.external}
{...props}
className={cn(
buttonVariants({
size: 'icon',
color: 'ghost',
}),
props.className,
)}
>
{item.icon}
</Link>
);
}
function ButtonItem({
item,
...props
}: { item: ButtonItem } & HTMLAttributes<HTMLAnchorElement>): ReactNode {
return (
<Link
href={item.url}
external={item.external}
{...props}
className={cn(
buttonVariants({ color: 'secondary' }),
'gap-1.5 [&_svg]:size-4',
props.className,
)}
>
{item.icon}
{item.text}
</Link>
);
+1 -1
View File
@@ -52,7 +52,7 @@ export function NavBox({
<header
{...props}
className={cn(
'sticky top-fd-sidebar-top z-40 border-b transition-colors',
'sticky top-[var(--fd-banner-height)] z-40 border-b transition-colors',
transparent
? 'border-transparent'
: 'border-fd-foreground/10 bg-fd-background/80 backdrop-blur-md',
+22 -16
View File
@@ -16,7 +16,7 @@ import { cn } from '@/utils/cn';
import { useTreeContext } from '@/contexts/tree';
import { ScrollArea, ScrollViewport } from '@/components/ui/scroll-area';
import { hasActive, isActive } from '@/utils/shared';
import { LinkItem, type LinkItemType } from '@/components/layout/link-item';
import { type LinkItemType, MenuItem } from '@/components/layout/link-item';
import { LargeSearchToggle } from '@/components/layout/search-toggle';
import { useSearchContext } from '@/contexts/search';
import { itemVariants } from '@/components/layout/variants';
@@ -113,13 +113,15 @@ export function Sidebar({
blockScrollingWidth={768} // md
{...props.aside}
className={cn(
'fixed z-30 flex flex-col bg-fd-card text-sm md:sticky md:top-fd-sidebar-top md:h-fd-sidebar-height md:w-[var(--fd-c-sidebar)] md:min-w-[var(--fd-sidebar-width)] md:border-e md:ps-[var(--fd-sidebar-offset)]',
'max-md:inset-0 max-md:top-fd-sidebar-top max-md:bg-fd-background/80 max-md:pt-[var(--fd-nav-height)] max-md:text-[15px] max-md:backdrop-blur-md max-md:data-[open=false]:invisible',
'fixed top-fd-layout-top z-30 flex flex-col bg-fd-card text-sm md:sticky md:h-[var(--fd-sidebar-height)] md:w-[var(--fd-c-sidebar)] md:min-w-[var(--fd-sidebar-width)] md:border-e md:ps-[var(--fd-sidebar-offset)]',
'max-md:inset-x-0 max-md:bottom-0 max-md:bg-fd-background/80 max-md:pt-[var(--fd-nav-height)] max-md:text-[15px] max-md:backdrop-blur-md max-md:data-[open=false]:invisible',
props.aside?.className,
)}
style={
{
...props.aside?.style,
'--fd-sidebar-height':
'calc(100dvh - var(--fd-banner-height) - var(--fd-nav-height))',
'--fd-sidebar-offset':
'calc(var(--fd-c-sidebar) - var(--fd-sidebar-width))',
} as object
@@ -140,7 +142,15 @@ export function Sidebar({
) : null}
</div>
) : null}
<ViewportContent items={items} />
<ViewportContent>
{items.length > 0 ? (
<div className="flex flex-col px-4 pt-6 md:hidden">
{items.map((item, i) => (
<MenuItem key={i} item={item} />
))}
</div>
) : null}
</ViewportContent>
{props.footer ? (
<div
{...props.footerProps}
@@ -158,9 +168,9 @@ export function Sidebar({
}
function ViewportContent({
items,
children,
}: {
items: LinkItemType[];
children: React.ReactNode;
}): React.ReactElement {
const { root } = useTreeContext();
@@ -171,13 +181,7 @@ function ViewportContent({
maskImage: 'linear-gradient(to bottom, transparent 2px, white 24px)',
}}
>
{items.length > 0 ? (
<div className="flex flex-col px-4 pt-6 md:hidden">
{items.map((item, i) => (
<LinkItem key={i} item={item} on="menu" />
))}
</div>
) : null}
{children}
<NodeList items={root.children} className="px-4 py-6 md:px-3" />
</ScrollViewport>
</ScrollArea>
@@ -227,7 +231,8 @@ function PageNode({
<Link
href={url}
external={external}
className={cn(itemVariants({ active }))}
data-active={active}
className={cn(itemVariants())}
prefetch={prefetch}
>
{icon ?? (external ? <ExternalLinkIcon /> : null)}
@@ -289,7 +294,8 @@ function FolderNode({
<Collapsible open={open} onOpenChange={setOpen}>
{item.index ? (
<Link
className={cn(itemVariants({ active }))}
data-active={active}
className={cn(itemVariants())}
href={item.index.url}
onClick={onClick}
prefetch={prefetch}
@@ -297,7 +303,7 @@ function FolderNode({
{content}
</Link>
) : (
<CollapsibleTrigger className={cn(itemVariants({ active }))}>
<CollapsibleTrigger data-active={active} className={cn(itemVariants())}>
{content}
</CollapsibleTrigger>
)}
+1 -1
View File
@@ -32,7 +32,7 @@ export function Toc({ header, footer, children }: TOCProps): ReactElement {
return (
<div
data-toc=""
className="sticky top-fd-toc-top flex w-[var(--fd-c-toc)] min-w-[var(--fd-toc-width)] flex-col gap-3 pb-2 pe-[var(--fd-toc-offset)] pt-12 max-lg:hidden md:h-fd-toc-height"
className="sticky top-fd-layout-top flex h-fd-toc-height w-[var(--fd-c-toc)] min-w-[var(--fd-toc-width)] flex-col gap-3 pb-2 pe-[var(--fd-toc-offset)] pt-12 max-lg:hidden"
style={
{
'--fd-toc-offset':
+4 -14
View File
@@ -1,17 +1,7 @@
import { cva } from 'class-variance-authority';
export const itemVariants = cva(
export const itemVariants = cva([
'flex w-full flex-row items-center gap-2 rounded-md px-2 py-1.5 text-fd-muted-foreground transition-colors duration-100 [overflow-wrap:anywhere] [&_svg]:size-4',
{
variants: {
active: {
true: 'bg-fd-primary/10 font-medium text-fd-primary',
false:
'hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none',
},
},
defaultVariants: {
active: false,
},
},
);
'hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none',
'data-[active=true]:!bg-fd-primary/10 data-[active=true]:font-medium data-[active=true]:!text-fd-primary data-[active=true]:hover:transition-colors',
]);
+7 -5
View File
@@ -25,10 +25,7 @@ export function SubNav({
<NavBox
id="nd-subnav"
{...props}
className={cn(
'flex h-[var(--fd-nav-height)] flex-row items-center px-4',
props.className,
)}
className={cn('flex flex-row items-center px-4', props.className)}
>
<Title url={url} title={title} />
<div className="flex flex-1 flex-row items-center gap-1">
@@ -76,7 +73,12 @@ export function SidebarCollapseTrigger(
);
}
export { LinksMenu, LinkItem } from '@/components/layout/link-item';
export {
LinksMenu,
LinkItem,
IconItem,
MenuItem,
} from '@/components/layout/link-item';
export { RootToggle } from '@/components/layout/root-toggle';
export { Sidebar } from '@/components/layout/sidebar';
export { TreeContextProvider } from '@/contexts/tree';
+14 -8
View File
@@ -21,9 +21,10 @@ declare const {
LanguageToggleText,
LinksMenu,
RootToggle,
LinkItem,
DynamicSidebar,
Sidebar,
IconItem,
MenuItem,
}: typeof import('./docs.client');
export type { LinkItemType };
@@ -85,7 +86,7 @@ export function DocsLayout({
<LinksMenu
key="links"
items={links.map((item, i) => (
<LinkItem key={i} item={item} on="menu" />
<MenuItem key={i} item={item} />
))}
className={cn(
buttonVariants({
@@ -121,11 +122,10 @@ export function DocsLayout({
<Fragment key="links">
{links
.filter((v) => v.type === 'icon')
.map((v, i) => (
<LinkItem
.map((item, i) => (
<IconItem
key={i}
item={v}
on="nav"
item={item}
className="text-fd-muted-foreground md:hidden"
/>
))}
@@ -147,11 +147,17 @@ export function DocsLayout({
return (
<TreeContextProvider tree={props.tree}>
{replaceOrDefault(nav, <SubNav className="md:hidden" {...nav} />)}
{replaceOrDefault(nav, <SubNav className="h-14 md:hidden" {...nav} />)}
<main
id="nd-docs-layout"
{...props.containerProps}
className={cn('flex flex-1 flex-row', props.containerProps?.className)}
className={cn(
'flex flex-1 flex-row',
!nav.component &&
nav.enabled !== false &&
'[--fd-nav-height:3.5rem] md:[--fd-nav-height:0px]',
props.containerProps?.className,
)}
>
{replaceOrDefault(
{ enabled: sidebarEnabled, component: sidebarReplace },
+13 -5
View File
@@ -1,11 +1,13 @@
'use client';
import { ChevronDown, Languages, MoreVertical } from 'lucide-react';
import { useMemo } from 'react';
import { useSearchContext } from '@/contexts/search';
import {
LinkItem,
type LinkItemType,
LinksMenu,
MenuItem,
} from '@/components/layout/link-item';
import {
LargeSearchToggle,
@@ -31,6 +33,13 @@ export function Nav({
items: LinkItemType[];
}): React.ReactElement {
const search = useSearchContext();
const [navItems, menuItems] = useMemo(
() => [
items.filter((item) => ['nav', 'all'].includes(item.on ?? 'all')),
items.filter((item) => ['menu', 'all'].includes(item.on ?? 'all')),
],
[items],
);
return (
<NavBox
@@ -41,7 +50,7 @@ export function Nav({
<nav className="mx-auto flex size-full max-w-fd-container flex-row items-center gap-6 px-4">
<Title title={props.title} url={props.url} />
{props.children}
{items
{navItems
.filter((item) => !isSecondary(item))
.map((item, i) => (
<LinkItem key={i} item={item} className="text-sm max-sm:hidden" />
@@ -62,18 +71,17 @@ export function Nav({
</LanguageToggle>
) : null}
{items.filter(isSecondary).map((item, i) => (
{navItems.filter(isSecondary).map((item, i) => (
<LinkItem key={i} item={item} className="max-lg:hidden" />
))}
<LinksMenu
items={
<>
{items.map((item, i) => (
<LinkItem
{menuItems.map((item, i) => (
<MenuItem
key={i}
item={item}
on="menu"
className={cn(!isSecondary(item) && 'sm:hidden')}
/>
))}
+1
View File
@@ -22,6 +22,7 @@ export function HomeLayout({
disableThemeSwitch={props.disableThemeSwitch}
{...nav}
>
<style>{`:root { --fd-nav-height 3.5rem; }`}</style>
{nav.children}
</Nav>,
)}
+1 -1
View File
@@ -106,7 +106,7 @@ export function DocsPage({
<div
id="nd-tocnav"
className={cn(
'sticky top-fd-toc-top z-10 border-b bg-fd-background/60 text-sm backdrop-blur-md md:top-[calc(4px+var(--fd-banner-height)+var(--fd-nav-height))] md:mx-3 md:rounded-full md:border md:shadow-md',
'sticky top-fd-layout-top z-10 border-b bg-fd-background/60 text-sm backdrop-blur-md md:top-[calc(4px+var(--fd-banner-height)+var(--fd-nav-height))] md:mx-3 md:rounded-full md:border md:shadow-md',
tocPopoverOptions.enabled !== true && 'lg:hidden',
)}
>
+5 -7
View File
@@ -126,9 +126,9 @@ export const docsUi = plugin.withOptions<DocsUIOptions>(
'--fd-sidebar-width': '0px',
'--fd-toc-width': '0px',
'--fd-layout-width': layoutWidth,
'--fd-nav-height': '56px',
'--fd-banner-height': '0px',
'--fd-nav-height': '0px',
// computed
'--fd-doc':
'calc(min(100vw, var(--fd-layout-width)) - var(--fd-toc-width) - var(--fd-sidebar-width))',
@@ -150,7 +150,6 @@ export const docsUi = plugin.withOptions<DocsUIOptions>(
'@screen md': {
':root': {
'--fd-sidebar-width': '260px',
'--fd-nav-height': '0px',
},
},
'@screen lg': {
@@ -202,7 +201,7 @@ export const docsUi = plugin.withOptions<DocsUIOptions>(
'.shiki code .diff': {
margin: '0 -16px',
padding: '0 16px',
display: 'inline-block',
position: 'relative',
},
'.shiki .highlighted': {
margin: '0 -16px',
@@ -278,11 +277,10 @@ export const docsUi = plugin.withOptions<DocsUIOptions>(
height: {
'fd-toc-height':
'calc(100dvh - var(--fd-banner-height) - var(--fd-nav-height))',
'fd-sidebar-height': 'calc(100dvh - var(--fd-banner-height))',
},
spacing: {
'fd-toc-top': 'calc(var(--fd-banner-height) + var(--fd-nav-height))',
'fd-sidebar-top': 'var(--fd-banner-height)',
'fd-layout-top':
'calc(var(--fd-banner-height) + var(--fd-nav-height))',
},
margin: {
// the offset given to docs content when the sidebar is collapsed