feat: merge upstream fumadocs 16.6+ (182 commits) and rename to @hanzo/docs-*

Upstream changes include:
- UI: improved TOC detection, clerk variant, slot support
- Base UI: redesigned home layout navigation menu
- CLI: slot support in customise command
- OpenAPI: unsetting auth fields in playground
- Multiple version package releases

All fumadocs-* imports renamed to @hanzo/docs-* namespace.
This commit is contained in:
Hanzo Dev
2026-03-22 18:43:20 -07:00
271 changed files with 16454 additions and 7101 deletions
-5
View File
@@ -1,5 +0,0 @@
---
'fumadocs-typescript': patch
---
More options for simplifying types
-6
View File
@@ -1,6 +0,0 @@
---
'fumadocs-ui': minor
'@fumadocs/base-ui': minor
---
Implement renderer API for replacing layout components, deprecate old options
-5
View File
@@ -1,5 +0,0 @@
---
'fumadocs-openapi': minor
---
Implement i18n
-5
View File
@@ -1,5 +0,0 @@
---
'fumadocs-mdx': patch
---
Fix dynamic index generation so generated `dynamic.ts` imports `node:path` and passes lazy entries to `create.doc()` / `create.docs()` as arrays.
-6
View File
@@ -1,6 +0,0 @@
---
'fumadocs-ui': minor
'@fumadocs/base-ui': minor
---
Improved `defineI18nUI()` usage: allow language translations to be defined at root config.
@@ -1,6 +1,7 @@
---
'fumadocs-ui': patch
'@fumadocs/base-ui': patch
'fumadocs-core': patch
---
Improve `<GithubInfo />` component
Improve TOC detection logic
-6
View File
@@ -1,6 +0,0 @@
---
'fumadocs-typescript': minor
'fumadocs-openapi': minor
---
Bump deps
-7
View File
@@ -1,7 +0,0 @@
---
'fumadocs-ui': minor
'@fumadocs/base-ui': minor
'fumadocs-core': minor
---
stablize Shiki factory API
-5
View File
@@ -1,5 +0,0 @@
---
'fumadocs-core': patch
---
Support `placeholder()` API in llms.txt generation
+2 -2
View File
@@ -372,14 +372,14 @@ export function AISearchPanel() {
<div
className={cn(
'overflow-hidden z-30 bg-fd-card text-fd-card-foreground [--ai-chat-width:400px] 2xl:[--ai-chat-width:460px]',
'max-lg:fixed max-lg:inset-x-2 max-lg:top-4 max-lg:border max-lg:rounded-2xl max-lg:shadow-xl',
'max-lg:fixed max-lg:inset-x-2 max-lg:inset-y-4 max-lg:border max-lg:rounded-2xl max-lg:shadow-xl',
'lg:sticky lg:top-0 lg:h-dvh lg:border-s lg:ms-auto lg:in-[#nd-docs-layout]:[grid-area:toc] lg:in-[#nd-notebook-layout]:row-span-full lg:in-[#nd-notebook-layout]:col-start-5',
open
? 'animate-fd-dialog-in lg:animate-[ask-ai-open_200ms]'
: 'animate-fd-dialog-out lg:animate-[ask-ai-close_200ms]',
)}
>
<div className="flex flex-col size-full p-2 max-lg:max-h-[80dvh] lg:p-3 lg:w-(--ai-chat-width)">
<div className="flex flex-col size-full p-2 lg:p-3 lg:w-(--ai-chat-width)">
<AISearchPanelHeader />
<AISearchPanelList className="flex-1" />
<div className="rounded-xl border bg-fd-secondary text-fd-secondary-foreground shadow-sm has-focus-visible:shadow-md">
+93
View File
@@ -5,6 +5,7 @@ import { Footer } from '@/components/footer';
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<<<<<<< HEAD
<>
<HomeLayout
{...baseOptions()}
@@ -24,5 +25,97 @@ export default function Layout({ children }: LayoutProps<'/'>) {
</HomeLayout>
<Footer />
</>
=======
<HomeLayout
{...baseOptions()}
links={[
{
type: 'menu',
on: 'menu',
text: 'Documentation',
items: [
{
text: 'Getting Started',
url: '/docs',
icon: <Book />,
},
{
text: 'Components',
url: '/docs/ui/components',
icon: <ComponentIcon />,
},
],
},
{
type: 'custom',
on: 'nav',
children: (
<NavbarMenu>
<NavbarMenuTrigger>
<Link href="/docs">Documentation</Link>
</NavbarMenuTrigger>
<NavbarMenuContent>
<NavbarMenuLink href="/docs" className="md:row-span-2">
<div className="-mx-3 -mt-3">
<Image
src={Preview}
alt="Preview"
className="rounded-t-lg object-cover"
style={{
maskImage: 'linear-gradient(to bottom,white 60%,transparent)',
}}
/>
</div>
<p className="font-medium">Getting Started</p>
<p className="text-fd-muted-foreground text-sm">
Learn to use Fumadocs on your docs site.
</p>
</NavbarMenuLink>
<NavbarMenuLink href="/docs/ui/components" className="lg:col-start-2">
<ComponentIcon className="bg-fd-primary text-fd-primary-foreground p-1 mb-2 rounded-md" />
<p className="font-medium">Components</p>
<p className="text-fd-muted-foreground text-sm">
Add interactive experience to your docs.
</p>
</NavbarMenuLink>
<NavbarMenuLink href="/docs/openapi" className="lg:col-start-2">
<Server className="bg-fd-primary text-fd-primary-foreground p-1 mb-2 rounded-md" />
<p className="font-medium">OpenAPI</p>
<p className="text-fd-muted-foreground text-sm">
Generate interactive playgrounds and docs for your OpenAPI schema.
</p>
</NavbarMenuLink>
<NavbarMenuLink href="/docs/markdown" className="lg:col-start-3 lg:row-start-1">
<Pencil className="bg-fd-primary text-fd-primary-foreground p-1 mb-2 rounded-md" />
<p className="font-medium">Markdown</p>
<p className="text-fd-muted-foreground text-sm">
Learn the writing format/syntax of Fumadocs.
</p>
</NavbarMenuLink>
<NavbarMenuLink
href="/docs/manual-installation"
className="lg:col-start-3 lg:row-start-2"
>
<PlusIcon className="bg-fd-primary text-fd-primary-foreground p-1 mb-2 rounded-md" />
<p className="font-medium">Manual Installation</p>
<p className="text-fd-muted-foreground text-sm">
Setup Fumadocs for your existing Next.js app.
</p>
</NavbarMenuLink>
</NavbarMenuContent>
</NavbarMenu>
),
},
...linkItems,
]}
className="dark:bg-neutral-950 dark:[--color-fd-background:var(--color-neutral-950)] [--color-fd-primary:var(--color-brand)]"
>
{children}
</HomeLayout>
>>>>>>> dev
);
}
-44
View File
@@ -212,50 +212,6 @@ export default async function Page() {
/>
</svg>
</a>
<a href="https://openai.com" rel="noreferrer noopener">
<svg
width="1604"
height="718"
viewBox="0 0 1604 718"
className="w-32 h-auto"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_1637_2932)">
<path
d="M358.8 239.3C293.02 239.3 239.2 293.12 239.2 358.9C239.2 424.68 293.02 478.5 358.8 478.5C424.58 478.5 478.4 425.012 478.4 358.9C478.4 292.787 424.912 239.3 358.8 239.3ZM358.8 435.643C317.937 435.643 285.046 402.089 285.046 358.9C285.046 315.711 317.937 282.156 358.8 282.156C399.663 282.156 432.553 315.711 432.553 358.9C432.553 402.089 399.663 435.643 358.8 435.643Z"
fill="currentColor"
/>
<path
d="M599.326 305.744C577.732 305.744 556.802 314.382 545.838 329V309.066H502.649V541.622H545.838V457.57C556.802 471.191 577.067 478.5 599.326 478.5C645.837 478.5 682.382 441.955 682.382 392.122C682.382 342.289 645.837 305.744 599.326 305.744ZM592.017 440.959C567.433 440.959 545.506 421.69 545.506 392.122C545.506 362.554 567.433 343.285 592.017 343.285C616.602 343.285 638.528 362.554 638.528 392.122C638.528 421.69 616.602 440.959 592.017 440.959Z"
fill="currentColor"
/>
<path
d="M784.046 305.744C736.871 305.744 699.662 342.621 699.662 392.122C699.662 441.623 732.22 478.5 785.375 478.5C828.896 478.5 856.803 452.254 865.441 422.686H823.249C817.933 434.979 802.983 443.616 785.043 443.616C762.784 443.616 745.841 428.002 741.854 405.743H867.434V388.8C867.434 343.617 835.873 305.744 784.046 305.744ZM742.186 375.179C746.838 354.249 764.113 340.627 785.043 340.627C807.302 340.627 824.245 355.245 826.239 375.179H742.186Z"
fill="currentColor"
/>
<path
d="M982.38 305.744C963.111 305.744 942.845 314.382 933.543 328.667V309.066H890.354V475.177H933.543V385.81C933.543 359.896 947.496 342.953 970.087 342.953C991.017 342.953 1002.31 358.9 1002.31 381.159V475.177H1045.5V374.182C1045.5 332.986 1020.25 305.744 982.38 305.744Z"
fill="currentColor"
/>
<path
d="M1156.12 242.628L1062.1 475.184H1108.28L1128.21 424.354H1235.19L1255.12 475.184H1301.96L1208.61 242.628H1156.12ZM1143.16 385.816L1181.7 288.475L1219.9 385.816H1143.16Z"
fill="currentColor"
/>
<path d="M1363.42 242.628H1319.57V475.184H1363.42V242.628Z" fill="currentColor" />
</g>
<defs>
<clipPath id="clip0_1637_2932">
<rect
width="1603.2"
height="717.6"
fill="white"
transform="translate(0 0.0999756)"
/>
</clipPath>
</defs>
</svg>
</a>
</div>
<h2 className="mt-12 font-mono text-xs">Individual Sponsors</h2>
+1 -1
View File
@@ -80,7 +80,7 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
return (
<DocsPage
toc={toc}
TOC={{
tableOfContent={{
style: 'clerk',
}}
>
+42
View File
@@ -16,6 +16,7 @@ export default function Layout({ children }: LayoutProps<'/docs'>) {
const base = baseOptions();
return (
<<<<<<< HEAD
<>
<DocsLayout
{...base}
@@ -50,6 +51,47 @@ export default function Layout({ children }: LayoutProps<'/docs'>) {
const meta = source.getNodeMeta(node);
if (!meta || !node.icon) return option;
const color = `var(--${getSection(meta.path)}-color, var(--color-fd-foreground))`;
=======
<DocsLayout
{...base}
tree={source.getPageTree()}
// just icon items
links={linkItems.filter((item) => item.type === 'icon')}
nav={{
...base.nav,
title: (
<>
{logo}
<span className="font-medium in-[.uwu]:hidden max-md:hidden">Fumadocs</span>
</>
),
}}
tabs={{
transform(option, node) {
const meta = source.getNodeMeta(node);
if (!meta || !node.icon) return option;
const color = `var(--${getSection(meta.path)}-color, var(--color-fd-foreground))`;
return {
...option,
icon: (
<div
className="[&_svg]:size-full rounded-lg size-full text-(--tab-color) max-md:bg-(--tab-color)/10 max-md:border max-md:p-1.5"
style={
{
'--tab-color': color,
} as object
}
>
{node.icon}
</div>
),
};
},
}}
>
{children}
>>>>>>> dev
return {
...option,
+5 -1
View File
@@ -1,9 +1,13 @@
import { baseOptions, linkItems, logo } from '@/components/layouts/shared';
import { source } from '@/lib/source';
import { getSection } from '@/lib/source/navigation';
<<<<<<< HEAD
import { getSidebarTabs } from '@hanzo/docs-base-ui/components/sidebar/tabs/index';
=======
>>>>>>> dev
import type { ReactNode } from 'react';
import { LayoutClient } from './client';
import { getLayoutTabs } from '@hanzo/docs-ui/layouts/shared';
export function FluxLayout({ children }: { children: ReactNode }) {
const base = baseOptions();
@@ -24,7 +28,7 @@ export function FluxLayout({ children }: { children: ReactNode }) {
),
}}
sidebar={{
tabs: getSidebarTabs(source.getPageTree(), {
tabs: getLayoutTabs(source.getPageTree(), {
transform(option, node) {
const meta = source.getNodeMeta(node);
if (!meta || !node.icon) return option;
+2 -2
View File
@@ -364,14 +364,14 @@ export function AISearchPanel() {
<div
className={cn(
'overflow-hidden z-30 bg-fd-card text-fd-card-foreground [--ai-chat-width:400px] 2xl:[--ai-chat-width:460px]',
'max-lg:fixed max-lg:inset-x-2 max-lg:top-4 max-lg:border max-lg:rounded-2xl max-lg:shadow-xl',
'max-lg:fixed max-lg:inset-x-2 max-lg:inset-y-4 max-lg:border max-lg:rounded-2xl max-lg:shadow-xl',
'lg:sticky lg:top-0 lg:h-dvh lg:border-s lg:ms-auto lg:in-[#nd-docs-layout]:[grid-area:toc] lg:in-[#nd-notebook-layout]:row-span-full lg:in-[#nd-notebook-layout]:col-start-5',
open
? 'animate-fd-dialog-in lg:animate-[ask-ai-open_200ms]'
: 'animate-fd-dialog-out lg:animate-[ask-ai-close_200ms]',
)}
>
<div className="flex flex-col size-full p-2 max-lg:max-h-[80dvh] lg:p-3 lg:w-(--ai-chat-width)">
<div className="flex flex-col size-full p-2 lg:p-3 lg:w-(--ai-chat-width)">
<AISearchPanelHeader />
<AISearchPanelList className="flex-1" />
<div className="rounded-xl border bg-fd-secondary text-fd-secondary-foreground shadow-sm has-focus-visible:shadow-md">
+4 -4
View File
@@ -45,14 +45,14 @@ export const registry: Registry = {
description: 'Replace Docs Layout (Minimal)',
files: [
{
type: 'block',
type: 'layout',
path: 'components/registry/layout/docs-min.tsx',
target: 'components/layout/docs/index.tsx',
target: '<dir>/docs/index.tsx',
},
{
type: 'block',
type: 'layout',
path: 'components/registry/layout/page-min.tsx',
target: 'components/layout/docs/page.tsx',
target: '<dir>/docs/page.tsx',
},
],
unlisted: true,
+1 -1
View File
@@ -145,7 +145,7 @@ export const source = loader({
</Accordion>
<Accordion id='multi-docs' title="How to implement multi-docs?">
We recommend to use [Sidebar Tabs](/docs/ui/layouts/docs#sidebar-tabs).
We recommend using [Layout Tabs](/docs/ui/layouts/docs#layout-tabs).
</Accordion>
</Accordions>
@@ -59,7 +59,6 @@ export const i18nUI = defineI18nUI(i18n, {
// [!code highlight]
export function baseOptions(locale: string): BaseLayoutProps {
return {
LanguageSwitch: true, // recommended [!code ++]
// different props based on `locale`
};
}
@@ -84,7 +84,6 @@ export const i18nUI = defineI18nUI(i18n, {
// [!code highlight]
export function baseOptions(locale: string): BaseLayoutProps {
return {
LanguageSwitch: true, // recommended [!code ++]
// different props based on `locale`
};
}
@@ -43,7 +43,6 @@ export const i18nUI = defineI18nUI(i18n, {
// [!code highlight]
export function baseOptions(locale: string): BaseLayoutProps {
return {
LanguageSwitch: true, // recommended [!code ++]
// different props based on `locale`
};
}
@@ -53,8 +53,8 @@ For example:
</Files>
<Callout title="Good to Know">
When grouping with folders, you can display them as tabs [using Sidebar
Tabs](/docs/ui/layouts/docs#sidebar-tabs).
When grouping with folders, you can display them as tabs [using Layout
Tabs](/docs/ui/layouts/docs#layout-tabs).
</Callout>
### Full Versioning
@@ -100,7 +100,11 @@ For example, when you are opening root folder `framework`, the other folders (e.
<Callout title='Hanzo Docs UI'>
<<<<<<< HEAD
Hanzo Docs UI renders root folders as [Sidebar Tabs](/docs/ui/layouts/docs#sidebar-tabs), which allows user to switch between them.
=======
Fumadocs UI renders root folders as [Layout Tabs](/docs/ui/layouts/docs#layout-tabs), which allows users to switch between them.
>>>>>>> dev
</Callout>
+299
View File
@@ -0,0 +1,299 @@
---
title: Docs Layout
description: The layout of documentation
---
The layout of documentation pages, it includes a sidebar and **mobile-only** navbar/header.
![preview](./docs.png)
<Customisation />
## Usage
Pass your page tree to the component.
```tsx title="layout.tsx"
import { DocsLayout } from '@hanzo/docs-ui/layouts/docs';
import { baseOptions } from '@/lib/layout.shared';
import type { ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout {...baseOptions()} tree={tree}>
{children}
</DocsLayout>
);
}
```
See detailed docs for [`links`](/docs/ui/layouts/links) and [`nav`](/docs/ui/layouts/nav) options.
### References
<auto-type-table
cwd path="content/docs/ui/props.ts"
type="Omit<DocsLayoutProps, 'children' | 'disableThemeSwitch'>"
/>
## Sidebar
```tsx title="layout.tsx"
import { DocsLayout } from '@hanzo/docs-ui/layouts/docs';
<DocsLayout
sidebar={{
enabled: true,
}}
/>;
```
### Sidebar Items
Customise sidebar navigation links.
<div className='flex justify-center items-center *:max-w-[200px] bg-linear-to-br from-fd-primary/10 rounded-xl border'>
![Sidebar](/docs/sidebar.png)
</div>
Sidebar items are rendered from the page tree you passed to `<DocsLayout />`.
For page tree from [`loader()`](/docs/headless/source-api), it generates the tree from your file structure, see [available patterns](/docs/page-conventions).
```tsx title="layout.tsx"
import { DocsLayout } from '@hanzo/docs-ui/layouts/docs';
import { source } from '@/lib/source';
import type { ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout
tree={source.getPageTree()}
// other props
>
{children}
</DocsLayout>
);
}
```
You may hardcode it too:
```tsx title="layout.tsx"
import { DocsLayout } from '@hanzo/docs-ui/layouts/docs';
import type { ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout
tree={{
name: 'docs',
children: [],
}}
// other props
>
{children}
</DocsLayout>
);
}
```
### Layout Tabs (Dropdown) [#layout-tabs]
Layout Tabs are folders with tab-like behaviours, only the content of opened tab will be visible.
<div className='flex justify-center items-center *:max-w-[360px] bg-linear-to-br from-fd-primary/10 rounded-xl border'>
![Layout Tabs](/docs/sidebar-tabs.png)
</div>
By default, the tab trigger will be displayed as a **Dropdown** component (hidden unless one of its items is active).
You can add items by marking folders as [Root Folders](/docs/page-conventions#root-folder), create a `meta.json` file in the folder:
```json title="content/docs/my-folder/meta.json"
{
"title": "Name of Folder",
"description": "The description of root folder (optional)",
"root": true
}
```
Or specify them explicitly:
```tsx title="/app/docs/layout.tsx"
import { DocsLayout } from '@hanzo/docs-ui/layouts/docs';
<DocsLayout
tabs={[
{
title: 'Components',
description: 'Hello World!',
// active for `/docs/components` and sub routes like `/docs/components/button`
url: '/docs/components',
// optionally, you can specify a set of urls which activates the item
// urls: new Set(['/docs/test', '/docs/components']),
},
]}
/>;
```
Set it to `false` to disable:
```tsx
import { DocsLayout } from '@hanzo/docs-ui/layouts/docs';
<DocsLayout tabs={false} />;
```
<Callout title="Want further customisations?">
You can specify a `banner` to the [Docs Layout](/docs/ui/layouts/docs) component.
```tsx
import { DocsLayout, type DocsLayoutProps } from '@hanzo/docs-ui/layouts/docs';
import type { ReactNode } from 'react';
import { baseOptions } from '@/lib/layout.shared';
import { source } from '@/lib/source';
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout
{...baseOptions()}
tree={source.getPageTree()}
sidebar={{
// [!code ++]
banner: <div>Hello World</div>,
}}
>
{children}
</DocsLayout>
);
}
```
</Callout>
#### Decoration
Change the icon/styles of layout tabs.
```tsx
import { DocsLayout } from '@hanzo/docs-ui/layouts/docs';
<DocsLayout
tabs={{
transform: (option, node) => ({
...option,
icon: <MyIcon />,
}),
}}
/>;
```
### Sidebar Components
You can replace certain components for rendering page tree.
```tsx tab="layout.tsx"
import { DocsLayout } from '@hanzo/docs-ui/layouts/docs';
import { SidebarSeparator } from './layout.client';
<DocsLayout
sidebar={{
enabled: true,
components: {
Separator: SidebarSeparator,
},
}}
/>;
```
```tsx tab="layout.client.tsx"
'use client';
import * as Base from '@hanzo/docs-ui/components/sidebar/base';
export function SidebarSeparator({ className, style, children, ...props }: ComponentProps<'p'>) {
const depth = Base.useFolderDepth();
return (
<Base.SidebarSeparator
className={cn('[&_svg]:size-4 [&_svg]:shrink-0', className)}
style={{
paddingInlineStart: `calc(${2 + 3 * depth} * var(--spacing))`,
...style,
}}
{...props}
>
{children}
</Base.SidebarSeparator>
);
}
```
### References
<auto-type-table cwd path="content/docs/ui/props.ts" name="SidebarProps" />
## Advanced
### Prefetching
Fumadocs use the `<Link />` component of your React framework, and keep their default prefetch behaviours.
On Vercel, prefetch requests may cause a higher usage of serverless functions and Data Cache.
It can also hit the limits of some other hosting platforms.
You can disable prefetching to reduce the amount of prefetch requests, or enable explicitly:
```tsx
import { DocsLayout } from '@hanzo/docs-ui/layouts/docs';
<DocsLayout sidebar={{ prefetch: false }} />;
```
### The Layout System
Handling layout is challenging, Fumadocs UI needed an approach that is:
- **Composable:** Layout components should manage their position and size effortlessly, ideally in place.
- **Flexible:** The system should avoid reliance on fixed values or heights, enabling seamless integration of external components, such as AI chat interfaces.
- **Cohesive:** Components should respond to changes in others, for instance, by animating sidebar collapses.
- **Predictable:** Layout properties should remain centralized, allowing the final result to be readily anticipated from the source code.
- **Compatible:** The solution should work on older browsers by leveraging only Baseline Widely Available CSS features.
Fumadocs UI does this with a grid system:
```css
#nd-docs-layout {
grid-template:
'sidebar header toc'
'sidebar toc-popover toc'
'sidebar main toc' 1fr / minmax(var(--fd-sidebar-col), 1fr) minmax(0, var(--fd-page-col))
minmax(min-content, 1fr);
--fd-docs-row-1: var(--fd-banner-height, 0px);
--fd-docs-row-2: calc(var(--fd-docs-row-1) + var(--fd-header-height));
--fd-docs-row-3: calc(var(--fd-docs-row-2) + var(--fd-toc-popover-height));
--fd-sidebar-col: var(--fd-sidebar-width);
--fd-page-col: calc(
var(--fd-layout-width, 97rem) - var(--fd-sidebar-width) - var(--fd-toc-width)
);
--fd-sidebar-width: 0px;
--fd-toc-width: 0px;
--fd-header-height: 0px;
--fd-toc-popover-height: 0px;
}
```
- The layout container uses grid layout, `grid-template` is set to produce predictable result.
- `--fd-docs-row-*` define the top offset of each row, allowing elements with `position: sticky` to hook a correct top offset.
- `--fd-*-width` and `--fd-*-height` are set by layout components using CSS, they are essential to maintain the grid structure, or calculating `--fd-docs-row-*`.
- `--fd-*-col` are dynamic values, updated as state changes (e.g. `--fd-sidebar-col` becomes `0px` when the sidebar is collapsed).
Both default and the notebook layout utilize this system.
@@ -0,0 +1,95 @@
---
title: Notebook Layout
description: A compact version of Docs Layout
---
A compact version of [`<DocsLayout />`](/docs/ui/layouts/docs).
![Notebook](/docs/notebook.png)
<Customisation />
## Usage
Enable the notebook layout with `@hanzo/docs-ui/layouts/notebook`.
```tsx title="layout.tsx"
import { DocsLayout } from '@hanzo/docs-ui/layouts/notebook'; // [!code highlight]
import { baseOptions } from '@/lib/layout.shared';
import { source } from '@/lib/source';
import type { ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout {...baseOptions()} tree={source.getPageTree()}>
{children}
</DocsLayout>
);
}
```
Make sure to update your page import too:
```tsx title="page.tsx"
import { ... } from '@hanzo/docs-ui/layouts/docs/page'; // [!code --]
import { ... } from '@hanzo/docs-ui/layouts/notebook/page'; // [!code ++]
```
## Configurations
The options are inherited from [Docs Layout](/docs/ui/layouts/docs), with minor differences:
- sidebar/navbar cannot be replaced, Notebook layout is more opinionated than the default one.
- additional options (see below).
### Tab Mode
Configure the style of [Layout Tabs](/docs/ui/layouts/docs#layout-tabs).
![Notebook](/docs/notebook-tab-mode.png)
```tsx title="layout.tsx"
import { DocsLayout } from '@hanzo/docs-ui/layouts/notebook';
import { baseOptions } from '@/lib/layout.shared';
import { source } from '@/lib/source';
import type { ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout
{...baseOptions()}
tabMode="navbar" // [!code ++]
tree={source.getPageTree()}
>
{children}
</DocsLayout>
);
}
```
### Nav Mode
Configure the style of navbar.
![Notebook](/docs/notebook-nav-mode.png)
```tsx title="layout.tsx"
import { DocsLayout } from '@hanzo/docs-ui/layouts/notebook';
import { baseOptions } from '@/lib/layout.shared';
import { source } from '@/lib/source';
import type { ReactNode } from 'react';
export default function Layout({ children }: { children: ReactNode }) {
const { nav, ...base } = baseOptions();
return (
<DocsLayout
{...base}
nav={{ ...nav, mode: 'top' }} // [!code ++]
tree={source.getPageTree()}
>
{children}
</DocsLayout>
);
}
```
+210 -16
View File
@@ -45,11 +45,12 @@ This will render the title in the MDX body.
</Callout>
### Edit on GitHub
### Page Actions
Link to the original GitHub file with your component.
Show GitHub link, and shortcut links for AIs.
```tsx
<<<<<<< HEAD
import { DocsPage } from '@hanzo/docs-ui/layouts/<layout>/page';
<DocsPage>
@@ -61,6 +62,14 @@ import { DocsPage } from '@hanzo/docs-ui/layouts/<layout>/page';
>
Edit on GitHub
</a>
=======
import { DocsPage, ViewOptionsPopover } from '@hanzo/docs-ui/layouts/<layout>/page';
const githubUrl = `https://github.com/fuma-nama/fumadocs/blob/main/content/docs/${page.path}`
<DocsPage>
<ViewOptionsPopover githubUrl={githubUrl} />
>>>>>>> dev
</DocsPage>;
```
@@ -142,27 +151,28 @@ import { DocsPage } from '@hanzo/docs-ui/layouts/<layout>/page';
<DocsPage toc={headings}>...</DocsPage>;
```
You can customise or disable it with the `TOC` prop, or with `TOCPopover` on smaller devices.
You can customise it with `tableOfContent`, or with `tableOfContentPopover` on smaller devices.
<<<<<<< HEAD
```tsx
import { DocsPage, type TOCMainProps } from '@hanzo/docs-ui/layouts/<layout>/page';
=======
```tsx title="page.tsx"
import { DocsPage } from '@hanzo/docs-ui/layouts/<layout>/page';
>>>>>>> dev
// pass options:
return (
<DocsPage TOC={options} TOCPopover={options}>
<DocsPage tableOfContent={options} tableOfContentPopover={options}>
...
</DocsPage>
);
// or to replace renderer:
function TOC(props: TOCMainProps) {
return <div />;
}
return <DocsPage TOC={TOC}>...</DocsPage>;
```
<<<<<<< HEAD
<auto-type-table type="import('@hanzo/docs-ui/layouts/docs/page').TOCMainProps" />
=======
<auto-type-table type="import('@hanzo/docs-ui/layouts/docs/page/slots/toc').TOCProps" />
>>>>>>> dev
#### Style
@@ -172,7 +182,7 @@ You can choose another style for TOC, like `clerk` inspired by https://clerk.com
import { DocsPage } from '@hanzo/docs-ui/layouts/<layout>/page';
<DocsPage
TOC={{
tableOfContent={{
style: 'clerk',
}}
>
@@ -180,24 +190,208 @@ import { DocsPage } from '@hanzo/docs-ui/layouts/<layout>/page';
</DocsPage>;
```
You can also style the TOC title with the `toc-title` ID.
#### Replace TOC
To replace the component:
```tsx tab="page.tsx"
import { TOCProvider, TOC, TOCPopover } from './my-toc';
return (
<DocsPage
slots={{
toc: {
provider: TOCProvider,
main: TOC,
popover: TOCPopover,
},
}}
>
...
</DocsPage>
);
```
```tsx tab="my-toc.tsx"
'use client';
import type {
TOCProps,
TOCPopoverProps,
TOCProviderProps,
} from '@hanzo/docs-ui/layouts/<layout>/page/slots/toc';
export function TOCProvider({ toc, children }: TOCProviderProps) {
// receive TOC items, you can pass it down via React contexts
console.log(toc);
return children;
}
export function TOC(props: TOCProps) {
return <div>Hello World</div>;
}
export function TOCPopover(props: TOCPopoverProps) {
return <div>Popover</div>;
}
```
You can start from the default implementation with Fumadocs CLI:
```bash tab="Docs Layout"
npx @hanzo/docs-cli add slots/docs/page/toc
```
```bash tab="Notebook Layout"
npx @hanzo/docs-cli add slots/notebook/page/toc
```
```bash tab="Flux Layout"
npx @hanzo/docs-cli add slots/flux/page/toc
```
### Footer
Footer is a navigation element that has two buttons to jump to the next and previous pages. When not specified, it shows the neighbour pages found from page tree.
Customise the footer with the `Footer` prop.
Customise the footer with the `footer` prop:
```tsx
import { DocsPage } from '@hanzo/docs-ui/layouts/<layout>/page';
<DocsPage Footer={options}>...</DocsPage>;
<DocsPage footer={options}>...</DocsPage>;
```
<auto-type-table type="Omit<import('@hanzo/docs-ui/layouts/docs/page').FooterProps, keyof React.ComponentProps<'div'>>" />
#### Replace Footer
To replace the component:
```tsx tab="page.tsx"
import { DocsPage } from '@hanzo/docs-ui/layouts/<layout>/page';
import { Footer } from './my-footer';
<DocsPage
slots={{
footer: Footer,
}}
/>;
```
```tsx tab="my-footer.tsx"
'use client';
import type { FooterProps } from '@hanzo/docs-ui/layouts/<layout>/page/slots/footer';
export function Footer(props: FooterProps) {
return <div>Hello World</div>;
}
```
You can start from the default implementation with Fumadocs CLI:
```bash tab="Docs Layout"
npx @hanzo/docs-cli add slots/docs/page/footer
```
```bash tab="Notebook Layout"
npx @hanzo/docs-cli add slots/notebook/page/footer
```
```bash tab="Flux Layout"
npx @hanzo/docs-cli add slots/flux/page/footer
```
### Breadcrumb
A navigation element, shown only when user is navigating in folders.
<<<<<<< HEAD
<auto-type-table type="Omit<import('@hanzo/docs-ui/layouts/docs/page').BreadcrumbProps, keyof React.ComponentProps<'div'>>" />
=======
Customise the breadcrumb with the `breadcrumb` option:
```tsx
import { DocsPage } from '@hanzo/docs-ui/layouts/<layout>/page';
<DocsPage breadcrumb={options}>...</DocsPage>;
```
<auto-type-table type="Omit<import('@hanzo/docs-ui/layouts/docs/page').BreadcrumbProps, keyof React.ComponentProps<'div'>>" />
#### Replace Breadcrumb
To replace the component:
```tsx tab="page.tsx"
import { DocsPage } from '@hanzo/docs-ui/layouts/<layout>/page';
import { Breadcrumb } from './my-breadcrumb';
<DocsPage
slots={{
breadcrumb: Breadcrumb,
}}
/>;
```
```tsx tab="my-breadcrumb.tsx"
'use client';
import type { BreadcrumbProps } from '@hanzo/docs-ui/layouts/<layout>/page/slots/breadcrumb';
export function Breadcrumb(props: BreadcrumbProps) {
return <div>Hello World</div>;
}
```
You can start from the default implementation with Fumadocs CLI:
```bash tab="Docs Layout"
npx @hanzo/docs-cli add slots/docs/page/breadcrumb
```
```bash tab="Notebook Layout"
npx @hanzo/docs-cli add slots/notebook/page/breadcrumb
```
```bash tab="Flux Layout"
npx @hanzo/docs-cli add slots/flux/page/breadcrumb
```
### Container
Container controls the article wrapper around breadcrumb, body, and footer.
To replace the page container:
```tsx tab="page.tsx"
import { DocsPage } from '@hanzo/docs-ui/layouts/<layout>/page';
import { Container } from './my-container';
<DocsPage
slots={{
container: Container,
}}
/>;
```
```tsx tab="my-container.tsx"
'use client';
export function Container(props: React.ComponentProps<'article'>) {
return <div />;
}
```
You can start from the default implementation with Fumadocs CLI:
```bash tab="Docs Layout"
npx @hanzo/docs-cli add slots/docs/page/container
```
```bash tab="Notebook Layout"
npx @hanzo/docs-cli add slots/notebook/page/container
```
```bash tab="Flux Layout"
npx @hanzo/docs-cli add slots/flux/page/container
```
>>>>>>> dev
+4 -4
View File
@@ -53,9 +53,9 @@ const config: NextConfig = {
// (a) fumadocs -> @hanzo/docs equivalents (real components/APIs)
'fumadocs-ui': '@hanzo/docs-base-ui',
'fumadocs-core': '@hanzo/docs-core',
'fumadocs-mdx': '@hanzo/docs-mdx',
'@hanzo/docs-ui': '@hanzo/docs-base-ui',
'@hanzo/docs-core': '@hanzo/docs-core',
'@hanzo/docs-mdx': '@hanzo/docs-mdx',
'@hanzo/docs-core': '@hanzo/docs-core',
'@hanzo/docs-mdx': '@hanzo/docs-mdx',
@@ -164,7 +164,7 @@ const config: NextConfig = {
],
typescript: {
// Upstream project docs import fumadocs-ui which is aliased to @hanzo/docs-base-ui
// Upstream project docs import @hanzo/docs-ui which is aliased to @hanzo/docs-base-ui
// at webpack level, but TS type-checker doesn't see webpack aliases.
ignoreBuildErrors: true,
},
+12
View File
@@ -15,6 +15,7 @@
"dependencies": {
"@ai-sdk/openai-compatible": "^2.0.37",
"@ai-sdk/react": "^3.0.136",
<<<<<<< HEAD
"@hanzo/docs": "workspace:*",
"@hanzo/docs-base-ui": "workspace:*",
"@hanzo/docs-core": "workspace:*",
@@ -30,6 +31,13 @@
"@hanzo/logo": "^1.0.5",
"@mixedbread/sdk": "^0.60.0",
"@openrouter/ai-sdk-provider": "^2.3.3",
=======
"@hanzo/docs-story": "workspace:*",
"@mixedbread/sdk": "^0.60.0",
"@openrouter/ai-sdk-provider": "^2.3.3",
"@orama/core": "^1.2.19",
"@orama/orama": "^3.1.18",
>>>>>>> dev
"@paper-design/shaders-react": "^0.0.72",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-hover-card": "^1.1.15",
@@ -71,7 +79,11 @@
"zod": "^4.3.6"
},
"devDependencies": {
<<<<<<< HEAD
"@hanzo/docs-cli": "workspace:*",
=======
"@hanzo/docs-cli": "workspace:*",
>>>>>>> dev
"@next/bundle-analyzer": "16.2.1",
"@next/env": "16.2.1",
"@next/eslint-plugin-next": "16.2.1",
+10 -4
View File
@@ -11,20 +11,26 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/mdx": "^5.0.0",
"@astrojs/react": "^5.0.0",
"@astrojs/mdx": "^5.0.2",
"@astrojs/react": "^5.0.1",
"@orama/orama": "^3.1.18",
<<<<<<< HEAD
"astro": "^6.0.4",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
=======
"astro": "^6.0.8",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
>>>>>>> dev
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sharp": "^0.34.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.2.1"
"tailwindcss": "^4.2.2"
}
}
+3 -3
View File
@@ -13,19 +13,19 @@
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"next": "16.2.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@tailwindcss/postcss": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3"
}
}
@@ -8,10 +8,17 @@
"start": "next start"
},
"dependencies": {
<<<<<<< HEAD
"@hanzo/docs-content-collections": "workspace:*",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-base-ui": "workspace:*",
"next": "16.1.6",
=======
"@hanzo/docs-content-collections": "workspace:*",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
"next": "16.2.1",
>>>>>>> dev
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
-1
View File
@@ -20,7 +20,6 @@ export const i18nUI = defineI18nUI(i18n, {
export function baseOptions(locale: string): BaseLayoutProps {
return {
LanguageSwitch: true,
nav: {
title: locale === 'cn' ? 'Chinese Docs' : 'English Docs',
url: `/${locale}`,
+7
View File
@@ -10,10 +10,17 @@
},
"dependencies": {
"@orama/tokenizers": "^3.1.18",
<<<<<<< HEAD
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-base-ui": "workspace:*",
"next": "16.1.6",
=======
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
"next": "16.2.1",
>>>>>>> dev
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
+3 -3
View File
@@ -13,18 +13,18 @@
"@hanzo/docs-base-ui": "workspace:*",
"github-slugger": "^2.0.0",
"html-react-parser": "^5.2.17",
"next": "16.1.6",
"next": "16.2.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"shiki": "^4.0.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3"
}
}
+9 -2
View File
@@ -9,20 +9,27 @@
"types:check": "@hanzo/docs-mdx && next typegen && tsc --noEmit"
},
"dependencies": {
<<<<<<< HEAD
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-base-ui": "workspace:*",
"next": "16.1.6",
=======
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
"next": "16.2.1",
>>>>>>> dev
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@tailwindcss/postcss": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3"
}
}
+3 -3
View File
@@ -14,20 +14,20 @@
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"next": "16.2.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@tailwindcss/postcss": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.8",
"serve": "^14.2.6",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3"
}
}
+3 -3
View File
@@ -13,19 +13,19 @@
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"next": "16.2.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@tailwindcss/postcss": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3"
}
}
+10 -2
View File
@@ -9,21 +9,29 @@
"types:check": "@hanzo/docs-mdx && next typegen && tsc --noEmit"
},
"dependencies": {
<<<<<<< HEAD
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-obsidian": "workspace:*",
"@hanzo/docs-base-ui": "workspace:*",
"next": "16.1.6",
=======
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"fumadocs-obsidian": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
"next": "16.2.1",
>>>>>>> dev
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@tailwindcss/postcss": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3"
}
}
+10 -2
View File
@@ -10,23 +10,31 @@
"types:check": "@hanzo/docs-mdx && tsc --noEmit"
},
"dependencies": {
<<<<<<< HEAD
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-openapi": "workspace:*",
"@hanzo/docs-base-ui": "workspace:*",
"next": "16.1.6",
=======
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-openapi": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
"next": "16.2.1",
>>>>>>> dev
"react": "^19.2.4",
"react-dom": "^19.2.4",
"shiki": "^4.0.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@tailwindcss/postcss": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.8",
"rimraf": "^6.1.3",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3"
}
}
+10 -2
View File
@@ -11,22 +11,30 @@
"types:check": "@hanzo/docs-mdx && tsc --noEmit"
},
"dependencies": {
<<<<<<< HEAD
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-python": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
"next": "16.1.6",
=======
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"fumadocs-python": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
"next": "16.2.1",
>>>>>>> dev
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
"@tailwindcss/postcss": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3"
}
}
@@ -3,7 +3,6 @@ import { i18n } from '@/lib/i18n';
export function baseOptions(locale = i18n.defaultLanguage): BaseLayoutProps {
return {
LanguageSwitch: true,
nav: {
title: `React Router ${locale}`,
},
+3 -3
View File
@@ -22,14 +22,14 @@
},
"devDependencies": {
"@react-router/dev": "^7.13.1",
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"react-router-devtools": "^6.2.0",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^8.0.0"
"vite": "^8.0.1"
}
}
+3 -3
View File
@@ -21,14 +21,14 @@
},
"devDependencies": {
"@react-router/dev": "^7.13.1",
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"react-router-devtools": "^6.2.0",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^8.0.0"
"vite": "^8.0.1"
}
}
+3 -3
View File
@@ -21,15 +21,15 @@
},
"devDependencies": {
"@react-router/dev": "^7.13.1",
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-rsc": "^0.5.21",
"react-router-devtools": "^6.2.0",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^8.0.0"
"vite": "^8.0.1"
}
}
+3 -3
View File
@@ -23,15 +23,15 @@
},
"devDependencies": {
"@react-router/dev": "^7.13.1",
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"react-router-devtools": "^6.2.0",
"serve": "^14.2.6",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^8.0.0"
"vite": "^8.0.1"
}
}
+10 -3
View File
@@ -11,10 +11,17 @@
"dependencies": {
"@react-router/node": "^7.13.1",
"@react-router/serve": "^7.13.1",
<<<<<<< HEAD
"@takumi-rs/image-response": "^0.72.0",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
=======
"@takumi-rs/image-response": "^0.73.1",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
>>>>>>> dev
"isbot": "^5.1.36",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
@@ -24,14 +31,14 @@
},
"devDependencies": {
"@react-router/dev": "^7.13.1",
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"react-router-devtools": "^6.2.0",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^8.0.0"
"vite": "^8.0.1"
}
}
+12 -3
View File
@@ -11,26 +11,35 @@
},
"dependencies": {
"@orama/tokenizers": "^3.1.18",
<<<<<<< HEAD
"@tanstack/react-router": "1.166.7",
"@tanstack/react-router-devtools": "1.166.7",
"@tanstack/react-start": "1.166.8",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
=======
"@tanstack/react-router": "1.168.1",
"@tanstack/react-router-devtools": "1.166.10",
"@tanstack/react-start": "1.167.2",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
>>>>>>> dev
"lucide-static": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"vite": "^8.0.0"
"vite": "^8.0.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3"
}
}
@@ -3,7 +3,6 @@ import { i18n } from '@/lib/i18n';
export function baseOptions(locale: string = i18n.defaultLanguage): BaseLayoutProps {
return {
LanguageSwitch: true,
nav: {
title: `Tanstack Start ${locale}`,
},
+13 -4
View File
@@ -11,27 +11,36 @@
"types:check": "@hanzo/docs-mdx && tsc --noEmit"
},
"dependencies": {
<<<<<<< HEAD
"@tanstack/react-router": "1.166.7",
"@tanstack/react-router-devtools": "1.166.7",
"@tanstack/react-start": "1.166.8",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
=======
"@tanstack/react-router": "1.168.1",
"@tanstack/react-router-devtools": "1.166.10",
"@tanstack/react-start": "1.167.2",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
>>>>>>> dev
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"vite": "^8.0.0"
"vite": "^8.0.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"nitro": "^3.0.260311-beta",
"srvx": "^0.11.9",
"tailwindcss": "^4.2.1",
"srvx": "^0.11.12",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3"
}
}
+14 -4
View File
@@ -12,6 +12,7 @@
},
"dependencies": {
"@orama/orama": "^3.1.18",
<<<<<<< HEAD
"@tanstack/react-router": "1.166.7",
"@tanstack/react-router-devtools": "1.166.7",
"@tanstack/react-start": "1.166.8",
@@ -19,14 +20,23 @@
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
=======
"@tanstack/react-router": "1.168.1",
"@tanstack/react-router-devtools": "1.166.10",
"@tanstack/react-start": "1.167.2",
"@tanstack/start-static-server-functions": "1.166.17",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
>>>>>>> dev
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"vite": "^8.0.0"
"vite": "^8.0.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
@@ -34,8 +44,8 @@
"@vitejs/plugin-react": "^6.0.1",
"nitro": "^3.0.260311-beta",
"serve": "^14.2.6",
"srvx": "^0.11.9",
"tailwindcss": "^4.2.1",
"srvx": "^0.11.12",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3"
}
}
+13 -3
View File
@@ -10,6 +10,7 @@
"types:check": "@hanzo/docs-mdx && tsc --noEmit"
},
"dependencies": {
<<<<<<< HEAD
"@hanzo/docs-story": "workspace:*",
"@tanstack/react-router": "1.166.7",
"@tanstack/react-router-devtools": "1.166.7",
@@ -17,21 +18,30 @@
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
=======
"@hanzo/docs-story": "workspace:*",
"@tanstack/react-router": "1.168.1",
"@tanstack/react-router-devtools": "1.166.10",
"@tanstack/react-start": "1.167.2",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
>>>>>>> dev
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"ts-morph": "^27.0.2",
"vite": "^8.0.0"
"vite": "^8.0.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3"
}
}
+12 -3
View File
@@ -11,27 +11,36 @@
"types:check": "@hanzo/docs-mdx && tsc --noEmit"
},
"dependencies": {
<<<<<<< HEAD
"@tanstack/react-router": "1.166.7",
"@tanstack/react-router-devtools": "1.166.7",
"@tanstack/react-start": "1.166.8",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
=======
"@tanstack/react-router": "1.168.1",
"@tanstack/react-router-devtools": "1.166.10",
"@tanstack/react-start": "1.167.2",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
>>>>>>> dev
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"vite": "^8.0.0"
"vite": "^8.0.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"nitro": "^3.0.260311-beta",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3"
}
}
+3 -3
View File
@@ -20,14 +20,14 @@
"waku": "1.0.0-alpha.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^8.0.0",
"vite": "^8.0.1",
"vite-tsconfig-paths": "^6.1.1"
}
}
@@ -6,6 +6,5 @@ export function baseOptions(locale: string): BaseLayoutProps {
title: 'Waku',
url: `/${locale}`,
},
LanguageSwitch: true,
};
}
+10 -3
View File
@@ -10,10 +10,17 @@
"types:check": "@hanzo/docs-mdx && tsc --noEmit"
},
"dependencies": {
<<<<<<< HEAD
"@takumi-rs/image-response": "^0.72.0",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
=======
"@takumi-rs/image-response": "^0.73.1",
"@hanzo/docs-core": "workspace:*",
"@hanzo/docs-mdx": "workspace:*",
"@hanzo/docs-ui": "workspace:*",
>>>>>>> dev
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -22,14 +29,14 @@
"waku": "1.0.0-alpha.5"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/vite": "^4.2.2",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4 .2.1",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^8.0.0",
"vite": "^8.0.1",
"vite-tsconfig-paths": "^6.1.1"
}
}
+6 -6
View File
@@ -13,23 +13,23 @@
"format": "oxfmt",
"release": "turbo run build --filter=./packages/* && changeset publish && pnpm -r run postpublish",
"test": "vitest",
"types:check": "turbo run types:check",
"types:check": "turbo run types:check --continue",
"version": "changeset version && pnpm install --lockfile-only"
},
"devDependencies": {
"@changesets/cli": "^2.30.0",
"@eslint/js": "^10.0.1",
"bun": "1.3.10",
"bun": "1.3.11",
"concurrently": "^9.2.1",
"eslint": "^10.0.3",
"eslint": "^10.1.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-tailwindcss": "^3.18.2",
"oxfmt": "^0.40.0",
"oxfmt": "^0.41.0",
"rimraf": "^6.1.3",
"turbo": "2.8.16",
"turbo": "2.8.20",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.0",
"typescript-eslint": "^8.57.1",
"vitest": "^4.1.0"
},
"engines": {
+45
View File
@@ -1,5 +1,50 @@
# @fumadocs/base-ui
## 16.7.4
### Patch Changes
- 57c83a5: fix toc
- fumadocs-core@16.7.4
## 16.7.3
### Patch Changes
- 9580621: fix sidebar scroll area
- 7aa66f2: Redesign home layout navigation menu
- fumadocs-core@16.7.3
## 16.7.2
### Patch Changes
- 652c725: Simplify internal types
- fumadocs-core@16.7.2
## 16.7.1
### Patch Changes
- 11b8691: hotfix
- 75b0b94: Refactor TOC slot
- fumadocs-core@16.7.1
## 16.7.0
### Minor Changes
- 8bdee70: Implement renderer API for replacing layout components, deprecate old options
- bdffeba: Improved `defineI18nUI()` usage: allow language translations to be defined at root config.
- f45d703: stabilize Shiki factory API
### Patch Changes
- 3d17757: Improve `<GithubInfo />` component
- Updated dependencies [f45d703]
- Updated dependencies [45aa454]
- fumadocs-core@16.7.0
## 16.6.17
### Patch Changes
+103 -79
View File
@@ -1,6 +1,11 @@
{
<<<<<<< HEAD
"name": "@hanzo/docs-base-ui",
"version": "16.6.17",
=======
"name": "@hanzo/docs-base-ui",
"version": "16.7.4",
>>>>>>> dev
"description": "The Base UI version of Fumadocs UI",
"keywords": [
"Docs",
@@ -16,87 +21,100 @@
],
"type": "module",
"exports": {
"./style.css": "./dist/style.css",
"./css/*": "./css/*",
"./image-zoom.css": "./dist/image-zoom.css",
"./components/*": {
"types": "./dist/components/*.d.ts",
"import": "./dist/components/*.js",
"default": "./dist/components/*.js"
},
"./i18n": {
"types": "./dist/i18n.d.ts",
"import": "./dist/i18n.js"
},
"./layouts/home": {
"types": "./dist/layouts/home/index.d.ts",
"import": "./dist/layouts/home/index.js"
},
"./layouts/home/not-found": {
"types": "./dist/layouts/home/not-found.d.ts",
"import": "./dist/layouts/home/not-found.js"
},
"./layouts/home/navbar": {
"types": "./dist/layouts/home/navbar.d.ts",
"import": "./dist/layouts/home/navbar.js"
},
"./layouts/docs": {
"types": "./dist/layouts/docs/index.d.ts",
"import": "./dist/layouts/docs/index.js"
},
"./layouts/docs/page": {
"types": "./dist/layouts/docs/page/index.d.ts",
"import": "./dist/layouts/docs/page/index.js"
},
"./layouts/flux": {
"types": "./dist/layouts/flux/index.d.ts",
"import": "./dist/layouts/flux/index.js"
},
"./layouts/flux/page": {
"types": "./dist/layouts/flux/page/index.d.ts",
"import": "./dist/layouts/flux/page/index.js"
},
"./layouts/notebook": {
"types": "./dist/layouts/notebook/index.d.ts",
"import": "./dist/layouts/notebook/index.js"
},
"./layouts/notebook/page": {
"types": "./dist/layouts/notebook/page/index.d.ts",
"import": "./dist/layouts/notebook/page/index.js"
},
"./layouts/shared": {
"types": "./dist/layouts/shared/index.d.ts",
"import": "./dist/layouts/shared/index.js"
},
"./page": {
"types": "./dist/page.d.ts",
"import": "./dist/page.js"
},
"./provider/*": {
"types": "./dist/provider/*.d.ts",
"import": "./dist/provider/*.js"
},
"./contexts/*": {
"types": "./dist/contexts/*.d.ts",
"import": "./dist/contexts/*.js"
},
"./components/accordion": "./dist/components/accordion.js",
"./components/banner": "./dist/components/banner.js",
"./components/callout": "./dist/components/callout.js",
"./components/card": "./dist/components/card.js",
"./components/codeblock": "./dist/components/codeblock.js",
"./components/codeblock.rsc": "./dist/components/codeblock.rsc.js",
"./components/dialog/search": "./dist/components/dialog/search.js",
"./components/dialog/search-algolia": "./dist/components/dialog/search-algolia.js",
"./components/dialog/search-default": "./dist/components/dialog/search-default.js",
"./components/dialog/search-orama": "./dist/components/dialog/search-orama.js",
"./components/dynamic-codeblock": "./dist/components/dynamic-codeblock.js",
"./components/dynamic-codeblock.core": "./dist/components/dynamic-codeblock.core.js",
"./components/files": "./dist/components/files.js",
"./components/github-info": "./dist/components/github-info.js",
"./components/heading": "./dist/components/heading.js",
"./components/image-zoom": "./dist/components/image-zoom.js",
"./components/inline-toc": "./dist/components/inline-toc.js",
"./components/sidebar/base": "./dist/components/sidebar/base.js",
"./components/sidebar/link-item": "./dist/components/sidebar/link-item.js",
"./components/sidebar/page-tree": "./dist/components/sidebar/page-tree.js",
"./components/sidebar/tabs": "./dist/components/sidebar/tabs/index.js",
"./components/sidebar/tabs/dropdown": "./dist/components/sidebar/tabs/dropdown.js",
"./components/steps": "./dist/components/steps.js",
"./components/tabs": "./dist/components/tabs.js",
"./components/toc": "./dist/components/toc/index.js",
"./components/toc/clerk": "./dist/components/toc/clerk.js",
"./components/toc/default": "./dist/components/toc/default.js",
"./components/type-table": "./dist/components/type-table.js",
"./components/ui/accordion": "./dist/components/ui/accordion.js",
"./components/ui/button": "./dist/components/ui/button.js",
"./components/ui/collapsible": "./dist/components/ui/collapsible.js",
"./components/ui/navigation-menu": "./dist/components/ui/navigation-menu.js",
"./components/ui/popover": "./dist/components/ui/popover.js",
"./components/ui/scroll-area": "./dist/components/ui/scroll-area.js",
"./components/ui/tabs": "./dist/components/ui/tabs.js",
"./contexts/i18n": "./dist/contexts/i18n.js",
"./contexts/search": "./dist/contexts/search.js",
"./contexts/tree": "./dist/contexts/tree.js",
"./i18n": "./dist/i18n.js",
"./layouts/docs": "./dist/layouts/docs/index.js",
"./layouts/docs/page": "./dist/layouts/docs/page/index.js",
"./layouts/docs/page/slots/breadcrumb": "./dist/layouts/docs/page/slots/breadcrumb.js",
"./layouts/docs/page/slots/container": "./dist/layouts/docs/page/slots/container.js",
"./layouts/docs/page/slots/footer": "./dist/layouts/docs/page/slots/footer.js",
"./layouts/docs/page/slots/toc": "./dist/layouts/docs/page/slots/toc.js",
"./layouts/docs/slots/container": "./dist/layouts/docs/slots/container.js",
"./layouts/docs/slots/header": "./dist/layouts/docs/slots/header.js",
"./layouts/docs/slots/sidebar": "./dist/layouts/docs/slots/sidebar.js",
"./layouts/flux": "./dist/layouts/flux/index.js",
"./layouts/flux/page": "./dist/layouts/flux/page/index.js",
"./layouts/flux/page/slots/breadcrumb": "./dist/layouts/flux/page/slots/breadcrumb.js",
"./layouts/flux/page/slots/container": "./dist/layouts/flux/page/slots/container.js",
"./layouts/flux/page/slots/footer": "./dist/layouts/flux/page/slots/footer.js",
"./layouts/flux/page/slots/toc": "./dist/layouts/flux/page/slots/toc.js",
"./layouts/flux/slots/container": "./dist/layouts/flux/slots/container.js",
"./layouts/flux/slots/sidebar": "./dist/layouts/flux/slots/sidebar.js",
"./layouts/flux/slots/tab-dropdown": "./dist/layouts/flux/slots/tab-dropdown.js",
"./layouts/home": "./dist/layouts/home/index.js",
"./layouts/home/navbar": "./dist/layouts/home/navbar.js",
"./layouts/home/not-found": "./dist/layouts/home/not-found.js",
"./layouts/home/slots/container": "./dist/layouts/home/slots/container.js",
"./layouts/home/slots/header": "./dist/layouts/home/slots/header.js",
"./layouts/notebook": "./dist/layouts/notebook/index.js",
"./layouts/notebook/page": "./dist/layouts/notebook/page/index.js",
"./layouts/notebook/page/slots/breadcrumb": "./dist/layouts/notebook/page/slots/breadcrumb.js",
"./layouts/notebook/page/slots/container": "./dist/layouts/notebook/page/slots/container.js",
"./layouts/notebook/page/slots/footer": "./dist/layouts/notebook/page/slots/footer.js",
"./layouts/notebook/page/slots/toc": "./dist/layouts/notebook/page/slots/toc.js",
"./layouts/notebook/slots/container": "./dist/layouts/notebook/slots/container.js",
"./layouts/notebook/slots/header": "./dist/layouts/notebook/slots/header.js",
"./layouts/notebook/slots/sidebar": "./dist/layouts/notebook/slots/sidebar.js",
"./layouts/shared": "./dist/layouts/shared/index.js",
"./layouts/shared/slots/language-select": "./dist/layouts/shared/slots/language-select.js",
"./layouts/shared/slots/search-trigger": "./dist/layouts/shared/slots/search-trigger.js",
"./layouts/shared/slots/theme-switch": "./dist/layouts/shared/slots/theme-switch.js",
"./mdx": {
"types": "./dist/mdx.d.ts",
"node": "./dist/mdx.server.js",
"import": "./dist/mdx.js"
},
"./og": {
"types": "./dist/og.d.ts",
"import": "./dist/og.js"
},
"./og/*": {
"types": "./dist/og/*.d.ts",
"import": "./dist/og/*.js"
},
"./utils/*": {
"types": "./dist/utils/*.d.ts",
"import": "./dist/utils/*.js"
}
"./og": "./dist/og.js",
"./og/takumi": "./dist/og/takumi.js",
"./page": "./dist/page.js",
"./provider/base": "./dist/provider/base.js",
"./provider/next": "./dist/provider/next.js",
"./provider/react-router": "./dist/provider/react-router.js",
"./provider/tanstack": "./dist/provider/tanstack.js",
"./provider/waku": "./dist/provider/waku.js",
"./utils/use-copy-button": "./dist/utils/use-copy-button.js",
"./utils/use-footer-items": "./dist/utils/use-footer-items.js",
"./utils/use-is-scroll-top": "./dist/utils/use-is-scroll-top.js",
"./package.json": "./package.json",
"./style.css": "./dist/style.css",
"./css/*": "./css/*"
},
"publishConfig": {
"access": "public"
@@ -115,7 +133,7 @@
"@hanzo/docs-tailwind": "workspace:*",
"class-variance-authority": "^0.7.1",
"lucide-react": "^0.577.0",
"motion": "^12.36.0",
"motion": "^12.38.0",
"next-themes": "^0.4.6",
"react-medium-image-zoom": "^5.4.1",
"react-remove-scroll": "^2.7.2",
@@ -125,9 +143,15 @@
"unist-util-visit": "^5.1.0"
},
"devDependencies": {
<<<<<<< HEAD
"@hanzo/docs-cli": "workspace:*",
"@tailwindcss/cli": "^4.2.1",
"@tsdown/css": "^0.21.2",
=======
"@hanzo/docs-cli": "workspace:*",
"@tailwindcss/cli": "^4.2.2",
"@tsdown/css": "^0.21.4",
>>>>>>> dev
"@types/hast": "^3.0.4",
"@types/mdx": "^2.0.13",
"@types/node": "^25.5.0",
@@ -136,9 +160,9 @@
"eslint-config-custom": "workspace:*",
"@hanzo/docs-core": "workspace:*",
"shiki": "^4.0.2",
"tailwindcss": "^4.2.1",
"tailwindcss": "^4.2.2",
"tsconfig": "workspace:*",
"tsdown": "0.21.2",
"tsdown": "0.21.4",
"unified": "^11.0.5"
},
"peerDependencies": {
+56 -105
View File
@@ -1,77 +1,68 @@
import { fileURLToPath } from 'node:url';
import type { Registry } from '@hanzo/docs-cli/build';
import * as path from 'node:path';
import { commonComponents, resolveExternal } from '../../shared/registry';
import { commonComponents, findSlotComponents, resolveExternal } from '../../shared/registry';
const srcDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '../src');
const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), '../src');
// in shadcn cli, the order of files matters when writing import paths on consumer's codebase
export const registry: Registry = {
<<<<<<< HEAD
name: 'hanzo-docs/base-ui',
dir: srcDir,
=======
name: 'fumadocs/base-ui',
dir,
>>>>>>> dev
tsconfigPath: '../tsconfig.json',
packageJson: '../package.json',
env: {
ui: '@hanzo/docs-base-ui',
},
onResolve(ref) {
<<<<<<< HEAD
return resolveExternal(ref, '@hanzo/docs-base-ui', srcDir) ?? ref;
=======
return resolveExternal(ref, '@hanzo/docs-ui', dir) ?? ref;
>>>>>>> dev
},
components: [
...commonComponents,
...(await findSlotComponents(dir)),
{
name: 'layouts/sidebar',
unlisted: true,
files: [
{
type: 'components',
path: 'components/sidebar/base.tsx',
target: '<dir>/docs-sidebar/base.tsx',
},
{
type: 'components',
path: 'components/sidebar/page-tree.tsx',
target: '<dir>/docs-sidebar/page-tree.tsx',
},
{
type: 'components',
path: 'components/sidebar/link-item.tsx',
target: '<dir>/docs-sidebar/link-item.tsx',
},
],
},
{
name: 'layouts/shared',
unlisted: true,
files: [
{
type: 'components',
type: 'layout',
path: 'layouts/shared/index.tsx',
target: '<dir>/layout/shared.tsx',
target: '<dir>/shared/index.tsx',
},
{
type: 'components',
path: 'layouts/shared/language-toggle.tsx',
target: '<dir>/layout/language-toggle.tsx',
},
{
type: 'components',
path: 'layouts/shared/page-actions.tsx',
target: '<dir>/layout/page-actions.tsx',
},
{
type: 'components',
path: 'layouts/shared/search-toggle.tsx',
target: '<dir>/layout/search-toggle.tsx',
},
{
type: 'components',
path: 'layouts/shared/theme-toggle.tsx',
target: '<dir>/layout/theme-toggle.tsx',
},
{
type: 'components',
path: 'components/sidebar/base.tsx',
target: '<dir>/layout/sidebar/base.tsx',
},
{
type: 'components',
path: 'components/sidebar/page-tree.tsx',
target: '<dir>/layout/sidebar/page-tree.tsx',
},
{
type: 'components',
path: 'components/sidebar/link-item.tsx',
target: '<dir>/layout/sidebar/link-item.tsx',
},
{
type: 'components',
path: 'components/sidebar/tabs/index.tsx',
target: '<dir>/layout/sidebar/tabs/index.tsx',
},
{
type: 'components',
path: 'components/sidebar/tabs/dropdown.tsx',
target: '<dir>/layout/sidebar/tabs/dropdown.tsx',
type: 'layout',
path: 'layouts/shared/client.tsx',
target: '<dir>/shared/client.tsx',
},
],
},
@@ -79,29 +70,19 @@ export const registry: Registry = {
name: 'layouts/docs',
files: [
{
type: 'components',
type: 'layout',
path: 'layouts/docs/index.tsx',
target: '<dir>/layout/docs/index.tsx',
target: '<dir>/docs/index.tsx',
},
{
type: 'components',
type: 'layout',
path: 'layouts/docs/client.tsx',
target: '<dir>/layout/docs/client.tsx',
target: '<dir>/docs/client.tsx',
},
{
type: 'components',
path: 'layouts/docs/sidebar.tsx',
target: '<dir>/layout/docs/sidebar.tsx',
},
{
type: 'components',
type: 'layout',
path: 'layouts/docs/page/index.tsx',
target: '<dir>/layout/docs/page/index.tsx',
},
{
type: 'components',
path: 'layouts/docs/page/client.tsx',
target: '<dir>/layout/docs/page/client.tsx',
target: '<dir>/docs/page/index.tsx',
},
],
unlisted: true,
@@ -110,29 +91,14 @@ export const registry: Registry = {
name: 'layouts/flux',
files: [
{
type: 'components',
type: 'layout',
path: 'layouts/flux/index.tsx',
target: '<dir>/layout/flux/index.tsx',
target: '<dir>/flux/index.tsx',
},
{
type: 'components',
path: 'layouts/flux/tab-dropdown.tsx',
target: '<dir>/layout/flux/tab-dropdown.tsx',
},
{
type: 'components',
path: 'layouts/flux/sidebar.tsx',
target: '<dir>/layout/flux/sidebar.tsx',
},
{
type: 'components',
type: 'layout',
path: 'layouts/flux/page/index.tsx',
target: '<dir>/layout/flux/page/index.tsx',
},
{
type: 'components',
path: 'layouts/flux/page/client.tsx',
target: '<dir>/layout/flux/page/client.tsx',
target: '<dir>/flux/page/index.tsx',
},
],
unlisted: true,
@@ -141,29 +107,19 @@ export const registry: Registry = {
name: 'layouts/notebook',
files: [
{
type: 'components',
type: 'layout',
path: 'layouts/notebook/index.tsx',
target: '<dir>/layout/notebook/index.tsx',
target: '<dir>/notebook/index.tsx',
},
{
type: 'components',
type: 'layout',
path: 'layouts/notebook/client.tsx',
target: '<dir>/layout/notebook/client.tsx',
target: '<dir>/notebook/client.tsx',
},
{
type: 'components',
path: 'layouts/notebook/sidebar.tsx',
target: '<dir>/layout/notebook/sidebar.tsx',
},
{
type: 'components',
type: 'layout',
path: 'layouts/notebook/page/index.tsx',
target: '<dir>/layout/notebook/page/index.tsx',
},
{
type: 'components',
path: 'layouts/notebook/page/client.tsx',
target: '<dir>/layout/notebook/page/client.tsx',
target: '<dir>/notebook/page/index.tsx',
},
],
unlisted: true,
@@ -172,14 +128,9 @@ export const registry: Registry = {
name: 'layouts/home',
files: [
{
type: 'components',
type: 'layout',
path: 'layouts/home/index.tsx',
target: '<dir>/layout/home/index.tsx',
},
{
type: 'components',
path: 'layouts/home/client.tsx',
target: '<dir>/layout/home/client.tsx',
target: '<dir>/home/index.tsx',
},
{
type: 'ui',
@@ -1,7 +1,7 @@
'use client';
import { Check, Link as LinkIcon } from 'lucide-react';
import { ComponentProps, type ReactNode, useEffect, useRef, useState } from 'react';
import { Check, LinkIcon } from 'lucide-react';
import { type ComponentProps, type ReactNode, useEffect, useRef, useState } from 'react';
import { cn } from '@/utils/cn';
import { useCopyButton } from '@/utils/use-copy-button';
import { buttonVariants } from '@/components/ui/button';
@@ -1,6 +1,6 @@
'use client';
import { ChevronRight, Hash, Search as SearchIcon } from 'lucide-react';
import { ChevronRight, Hash, SearchIcon } from 'lucide-react';
import {
type ComponentProps,
createContext,
+1 -1
View File
@@ -1,7 +1,7 @@
'use client';
import { cva } from 'class-variance-authority';
import { File as FileIcon, Folder as FolderIcon, FolderOpen } from 'lucide-react';
import { FileIcon, FolderIcon, FolderOpen } from 'lucide-react';
import { type HTMLAttributes, type ReactNode, useState } from 'react';
import { cn } from '@/utils/cn';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
+2 -2
View File
@@ -1,4 +1,4 @@
import { Link } from 'lucide-react';
import { LinkIcon } from 'lucide-react';
import type { ComponentPropsWithoutRef } from 'react';
import { cn } from '@/utils/cn';
@@ -21,7 +21,7 @@ export function Heading<T extends Types = 'h1'>({
<a data-card="" href={`#${props.id}`} className="peer">
{props.children}
</a>
<Link
<LinkIcon
aria-hidden
className="size-3.5 shrink-0 text-fd-muted-foreground opacity-0 transition-opacity peer-hover:opacity-100"
/>
@@ -226,15 +226,7 @@ export function SidebarViewport({ className, ...props }: ScrollAreaProps) {
}
{...props}
>
<ScrollViewport
className="p-4 overscroll-contain"
style={
{
maskImage:
'linear-gradient(to bottom, transparent, white 12px, white calc(100% - 12px), transparent)',
} as object
}
>
<ScrollViewport className="p-4 overscroll-contain mask-[linear-gradient(to_bottom,transparent,white_12px,white_calc(100%-12px),transparent)]">
{props.children}
</ScrollViewport>
</ScrollArea>
@@ -1,7 +1,8 @@
'use client';
import type { HTMLAttributes } from 'react';
import type * as Base from './base';
import { useLinkItemActive, type LinkItemType } from '@/utils/link-item';
import { usePathname } from '@hanzo/docs-core/framework';
import { isLinkItemActive, type LinkItemType } from '@/layouts/shared';
type InternalComponents = Pick<
typeof Base,
@@ -28,7 +29,8 @@ export function createLinkItemRenderer({
}: HTMLAttributes<HTMLElement> & {
item: Exclude<LinkItemType, { type: 'icon' }>;
}) {
const active = useLinkItemActive(item);
const pathname = usePathname();
const active = isLinkItemActive(item, pathname);
if (item.type === 'custom') return <div {...props}>{item.children}</div>;
if (item.type === 'menu')
@@ -4,14 +4,11 @@ import { type ComponentProps, type ReactNode, useMemo, useState } from 'react';
import Link from '@hanzo/docs-core/link';
import { usePathname } from '@hanzo/docs-core/framework';
import { cn } from '@/utils/cn';
import { normalize, isActive } from '@/utils/urls';
import { useSidebar } from '@/components/sidebar/base';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { SidebarTab } from '.';
import { isLayoutTabActive, type LayoutTab } from '@/layouts/shared';
export interface SidebarTabWithProps extends SidebarTab {
props?: ComponentProps<'a'>;
}
export type SidebarTabWithProps = LayoutTab;
export function SidebarTabsDropdown({
options,
@@ -100,8 +97,4 @@ export function SidebarTabsDropdown({
);
}
export function isTabActive(tab: SidebarTab, pathname: string) {
if (tab.urls) return tab.urls.has(normalize(pathname));
return isActive(tab.url, pathname, true);
}
export const isTabActive = isLayoutTabActive;
@@ -1,22 +1,12 @@
<<<<<<< HEAD
import type * as PageTree from '@hanzo/docs-core/page-tree';
import type { ReactNode } from 'react';
=======
import type { LayoutTab } from '@/layouts/shared';
import type * as PageTree from '@hanzo/docs-core/page-tree';
>>>>>>> dev
export interface SidebarTab {
/**
* Redirect URL of the folder, usually the index page
*/
url: string;
icon?: ReactNode;
title: ReactNode;
description?: ReactNode;
/**
* Detect from a list of urls
*/
urls?: Set<string>;
unlisted?: boolean;
}
export type SidebarTab = LayoutTab;
export interface GetSidebarTabsOptions {
transform?: (option: SidebarTab, node: PageTree.Folder) => SidebarTab | null;
+32 -32
View File
@@ -49,39 +49,39 @@ function useTabContext() {
return ctx;
}
export const TabsList = React.forwardRef<
React.ComponentRef<typeof Unstyled.TabsList>,
React.ComponentPropsWithoutRef<typeof Unstyled.TabsList>
>(({ className, ...props }, ref) => (
<Unstyled.TabsList
ref={ref}
{...props}
className={(s) =>
cn(
'flex gap-3.5 text-fd-secondary-foreground overflow-x-auto px-4 not-prose',
typeof className === 'function' ? className(s) : className,
)
}
/>
));
TabsList.displayName = 'TabsList';
export function TabsList({
className,
...props
}: React.ComponentPropsWithRef<typeof Unstyled.TabsList>) {
return (
<Unstyled.TabsList
{...props}
className={(s) =>
cn(
'flex gap-3.5 text-fd-secondary-foreground overflow-x-auto px-4 not-prose',
typeof className === 'function' ? className(s) : className,
)
}
/>
);
}
export const TabsTrigger = React.forwardRef<
React.ComponentRef<typeof Unstyled.TabsTrigger>,
React.ComponentPropsWithoutRef<typeof Unstyled.TabsTrigger>
>(({ className, ...props }, ref) => (
<Unstyled.TabsTrigger
ref={ref}
{...props}
className={(s) =>
cn(
'inline-flex items-center gap-2 whitespace-nowrap text-fd-muted-foreground border-b border-transparent py-2 text-sm font-medium transition-colors [&_svg]:size-4 hover:text-fd-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[active]:border-fd-primary data-[active]:text-fd-primary',
typeof className === 'function' ? className(s) : className,
)
}
/>
));
TabsTrigger.displayName = 'TabsTrigger';
export function TabsTrigger({
className,
...props
}: React.ComponentPropsWithRef<typeof Unstyled.TabsTrigger>) {
return (
<Unstyled.TabsTrigger
{...props}
className={(s) =>
cn(
'inline-flex items-center gap-2 whitespace-nowrap text-fd-muted-foreground border-b border-transparent py-2 text-sm font-medium transition-colors [&_svg]:size-4 hover:text-fd-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[active]:border-fd-primary data-[active]:text-fd-primary',
typeof className === 'function' ? className(s) : className,
)
}
/>
);
}
export function Tabs({
ref,
+114 -62
View File
@@ -1,64 +1,78 @@
'use client';
<<<<<<< HEAD
import * as Primitive from '@hanzo/docs-core/toc';
import { type ComponentProps, useEffect, useRef, useState } from 'react';
=======
import * as Primitive from '@hanzo/docs-core/toc';
import { type ComponentProps, useEffect, useEffectEvent, useRef, useState } from 'react';
>>>>>>> dev
import { cn } from '@/utils/cn';
import { TocThumb, useTOCItems } from '.';
import { mergeRefs } from '@/utils/merge-refs';
import { useI18n } from '@/contexts/i18n';
interface ComputedSVG {
d: string;
width: number;
height: number;
}
export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
const containerRef = useRef<HTMLDivElement>(null);
const items = useTOCItems();
const { text } = useI18n();
const [svg, setSvg] = useState<ComputedSVG>();
const [svg, setSvg] = useState<{
path: string;
width: number;
height: number;
}>();
const onResize = useEffectEvent(() => {
const container = containerRef.current;
if (!container || container.clientHeight === 0) return;
let w = 0;
let h = 0;
let b0 = 0;
let d = '';
for (let i = 0; i < items.length; i++) {
const element: HTMLElement | null = container.querySelector(
`a[href="#${items[i].url.slice(1)}"]`,
);
if (!element) continue;
const styles = getComputedStyle(element);
const offset = getLineOffset(items[i].depth) + 1,
top = element.offsetTop + parseFloat(styles.paddingTop),
bottom = element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom);
w = Math.max(offset, w);
h = Math.max(h, bottom);
if (i === 0) {
d += ` M${offset} ${top} L${offset} ${bottom}`;
} else {
const pOffset = getLineOffset(items[i - 1].depth) + 1;
d += ` C ${pOffset} ${top - 4} ${offset} ${b0! + 4} ${offset} ${top} L${offset} ${bottom}`;
}
b0 = bottom;
}
w += 1;
setSvg({
d,
width: w,
height: h,
});
});
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
function onResize(): void {
if (container.clientHeight === 0) return;
let w = 0,
h = 0;
const d: string[] = [];
for (let i = 0; i < items.length; i++) {
const element: HTMLElement | null = container.querySelector(
`a[href="#${items[i].url.slice(1)}"]`,
);
if (!element) continue;
const styles = getComputedStyle(element);
const offset = getLineOffset(items[i].depth) + 1,
top = element.offsetTop + parseFloat(styles.paddingTop),
bottom = element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom);
w = Math.max(offset, w);
h = Math.max(h, bottom);
d.push(`${i === 0 ? 'M' : 'L'}${offset} ${top}`);
d.push(`L${offset} ${bottom}`);
}
setSvg({
path: d.join(' '),
width: w + 1,
height: h,
});
}
const observer = new ResizeObserver(onResize);
onResize();
observer.observe(container);
observer.observe(containerRef.current);
return () => {
observer.disconnect();
};
}, [items]);
}, []);
if (items.length === 0)
return (
@@ -70,24 +84,28 @@ export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
return (
<>
{svg && (
<div
className="absolute start-0 top-0 rtl:-scale-x-100"
<TocThumb
containerRef={containerRef}
className="absolute top-0 inset-s-0"
style={{
width: svg.width,
height: svg.height,
maskImage: `url("data:image/svg+xml,${
// Inline SVG
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${svg.width} ${svg.height}"><path d="${svg.path}" stroke="black" stroke-width="1" fill="none" /></svg>`,
)
}")`,
}}
>
<TocThumb
containerRef={containerRef}
className="absolute w-full top-(--fd-top) h-(--fd-height) bg-fd-primary transition-[top,height]"
/>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={`0 0 ${svg.width} ${svg.height}`}
className="absolute transition-[clip-path]"
style={{
width: svg.width,
height: svg.height,
clipPath: `polygon(0 var(--fd-top), 100% var(--fd-top), 100% calc(var(--fd-top) + var(--fd-height)), 0 calc(var(--fd-top) + var(--fd-height)))`,
}}
>
<path d={svg.d} className="stroke-fd-primary" strokeWidth="1" fill="none" />
</svg>
<ThumbBox />
</TocThumb>
)}
<div ref={mergeRefs(containerRef, ref)} className={cn('flex flex-col', className)} {...props}>
{items.map((item, i) => (
@@ -103,6 +121,34 @@ export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
);
}
function ThumbBox() {
const itemInfos = Primitive.useItems();
const startIdx = itemInfos.findIndex((info) => info.active);
const endIdx = itemInfos.findLastIndex((info) => info.active);
if (startIdx === -1) return;
let lastInactiveIdx = -1;
for (let i = 0; i < itemInfos.length; i++) {
const item = itemInfos[i];
if (item.active) continue;
if (lastInactiveIdx === -1 || itemInfos[lastInactiveIdx].t < item.t) {
lastInactiveIdx = i;
}
}
const isStart = endIdx < lastInactiveIdx;
return (
<div
className="absolute size-1 bg-fd-primary rounded-full transition-transform"
style={{
translate: `calc(${getLineOffset(itemInfos[isStart ? startIdx : endIdx].original.depth)}px - 1.25px) ${
isStart ? 'var(--fd-top)' : 'calc(var(--fd-top) + var(--fd-height))'
}`,
}}
/>
);
}
function getItemOffset(depth: number): number {
if (depth <= 2) return 14;
if (depth === 3) return 26;
@@ -110,7 +156,9 @@ function getItemOffset(depth: number): number {
}
function getLineOffset(depth: number): number {
return depth >= 3 ? 10 : 0;
if (depth <= 2) return 2;
if (depth === 3) return 10;
return 20;
}
function TOCItem({
@@ -132,21 +180,25 @@ function TOCItem({
style={{
paddingInlineStart: getItemOffset(item.depth),
}}
className="prose relative py-1.5 text-sm text-fd-muted-foreground hover:text-fd-accent-foreground transition-colors wrap-anywhere first:pt-0 last:pb-0 data-[active=true]:text-fd-primary"
className="prose relative py-1.5 text-sm scroll-m-4 text-fd-muted-foreground hover:text-fd-accent-foreground transition-colors wrap-anywhere first:pt-0 last:pb-0 data-[active=true]:text-fd-primary"
>
{offset !== upperOffset && (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
className="absolute -top-1.5 start-0 size-4 rtl:-scale-x-100"
viewBox={`${Math.min(offset, upperOffset)} 0 ${Math.abs(upperOffset - offset)} 12`}
className="absolute -top-1.5"
style={{
width: Math.abs(upperOffset - offset) + 1,
height: 12,
insetInlineStart: Math.min(offset, upperOffset),
}}
>
<line
x1={upperOffset}
y1="0"
x2={offset}
y2="12"
className="stroke-fd-foreground/10"
<path
d={`M ${upperOffset} 0 C ${upperOffset} 8 ${offset} 4 ${offset} 12`}
stroke="black"
strokeWidth="1"
fill="none"
className="stroke-fd-foreground/10"
/>
</svg>
)}
+15 -13
View File
@@ -19,21 +19,23 @@ export function TOCItems({ ref, className, ...props }: ComponentProps<'div'>) {
);
return (
<>
<div
ref={mergeRefs(ref, containerRef)}
className={cn('relative flex flex-col border-s border-fd-foreground/10', className)}
{...props}
>
<TocThumb
containerRef={containerRef}
className="absolute top-(--fd-top) h-(--fd-height) w-0.5 rounded-e-sm bg-fd-primary transition-[top,height] ease-linear"
className="absolute inset-y-0 inset-s-0 bg-fd-primary w-px transition-[clip-path]"
style={{
clipPath:
'polygon(0 var(--fd-top), 100% var(--fd-top), 100% calc(var(--fd-top) + var(--fd-height)), 0 calc(var(--fd-top) + var(--fd-height)))',
}}
/>
<div
ref={mergeRefs(ref, containerRef)}
className={cn('flex flex-col border-s border-fd-foreground/10', className)}
{...props}
>
{items.map((item) => (
<TOCItem key={item.url} item={item} />
))}
</div>
</>
{items.map((item) => (
<TOCItem key={item.url} item={item} />
))}
</div>
);
}
@@ -42,7 +44,7 @@ function TOCItem({ item }: { item: Primitive.TOCItemType }) {
<Primitive.TOCItem
href={item.url}
className={cn(
'prose py-1.5 text-sm text-fd-muted-foreground transition-colors wrap-anywhere first:pt-0 last:pb-0 data-[active=true]:text-fd-primary',
'prose py-1.5 text-sm text-fd-muted-foreground scroll-m-4 transition-colors wrap-anywhere first:pt-0 last:pb-0 data-[active=true]:text-fd-primary hover:text-fd-accent-foreground',
item.depth <= 2 && 'ps-3',
item.depth === 3 && 'ps-6',
item.depth >= 4 && 'ps-8',
+59 -44
View File
@@ -19,11 +19,11 @@ export function useTOCItems(): Primitive.TOCItemType[] {
return use(TOCContext);
}
export function TOCProvider({
toc,
children,
...props
}: ComponentProps<typeof Primitive.AnchorProvider>) {
export type TOCProviderProps = Primitive.AnchorProviderProps;
export const { useActiveAnchor, useActiveAnchors, useItems } = Primitive;
export function TOCProvider({ toc, children, ...props }: TOCProviderProps) {
return (
<TOCContext value={toc}>
<Primitive.AnchorProvider toc={toc} {...props}>
@@ -50,26 +50,65 @@ export function TOCScrollArea({ ref, className, ...props }: ComponentProps<'div'
);
}
type TocThumbType = [top: number, height: number];
interface RefProps {
containerRef: RefObject<HTMLElement | null>;
interface TocThumbInfo {
top: number;
height: number;
}
export function TocThumb({ containerRef, ...props }: ComponentProps<'div'> & RefProps) {
interface TocThumbProps extends ComponentProps<'div'> {
containerRef: RefObject<HTMLElement | null>;
align?: 'center' | 'end';
}
export function TocThumb({ containerRef, align = 'end', ...props }: TocThumbProps) {
const thumbRef = useRef<HTMLDivElement>(null);
const active = Primitive.useActiveAnchors();
function update(info: TocThumbType): void {
const active = useActiveAnchors();
function update(info: TocThumbInfo): void {
const element = thumbRef.current;
if (!element) return;
element.style.setProperty('--fd-top', `${info[0]}px`);
element.style.setProperty('--fd-height', `${info[1]}px`);
const container = containerRef.current;
if (!element || !container) return;
element.style.setProperty('--fd-top', `${info.top}px`);
element.style.setProperty('--fd-height', `${info.height}px`);
}
function calc(active: string[]): TocThumbInfo | null {
const container = containerRef.current;
if (!container || container.clientHeight === 0) return null;
if (active.length === 0) return { height: 0, top: 0 };
let upper = Number.MAX_VALUE;
let lower = 0;
for (const item of active) {
const element = container.querySelector<HTMLElement>(`a[href="#${item}"]`);
if (!element) continue;
if (align === 'center') {
const y = element.offsetTop + element.clientHeight / 2;
upper = Math.min(upper, y);
lower = Math.max(lower, y);
} else {
const styles = getComputedStyle(element);
upper = Math.min(upper, element.offsetTop + parseFloat(styles.paddingTop));
lower = Math.max(
lower,
element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom),
);
}
}
return {
top: upper,
height: lower - upper,
};
}
const onPrint = useEffectEvent(() => {
if (containerRef.current) {
update(calc(containerRef.current, active));
}
const result = calc(active);
if (result) update(result);
});
useEffect(() => {
@@ -85,33 +124,9 @@ export function TocThumb({ containerRef, ...props }: ComponentProps<'div'> & Ref
}, [containerRef]);
useOnChange(active, () => {
if (containerRef.current) {
update(calc(containerRef.current, active));
}
const result = calc(active);
if (result) update(result);
});
return <div ref={thumbRef} data-hidden={active.length === 0} {...props} />;
}
function calc(container: HTMLElement, active: string[]): TocThumbType {
if (active.length === 0 || container.clientHeight === 0) {
return [0, 0];
}
let upper = Number.MAX_VALUE,
lower = 0;
for (const item of active) {
const element = container.querySelector<HTMLElement>(`a[href="#${item}"]`);
if (!element) continue;
const styles = getComputedStyle(element);
upper = Math.min(upper, element.offsetTop + parseFloat(styles.paddingTop));
lower = Math.max(
lower,
element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom),
);
}
return [upper, lower - upper];
}
@@ -17,7 +17,7 @@ export function CollapsibleContent({
{...props}
className={(s) =>
cn(
"overflow-hidden [&[hidden]:not([hidden='until-found'])]:hidden h-(--collapsible-panel-height) transition-[height] data-[starting-style]:h-0 data-[ending-style]:h-0",
"overflow-hidden [&[hidden]:not([hidden='until-found'])]:hidden h-(--collapsible-panel-height) transition-[height,opacity] data-starting-style:opacity-0 data-starting-style:h-0 data-ending-style:h-0 data-ending-style:opacity-0",
typeof className === 'function' ? className(s) : className,
)
}
@@ -6,65 +6,83 @@ import { cn } from '@/utils/cn';
export type NavigationMenuContentProps = Primitive.Content.Props;
export type NavigationMenuTriggerProps = Primitive.Trigger.Props;
const NavigationMenuRoot = Primitive.Root;
export const NavigationMenu = Primitive.Root;
export const NavigationMenuList = Primitive.List;
const NavigationMenuList = Primitive.List;
export function NavigationMenuItem({
className,
children,
...props
}: React.ComponentPropsWithRef<typeof Primitive.Item>) {
return (
<Primitive.Item
className={(s) => cn('list-none', typeof className === 'function' ? className(s) : className)}
{...props}
>
{children}
</Primitive.Item>
);
}
const NavigationMenuItem = React.forwardRef<
React.ComponentRef<typeof Primitive.Item>,
React.ComponentPropsWithoutRef<typeof Primitive.Item>
>(({ className, children, ...props }, ref) => (
<Primitive.Item
ref={ref}
className={(s) => cn('list-none', typeof className === 'function' ? className(s) : className)}
{...props}
>
{children}
</Primitive.Item>
));
export function NavigationMenuTrigger({
children,
...props
}: React.ComponentPropsWithRef<typeof Primitive.Trigger>) {
return <Primitive.Trigger {...props}>{children}</Primitive.Trigger>;
}
NavigationMenuItem.displayName = Primitive.Item.displayName;
export function NavigationMenuContent({
className,
...props
}: React.ComponentPropsWithRef<typeof Primitive.Content>) {
return (
<Primitive.Content
className={(s) =>
cn(
'size-full p-3',
'transition-[opacity,transform,translate] duration-(--duration) ease-(--easing)',
'data-starting-style:opacity-0 data-ending-style:opacity-0',
'data-starting-style:data-[activation-direction=left]:-translate-x-1/2',
'data-starting-style:data-[activation-direction=right]:translate-x-1/2',
'data-ending-style:data-[activation-direction=left]:translate-x-1/2',
'data-ending-style:data-[activation-direction=right]:-translate-x-1/2',
typeof className === 'function' ? className(s) : className,
)
}
{...props}
/>
);
}
const NavigationMenuTrigger = React.forwardRef<
React.ComponentRef<typeof Primitive.Trigger>,
React.ComponentPropsWithoutRef<typeof Primitive.Trigger>
>(({ children, ...props }, ref) => (
<Primitive.Trigger ref={ref} {...props}>
{children}
</Primitive.Trigger>
));
NavigationMenuTrigger.displayName = Primitive.Trigger.displayName;
export function NavigationMenuLink({
children,
...props
}: React.ComponentPropsWithRef<typeof Primitive.Link>) {
return <Primitive.Link {...props}>{children}</Primitive.Link>;
}
const NavigationMenuContent = React.forwardRef<
React.ComponentRef<typeof Primitive.Content>,
React.ComponentPropsWithoutRef<typeof Primitive.Content>
>(({ className, ...props }, ref) => (
<Primitive.Content
ref={ref}
className={(s) =>
cn(
'size-full p-4',
'transition-[opacity,transform,translate] duration-(--duration) ease-(--easing)',
'data-starting-style:opacity-0 data-ending-style:opacity-0',
'data-starting-style:data-[activation-direction=left]:-translate-x-1/2',
'data-starting-style:data-[activation-direction=right]:translate-x-1/2',
'data-ending-style:data-[activation-direction=left]:translate-x-1/2',
'data-ending-style:data-[activation-direction=right]:-translate-x-1/2',
typeof className === 'function' ? className(s) : className,
)
}
{...props}
/>
));
NavigationMenuContent.displayName = Primitive.Content.displayName;
const NavigationMenuLink = Primitive.Link;
export {
NavigationMenuRoot,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
};
export function NavigationMenuViewport(props: Primitive.Positioner.Props) {
return (
<Primitive.Portal>
<Primitive.Positioner
collisionPadding={{ top: 5, bottom: 5, left: 20, right: 20 }}
{...props}
className={(s) =>
cn(
"box-border h-(--positioner-height) w-(--anchor-width) max-w-(--available-width) duration-(--duration) ease-(--easing) before:absolute before:content-[''] data-instant:transition-none data-[side=bottom]:before:top-[-10px] data-[side=bottom]:before:right-0 data-[side=bottom]:before:left-0 data-[side=bottom]:before:h-2.5 data-[side=left]:before:top-0 data-[side=left]:before:right-[-10px] data-[side=left]:before:bottom-0 data-[side=left]:before:w-2.5 data-[side=right]:before:top-0 data-[side=right]:before:bottom-0 data-[side=right]:before:left-[-10px] data-[side=right]:before:w-2.5 data-[side=top]:before:right-0 data-[side=top]:before:bottom-[-10px] data-[side=top]:before:left-0 data-[side=top]:before:h-2.5",
typeof props.className === 'function' ? props.className(s) : props.className,
)
}
style={{
['--duration' as string]: '0.35s',
['--easing' as string]: 'cubic-bezier(0.22, 1, 0.36, 1)',
...props.style,
}}
>
<Primitive.Popup className="data-[ending-style]:easing-[ease] relative border h-(--popup-height) origin-(--transform-origin) rounded-xl bg-fd-popover/80 text-fd-popover-foreground backdrop-blur-md shadow-lg transition-[opacity,transform,width,height,scale,translate] duration-(--duration) ease-(--easing) data-ending-style:scale-90 data-ending-style:opacity-0 data-ending-style:duration-150 data-starting-style:scale-90 data-starting-style:opacity-0 w-(--popup-width)">
<Primitive.Viewport className="relative size-full overflow-hidden" />
</Primitive.Popup>
</Primitive.Positioner>
</Primitive.Portal>
);
}
+26 -26
View File
@@ -3,32 +3,32 @@ import { Popover as Primitive } from '@base-ui/react/popover';
import * as React from 'react';
import { cn } from '@/utils/cn';
const Popover = Primitive.Root;
export const Popover = Primitive.Root;
const PopoverTrigger = Primitive.Trigger;
export const PopoverTrigger = Primitive.Trigger;
const PopoverContent = React.forwardRef<
React.ComponentRef<typeof Primitive.Popup>,
React.ComponentPropsWithoutRef<typeof Primitive.Popup> &
Pick<Primitive.Positioner.Props, 'sideOffset' | 'align'>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<Primitive.Portal>
<Primitive.Positioner align={align} side="bottom" sideOffset={sideOffset} className="z-50">
<Primitive.Popup
ref={ref}
className={(s) =>
cn(
'z-50 origin-(--transform-origin) overflow-y-auto max-h-(--available-height) min-w-[240px] max-w-[98vw] rounded-xl border bg-fd-popover/60 backdrop-blur-lg p-2 text-sm text-fd-popover-foreground shadow-lg focus-visible:outline-none data-[closed]:animate-fd-popover-out data-[open]:animate-fd-popover-in',
typeof className === 'function' ? className(s) : className,
)
}
{...props}
/>
</Primitive.Positioner>
</Primitive.Portal>
));
PopoverContent.displayName = Primitive.Popup.displayName;
export function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentPropsWithRef<typeof Primitive.Popup> &
Pick<Primitive.Positioner.Props, 'sideOffset' | 'align'>) {
return (
<Primitive.Portal>
<Primitive.Positioner align={align} side="bottom" sideOffset={sideOffset} className="z-50">
<Primitive.Popup
className={(s) =>
cn(
'z-50 origin-(--transform-origin) overflow-y-auto max-h-(--available-height) min-w-[240px] max-w-[98vw] rounded-xl border bg-fd-popover/60 backdrop-blur-lg p-2 text-sm text-fd-popover-foreground shadow-lg focus-visible:outline-none data-[closed]:animate-fd-popover-out data-[open]:animate-fd-popover-in',
typeof className === 'function' ? className(s) : className,
)
}
{...props}
/>
</Primitive.Positioner>
</Primitive.Portal>
);
}
const PopoverClose = Primitive.Close;
export { Popover, PopoverTrigger, PopoverContent, PopoverClose };
export const PopoverClose = Primitive.Close;
@@ -2,64 +2,61 @@ import { ScrollArea as Primitive } from '@base-ui/react/scroll-area';
import * as React from 'react';
import { cn } from '@/utils/cn';
const ScrollArea = React.forwardRef<
React.ComponentRef<typeof Primitive.Root>,
React.ComponentPropsWithoutRef<typeof Primitive.Root>
>(({ className, children, ...props }, ref) => (
<Primitive.Root
ref={ref}
className={(s) =>
cn('overflow-hidden', typeof className === 'function' ? className(s) : className)
}
{...props}
>
{children}
<Primitive.Corner />
<ScrollBar orientation="vertical" />
</Primitive.Root>
));
export function ScrollArea({
children,
...props
}: React.ComponentPropsWithRef<typeof Primitive.Root>) {
return (
<Primitive.Root {...props}>
{children}
<Primitive.Corner />
<ScrollBar orientation="vertical" />
</Primitive.Root>
);
}
ScrollArea.displayName = Primitive.Root.displayName;
export function ScrollViewport({
className,
children,
...props
}: React.ComponentPropsWithRef<typeof Primitive.Viewport>) {
return (
<Primitive.Viewport
className={(s) =>
cn(
'size-full rounded-[inherit]',
typeof className === 'function' ? className(s) : className,
)
}
{...props}
>
{children}
</Primitive.Viewport>
);
}
const ScrollViewport = React.forwardRef<
React.ComponentRef<typeof Primitive.Viewport>,
React.ComponentPropsWithoutRef<typeof Primitive.Viewport>
>(({ className, children, ...props }, ref) => (
<Primitive.Viewport
ref={ref}
className={(s) =>
cn('size-full rounded-[inherit]', typeof className === 'function' ? className(s) : className)
}
{...props}
>
{children}
</Primitive.Viewport>
));
export function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentPropsWithRef<typeof Primitive.Scrollbar>) {
return (
<Primitive.Scrollbar
orientation={orientation}
className={(s) =>
cn(
'flex select-none transition-opacity',
!s.hovering && 'opacity-0',
orientation === 'vertical' && 'h-full w-1.5',
orientation === 'horizontal' && 'h-1.5 flex-col',
typeof className === 'function' ? className(s) : className,
)
}
{...props}
>
<Primitive.Thumb className="relative flex-1 rounded-full bg-fd-border" />
</Primitive.Scrollbar>
);
}
ScrollViewport.displayName = Primitive.Viewport.displayName;
const ScrollBar = React.forwardRef<
React.ComponentRef<typeof Primitive.Scrollbar>,
React.ComponentPropsWithoutRef<typeof Primitive.Scrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<Primitive.Scrollbar
ref={ref}
orientation={orientation}
className={(s) =>
cn(
'flex select-none transition-opacity',
!s.hovering && 'opacity-0',
orientation === 'vertical' && 'h-full w-1.5',
orientation === 'horizontal' && 'h-1.5 flex-col',
typeof className === 'function' ? className(s) : className,
)
}
{...props}
>
<Primitive.Thumb className="relative flex-1 rounded-full bg-fd-border" />
</Primitive.Scrollbar>
));
ScrollBar.displayName = Primitive.Scrollbar.displayName;
export { ScrollArea, ScrollBar, ScrollViewport };
export type ScrollAreaProps = Primitive.Root.Props;
+123 -74
View File
@@ -1,19 +1,74 @@
'use client';
import { type ComponentProps, createContext, type ReactNode, use, useMemo } from 'react';
import { type ComponentProps, createContext, type FC, use, useMemo } from 'react';
import { cn } from '@/utils/cn';
<<<<<<< HEAD
import { useSidebar } from '@/components/sidebar/base';
import { usePathname } from '@hanzo/docs-core/framework';
import Link from '@hanzo/docs-core/link';
import type { SidebarTab } from '@/components/sidebar/tabs';
import { isTabActive } from '@/components/sidebar/tabs/dropdown';
=======
import { usePathname } from '@hanzo/docs-core/framework';
import Link from '@hanzo/docs-core/link';
>>>>>>> dev
import { useIsScrollTop } from '@/utils/use-is-scroll-top';
import { renderer, type Renderer } from '@/utils/renderer';
import type { LinkItemType } from '@/layouts/shared';
import {
Sidebar,
SidebarProvider,
SidebarTrigger,
useSidebar,
type SidebarProps,
type SidebarProviderProps,
} from './slots/sidebar';
import type { DocsLayoutProps } from '.';
import {
baseSlots,
isLayoutTabActive,
useLinkItems,
type LayoutTab,
type BaseSlots,
type BaseSlotsProps,
} from '../shared';
import { TreeContextProvider } from '@/contexts/tree';
import { Header } from './slots/header';
import { Container } from './slots/container';
export const LayoutContext = createContext<{
export interface DocsSlots extends BaseSlots {
container: FC<ComponentProps<'div'>>;
header: FC<ComponentProps<'header'>>;
sidebar: {
provider: FC<SidebarProviderProps>;
root: FC<SidebarProps>;
trigger: FC<ComponentProps<'button'>>;
useSidebar: () => { collapsed: boolean; open: boolean; setOpen: (v: boolean) => void };
};
}
const { useProvider } = baseSlots({
useProps() {
return useDocsLayout().props;
},
});
interface SlotsProps extends BaseSlotsProps<DocsLayoutProps> {
tabs: LayoutTab[];
tabMode: NonNullable<DocsLayoutProps['tabMode']>;
}
const LayoutContext = createContext<{
props: SlotsProps;
isNavTransparent: boolean;
navItems: LinkItemType[];
menuItems: LinkItemType[];
slots: DocsSlots;
} | null>(null);
export function useIsDocsLayout() {
return use(LayoutContext) !== null;
}
export function useDocsLayout() {
const context = use(LayoutContext);
if (!context)
@@ -23,85 +78,79 @@ export function useDocsLayout() {
return context;
}
export function LayoutContextProvider({
navTransparentMode = 'none',
children,
}: {
navTransparentMode?: 'always' | 'top' | 'none';
children: ReactNode;
}) {
export function LayoutBody(
props: Omit<DocsLayoutProps, 'tabs'> & {
tabs: LayoutTab[];
},
) {
const {
nav: { enabled: navEnabled = true, transparentMode: navTransparentMode = 'none' } = {},
sidebar: { enabled: sidebarEnabled = true, defaultOpenLevel, prefetch, ...sidebarProps } = {},
slots: defaultSlots,
tabs,
tabMode = 'auto',
tree,
containerProps,
children,
} = props;
const isTop = useIsScrollTop({ enabled: navTransparentMode === 'top' }) ?? true;
const isNavTransparent = navTransparentMode === 'top' ? isTop : navTransparentMode === 'always';
const { baseSlots, baseProps } = useProvider(props);
const linkItems = useLinkItems(props);
const slots: DocsSlots = {
...baseSlots,
header: defaultSlots?.header ?? Header,
container: defaultSlots?.container ?? Container,
sidebar: defaultSlots?.sidebar ?? {
provider: SidebarProvider,
root: Sidebar,
trigger: SidebarTrigger,
useSidebar: useSidebar,
},
};
return (
<LayoutContext
value={useMemo(
() => ({
<TreeContextProvider tree={tree}>
<LayoutContext
value={{
props: {
tabMode,
tabs,
...baseProps,
},
isNavTransparent,
}),
[isNavTransparent],
)}
>
{children}
</LayoutContext>
slots,
...linkItems,
}}
>
<slots.sidebar.provider defaultOpenLevel={defaultOpenLevel} prefetch={prefetch}>
<slots.container {...containerProps}>
{navEnabled && <slots.header />}
{sidebarEnabled && <slots.sidebar.root {...sidebarProps} />}
{tabMode === 'top' && tabs.length > 0 && (
<LayoutTabs
tabs={tabs}
className="z-10 bg-fd-background border-b px-6 pt-3 xl:px-8 max-md:hidden"
/>
)}
{children}
</slots.container>
</slots.sidebar.provider>
</LayoutContext>
</TreeContextProvider>
);
}
export function LayoutHeader(props: ComponentProps<'header'>) {
const { isNavTransparent } = useDocsLayout();
return (
<header data-transparent={isNavTransparent} {...props}>
{props.children}
</header>
);
}
export function LayoutBody({
_,
children,
}: {
_: Renderer<ComponentProps<'div'>>;
children: ReactNode;
}) {
const render = renderer(_, 'div');
const { collapsed } = useSidebar();
return render?.(
(t) =>
({
id: 'nd-docs-layout',
'data-sidebar-collapsed': collapsed,
children,
...t,
className: cn(
'grid transition-[grid-template-columns] overflow-x-clip min-h-(--fd-docs-height) [--fd-docs-height:100dvh] [--fd-header-height:0px] [--fd-toc-popover-height:0px] [--fd-sidebar-width:0px] [--fd-toc-width:0px]',
t?.className,
),
style: {
gridTemplate: `"sidebar sidebar header toc toc"
"sidebar sidebar toc-popover toc toc"
"sidebar sidebar main toc toc" 1fr / minmax(min-content, 1fr) var(--fd-sidebar-col) minmax(0, calc(var(--fd-layout-width,97rem) - var(--fd-sidebar-width) - var(--fd-toc-width))) var(--fd-toc-width) minmax(min-content, 1fr)`,
'--fd-docs-row-1': 'var(--fd-banner-height, 0px)',
'--fd-docs-row-2': 'calc(var(--fd-docs-row-1) + var(--fd-header-height))',
'--fd-docs-row-3': 'calc(var(--fd-docs-row-2) + var(--fd-toc-popover-height))',
'--fd-sidebar-col': collapsed ? '0px' : 'var(--fd-sidebar-width)',
...t?.style,
},
}) as ComponentProps<'div'>,
);
}
export function LayoutTabs({
options,
function LayoutTabs({
tabs,
...props
}: ComponentProps<'div'> & {
options: SidebarTab[];
tabs: LayoutTab[];
}) {
const pathname = usePathname();
const selected = useMemo(() => {
return options.findLast((option) => isTabActive(option, pathname));
}, [options, pathname]);
return tabs.findLast((option) => isLayoutTabActive(option, pathname));
}, [tabs, pathname]);
return (
<div
@@ -111,17 +160,17 @@ export function LayoutTabs({
props.className,
)}
>
{options.map((option, i) => (
{tabs.map((tab, i) => (
<Link
key={i}
href={option.url}
href={tab.url}
className={cn(
'inline-flex border-b-2 border-transparent transition-colors items-center pb-1.5 font-medium gap-2 text-fd-muted-foreground text-sm text-nowrap hover:text-fd-accent-foreground',
option.unlisted && selected !== option && 'hidden',
selected === option && 'border-fd-primary text-fd-primary',
tab.unlisted && selected !== tab && 'hidden',
selected === tab && 'border-fd-primary text-fd-primary',
)}
>
{option.title}
{tab.title}
</Link>
))}
</div>
+40 -242
View File
@@ -1,3 +1,4 @@
<<<<<<< HEAD
import type * as PageTree from '@hanzo/docs-core/page-tree';
import { type ComponentProps, type HTMLAttributes, type ReactNode, useMemo } from 'react';
import { Languages, Sidebar as SidebarIcon } from 'lucide-react';
@@ -13,273 +14,70 @@ import {
SidebarTrigger,
SidebarViewport,
} from './sidebar';
=======
import type * as PageTree from '@hanzo/docs-core/page-tree';
import { type HTMLAttributes, type ReactNode, useMemo } from 'react';
import type { SidebarProps, SidebarProviderProps } from './slots/sidebar';
>>>>>>> dev
import {
getLayoutTabs,
type BaseLayoutProps,
parseLayoutProps,
renderTitleNav,
useLinkItems,
type GetLayoutTabsOptions,
type LayoutTab,
} from '@/layouts/shared';
import { LinkItem } from '@/utils/link-item';
import { LanguageToggle, LanguageToggleText } from '@/layouts/shared/language-toggle';
import { LayoutBody, LayoutContextProvider, LayoutHeader, LayoutTabs } from './client';
import { TreeContextProvider } from '@/contexts/tree';
import { ThemeToggle } from '../shared/theme-toggle';
import { LargeSearchToggle, SearchToggle } from '@/layouts/shared/search-toggle';
import { getSidebarTabs, type GetSidebarTabsOptions } from '@/components/sidebar/tabs';
import type { SidebarPageTreeComponents } from '@/components/sidebar/page-tree';
import { SidebarTabsDropdown, type SidebarTabWithProps } from '@/components/sidebar/tabs/dropdown';
import { type Renderer, renderer } from '@/utils/renderer';
import { type DocsSlots, LayoutBody } from './client';
export interface DocsLayoutProps extends BaseLayoutProps {
tree: PageTree.Root;
sidebar?: SidebarOptions;
tabMode?: 'top' | 'auto';
SidebarTrigger?: Renderer<ComponentProps<'button'>>;
Container?: Renderer<ComponentProps<'div'>>;
/**
* @deprecated use `Container` instead.
*/
tabs?: LayoutTab[] | GetLayoutTabsOptions | false;
containerProps?: HTMLAttributes<HTMLDivElement>;
slots?: Partial<DocsSlots>;
}
interface SidebarOptions
extends
ComponentProps<'aside'>,
Pick<ComponentProps<typeof Sidebar>, 'defaultOpenLevel' | 'prefetch'> {
interface SidebarOptions extends SidebarProps, SidebarProviderProps {
enabled?: boolean;
/**
* @deprecated use `slots.sidebar` instead.
*/
component?: ReactNode;
components?: Partial<SidebarPageTreeComponents>;
/**
* Root Toggle options
* @deprecated use layout-level `tabMode` option instead.
*/
tabs?: SidebarTabWithProps[] | GetSidebarTabsOptions | false;
banner?: ReactNode;
footer?: ReactNode;
tabMode?: 'auto' | 'top';
/**
* Support collapsing the sidebar on desktop mode
*
* @defaultValue true
* @deprecated use layout-level `tabs` option instead.
*/
collapsible?: boolean;
tabs?: LayoutTab[] | GetLayoutTabsOptions | false;
}
export function DocsLayout(_: DocsLayoutProps) {
const {
nav: { transparentMode, ...nav } = {},
sidebar: {
tabs: sidebarTabs,
enabled: sidebarEnabled = true,
defaultOpenLevel,
prefetch,
...sidebarProps
} = {},
SearchToggle: SearchToggleRenderer,
LargeSearchToggle: LargeSearchToggleRenderer,
ThemeSwitch,
containerProps,
Container = containerProps ?? true,
tabMode = 'auto',
LanguageSwitch,
SidebarTrigger: SidebarTriggerRenderer = sidebarEnabled,
children,
tree,
...props
} = parseLayoutProps<DocsLayoutProps>(_);
const renderSearchToggle = renderer(SearchToggleRenderer, SearchToggle);
const renderLargeSearchToggle = renderer(LargeSearchToggleRenderer, LargeSearchToggle);
const renderThemeSwitch = renderer(ThemeSwitch, ThemeToggle);
const renderLanguageSwitch = renderer(LanguageSwitch, LanguageToggle);
const renderSidebarTrigger = renderer(SidebarTriggerRenderer, SidebarTrigger);
export function DocsLayout({
tree,
sidebar: { tabs: _tabs, tabMode: _tabMode, ...sidebarProps } = {},
tabs: layoutTabs = _tabs,
tabMode = _tabMode,
children,
...props
}: DocsLayoutProps) {
const tabs = useMemo(() => {
if (Array.isArray(sidebarTabs)) {
return sidebarTabs;
if (Array.isArray(layoutTabs)) {
return layoutTabs;
}
if (typeof sidebarTabs === 'object') {
return getSidebarTabs(tree, sidebarTabs);
if (typeof layoutTabs === 'object') {
return getLayoutTabs(tree, layoutTabs);
}
if (sidebarTabs !== false) {
return getSidebarTabs(tree);
if (layoutTabs !== false) {
return getLayoutTabs(tree);
}
return [];
}, [tree, sidebarTabs]);
const { menuItems } = useLinkItems(props);
function sidebar() {
const { footer, banner, collapsible = true, component, components, ...rest } = sidebarProps;
if (component) return component;
const iconLinks = menuItems.filter((item) => item.type === 'icon');
const viewport = (
<SidebarViewport>
{menuItems
.filter((v) => v.type !== 'icon')
.map((item, i, list) => (
<SidebarLinkItem key={i} item={item} className={cn(i === list.length - 1 && 'mb-4')} />
))}
<SidebarPageTree {...components} />
</SidebarViewport>
);
return (
<>
<SidebarContent {...rest}>
<div className="flex flex-col gap-3 p-4 pb-2">
<div className="flex">
{renderTitleNav(nav, {
className: 'inline-flex text-[0.9375rem] items-center gap-2.5 font-medium me-auto',
})}
{nav.children}
{collapsible && (
<SidebarCollapseTrigger
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon-sm',
className: 'mb-auto text-fd-muted-foreground',
}),
)}
>
<SidebarIcon />
</SidebarCollapseTrigger>
)}
</div>
{renderLargeSearchToggle?.((o) => ({
hideIfDisabled: true,
...o,
}))}
{tabs.length > 0 && tabMode === 'auto' && <SidebarTabsDropdown options={tabs} />}
{banner}
</div>
{viewport}
{(renderLanguageSwitch || iconLinks.length > 0 || renderThemeSwitch || footer) && (
<div className="flex flex-col border-t p-4 pt-2 empty:hidden">
<div className="flex text-fd-muted-foreground items-center empty:hidden">
{renderLanguageSwitch?.((o) => ({
children: <Languages className="size-4.5" />,
...o,
}))}
{iconLinks.map((item, i) => (
<LinkItem
key={i}
item={item}
className={cn(buttonVariants({ size: 'icon-sm', color: 'ghost' }))}
aria-label={item.label}
>
{item.icon}
</LinkItem>
))}
{renderThemeSwitch?.((t) => ({ ...t, className: cn('ms-auto p-0', t?.className) }))}
</div>
{footer}
</div>
)}
</SidebarContent>
<SidebarDrawer>
<div className="flex flex-col gap-3 p-4 pb-2">
<div className="flex text-fd-muted-foreground items-center gap-1.5">
<div className="flex flex-1">
{iconLinks.map((item, i) => (
<LinkItem
key={i}
item={item}
className={cn(
buttonVariants({
size: 'icon-sm',
color: 'ghost',
className: 'p-2',
}),
)}
aria-label={item.label}
>
{item.icon}
</LinkItem>
))}
</div>
{renderLanguageSwitch?.((o) => ({
children: (
<>
<Languages className="size-4.5" />
<LanguageToggleText />
</>
),
...o,
}))}
{renderThemeSwitch?.((t) => ({ ...t, className: cn('p-0', t?.className) }))}
{renderSidebarTrigger?.((t) => ({
children: <SidebarIcon />,
...t,
className: cn(
buttonVariants({
color: 'ghost',
size: 'icon-sm',
}),
'p-2',
t?.className,
),
}))}
</div>
{tabs.length > 0 && <SidebarTabsDropdown options={tabs} />}
{banner}
</div>
{viewport}
<div className="flex flex-col border-t p-4 pt-2 empty:hidden">{footer}</div>
</SidebarDrawer>
</>
);
}
}, [tree, layoutTabs]);
return (
<TreeContextProvider tree={tree}>
<LayoutContextProvider navTransparentMode={transparentMode}>
<Sidebar defaultOpenLevel={defaultOpenLevel} prefetch={prefetch}>
<LayoutBody _={Container}>
{nav.enabled !== false &&
(nav.component ?? (
<LayoutHeader
id="nd-subnav"
className="[grid-area:header] sticky top-(--fd-docs-row-1) z-30 flex items-center ps-4 pe-2.5 border-b transition-colors backdrop-blur-sm h-(--fd-header-height) md:hidden max-md:layout:[--fd-header-height:--spacing(14)] data-[transparent=false]:bg-fd-background/80"
>
{renderTitleNav(nav, {
className: 'inline-flex items-center gap-2.5 font-semibold',
})}
<div className="flex-1">{nav.children}</div>
{renderSearchToggle?.((o) => ({
hideIfDisabled: true,
...o,
className: cn('p-2', o?.className),
}))}
{renderSidebarTrigger?.((t) => ({
children: <SidebarIcon />,
...t,
className: cn(
buttonVariants({
color: 'ghost',
size: 'icon-sm',
}),
'p-2',
t?.className,
),
}))}
</LayoutHeader>
))}
{sidebarEnabled && sidebar()}
{tabMode === 'top' && tabs.length > 0 && (
<LayoutTabs
options={tabs}
className="z-10 bg-fd-background border-b px-6 pt-3 xl:px-8 max-md:hidden"
/>
)}
{children}
</LayoutBody>
</Sidebar>
</LayoutContextProvider>
</TreeContextProvider>
<LayoutBody tree={tree} tabs={tabs} tabMode={tabMode} sidebar={sidebarProps} {...props}>
{children}
</LayoutBody>
);
}
export { type DocsSlots, useDocsLayout } from './client';
+123 -166
View File
@@ -1,9 +1,6 @@
import type { ComponentProps, ReactNode } from 'react';
import { cn } from '@/utils/cn';
import { buttonVariants } from '@/components/ui/button';
import { Edit, Text } from 'lucide-react';
import { I18nLabel } from '@/contexts/i18n';
'use client';
import {
<<<<<<< HEAD
type BreadcrumbProps,
type FooterProps,
PageBreadcrumb,
@@ -17,131 +14,152 @@ import * as TocDefault from '@/components/toc/default';
import * as TocClerk from '@/components/toc/clerk';
import { TOCProvider, TOCScrollArea } from '@/components/toc';
import { ChildrenRenderer, renderer, type Renderer } from '@/utils/renderer';
=======
type ComponentProps,
createContext,
type FC,
type ReactNode,
use,
useEffect,
useState,
} from 'react';
import { cn } from '@/utils/cn';
import { I18nLabel, useI18n } from '@/contexts/i18n';
import {
TOC,
TOCPopover,
TOCProvider,
type TOCProviderProps,
type TOCPopoverProps,
type TOCProps,
} from './slots/toc';
import { Footer, type FooterProps } from './slots/footer';
import { Breadcrumb, type BreadcrumbProps } from './slots/breadcrumb';
import { Container } from './slots/container';
import type { TOCItemType } from '@hanzo/docs-core/toc';
import { buttonVariants } from '@/components/ui/button';
import { Edit } from 'lucide-react';
>>>>>>> dev
export interface DocsPageProps {
export interface DocsPageProps extends ComponentProps<'article'> {
toc?: TOCItemType[];
/**
* - `multiple` (default): Accept multiple active items
* - `single`: Only accept one active item at most
* */
tocMode?: 'single' | 'multiple';
/**
* Extend the page to fill all available space
*
* @defaultValue false
*/
full?: boolean;
children?: ReactNode;
slots?: Partial<DocsPageSlots>;
TOC?: Renderer<TOCMainProps>;
TOCPopover?: Renderer<TOCPopoverProps>;
/** Footer navigation, located under the page body. */
Footer?: Renderer<FooterProps>;
Container?: Renderer<ComponentProps<'div'>>;
Breadcrumb?: Renderer<BreadcrumbProps>;
/** @deprecated use `Footer` instead. */
footer?: FooterOptions;
/** @deprecated use `Container` instead. */
className?: string;
/** @deprecated use `Breadcrumb` instead. */
breadcrumb?: BreadcrumbOptions;
/** @deprecated use `TOC` instead, or `tocMode` for enabling `single`. */
tableOfContent?: TableOfContentOptions;
/** @deprecated use `TOCPopover` instead. */
tableOfContentPopover?: TableOfContentPopoverOptions;
}
interface BreadcrumbOptions extends BreadcrumbProps {
enabled?: boolean;
/**
* @deprecated use `slots.breadcrumb` instead.
*/
component?: ReactNode;
}
interface FooterOptions extends FooterProps {
enabled?: boolean;
/**
* @deprecated use `slots.footer` instead.
*/
component?: ReactNode;
}
interface TableOfContentOptions extends Pick<AnchorProviderProps, 'single'>, TOCProps {
interface TableOfContentOptions extends Pick<TOCProviderProps, 'single'>, TOCProps {
enabled?: boolean;
/**
* @deprecated use `slots.toc` instead.
*/
component?: ReactNode;
}
interface TableOfContentPopoverOptions extends TOCProps {
interface TableOfContentPopoverOptions extends TOCPopoverProps {
enabled?: boolean;
/**
* @deprecated use `slots.tocPopover` instead.
*/
component?: ReactNode;
}
interface DocsPageSlots {
toc: {
provider: FC<TOCProviderProps>;
main: FC<TOCProps>;
popover: FC<TOCPopoverProps>;
};
container: FC<ComponentProps<'article'>>;
footer: FC<FooterProps>;
breadcrumb: FC<BreadcrumbProps>;
}
type PageSlotsProps = Pick<DocsPageProps, 'full'>;
const PageContext = createContext<{
props: PageSlotsProps;
slots: DocsPageSlots;
} | null>(null);
export function useDocsPage() {
const context = use(PageContext);
if (!context)
throw new Error(
'Please use page components under <DocsPage /> (`@hanzo/docs-ui/layouts/docs/page`).',
);
return context;
}
export function DocsPage({
tableOfContent: tocProps = {},
TOC: TOCRenderer,
tableOfContentPopover: tocPopoverProps = {},
TOCPopover: TOCPopoverRenderer,
footer = {},
Footer = footer.enabled === false
? false
: footer.component
? new ChildrenRenderer(footer.component)
: footer,
className,
Container = { className },
breadcrumb = {},
Breadcrumb = breadcrumb.enabled === false
? false
: breadcrumb.component
? new ChildrenRenderer(breadcrumb.component)
: breadcrumb,
tableOfContent: { enabled: tocEnabled, single, ...tocProps } = {},
tableOfContentPopover: { enabled: tocPopoverEnabled, ...tocPopoverProps } = {},
breadcrumb: { enabled: breadcrumbEnabled = true, ...breadcrumb } = {},
footer: { enabled: footerEnabled = true, ...footer } = {},
full = false,
toc = [],
tocMode = tocProps.single ? 'single' : 'multiple',
slots: defaultSlots = {},
children,
...containerProps
}: DocsPageProps) {
if (!full && (tocProps.enabled ?? (toc.length > 0 || tocProps.footer || tocProps.header))) {
TOCRenderer ??= tocProps.component ? new ChildrenRenderer(tocProps.component) : tocProps;
} else {
// force disable toc in full mode or when no content
TOCRenderer = false;
}
tocEnabled ??= Boolean(!full && (toc.length > 0 || tocProps.footer || tocProps.header));
tocPopoverEnabled ??= Boolean(toc.length > 0 || tocPopoverProps.header || tocPopoverProps.footer);
if (
tocPopoverProps.enabled ??
(toc.length > 0 || tocPopoverProps.header || tocPopoverProps.footer)
) {
TOCPopoverRenderer ??= tocPopoverProps.component
? new ChildrenRenderer(tocPopoverProps.component)
: tocPopoverProps;
} else {
TOCPopoverRenderer = false;
}
const renderBreadcrumb = renderer(Breadcrumb, PageBreadcrumb);
const renderFooter = renderer(Footer, PageFooter);
const renderContainer = renderer(Container, 'article');
const renderToc = renderer(TOCRenderer, TOC);
const renderTocPopover = renderer(TOCPopoverRenderer, TOCPopover);
const slots: DocsPageSlots = {
breadcrumb: defaultSlots.breadcrumb ?? Breadcrumb,
footer: defaultSlots.footer ?? Footer,
toc: defaultSlots.toc ?? {
provider: TOCProvider,
main: TOC,
popover: TOCPopover,
},
container: defaultSlots.container ?? Container,
};
return (
<TOCProvider single={tocMode === 'single'} toc={renderToc || renderTocPopover ? toc : []}>
{renderTocPopover?.((t) => t ?? {})}
{renderContainer?.((t) => ({
id: 'nd-page',
'data-full': full,
children: (
<>
{renderBreadcrumb?.((t) => t ?? {})}
{children}
{renderFooter?.((t) => t ?? {})}
</>
),
...t,
className: cn(
'flex flex-col w-full max-w-[900px] mx-auto [grid-area:main] px-4 py-6 gap-4 md:px-6 md:pt-8 xl:px-8 xl:pt-14',
full ? 'max-w-[1168px]' : 'xl:layout:[--fd-toc-width:268px]',
t?.className,
),
}))}
{renderToc?.((t) => t ?? {})}
</TOCProvider>
<PageContext
value={{
props: { full },
slots,
}}
>
<slots.toc.provider single={single} toc={tocEnabled || tocPopoverEnabled ? toc : []}>
{tocPopoverEnabled &&
(tocPopoverProps.component ?? <slots.toc.popover {...tocPopoverProps} />)}
<slots.container {...containerProps}>
{breadcrumbEnabled && (breadcrumb.component ?? <slots.breadcrumb {...breadcrumb} />)}
{children}
{footerEnabled && (footer.component ?? <slots.footer {...footer} />)}
</slots.container>
{tocEnabled && (tocProps.component ?? <slots.toc.main {...tocProps} />)}
</slots.toc.provider>
</PageContext>
);
}
@@ -155,8 +173,8 @@ export function EditOnGitHub(props: ComponentProps<'a'>) {
buttonVariants({
color: 'secondary',
size: 'sm',
className: 'gap-1.5 not-prose',
}),
'gap-1.5 not-prose',
props.className,
)}
>
@@ -200,86 +218,25 @@ export function DocsTitle({ children, className, ...props }: ComponentProps<'h1'
);
}
export interface TOCPopoverProps extends TOCProps {
container?: ComponentProps<typeof PageTOCPopover>;
trigger?: ComponentProps<typeof PageTOCPopoverTrigger>;
content?: ComponentProps<typeof PageTOCPopoverContent>;
}
export function PageLastUpdate({
date: value,
...props
}: Omit<ComponentProps<'p'>, 'children'> & { date: Date }) {
const { text } = useI18n();
const [date, setDate] = useState('');
export interface TOCMainProps extends TOCProps {
container?: ComponentProps<typeof PageTOCPopover>;
}
useEffect(() => {
// to the timezone of client
setDate(value.toLocaleDateString());
}, [value]);
interface TOCProps {
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
/**
* @defaultValue 'normal'
*/
style?: 'normal' | 'clerk';
}
export function TOC({ container, header, footer, style }: TOCMainProps) {
return (
<div
id="nd-toc"
{...container}
className={cn(
'sticky top-(--fd-docs-row-1) h-[calc(var(--fd-docs-height)-var(--fd-docs-row-1))] flex flex-col [grid-area:toc] w-(--fd-toc-width) pt-12 pe-4 pb-2 max-xl:hidden',
container?.className,
)}
>
{header}
<h3
id="toc-title"
className="inline-flex items-center gap-1.5 text-sm text-fd-muted-foreground"
>
<Text className="size-4" />
<I18nLabel label="toc" />
</h3>
<TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
{footer}
</div>
<p {...props} className={cn('text-sm text-fd-muted-foreground', props.className)}>
{text.lastUpdate} {date}
</p>
);
}
export function TOCPopover({
container,
trigger,
content,
header,
footer,
style,
}: TOCPopoverProps) {
return (
<PageTOCPopover {...container}>
<PageTOCPopoverTrigger {...trigger} />
<PageTOCPopoverContent {...content}>
{header}
<TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
{footer}
</PageTOCPopoverContent>
</PageTOCPopover>
);
}
export {
PageLastUpdate,
PageBreadcrumb,
PageFooter,
type BreadcrumbProps,
type FooterProps,
} from './client';
export { type BreadcrumbProps, Breadcrumb as PageBreadcrumb } from './slots/breadcrumb';
export { type FooterProps, Footer as PageFooter } from './slots/footer';
export { MarkdownCopyButton, ViewOptionsPopover } from '@/layouts/shared/page-actions';
@@ -0,0 +1,56 @@
'use client';
import { useTreePath, useTreeContext } from '@/contexts/tree';
import { cn } from '@/utils/cn';
import { type BreadcrumbOptions, getBreadcrumbItemsFromPath } from '@hanzo/docs-core/breadcrumb';
import Link from '@hanzo/docs-core/link';
import { ChevronRight } from 'lucide-react';
import { type ComponentProps, useMemo, Fragment } from 'react';
export type BreadcrumbProps = BreadcrumbOptions & ComponentProps<'div'>;
export function Breadcrumb({
includeRoot,
includeSeparator,
includePage,
...props
}: BreadcrumbProps) {
const path = useTreePath();
const { root } = useTreeContext();
const items = useMemo(() => {
return getBreadcrumbItemsFromPath(root, path, {
includePage,
includeSeparator,
includeRoot,
});
}, [includePage, includeRoot, includeSeparator, path, root]);
if (items.length === 0) return null;
return (
<div
{...props}
className={cn('flex items-center gap-1.5 text-sm text-fd-muted-foreground', props.className)}
>
{items.map((item, i) => {
const className = cn('truncate', i === items.length - 1 && 'text-fd-primary font-medium');
return (
<Fragment key={i}>
{i !== 0 && <ChevronRight className="size-3.5 shrink-0" />}
{item.url ? (
<Link
href={item.url}
className={cn(className, 'transition-opacity hover:opacity-80')}
>
{item.name}
</Link>
) : (
<span className={className}>{item.name}</span>
)}
</Fragment>
);
})}
</div>
);
}
@@ -0,0 +1,26 @@
'use client';
import type { ComponentProps } from 'react';
import { useDocsPage } from '..';
import { cn } from '@/utils/cn';
export function Container(props: ComponentProps<'article'>) {
const {
props: { full },
} = useDocsPage();
return (
<article
id="nd-page"
data-full={full}
{...props}
className={cn(
'flex flex-col w-full max-w-[900px] mx-auto [grid-area:main] px-4 py-6 gap-4 md:px-6 md:pt-8 xl:px-8 xl:pt-14',
full ? 'max-w-[1168px]' : 'xl:layout:[--fd-toc-width:268px]',
props.className,
)}
>
{props.children}
</article>
);
}
@@ -0,0 +1,84 @@
'use client';
import { useI18n } from '@/contexts/i18n';
import { cn } from '@/utils/cn';
import { isActive } from '@/utils/urls';
import { useFooterItems } from '@/utils/use-footer-items';
import { usePathname } from '@hanzo/docs-core/framework';
import Link from '@hanzo/docs-core/link';
import type * as PageTree from '@hanzo/docs-core/page-tree';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { type ComponentProps, useMemo } from 'react';
type Item = Pick<PageTree.Item, 'name' | 'description' | 'url'>;
export interface FooterProps extends ComponentProps<'div'> {
/**
* Items including information for the next and previous page
*/
items?: {
previous?: Item;
next?: Item;
};
}
export function Footer({ items, children, className, ...props }: FooterProps) {
const footerList = useFooterItems();
const pathname = usePathname();
const { previous, next } = useMemo(() => {
if (items) return items;
const idx = footerList.findIndex((item) => isActive(item.url, pathname));
if (idx === -1) return {};
return {
previous: footerList[idx - 1],
next: footerList[idx + 1],
};
}, [footerList, items, pathname]);
return (
<>
<div
className={cn(
'@container grid gap-4',
previous && next ? 'grid-cols-2' : 'grid-cols-1',
className,
)}
{...props}
>
{previous && <FooterItem item={previous} index={0} />}
{next && <FooterItem item={next} index={1} />}
</div>
{children}
</>
);
}
function FooterItem({ item, index }: { item: Item; index: 0 | 1 }) {
const { text } = useI18n();
const Icon = index === 0 ? ChevronLeft : ChevronRight;
return (
<Link
href={item.url}
className={cn(
'flex flex-col gap-2 rounded-lg border p-4 text-sm transition-colors hover:bg-fd-accent/80 hover:text-fd-accent-foreground @max-lg:col-span-full',
index === 1 && 'text-end',
)}
>
<div
className={cn(
'inline-flex items-center gap-1.5 font-medium',
index === 1 && 'flex-row-reverse',
)}
>
<Icon className="-mx-1 size-4 shrink-0 rtl:rotate-180" />
<p>{item.name}</p>
</div>
<p className="text-fd-muted-foreground truncate">
{item.description ?? (index === 0 ? text.previousPage : text.nextPage)}
</p>
</Link>
);
}
@@ -0,0 +1,284 @@
'use client';
import * as TocDefault from '@/components/toc/default';
import * as TocClerk from '@/components/toc/clerk';
import * as Base from '@/components/toc';
import { I18nLabel, useI18n } from '@/contexts/i18n';
import { cn } from '@/utils/cn';
import { ChevronDown, Text } from 'lucide-react';
import {
createContext,
use,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
type ComponentProps,
type ReactNode,
} from 'react';
import { useTreePath } from '@/contexts/tree';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { useDocsLayout } from '../..';
export type TOCProviderProps = Base.TOCProviderProps;
export function TOCProvider(props: TOCProviderProps) {
return <Base.TOCProvider {...props} />;
}
export interface TOCProps {
container?: ComponentProps<'div'>;
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
/**
* @defaultValue 'normal'
*/
style?: 'normal' | 'clerk';
}
export function TOC({ container, header, footer, style }: TOCProps) {
return (
<div
id="nd-toc"
{...container}
className={cn(
'sticky top-(--fd-docs-row-1) h-[calc(var(--fd-docs-height)-var(--fd-docs-row-1))] flex flex-col [grid-area:toc] w-(--fd-toc-width) pt-12 pe-4 pb-2 max-xl:hidden',
container?.className,
)}
>
{header}
<h3
id="toc-title"
className="inline-flex items-center gap-1.5 text-sm text-fd-muted-foreground"
>
<Text className="size-4" />
<I18nLabel label="toc" />
</h3>
<Base.TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</Base.TOCScrollArea>
{footer}
</div>
);
}
const TocPopoverContext = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
} | null>(null);
export interface TOCPopoverProps {
container?: ComponentProps<'div'>;
trigger?: ComponentProps<'button'>;
content?: ComponentProps<'div'>;
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
/**
* @defaultValue 'normal'
*/
style?: 'normal' | 'clerk';
}
export function TOCPopover({
container,
trigger,
content,
header,
footer,
style,
}: TOCPopoverProps) {
return (
<PageTOCPopover {...container}>
<PageTOCPopoverTrigger {...trigger} />
<PageTOCPopoverContent {...content}>
{header}
<Base.TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</Base.TOCScrollArea>
{footer}
</PageTOCPopoverContent>
</PageTOCPopover>
);
}
function PageTOCPopover({ className, children, ...rest }: ComponentProps<'div'>) {
const ref = useRef<HTMLElement>(null);
const [open, setOpen] = useState(false);
const { isNavTransparent } = useDocsLayout();
const onClick = useEffectEvent((e: Event) => {
if (!open) return;
if (ref.current && !ref.current.contains(e.target as HTMLElement)) setOpen(false);
});
useEffect(() => {
window.addEventListener('click', onClick);
return () => {
window.removeEventListener('click', onClick);
};
}, []);
return (
<TocPopoverContext
value={useMemo(
() => ({
open,
setOpen,
}),
[setOpen, open],
)}
>
<Collapsible
open={open}
onOpenChange={setOpen}
data-toc-popover=""
className={cn(
'sticky top-(--fd-docs-row-2) z-10 [grid-area:toc-popover] h-(--fd-toc-popover-height) xl:hidden max-xl:layout:[--fd-toc-popover-height:--spacing(10)]',
className,
)}
{...rest}
>
<header
ref={ref}
className={cn(
'border-b backdrop-blur-sm transition-colors',
(!isNavTransparent || open) && 'bg-fd-background/80',
open && 'shadow-lg',
)}
>
{children}
</header>
</Collapsible>
</TocPopoverContext>
);
}
function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>) {
const { text } = useI18n();
const { open } = use(TocPopoverContext)!;
const items = Base.useItems();
const selectedIdx = items.findIndex((item) => item.active);
const path = useTreePath().at(-1);
const showItem = selectedIdx !== -1 && !open;
return (
<CollapsibleTrigger
className={cn(
'flex w-full h-10 items-center text-sm text-fd-muted-foreground gap-2.5 px-4 py-2.5 text-start focus-visible:outline-none [&_svg]:size-4 md:px-6',
className,
)}
data-toc-popover-trigger=""
{...props}
>
<ProgressCircle
value={(selectedIdx + 1) / Math.max(1, items.length)}
max={1}
className={cn('shrink-0', open && 'text-fd-primary')}
/>
<span className="grid flex-1 *:my-auto *:row-start-1 *:col-start-1">
<span
className={cn(
'truncate transition-[opacity,translate,color]',
open && 'text-fd-foreground',
showItem && 'opacity-0 -translate-y-full pointer-events-none',
)}
>
{path?.name ?? text.toc}
</span>
<span
className={cn(
'truncate transition-[opacity,translate]',
!showItem && 'opacity-0 translate-y-full pointer-events-none',
)}
>
{items[selectedIdx]?.original.title}
</span>
</span>
<ChevronDown className={cn('shrink-0 transition-transform mx-0.5', open && 'rotate-180')} />
</CollapsibleTrigger>
);
}
interface ProgressCircleProps extends Omit<React.ComponentProps<'svg'>, 'strokeWidth'> {
value: number;
strokeWidth?: number;
size?: number;
min?: number;
max?: number;
}
function clamp(input: number, min: number, max: number): number {
if (input < min) return min;
if (input > max) return max;
return input;
}
function ProgressCircle({
value,
strokeWidth = 2,
size = 24,
min = 0,
max = 100,
...restSvgProps
}: ProgressCircleProps) {
const normalizedValue = clamp(value, min, max);
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const progress = (normalizedValue / max) * circumference;
const circleProps = {
cx: size / 2,
cy: size / 2,
r: radius,
fill: 'none',
strokeWidth,
};
return (
<svg
role="progressbar"
viewBox={`0 0 ${size} ${size}`}
aria-valuenow={normalizedValue}
aria-valuemin={min}
aria-valuemax={max}
{...restSvgProps}
>
<circle {...circleProps} className="stroke-current/25" />
<circle
{...circleProps}
stroke="currentColor"
strokeDasharray={circumference}
strokeDashoffset={circumference - progress}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all"
/>
</svg>
);
}
function PageTOCPopoverContent(props: ComponentProps<'div'>) {
return (
<CollapsibleContent data-toc-popover-content="" {...props}>
<div className="flex flex-col px-4 max-h-[50vh] md:px-6">{props.children}</div>
</CollapsibleContent>
);
}
@@ -1,267 +0,0 @@
'use client';
import * as Base from '@/components/sidebar/base';
import { cn } from '@/utils/cn';
import { type ComponentProps, useRef } from 'react';
import { cva } from 'class-variance-authority';
import { createPageTreeRenderer } from '@/components/sidebar/page-tree';
import { createLinkItemRenderer } from '@/components/sidebar/link-item';
import { buttonVariants } from '@/components/ui/button';
import { SearchToggle } from '@/layouts/shared/search-toggle';
import { Sidebar as SidebarIcon } from 'lucide-react';
import { mergeRefs } from '@/utils/merge-refs';
const itemVariants = cva(
'relative flex flex-row items-center gap-2 rounded-lg p-2 text-start text-fd-muted-foreground wrap-anywhere [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
link: 'transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none data-[active=true]:bg-fd-primary/10 data-[active=true]:text-fd-primary data-[active=true]:hover:transition-colors',
button:
'transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none',
},
highlight: {
true: "data-[active=true]:before:content-[''] data-[active=true]:before:bg-fd-primary data-[active=true]:before:absolute data-[active=true]:before:w-px data-[active=true]:before:inset-y-2.5 data-[active=true]:before:start-2.5",
},
},
},
);
function getItemOffset(depth: number) {
return `calc(${2 + 3 * depth} * var(--spacing))`;
}
export function Sidebar(props: ComponentProps<typeof Base.SidebarProvider>) {
return <Base.SidebarProvider {...props} />;
}
export function SidebarFolder(props: ComponentProps<typeof Base.SidebarFolder>) {
return <Base.SidebarFolder {...props} />;
}
export function SidebarCollapseTrigger(props: ComponentProps<typeof Base.SidebarCollapseTrigger>) {
return <Base.SidebarCollapseTrigger {...props} />;
}
export function SidebarViewport(props: ComponentProps<typeof Base.SidebarViewport>) {
return <Base.SidebarViewport {...props} />;
}
export function SidebarTrigger(props: ComponentProps<typeof Base.SidebarTrigger>) {
return <Base.SidebarTrigger {...props} />;
}
export function SidebarContent({
ref: refProp,
className,
children,
...props
}: ComponentProps<'aside'>) {
const ref = useRef<HTMLElement>(null);
return (
<Base.SidebarContent>
{({ collapsed, hovered, ref: asideRef, ...rest }) => (
<>
<div
data-sidebar-placeholder=""
className="sticky top-(--fd-docs-row-1) z-20 [grid-area:sidebar] pointer-events-none *:pointer-events-auto h-[calc(var(--fd-docs-height)-var(--fd-docs-row-1))] md:layout:[--fd-sidebar-width:268px] max-md:hidden"
>
{collapsed && <div className="absolute start-0 inset-y-0 w-4" {...rest} />}
<aside
id="nd-sidebar"
ref={mergeRefs(ref, refProp, asideRef)}
data-collapsed={collapsed}
data-hovered={collapsed && hovered}
className={cn(
'absolute flex flex-col w-full start-0 inset-y-0 items-end bg-fd-card text-sm border-e duration-250 *:w-(--fd-sidebar-width)',
collapsed && [
'inset-y-2 rounded-xl transition-transform border w-(--fd-sidebar-width)',
hovered
? 'shadow-lg translate-x-2 rtl:-translate-x-2'
: '-translate-x-(--fd-sidebar-width) rtl:translate-x-full',
],
ref.current &&
(ref.current.getAttribute('data-collapsed') === 'true') !== collapsed &&
'transition-[width,inset-block,translate,background-color]',
className,
)}
{...props}
{...rest}
>
{children}
</aside>
</div>
<div
data-sidebar-panel=""
className={cn(
'fixed flex top-[calc(--spacing(4)+var(--fd-docs-row-3))] start-4 shadow-lg transition-opacity rounded-xl p-0.5 border bg-fd-muted text-fd-muted-foreground z-10',
(!collapsed || hovered) && 'pointer-events-none opacity-0',
)}
>
<Base.SidebarCollapseTrigger
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon-sm',
className: 'rounded-lg',
}),
)}
>
<SidebarIcon />
</Base.SidebarCollapseTrigger>
<SearchToggle className="rounded-lg" hideIfDisabled />
</div>
</>
)}
</Base.SidebarContent>
);
}
export function SidebarDrawer({
children,
className,
...props
}: ComponentProps<typeof Base.SidebarDrawerContent>) {
return (
<>
<Base.SidebarDrawerOverlay className="fixed z-40 inset-0 backdrop-blur-xs data-[state=open]:animate-fd-fade-in data-[state=closed]:animate-fd-fade-out" />
<Base.SidebarDrawerContent
className={cn(
'fixed text-[0.9375rem] flex flex-col shadow-lg border-s end-0 inset-y-0 w-[85%] max-w-[380px] z-40 bg-fd-background data-[state=open]:animate-fd-sidebar-in data-[state=closed]:animate-fd-sidebar-out',
className,
)}
{...props}
>
{children}
</Base.SidebarDrawerContent>
</>
);
}
export function SidebarSeparator({ className, style, children, ...props }: ComponentProps<'p'>) {
const depth = Base.useFolderDepth();
return (
<Base.SidebarSeparator
className={cn('[&_svg]:size-4 [&_svg]:shrink-0', className)}
style={{
paddingInlineStart: getItemOffset(depth),
...style,
}}
{...props}
>
{children}
</Base.SidebarSeparator>
);
}
export function SidebarItem({
className,
style,
children,
...props
}: ComponentProps<typeof Base.SidebarItem>) {
const depth = Base.useFolderDepth();
return (
<Base.SidebarItem
className={cn(itemVariants({ variant: 'link', highlight: depth >= 1 }), className)}
style={{
paddingInlineStart: getItemOffset(depth),
...style,
}}
{...props}
>
{children}
</Base.SidebarItem>
);
}
export function SidebarFolderTrigger({
className,
style,
...props
}: ComponentProps<typeof Base.SidebarFolderTrigger>) {
const { depth, collapsible } = Base.useFolder()!;
return (
<Base.SidebarFolderTrigger
className={(s) =>
cn(
itemVariants({ variant: collapsible ? 'button' : null }),
'w-full',
typeof className === 'function' ? className(s) : className,
)
}
style={{
paddingInlineStart: getItemOffset(depth - 1),
...style,
}}
{...props}
>
{props.children}
</Base.SidebarFolderTrigger>
);
}
export function SidebarFolderLink({
className,
style,
...props
}: ComponentProps<typeof Base.SidebarFolderLink>) {
const depth = Base.useFolderDepth();
return (
<Base.SidebarFolderLink
className={cn(itemVariants({ variant: 'link', highlight: depth > 1 }), 'w-full', className)}
style={{
paddingInlineStart: getItemOffset(depth - 1),
...style,
}}
{...props}
>
{props.children}
</Base.SidebarFolderLink>
);
}
export function SidebarFolderContent({
className,
children,
...props
}: ComponentProps<typeof Base.SidebarFolderContent>) {
const depth = Base.useFolderDepth();
return (
<Base.SidebarFolderContent
className={(s) =>
cn(
'relative',
depth === 1 &&
"before:content-[''] before:absolute before:w-px before:inset-y-1 before:bg-fd-border before:start-2.5",
typeof className === 'function' ? className(s) : className,
)
}
{...props}
>
{children}
</Base.SidebarFolderContent>
);
}
export const SidebarPageTree = createPageTreeRenderer({
SidebarFolder,
SidebarFolderContent,
SidebarFolderLink,
SidebarFolderTrigger,
SidebarItem,
SidebarSeparator,
});
export const SidebarLinkItem = createLinkItemRenderer({
SidebarFolder,
SidebarFolderContent,
SidebarFolderLink,
SidebarFolderTrigger,
SidebarItem,
});
@@ -0,0 +1,35 @@
'use client';
import { cn } from '@/utils/cn';
import type { ComponentProps } from 'react';
import { useDocsLayout } from '..';
export function Container(props: ComponentProps<'div'>) {
const { slots } = useDocsLayout();
const { collapsed } = slots.sidebar?.useSidebar?.() ?? {};
return (
<div
id="nd-docs-layout"
data-sidebar-collapsed={collapsed}
{...props}
style={
{
gridTemplate: `"sidebar sidebar header toc toc"
"sidebar sidebar toc-popover toc toc"
"sidebar sidebar main toc toc" 1fr / minmax(min-content, 1fr) var(--fd-sidebar-col) minmax(0, calc(var(--fd-layout-width,97rem) - var(--fd-sidebar-width) - var(--fd-toc-width))) var(--fd-toc-width) minmax(min-content, 1fr)`,
'--fd-docs-row-1': 'var(--fd-banner-height, 0px)',
'--fd-docs-row-2': 'calc(var(--fd-docs-row-1) + var(--fd-header-height))',
'--fd-docs-row-3': 'calc(var(--fd-docs-row-2) + var(--fd-toc-popover-height))',
'--fd-sidebar-col': collapsed ? '0px' : 'var(--fd-sidebar-width)',
...props.style,
} as object
}
className={cn(
'grid transition-[grid-template-columns] overflow-x-clip min-h-(--fd-docs-height) [--fd-docs-height:100dvh] [--fd-header-height:0px] [--fd-toc-popover-height:0px] [--fd-sidebar-width:0px] [--fd-toc-width:0px]',
props.className,
)}
>
{props.children}
</div>
);
}
@@ -0,0 +1,47 @@
'use client';
import type { ComponentProps } from 'react';
import { useDocsLayout } from '../client';
import { cn } from '@/utils/cn';
import { SidebarIcon } from 'lucide-react';
import { buttonVariants } from '@/components/ui/button';
export function Header(props: ComponentProps<'header'>) {
const {
isNavTransparent,
slots,
props: { nav },
} = useDocsLayout();
if (nav?.component) return nav.component;
return (
<header
id="nd-subnav"
data-transparent={isNavTransparent}
{...props}
className={cn(
'[grid-area:header] sticky top-(--fd-docs-row-1) z-30 flex items-center ps-4 pe-2.5 border-b transition-colors backdrop-blur-sm h-(--fd-header-height) md:hidden max-md:layout:[--fd-header-height:--spacing(14)] data-[transparent=false]:bg-fd-background/80',
props.className,
)}
>
{slots.navTitle && (
<slots.navTitle className="inline-flex items-center gap-2.5 font-semibold" />
)}
<div className="flex-1">{nav?.children}</div>
{slots.searchTrigger && <slots.searchTrigger.sm hideIfDisabled className="p-2" />}
{slots.sidebar && (
<slots.sidebar.trigger
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon-sm',
className: 'p-2',
}),
)}
>
<SidebarIcon />
</slots.sidebar.trigger>
)}
</header>
);
}
@@ -0,0 +1,497 @@
'use client';
import * as Base from '@/components/sidebar/base';
import { cn } from '@/utils/cn';
import { type ComponentProps, type ReactNode, useMemo, useRef, useState } from 'react';
import { cva } from 'class-variance-authority';
import {
createPageTreeRenderer,
type SidebarPageTreeComponents,
} from '@/components/sidebar/page-tree';
import { createLinkItemRenderer } from '@/components/sidebar/link-item';
import { buttonVariants } from '@/components/ui/button';
import { SearchTrigger } from '@/layouts/shared/slots/search-trigger';
import { Check, ChevronsUpDown, Languages, SidebarIcon } from 'lucide-react';
import { mergeRefs } from '@/utils/merge-refs';
import { useDocsLayout } from '../client';
import { LinkItem } from '@/layouts/shared';
import { isLayoutTabActive, type LayoutTab } from '@/layouts/shared';
import { usePathname } from '@hanzo/docs-core/framework';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import Link from '@hanzo/docs-core/link';
const itemVariants = cva(
'relative flex flex-row items-center gap-2 rounded-lg p-2 text-start text-fd-muted-foreground wrap-anywhere [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
link: 'transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none data-[active=true]:bg-fd-primary/10 data-[active=true]:text-fd-primary data-[active=true]:hover:transition-colors',
button:
'transition-colors hover:bg-fd-accent/50 hover:text-fd-accent-foreground/80 hover:transition-none',
},
highlight: {
true: "data-[active=true]:before:content-[''] data-[active=true]:before:bg-fd-primary data-[active=true]:before:absolute data-[active=true]:before:w-px data-[active=true]:before:inset-y-2.5 data-[active=true]:before:start-2.5",
},
},
},
);
export interface SidebarProps extends ComponentProps<'aside'> {
components?: Partial<SidebarPageTreeComponents>;
banner?: ReactNode;
footer?: ReactNode;
/**
* Support collapsing the sidebar on desktop mode
*
* @defaultValue true
*/
collapsible?: boolean;
}
export type SidebarProviderProps = Base.SidebarProviderProps;
export const { useSidebar } = Base;
export function SidebarProvider(props: SidebarProviderProps) {
return <Base.SidebarProvider {...props} />;
}
export function Sidebar({ footer, banner, collapsible = true, components, ...rest }: SidebarProps) {
const {
menuItems,
slots,
props: { tabs, nav, tabMode },
} = useDocsLayout();
const iconLinks = menuItems.filter((item) => item.type === 'icon');
const viewport = (
<Base.SidebarViewport>
<div className="flex flex-col gap-0.5">
{menuItems
.filter((v) => v.type !== 'icon')
.map((item, i, list) => (
<SidebarLinkItem key={i} item={item} className={cn(i === list.length - 1 && 'mb-4')} />
))}
<SidebarPageTree {...components} />
</div>
</Base.SidebarViewport>
);
return (
<>
<SidebarContent {...rest}>
<div className="flex flex-col gap-3 p-4 pb-2">
<div className="flex">
{slots.navTitle && (
<slots.navTitle className="inline-flex text-[0.9375rem] items-center gap-2.5 font-medium me-auto" />
)}
{nav?.children}
{collapsible && (
<SidebarCollapseTrigger
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon-sm',
className: 'mb-auto text-fd-muted-foreground',
}),
)}
>
<SidebarIcon />
</SidebarCollapseTrigger>
)}
</div>
{slots.searchTrigger && <slots.searchTrigger.full hideIfDisabled />}
{tabs.length > 0 && tabMode === 'auto' && <SidebarTabsDropdown tabs={tabs} />}
{banner}
</div>
{viewport}
{(slots.languageSelect || iconLinks.length > 0 || slots.themeSwitch || footer) && (
<div className="flex flex-col border-t p-4 pt-2 empty:hidden">
<div className="flex text-fd-muted-foreground items-center empty:hidden">
{slots.languageSelect && (
<slots.languageSelect.root>
<Languages className="size-4.5" />
</slots.languageSelect.root>
)}
{iconLinks.map((item, i) => (
<LinkItem
key={i}
item={item}
className={cn(buttonVariants({ size: 'icon-sm', color: 'ghost' }))}
aria-label={item.label}
>
{item.icon}
</LinkItem>
))}
{slots.themeSwitch && <slots.themeSwitch className="ms-auto p-0" />}
</div>
{footer}
</div>
)}
</SidebarContent>
<SidebarDrawer>
<div className="flex flex-col gap-3 p-4 pb-2">
<div className="flex text-fd-muted-foreground items-center gap-1.5">
<div className="flex flex-1">
{iconLinks.map((item, i) => (
<LinkItem
key={i}
item={item}
className={cn(
buttonVariants({
size: 'icon-sm',
color: 'ghost',
className: 'p-2',
}),
)}
aria-label={item.label}
>
{item.icon}
</LinkItem>
))}
</div>
{slots.languageSelect && (
<slots.languageSelect.root>
<Languages className="size-4.5" />
<slots.languageSelect.text />
</slots.languageSelect.root>
)}
{slots.themeSwitch && <slots.themeSwitch className="p-0" />}
<SidebarTrigger
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon-sm',
className: 'p-2',
}),
)}
>
<SidebarIcon />
</SidebarTrigger>
</div>
{tabs.length > 0 && <SidebarTabsDropdown tabs={tabs} />}
{banner}
</div>
{viewport}
<div className="flex flex-col border-t p-4 pt-2 empty:hidden">{footer}</div>
</SidebarDrawer>
</>
);
}
function SidebarFolder(props: ComponentProps<typeof Base.SidebarFolder>) {
return <Base.SidebarFolder {...props} />;
}
function SidebarCollapseTrigger(props: ComponentProps<typeof Base.SidebarCollapseTrigger>) {
return <Base.SidebarCollapseTrigger {...props} />;
}
export function SidebarTrigger(props: ComponentProps<'button'>) {
return <Base.SidebarTrigger {...props} />;
}
function SidebarContent({ ref: refProp, className, children, ...props }: ComponentProps<'aside'>) {
const ref = useRef<HTMLElement>(null);
return (
<Base.SidebarContent>
{({ collapsed, hovered, ref: asideRef, ...rest }) => (
<>
<div
data-sidebar-placeholder=""
className="sticky top-(--fd-docs-row-1) z-20 [grid-area:sidebar] pointer-events-none *:pointer-events-auto h-[calc(var(--fd-docs-height)-var(--fd-docs-row-1))] md:layout:[--fd-sidebar-width:268px] max-md:hidden"
>
{collapsed && <div className="absolute start-0 inset-y-0 w-4" {...rest} />}
<aside
id="nd-sidebar"
ref={mergeRefs(ref, refProp, asideRef)}
data-collapsed={collapsed}
data-hovered={collapsed && hovered}
className={cn(
'absolute flex flex-col w-full start-0 inset-y-0 items-end bg-fd-card text-sm border-e duration-250 *:w-(--fd-sidebar-width)',
collapsed && [
'inset-y-2 rounded-xl transition-transform border w-(--fd-sidebar-width)',
hovered
? 'shadow-lg translate-x-2 rtl:-translate-x-2'
: '-translate-x-(--fd-sidebar-width) rtl:translate-x-full',
],
ref.current &&
(ref.current.getAttribute('data-collapsed') === 'true') !== collapsed &&
'transition-[width,inset-block,translate,background-color]',
className,
)}
{...props}
{...rest}
>
{children}
</aside>
</div>
<div
data-sidebar-panel=""
className={cn(
'fixed flex top-[calc(--spacing(4)+var(--fd-docs-row-3))] start-4 shadow-lg transition-opacity rounded-xl p-0.5 border bg-fd-muted text-fd-muted-foreground z-10',
(!collapsed || hovered) && 'pointer-events-none opacity-0',
)}
>
<Base.SidebarCollapseTrigger
className={cn(
buttonVariants({
color: 'ghost',
size: 'icon-sm',
className: 'rounded-lg',
}),
)}
>
<SidebarIcon />
</Base.SidebarCollapseTrigger>
<SearchTrigger className="rounded-lg" hideIfDisabled />
</div>
</>
)}
</Base.SidebarContent>
);
}
function SidebarDrawer({
children,
className,
...props
}: ComponentProps<typeof Base.SidebarDrawerContent>) {
return (
<>
<Base.SidebarDrawerOverlay className="fixed z-40 inset-0 backdrop-blur-xs data-[state=open]:animate-fd-fade-in data-[state=closed]:animate-fd-fade-out" />
<Base.SidebarDrawerContent
className={cn(
'fixed text-[0.9375rem] flex flex-col shadow-lg border-s end-0 inset-y-0 w-[85%] max-w-[380px] z-40 bg-fd-background data-[state=open]:animate-fd-sidebar-in data-[state=closed]:animate-fd-sidebar-out',
className,
)}
{...props}
>
{children}
</Base.SidebarDrawerContent>
</>
);
}
function SidebarSeparator({ className, style, children, ...props }: ComponentProps<'p'>) {
const depth = Base.useFolderDepth();
return (
<Base.SidebarSeparator
className={cn(
'inline-flex items-center gap-2 mb-1 px-2 mt-6 empty:mb-0 [&_svg]:size-4 [&_svg]:shrink-0',
depth === 0 && 'first:mt-0',
className,
)}
style={{
paddingInlineStart: getItemOffset(depth),
...style,
}}
{...props}
>
{children}
</Base.SidebarSeparator>
);
}
function SidebarItem({
className,
style,
children,
...props
}: ComponentProps<typeof Base.SidebarItem>) {
const depth = Base.useFolderDepth();
return (
<Base.SidebarItem
className={cn(itemVariants({ variant: 'link', highlight: depth >= 1 }), className)}
style={{
paddingInlineStart: getItemOffset(depth),
...style,
}}
{...props}
>
{children}
</Base.SidebarItem>
);
}
function SidebarFolderTrigger({
className,
style,
...props
}: ComponentProps<typeof Base.SidebarFolderTrigger>) {
const { depth, collapsible } = Base.useFolder()!;
return (
<Base.SidebarFolderTrigger
className={(state) =>
cn(
itemVariants({ variant: collapsible ? 'button' : null }),
'w-full',
typeof className === 'function' ? className(state) : className,
)
}
style={{
paddingInlineStart: getItemOffset(depth - 1),
...style,
}}
{...props}
>
{props.children}
</Base.SidebarFolderTrigger>
);
}
function SidebarFolderLink({
className,
style,
...props
}: ComponentProps<typeof Base.SidebarFolderLink>) {
const depth = Base.useFolderDepth();
return (
<Base.SidebarFolderLink
className={cn(itemVariants({ variant: 'link', highlight: depth > 1 }), 'w-full', className)}
style={{
paddingInlineStart: getItemOffset(depth - 1),
...style,
}}
{...props}
>
{props.children}
</Base.SidebarFolderLink>
);
}
function SidebarFolderContent({
className,
children,
...props
}: ComponentProps<typeof Base.SidebarFolderContent>) {
const depth = Base.useFolderDepth();
return (
<Base.SidebarFolderContent
className={(state) =>
cn(
'relative flex flex-col gap-0.5 pt-0.5',
depth === 1 &&
"before:content-[''] before:absolute before:w-px before:inset-y-1 before:bg-fd-border before:start-2.5",
typeof className === 'function' ? className(state) : className,
)
}
{...props}
>
{children}
</Base.SidebarFolderContent>
);
}
function SidebarTabsDropdown({
tabs,
placeholder,
...props
}: {
placeholder?: ReactNode;
tabs: LayoutTab[];
} & ComponentProps<'button'>) {
const [open, setOpen] = useState(false);
const { closeOnRedirect } = useSidebar();
const pathname = usePathname();
const selected = useMemo(() => {
return tabs.findLast((item) => isLayoutTabActive(item, pathname));
}, [tabs, pathname]);
const onClick = () => {
closeOnRedirect.current = false;
setOpen(false);
};
const item = selected ? (
<>
<div className="size-9 shrink-0 empty:hidden md:size-5">{selected.icon}</div>
<div>
<p className="text-sm font-medium">{selected.title}</p>
<p className="text-sm text-fd-muted-foreground empty:hidden md:hidden">
{selected.description}
</p>
</div>
</>
) : (
placeholder
);
return (
<Popover open={open} onOpenChange={setOpen}>
{item && (
<PopoverTrigger
{...props}
className={cn(
'flex items-center gap-2 rounded-lg p-2 border bg-fd-secondary/50 text-start text-fd-secondary-foreground transition-colors hover:bg-fd-accent data-[state=open]:bg-fd-accent data-[state=open]:text-fd-accent-foreground',
props.className,
)}
>
{item}
<ChevronsUpDown className="shrink-0 ms-auto size-4 text-fd-muted-foreground" />
</PopoverTrigger>
)}
<PopoverContent className="flex flex-col gap-1 w-(--radix-popover-trigger-width) p-1 fd-scroll-container">
{tabs.map((item) => {
const isActive = selected && item.url === selected.url;
if (!isActive && item.unlisted) return;
return (
<Link
key={item.url}
href={item.url}
onClick={onClick}
{...item.props}
className={cn(
'flex items-center gap-2 rounded-lg p-1.5 hover:bg-fd-accent hover:text-fd-accent-foreground',
item.props?.className,
)}
>
<div className="shrink-0 size-9 md:mb-auto md:size-5 empty:hidden">{item.icon}</div>
<div>
<p className="text-sm font-medium leading-none">{item.title}</p>
<p className="text-[0.8125rem] text-fd-muted-foreground mt-1 empty:hidden">
{item.description}
</p>
</div>
<Check
className={cn(
'shrink-0 ms-auto size-3.5 text-fd-primary',
!isActive && 'invisible',
)}
/>
</Link>
);
})}
</PopoverContent>
</Popover>
);
}
function getItemOffset(depth: number) {
return `calc(${2 + 3 * depth} * var(--spacing))`;
}
const SidebarPageTree = createPageTreeRenderer({
SidebarFolder,
SidebarFolderContent,
SidebarFolderLink,
SidebarFolderTrigger,
SidebarItem,
SidebarSeparator,
});
const SidebarLinkItem = createLinkItemRenderer({
SidebarFolder,
SidebarFolderContent,
SidebarFolderLink,
SidebarFolderTrigger,
SidebarItem,
});
+150 -165
View File
@@ -1,218 +1,203 @@
'use client';
<<<<<<< HEAD
import type * as PageTree from '@hanzo/docs-core/page-tree';
import type { BaseLayoutProps } from '@/layouts/shared';
=======
import type * as PageTree from '@hanzo/docs-core/page-tree';
import {
type BaseLayoutProps,
baseSlots,
type BaseSlots,
type BaseSlotsProps,
getLayoutTabs,
type GetLayoutTabsOptions,
type LayoutTab,
useLinkItems,
} from '@/layouts/shared';
>>>>>>> dev
import { TreeContextProvider } from '@/contexts/tree';
import { getSidebarTabs, type GetSidebarTabsOptions } from '@/components/sidebar/tabs';
import type { SidebarPageTreeComponents } from '@/components/sidebar/page-tree';
import { type ComponentProps, type ReactNode, useMemo } from 'react';
import { type ComponentProps, createContext, type FC, type ReactNode, use, useMemo } from 'react';
import { cn } from '@/utils/cn';
import { useSidebar } from '@/components/sidebar/base';
import { SidebarTabsDropdown, type SidebarTabWithProps } from './tab-dropdown';
import { Sidebar, SidebarContent, SidebarLinkItem, SidebarPageTree } from './sidebar';
import { TabDropdown, type TabDropdownProps } from './slots/tab-dropdown';
import { buttonVariants } from '@/components/ui/button';
import { Languages, SidebarIcon, XIcon } from 'lucide-react';
import { parseLayoutProps, renderTitleNav, useLinkItems } from '../shared';
import { LanguageToggle } from '../shared/language-toggle';
import { SearchToggle } from '../shared/search-toggle';
import { ThemeToggle } from '../shared/theme-toggle';
import { LinkItem } from '@/utils/link-item';
import { AnimatePresence, motion } from 'motion/react';
import { Languages } from 'lucide-react';
import { LinkItem, type LinkItemType } from '@/layouts/shared';
import { motion } from 'motion/react';
import { RemoveScroll } from 'react-remove-scroll';
import { useSearchContext } from '@/contexts/search';
import { ScrollArea, ScrollViewport } from '@/components/ui/scroll-area';
import { renderer, type Renderer } from '@/utils/renderer';
import {
SidebarProvider,
Sidebar,
SidebarTrigger,
useSidebar,
type SidebarProviderProps,
type SidebarProps,
} from './slots/sidebar';
import { Container } from './slots/container';
export interface DocsSlots extends BaseSlots {
container: FC<ComponentProps<'div'>>;
tabDropdown: FC<TabDropdownProps>;
sidebar: {
provider: FC<SidebarProviderProps>;
trigger: FC<ComponentProps<'button'>>;
root: FC<SidebarProps>;
useSidebar: () => { collapsed: boolean; open: boolean; setOpen: (v: boolean) => void };
};
}
export interface DocsLayoutProps extends BaseLayoutProps {
tree: PageTree.Root;
sidebar?: SidebarOptions;
Container?: Renderer<ComponentProps<'div'>>;
tabs?: LayoutTab[] | GetLayoutTabsOptions | false;
slots?: Partial<DocsSlots>;
renderNavigationPanel?: (props: NavigationPanelProps) => ReactNode;
/**
* @deprecated use `Container` instead.
*/
containerProps?: ComponentProps<'div'>;
}
interface SidebarOptions
extends
ComponentProps<'aside'>,
Pick<ComponentProps<typeof Sidebar>, 'defaultOpenLevel' | 'prefetch'> {
interface SidebarOptions extends SidebarProps, SidebarProviderProps {
enabled?: boolean;
component?: ReactNode;
components?: Partial<SidebarPageTreeComponents>;
/**
* Root Toggle options
* @deprecated use layout-level `tabs` instead.
*/
tabs?: SidebarTabWithProps[] | GetSidebarTabsOptions | false;
banner?: ReactNode;
footer?: ReactNode;
tabs?: LayoutTab[] | GetLayoutTabsOptions | false;
}
export function DocsLayout(_: DocsLayoutProps) {
const LayoutContext = createContext<{
props: BaseSlotsProps;
menuItems: LinkItemType[];
navItems: LinkItemType[];
slots: DocsSlots;
} | null>(null);
export function useFluxLayout() {
const context = use(LayoutContext);
if (!context)
throw new Error(
'Please use Flux layout components under <DocsLayout /> (`@hanzo/docs-ui/layouts/flux`).',
);
return context;
}
const { useProvider } = baseSlots({
useProps() {
return useFluxLayout().props;
},
});
export function DocsLayout(props: DocsLayoutProps) {
const {
tree,
nav = {},
sidebar: {
enabled: sidebarEnabled = true,
tabs: sidebarTabs,
tabs: _tabs,
defaultOpenLevel,
prefetch,
...sidebarProps
} = {},
tabs: defaultTabs = _tabs,
children,
containerProps,
Container = containerProps ?? true,
LanguageSwitch,
SearchToggle: SearchToggleRenderer,
ThemeSwitch,
renderNavigationPanel = (props) => <NavigationPanel {...props} />,
...props
} = parseLayoutProps<DocsLayoutProps>(_);
const { menuItems } = useLinkItems(props);
const renderContainer = renderer(Container, 'div');
const renderLanguageSwitch = renderer(LanguageSwitch, LanguageToggle);
const renderThemeSwitch = renderer(ThemeSwitch, ThemeToggle);
const renderSearchToggle = renderer(SearchToggleRenderer, SearchToggle);
slots: defaultSlots = {},
} = props;
const linkItems = useLinkItems(props);
const { baseSlots, baseProps } = useProvider(props);
const tabs = useMemo(() => {
if (Array.isArray(sidebarTabs)) {
return sidebarTabs;
if (Array.isArray(defaultTabs)) {
return defaultTabs;
}
if (typeof sidebarTabs === 'object') {
return getSidebarTabs(tree, sidebarTabs);
if (typeof defaultTabs === 'object') {
return getLayoutTabs(tree, defaultTabs);
}
if (sidebarTabs !== false) {
return getSidebarTabs(tree);
if (defaultTabs !== false) {
return getLayoutTabs(tree);
}
return [];
}, [tree, sidebarTabs]);
const iconLinks = menuItems.filter((item) => item.type === 'icon');
function sidebar() {
const { footer, banner, component, components, ...rest } = sidebarProps;
if (component) return component;
return (
<SidebarContent {...rest}>
<div className="flex flex-col gap-3 p-4 pb-2 empty:hidden">{banner}</div>
<ScrollArea className="min-h-0 flex-1">
<ScrollViewport
className="p-4 overscroll-contain"
style={
{
maskImage:
'linear-gradient(to bottom, transparent, white 12px, white calc(100% - 12px), transparent)',
} as object
}
>
{menuItems
.filter((v) => v.type !== 'icon')
.map((item, i, list) => (
<SidebarLinkItem
key={i}
item={item}
className={cn(i === list.length - 1 && 'mb-4')}
/>
))}
<SidebarPageTree {...components} />
</ScrollViewport>
</ScrollArea>
{footer}
</SidebarContent>
);
}
}, [tree, defaultTabs]);
const slots: DocsSlots = {
...baseSlots,
container: defaultSlots.container ?? Container,
tabDropdown: defaultSlots.tabDropdown ?? TabDropdown,
sidebar: defaultSlots.sidebar ?? {
root: Sidebar,
provider: SidebarProvider,
trigger: SidebarTrigger,
useSidebar,
},
};
return (
<TreeContextProvider tree={tree}>
<Sidebar defaultOpenLevel={defaultOpenLevel} prefetch={prefetch}>
{renderContainer?.((t) => ({
id: 'nd-flux-layout',
children: (
<LayoutContext
value={{
props: baseProps,
slots,
...linkItems,
}}
>
<TreeContextProvider tree={tree}>
<slots.sidebar.provider defaultOpenLevel={defaultOpenLevel} prefetch={prefetch}>
<slots.container {...containerProps}>
{sidebarEnabled && <slots.sidebar.root {...sidebarProps} />}
{children}
</slots.container>
</slots.sidebar.provider>
{renderNavigationPanel({
head: (
<>
{sidebarEnabled && sidebar()}
{children}
{slots.navTitle && (
<slots.navTitle className="inline-flex items-center gap-2.5 text-sm font-semibold" />
)}
{nav.children}
</>
),
...t,
className: cn('flex flex-col items-center pb-24 overflow-x-clip', t?.className),
}))}
{renderNavigationPanel({
head: renderTitleNav(nav, {
className: 'inline-flex items-center gap-2.5 text-sm font-semibold',
}),
tabDropdown: tabs.length > 0 && <SidebarTabsDropdown className="flex-1" options={tabs} />,
tabDropdown: slots.tabDropdown && tabs.length > 0 && (
<slots.tabDropdown className="flex-1" tabs={tabs} />
),
tool: (
<>
{renderLanguageSwitch?.((t) => ({
children: <Languages className="size-4.5" />,
...t,
}))}
{renderSearchToggle?.((t) => ({
hideIfDisabled: true,
...t,
className: cn('rounded-lg', t?.className),
}))}
<NavigationSidebarTrigger />
{renderThemeSwitch?.((t) => ({
...t,
className: cn('p-1 h-full ms-1 rounded-xl bg-fd-muted *:rounded-lg', t?.className),
}))}
{slots.languageSelect && (
<slots.languageSelect.root>
<Languages className="size-4.5" />
</slots.languageSelect.root>
)}
{slots.searchTrigger && (
<slots.searchTrigger.sm hideIfDisabled className="rounded-lg" />
)}
{slots.sidebar && (
<slots.sidebar.trigger
className={cn(
buttonVariants({
variant: 'ghost',
size: 'icon-sm',
className: 'overflow-hidden',
}),
)}
/>
)}
{slots.themeSwitch && (
<slots.themeSwitch className="p-1 h-full ms-1 rounded-xl bg-fd-muted *:rounded-lg" />
)}
</>
),
link: iconLinks.map((item, i) => (
<LinkItem
key={i}
item={item}
className={cn(buttonVariants({ size: 'icon-sm', color: 'ghost' }))}
aria-label={item.label}
>
{item.icon}
</LinkItem>
)),
link: linkItems.menuItems
.filter((item) => item.type === 'icon')
.map((item, i) => (
<LinkItem
key={i}
item={item}
className={cn(buttonVariants({ size: 'icon-sm', color: 'ghost' }))}
aria-label={item.label}
>
{item.icon}
</LinkItem>
)),
})}
</Sidebar>
</TreeContextProvider>
);
}
function NavigationSidebarTrigger() {
const { open, setOpen } = useSidebar();
return (
<button
className={cn(
buttonVariants({
variant: 'ghost',
size: 'icon-sm',
className: 'overflow-hidden',
}),
)}
onClick={() => setOpen((prev) => !prev)}
>
<AnimatePresence mode="wait">
<motion.span
key={open ? 'open' : 'closed'}
transition={{ duration: 0.2 }}
initial={{
y: '100%',
opacity: 0,
}}
animate={{
y: 0,
opacity: 1,
}}
exit={{
y: '100%',
opacity: 0,
}}
>
{open ? <XIcon /> : <SidebarIcon />}
</motion.span>
</AnimatePresence>
</button>
</TreeContextProvider>
</LayoutContext>
);
}
+100 -94
View File
@@ -1,7 +1,23 @@
import type { ComponentProps, ReactNode } from 'react';
'use client';
import {
type ComponentProps,
createContext,
type FC,
type ReactNode,
use,
useEffect,
useState,
} from 'react';
import { cn } from '@/utils/cn';
import { I18nLabel, useI18n } from '@/contexts/i18n';
import { TOC, TOCProvider, type TOCProviderProps, type TOCProps } from './slots/toc';
import { Footer, type FooterProps } from './slots/footer';
import { Breadcrumb, type BreadcrumbProps } from './slots/breadcrumb';
import { Container } from './slots/container';
import type { TOCItemType } from '@hanzo/docs-core/toc';
import { buttonVariants } from '@/components/ui/button';
import { Edit } from 'lucide-react';
<<<<<<< HEAD
import { I18nLabel } from '@/contexts/i18n';
import {
type BreadcrumbProps,
@@ -17,19 +33,11 @@ import * as TocDefault from '@/components/toc/default';
import * as TocClerk from '@/components/toc/clerk';
import { TOCProvider, TOCScrollArea } from '@/components/toc';
import { ChildrenRenderer, renderer, type Renderer } from '@/utils/renderer';
=======
>>>>>>> dev
export interface DocsPageProps {
export interface DocsPageProps extends ComponentProps<'article'> {
toc?: TOCItemType[];
/**
* - `multiple` (default): Accept multiple active items
* - `single`: Only accept one active item at most
* */
tocMode?: 'single' | 'multiple';
TOC?: Renderer<TOCProps>;
/** Footer navigation, located under the page body. */
Footer?: Renderer<FooterProps>;
Container?: Renderer<ComponentProps<'div'>>;
Breadcrumb?: Renderer<BreadcrumbProps>;
/**
* Extend the page to fill all available space
@@ -38,107 +46,101 @@ export interface DocsPageProps {
*/
full?: boolean;
children?: ReactNode;
slots?: Partial<DocsPageSlots>;
/** @deprecated use `Footer` instead. */
footer?: FooterOptions;
/** @deprecated use `Container` instead. */
className?: string;
/** @deprecated use `Breadcrumb` instead. */
breadcrumb?: BreadcrumbOptions;
/** @deprecated use `TOC` instead, or `tocMode` for enabling `single`. */
tableOfContent?: TableOfContentOptions;
}
interface TableOfContentOptions extends Pick<AnchorProviderProps, 'single'>, TOCProps {
interface TableOfContentOptions extends Pick<TOCProviderProps, 'single'>, TOCProps {
enabled?: boolean;
/**
* @deprecated use `slots.toc` instead.
*/
component?: ReactNode;
}
interface BreadcrumbOptions extends BreadcrumbProps {
enabled?: boolean;
/**
* @deprecated use `slots.breadcrumb` instead.
*/
component?: ReactNode;
}
interface FooterOptions extends FooterProps {
enabled?: boolean;
/**
* @deprecated use `slots.footer` instead.
*/
component?: ReactNode;
}
export interface TOCProps {
container?: ComponentProps<typeof PageTOCPopover>;
trigger?: ComponentProps<typeof PageTOCPopoverTrigger>;
content?: ComponentProps<typeof PageTOCPopoverContent>;
interface DocsPageSlots {
toc: {
provider: FC<TOCProviderProps>;
main: FC<TOCProps>;
};
container: FC<ComponentProps<'article'>>;
footer: FC<FooterProps>;
breadcrumb: FC<BreadcrumbProps>;
}
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
type PageSlotsProps = Pick<DocsPageProps, 'full'>;
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
const PageContext = createContext<{
props: PageSlotsProps;
slots: DocsPageSlots;
} | null>(null);
/**
* @defaultValue 'normal'
*/
style?: 'normal' | 'clerk';
export function useDocsPage() {
const context = use(PageContext);
if (!context)
throw new Error(
'Please use page components under <DocsPage /> (`@hanzo/docs-ui/layouts/flux/page`).',
);
return context;
}
export function DocsPage({
tableOfContent: tocProps = {},
TOC: TOCRenderer,
footer = {},
Footer = footer.enabled === false
? false
: footer.component
? new ChildrenRenderer(footer.component)
: footer,
className,
Container = { className },
breadcrumb = {},
Breadcrumb = breadcrumb.enabled === false
? false
: breadcrumb.component
? new ChildrenRenderer(breadcrumb.component)
: breadcrumb,
tableOfContent: { enabled: tocEnabled, single, ...tocProps } = {},
breadcrumb: { enabled: breadcrumbEnabled = true, ...breadcrumb } = {},
footer: { enabled: footerEnabled = true, ...footer } = {},
full = false,
toc = [],
tocMode = tocProps.single ? 'single' : 'multiple',
slots: defaultSlots = {},
children,
...containerProps
}: DocsPageProps) {
if (tocProps.enabled ?? (toc.length > 0 || tocProps.header || tocProps.footer)) {
TOCRenderer ??= tocProps.component ? new ChildrenRenderer(tocProps.component) : tocProps;
} else {
TOCRenderer = false;
}
tocEnabled ??= Boolean(toc.length > 0 || tocProps.header || tocProps.footer);
const renderBreadcrumb = renderer(Breadcrumb, PageBreadcrumb);
const renderFooter = renderer(Footer, PageFooter);
const renderContainer = renderer(Container, 'article');
const renderToc = renderer(TOCRenderer, TOC);
const slots: DocsPageSlots = {
breadcrumb: defaultSlots.breadcrumb ?? Breadcrumb,
footer: defaultSlots.footer ?? Footer,
container: defaultSlots.container ?? Container,
toc: defaultSlots.toc ?? {
provider: TOCProvider,
main: TOC,
},
};
return (
<TOCProvider single={tocMode === 'single'} toc={renderToc ? toc : []}>
{renderToc?.((t) => t ?? {})}
{renderContainer?.((t) => ({
id: 'nd-page',
'data-full': full,
children: (
<>
{renderBreadcrumb?.((t) => t ?? {})}
{children}
{renderFooter?.((t) => t ?? {})}
</>
),
...t,
className: cn(
'flex flex-col w-full max-w-[900px] mx-auto [grid-area:main] px-4 py-6 gap-4 md:px-6 md:pt-8 xl:px-8 xl:pt-14',
full ? 'max-w-[1200px]' : 'xl:layout:[--fd-toc-width:268px]',
t?.className,
),
}))}
</TOCProvider>
<PageContext
value={{
props: { full },
slots,
}}
>
<slots.toc.provider single={single} toc={tocEnabled ? toc : []}>
{tocEnabled && (tocProps.component ?? <slots.toc.main {...tocProps} />)}
<slots.container {...containerProps}>
{breadcrumbEnabled && (breadcrumb.component ?? <slots.breadcrumb {...breadcrumb} />)}
{children}
{footerEnabled && (footer.component ?? <slots.footer {...footer} />)}
</slots.container>
</slots.toc.provider>
</PageContext>
);
}
@@ -152,8 +154,8 @@ export function EditOnGitHub(props: ComponentProps<'a'>) {
buttonVariants({
color: 'secondary',
size: 'sm',
className: 'gap-1.5 not-prose',
}),
'gap-1.5 not-prose',
props.className,
)}
>
@@ -197,21 +199,25 @@ export function DocsTitle({ children, className, ...props }: ComponentProps<'h1'
);
}
export function TOC({ container, trigger, content, header, footer, style }: TOCProps) {
export function PageLastUpdate({
date: value,
...props
}: Omit<ComponentProps<'p'>, 'children'> & { date: Date }) {
const { text } = useI18n();
const [date, setDate] = useState('');
useEffect(() => {
// to the timezone of client
setDate(value.toLocaleDateString());
}, [value]);
return (
<PageTOCPopover {...container}>
<PageTOCPopoverContent {...content}>
{header}
<TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</TOCScrollArea>
{footer}
</PageTOCPopoverContent>
<PageTOCPopoverTrigger {...trigger} />
</PageTOCPopover>
<p {...props} className={cn('text-sm text-fd-muted-foreground', props.className)}>
{text.lastUpdate} {date}
</p>
);
}
export { PageLastUpdate, PageBreadcrumb, PageFooter } from './client';
export type { FooterProps, BreadcrumbProps } from './client';
export { type FooterProps, Footer as PageFooter } from './slots/footer';
export { type BreadcrumbProps, Breadcrumb as PageBreadcrumb } from './slots/breadcrumb';
export { MarkdownCopyButton, ViewOptionsPopover } from '@/layouts/shared/page-actions';
@@ -0,0 +1,56 @@
'use client';
import { useTreeContext, useTreePath } from '@/contexts/tree';
import { cn } from '@/utils/cn';
import { type BreadcrumbOptions, getBreadcrumbItemsFromPath } from '@hanzo/docs-core/breadcrumb';
import { ChevronRight } from 'lucide-react';
import Link from '@hanzo/docs-core/link';
import { type ComponentProps, Fragment, useMemo } from 'react';
export type BreadcrumbProps = BreadcrumbOptions & ComponentProps<'div'>;
export function Breadcrumb({
includeRoot,
includeSeparator,
includePage,
...props
}: BreadcrumbProps) {
const path = useTreePath();
const { root } = useTreeContext();
const items = useMemo(() => {
return getBreadcrumbItemsFromPath(root, path, {
includePage,
includeSeparator,
includeRoot,
});
}, [includePage, includeRoot, includeSeparator, path, root]);
if (items.length === 0) return null;
return (
<div
{...props}
className={cn('flex items-center gap-1.5 text-sm text-fd-muted-foreground', props.className)}
>
{items.map((item, i) => {
const className = cn('truncate', i === items.length - 1 && 'text-fd-primary font-medium');
return (
<Fragment key={i}>
{i !== 0 && <ChevronRight className="size-3.5 shrink-0" />}
{item.url ? (
<Link
href={item.url}
className={cn(className, 'transition-opacity hover:opacity-80')}
>
{item.name}
</Link>
) : (
<span className={className}>{item.name}</span>
)}
</Fragment>
);
})}
</div>
);
}
@@ -0,0 +1,26 @@
'use client';
import type { ComponentProps } from 'react';
import { cn } from '@/utils/cn';
import { useDocsPage } from '..';
export function Container(props: ComponentProps<'article'>) {
const {
props: { full },
} = useDocsPage();
return (
<article
id="nd-page"
data-full={full}
{...props}
className={cn(
'flex flex-col w-full max-w-[900px] mx-auto [grid-area:main] px-4 py-6 gap-4 md:px-6 md:pt-8 xl:px-8 xl:pt-14',
full ? 'max-w-[1200px]' : 'xl:layout:[--fd-toc-width:268px]',
props.className,
)}
>
{props.children}
</article>
);
}
@@ -0,0 +1,84 @@
'use client';
import { useI18n } from '@/contexts/i18n';
import { cn } from '@/utils/cn';
import { isActive } from '@/utils/urls';
import { useFooterItems } from '@/utils/use-footer-items';
import { usePathname } from '@hanzo/docs-core/framework';
import type * as PageTree from '@hanzo/docs-core/page-tree';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import Link from '@hanzo/docs-core/link';
import { type ComponentProps, useMemo } from 'react';
type Item = Pick<PageTree.Item, 'name' | 'description' | 'url'>;
export interface FooterProps extends ComponentProps<'div'> {
/**
* Items including information for the next and previous page
*/
items?: {
previous?: Item;
next?: Item;
};
}
export function Footer({ items, children, className, ...props }: FooterProps) {
const footerList = useFooterItems();
const pathname = usePathname();
const { previous, next } = useMemo(() => {
if (items) return items;
const idx = footerList.findIndex((item) => isActive(item.url, pathname));
if (idx === -1) return {};
return {
previous: footerList[idx - 1],
next: footerList[idx + 1],
};
}, [footerList, items, pathname]);
return (
<>
<div
className={cn(
'@container grid gap-4',
previous && next ? 'grid-cols-2' : 'grid-cols-1',
className,
)}
{...props}
>
{previous && <FooterItem item={previous} index={0} />}
{next && <FooterItem item={next} index={1} />}
</div>
{children}
</>
);
}
function FooterItem({ item, index }: { item: Item; index: 0 | 1 }) {
const { text } = useI18n();
const Icon = index === 0 ? ChevronLeft : ChevronRight;
return (
<Link
href={item.url}
className={cn(
'flex flex-col gap-2 rounded-lg border p-4 text-sm transition-colors hover:bg-fd-accent/80 hover:text-fd-accent-foreground @max-lg:col-span-full',
index === 1 && 'text-end',
)}
>
<div
className={cn(
'inline-flex items-center gap-1.5 font-medium',
index === 1 && 'flex-row-reverse',
)}
>
<Icon className="-mx-1 size-4 shrink-0 rtl:rotate-180" />
<p>{item.name}</p>
</div>
<p className="text-fd-muted-foreground truncate">
{item.description ?? (index === 0 ? text.previousPage : text.nextPage)}
</p>
</Link>
);
}
@@ -0,0 +1,255 @@
'use client';
import * as Base from '@/components/toc';
import { useI18n } from '@/contexts/i18n';
import { useTreePath } from '@/contexts/tree';
import { cn } from '@/utils/cn';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronDown } from 'lucide-react';
import {
type ComponentProps,
type ReactNode,
createContext,
use,
useEffect,
useEffectEvent,
useMemo,
useRef,
useState,
} from 'react';
import * as TocDefault from '@/components/toc/default';
import * as TocClerk from '@/components/toc/clerk';
import { AnimatePresence, motion } from 'motion/react';
import { createPortal } from 'react-dom';
const TocPopoverContext = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
} | null>(null);
export type TOCProviderProps = Base.TOCProviderProps;
export const { TOCProvider } = Base;
export interface TOCProps {
container?: ComponentProps<'div'>;
trigger?: ComponentProps<'button'>;
content?: ComponentProps<'div'>;
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
/**
* @defaultValue 'normal'
*/
style?: 'normal' | 'clerk';
}
export function TOC({ container, trigger, content, header, footer, style }: TOCProps) {
return (
<PageTOCPopover {...container}>
<PageTOCPopoverContent {...content}>
{header}
<Base.TOCScrollArea>
{style === 'clerk' ? <TocClerk.TOCItems /> : <TocDefault.TOCItems />}
</Base.TOCScrollArea>
{footer}
</PageTOCPopoverContent>
<PageTOCPopoverTrigger {...trigger} />
</PageTOCPopover>
);
}
function PageTOCPopover(props: ComponentProps<'div'>) {
const [container, setContainer] = useState<HTMLElement | null>(null);
useEffect(() => {
const element = document.getElementById('flux-layout-slot');
if (!element) return;
setContainer(element);
}, []);
if (!container) return;
return createPortal(<PageTOCPopoverPhysical {...props} />, container);
}
function PageTOCPopoverPhysical({ className, children, ...rest }: ComponentProps<'div'>) {
const ref = useRef<HTMLElement>(null);
const [open, setOpen] = useState(false);
const onClick = useEffectEvent((e: Event) => {
if (!open) return;
if (ref.current && !ref.current.contains(e.target as HTMLElement)) setOpen(false);
});
useEffect(() => {
window.addEventListener('click', onClick);
return () => {
window.removeEventListener('click', onClick);
};
}, []);
return (
<TocPopoverContext
value={useMemo(
() => ({
open,
setOpen,
}),
[setOpen, open],
)}
>
<Collapsible
open={open}
onOpenChange={setOpen}
data-toc-popover=""
className={cn('relative h-9 animate-fd-fade-in', className)}
{...rest}
>
<header
ref={ref}
className={cn(
'absolute w-full bottom-0 border rounded-xl transition-colors bg-fd-secondary text-fd-secondary-foreground backdrop-blur-sm',
open && 'shadow-lg bg-fd-popover/80 text-fd-popover-foreground',
)}
>
{children}
</header>
</Collapsible>
</TocPopoverContext>
);
}
function PageTOCPopoverTrigger({ className, ...props }: ComponentProps<'button'>) {
const { text } = useI18n();
const { open } = use(TocPopoverContext)!;
const items = Base.useItems();
const selectedIdx = items.findIndex((item) => item.active);
const path = useTreePath().at(-1);
const spanProps = {
transition: {
duration: 0.1,
},
initial: {
opacity: 0,
y: 10,
},
animate: {
opacity: 1,
y: 0,
},
exit: {
opacity: 0,
y: -10,
},
className: cn(open && 'text-fd-popover-foreground'),
};
return (
<CollapsibleTrigger
className={cn(
'flex w-full h-8.5 items-center text-sm text-fd-muted-foreground gap-2.5 px-2 text-start focus-visible:outline-none [&_svg]:size-4',
className,
)}
data-toc-popover-trigger=""
{...props}
>
<ProgressCircle
value={(selectedIdx + 1) / Math.max(1, items.length)}
max={1}
className={cn('shrink-0', open && 'text-fd-primary')}
/>
<AnimatePresence mode="wait">
{items[selectedIdx] && !open ? (
<motion.span key={selectedIdx} {...spanProps}>
{items[selectedIdx].original.title}
</motion.span>
) : path ? (
<motion.span key={path.$id ?? ':pathId'} {...spanProps}>
{path.name}
</motion.span>
) : (
<motion.span key=":toc" {...spanProps}>
{text.toc}
</motion.span>
)}
</AnimatePresence>
<ChevronDown className={cn('ms-auto shrink-0 transition-transform', open && 'rotate-180')} />
</CollapsibleTrigger>
);
}
interface ProgressCircleProps extends Omit<ComponentProps<'svg'>, 'strokeWidth'> {
value: number;
strokeWidth?: number;
size?: number;
min?: number;
max?: number;
}
function clamp(input: number, min: number, max: number): number {
if (input < min) return min;
if (input > max) return max;
return input;
}
function ProgressCircle({
value,
strokeWidth = 2,
size = 24,
min = 0,
max = 100,
...restSvgProps
}: ProgressCircleProps) {
const normalizedValue = clamp(value, min, max);
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const progress = (normalizedValue / max) * circumference;
const circleProps = {
cx: size / 2,
cy: size / 2,
r: radius,
fill: 'none',
strokeWidth,
};
return (
<svg
role="progressbar"
viewBox={`0 0 ${size} ${size}`}
aria-valuenow={normalizedValue}
aria-valuemin={min}
aria-valuemax={max}
{...restSvgProps}
>
<circle {...circleProps} className="stroke-current/25" />
<circle
{...circleProps}
stroke="currentColor"
strokeDasharray={circumference}
strokeDashoffset={circumference - progress}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all"
/>
</svg>
);
}
function PageTOCPopoverContent(props: ComponentProps<'div'>) {
return (
<CollapsibleContent data-toc-popover-content="" {...props}>
<div className="flex flex-col px-2 max-h-[50vh]">{props.children}</div>
</CollapsibleContent>
);
}
@@ -0,0 +1,14 @@
'use client';
import { cn } from '@/utils/cn';
import type { ComponentProps } from 'react';
export function Container(props: ComponentProps<'div'>) {
return (
<div
id="nd-flux-layout"
{...props}
className={cn('flex flex-col items-center pb-24 overflow-x-clip', props.className)}
/>
);
}
@@ -1,13 +1,26 @@
'use client';
import * as Base from '@/components/sidebar/base';
import { cn } from '@/utils/cn';
import { type ComponentProps, useEffect, useEffectEvent, useRef, useState } from 'react';
import {
type ComponentProps,
type ReactNode,
useEffect,
useEffectEvent,
useRef,
useState,
} from 'react';
import { cva } from 'class-variance-authority';
import { createPageTreeRenderer } from '@/components/sidebar/page-tree';
import {
createPageTreeRenderer,
type SidebarPageTreeComponents,
} from '@/components/sidebar/page-tree';
import { createLinkItemRenderer } from '@/components/sidebar/link-item';
import { mergeRefs } from '@/utils/merge-refs';
import { motion } from 'motion/react';
import { AnimatePresence, motion } from 'motion/react';
import { RemoveScroll } from 'react-remove-scroll';
import { useFluxLayout } from '..';
import { XIcon, SidebarIcon } from 'lucide-react';
const MotionSidebarItem = motion.create(Base.SidebarItem);
const MotionSidebarFolderTrigger = motion.create(Base.SidebarFolderTrigger);
@@ -34,35 +47,77 @@ function getItemOffset(depth: number) {
return `calc(${2 + 3 * depth} * var(--spacing))`;
}
export function Sidebar(props: ComponentProps<typeof Base.SidebarProvider>) {
export interface SidebarProps extends ComponentProps<'aside'> {
components?: Partial<SidebarPageTreeComponents>;
banner?: ReactNode;
footer?: ReactNode;
}
export type SidebarProviderProps = Base.SidebarProviderProps;
export const { useSidebar } = Base;
export function SidebarProvider(props: SidebarProviderProps) {
return <Base.SidebarProvider {...props} />;
}
export function SidebarFolder(props: ComponentProps<typeof Base.SidebarFolder>) {
return <Base.SidebarFolder {...props} />;
export function Sidebar({ footer, banner, components, ...rest }: SidebarProps) {
const { menuItems } = useFluxLayout();
return (
<SidebarContent {...rest}>
<div className="flex flex-col gap-3 p-4 pb-2 empty:hidden">{banner}</div>
<Base.SidebarViewport>
<div className="flex flex-col">
{menuItems
.filter((v) => v.type !== 'icon')
.map((item, i, list) => (
<SidebarLinkItem
key={i}
item={item}
className={cn(i === list.length - 1 && 'mb-4')}
/>
))}
<SidebarPageTree {...components} />
</div>
</Base.SidebarViewport>
{footer}
</SidebarContent>
);
}
export function SidebarCollapseTrigger(props: ComponentProps<typeof Base.SidebarCollapseTrigger>) {
return <Base.SidebarCollapseTrigger {...props} />;
export function SidebarTrigger(props: ComponentProps<'button'>) {
const { open, setOpen } = useSidebar();
return (
<button onClick={() => setOpen((prev) => !prev)} {...props}>
<AnimatePresence mode="wait">
<motion.span
key={open ? 'open' : 'closed'}
transition={{ duration: 0.2 }}
initial={{
y: '100%',
opacity: 0,
}}
animate={{
y: 0,
opacity: 1,
}}
exit={{
y: '100%',
opacity: 0,
}}
>
{open ? <XIcon /> : <SidebarIcon />}
</motion.span>
</AnimatePresence>
</button>
);
}
export function SidebarViewport(props: ComponentProps<typeof Base.SidebarViewport>) {
return <Base.SidebarViewport {...props} />;
}
export function SidebarTrigger(props: ComponentProps<typeof Base.SidebarTrigger>) {
return <Base.SidebarTrigger {...props} />;
}
export function SidebarContent({
ref: refProp,
className,
children,
...props
}: ComponentProps<'aside'>) {
function SidebarContent({ ref: refProp, className, children, ...props }: ComponentProps<'aside'>) {
const ref = useRef<HTMLElement>(null);
const [blockScroll, setBlockScroll] = useState(false);
const { open, setOpen } = Base.useSidebar();
const { open, setOpen } = useSidebar();
const listener = useEffectEvent((e: KeyboardEvent) => {
if (open && e.key === 'Escape') {
@@ -136,12 +191,20 @@ export function SidebarContent({
);
}
export function SidebarSeparator({ className, style, children, ...props }: ComponentProps<'p'>) {
function SidebarFolder(props: ComponentProps<typeof Base.SidebarFolder>) {
return <Base.SidebarFolder {...props} />;
}
function SidebarSeparator({ className, style, children, ...props }: ComponentProps<'p'>) {
const depth = Base.useFolderDepth();
return (
<Base.SidebarSeparator
className={cn('[&_svg]:size-4 [&_svg]:shrink-0', className)}
className={cn(
'inline-flex items-center gap-2 mb-1.5 px-2 mt-6 empty:mb-0 [&_svg]:size-4 [&_svg]:shrink-0',
depth === 0 && 'first:mt-0',
className,
)}
style={{
paddingInlineStart: getItemOffset(depth),
...style,
@@ -153,7 +216,7 @@ export function SidebarSeparator({ className, style, children, ...props }: Compo
);
}
export function SidebarItem({
function SidebarItem({
className,
style,
children,
@@ -175,7 +238,7 @@ export function SidebarItem({
);
}
export function SidebarFolderTrigger({
function SidebarFolderTrigger({
className,
style,
...props
@@ -184,11 +247,11 @@ export function SidebarFolderTrigger({
return (
<MotionSidebarFolderTrigger
className={(s) =>
className={(state) =>
cn(
itemVariants({ variant: collapsible ? 'button' : null }),
'w-full',
typeof className === 'function' ? className(s) : className,
typeof className === 'function' ? className(state) : className,
)
}
style={{
@@ -202,7 +265,7 @@ export function SidebarFolderTrigger({
);
}
export function SidebarFolderLink({
function SidebarFolderLink({
className,
style,
...props
@@ -223,7 +286,7 @@ export function SidebarFolderLink({
);
}
export function SidebarFolderContent({
function SidebarFolderContent({
className,
children,
...props
@@ -233,13 +296,12 @@ export function SidebarFolderContent({
return (
<MotionSidebarFolderContent
className={(s) =>
className={(state) =>
cn(
'relative',
depth === 1 &&
"before:content-[''] before:absolute before:w-px before:inset-y-1 before:bg-fd-border before:start-2.5",
typeof className === 'function' ? className(s) : className,
typeof className === 'function' ? className(state) : className,
)
}
{...(props as ComponentProps<typeof MotionSidebarFolderContent>)}
@@ -263,7 +325,7 @@ export function SidebarFolderContent({
);
}
export const SidebarPageTree = createPageTreeRenderer({
const SidebarPageTree = createPageTreeRenderer({
SidebarFolder,
SidebarFolderContent,
SidebarSeparator,
@@ -272,7 +334,7 @@ export const SidebarPageTree = createPageTreeRenderer({
SidebarItem,
});
export const SidebarLinkItem = createLinkItemRenderer({
const SidebarLinkItem = createLinkItemRenderer({
SidebarFolder,
SidebarFolderContent,
SidebarFolderLink,
@@ -4,35 +4,24 @@ import { type ComponentProps, type ReactNode, useMemo, useState } from 'react';
import Link from '@hanzo/docs-core/link';
import { usePathname } from '@hanzo/docs-core/framework';
import { cn } from '@/utils/cn';
import { isActive, normalize } from '@/utils/urls';
import { useSidebar } from '@/components/sidebar/base';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { SidebarTab } from '@/components/sidebar/tabs';
import { AnimatePresence, motion } from 'motion/react';
import { isLayoutTabActive, type LayoutTab } from '../../shared';
export interface SidebarTabWithProps extends SidebarTab {
props?: ComponentProps<'a'>;
export interface TabDropdownProps extends ComponentProps<'button'> {
placeholder?: ReactNode;
tabs: LayoutTab[];
}
export function SidebarTabsDropdown({
options,
placeholder,
className,
...props
}: {
placeholder?: ReactNode;
options: SidebarTabWithProps[];
} & ComponentProps<'button'>) {
export function TabDropdown({ tabs, placeholder, className, ...props }: TabDropdownProps) {
const [open, setOpen] = useState(false);
const { closeOnRedirect } = useSidebar();
const pathname = usePathname();
const selectedIdx = useMemo(() => {
return options.findLastIndex((item) => isTabActive(item, pathname));
}, [options, pathname]);
const selected = selectedIdx !== -1 ? options[selectedIdx] : undefined;
return tabs.findLastIndex((item) => isLayoutTabActive(item, pathname));
}, [tabs, pathname]);
const selected = selectedIdx !== -1 ? tabs[selectedIdx] : undefined;
const onClick = () => {
closeOnRedirect.current = false;
setOpen(false);
};
@@ -83,7 +72,7 @@ export function SidebarTabsDropdown({
align="start"
className="flex flex-col gap-1 max-w-svw p-1 fd-scroll-container"
>
{options.map((item, i) => {
{tabs.map((item, i) => {
const isActive = i === selectedIdx;
if (!isActive && item.unlisted) return;
@@ -119,9 +108,3 @@ export function SidebarTabsDropdown({
</Popover>
);
}
export function isTabActive(tab: SidebarTab, pathname: string) {
if (tab.urls) return tab.urls.has(normalize(pathname));
return isActive(tab.url, pathname, true);
}
+81 -56
View File
@@ -1,62 +1,87 @@
import type { ComponentProps } from 'react';
import { cn } from '@/utils/cn';
import { type BaseLayoutProps, type NavOptions } from '@/layouts/shared';
import { Header } from '@/layouts/home/client';
import { renderer, type Renderer } from '@/utils/renderer';
'use client';
export interface HomeLayoutProps extends BaseLayoutProps {
Container?: Renderer<ComponentProps<'main'>>;
nav?: Partial<
NavOptions & {
/**
* Open mobile menu when hovering the trigger
*/
enableHoverToOpen?: boolean;
}
>;
import type { BaseLayoutProps, NavOptions } from '@/layouts/shared';
import { type ComponentProps, createContext, type FC, use } from 'react';
import { baseSlots, useLinkItems, type BaseSlots, type BaseSlotsProps } from '@/layouts/shared';
import type { LinkItemType } from '@/layouts/shared';
import { Container } from './slots/container';
import { Header } from './slots/header';
export interface HomeLayoutProps extends BaseLayoutProps, ComponentProps<'main'> {
nav?: Nav;
slots?: Partial<HomeSlots>;
}
export function HomeLayout({
nav = {},
links,
githubUrl,
i18n,
LanguageSwitch,
ThemeSwitch,
SearchToggle,
LargeSearchToggle,
themeSwitch = {},
searchToggle,
Container,
children,
...rest
}: HomeLayoutProps & ComponentProps<'main'>) {
const renderMain = renderer(Container ?? rest, 'main');
interface Nav extends NavOptions {
/**
* Open mobile menu when hovering the trigger
*/
enableHoverToOpen?: boolean;
}
return renderMain?.((t) => ({
id: 'nd-home-layout',
...t,
...rest,
className: cn('flex flex-1 flex-col [--fd-layout-width:1400px]', t?.className, rest.className),
children: (
<>
{nav.enabled !== false &&
(nav.component ?? (
<Header
links={links}
nav={nav}
themeSwitch={themeSwitch}
searchToggle={searchToggle}
i18n={i18n}
LanguageSwitch={LanguageSwitch}
ThemeSwitch={ThemeSwitch}
SearchToggle={SearchToggle}
LargeSearchToggle={LargeSearchToggle}
githubUrl={githubUrl}
/>
))}
export interface HomeSlots extends BaseSlots {
header: FC<ComponentProps<'header'>>;
container: FC<ComponentProps<'main'>>;
}
const LayoutContext = createContext<{
props: BaseSlotsProps<HomeLayoutProps>;
navItems: LinkItemType[];
menuItems: LinkItemType[];
slots: HomeSlots;
} | null>(null);
export function useHomeLayout() {
const context = use(LayoutContext);
if (!context)
throw new Error('Please use this component under <HomeLayout /> (`@hanzo/docs-ui/layouts/home`).');
return context;
}
const { useProvider } = baseSlots({
useProps() {
return useHomeLayout().props;
},
});
export function HomeLayout(props: HomeLayoutProps) {
const {
nav: { enabled: navEnabled = true } = {},
slots: defaultSlots,
children,
i18n: _i18n,
githubUrl: _githubUrl,
links: _links,
themeSwitch: _themeSwitch,
searchToggle: _searchToggle,
...rest
} = props;
const { baseSlots, baseProps } = useProvider(props);
const linkItems = useLinkItems(props);
const slots: HomeSlots = {
...baseSlots,
header: defaultSlots?.header ?? InlineHeader,
container: defaultSlots?.container ?? Container,
};
return (
<LayoutContext
value={{
props: baseProps,
slots,
...linkItems,
}}
>
<slots.container {...rest}>
{navEnabled && <slots.header />}
{children}
</>
),
}));
</slots.container>
</LayoutContext>
);
}
function InlineHeader(props: ComponentProps<'header'>) {
const { nav } = useHomeLayout().props;
if (nav?.component) return nav.component;
return <Header {...props} />;
}
+12 -10
View File
@@ -9,35 +9,37 @@ import {
NavigationMenuTrigger,
type NavigationMenuTriggerProps,
} from '@/components/ui/navigation-menu';
import { navItemVariants } from './client';
import { navItemVariants } from './slots/header';
export const NavbarMenu = NavigationMenuItem;
export function NavbarMenuContent({ className, ...props }: NavigationMenuContentProps) {
export function NavbarMenuContent(props: NavigationMenuContentProps) {
const { className, ...rest } = props;
return (
<NavigationMenuContent
className={(s) =>
{...rest}
className={(state) =>
cn(
'grid grid-cols-1 gap-2 p-4 md:grid-cols-2 lg:grid-cols-3',
typeof className === 'function' ? className(s) : className,
'grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3',
typeof className === 'function' ? className(state) : className,
)
}
{...props}
>
{props.children}
</NavigationMenuContent>
);
}
export function NavbarMenuTrigger({ className, ...props }: NavigationMenuTriggerProps) {
export function NavbarMenuTrigger(props: NavigationMenuTriggerProps) {
const { className, ...rest } = props;
return (
<NavigationMenuTrigger
{...props}
className={(s) =>
{...rest}
className={(state) =>
cn(
navItemVariants(),
'text-sm rounded-md',
typeof className === 'function' ? className(s) : className,
typeof className === 'function' ? className(state) : className,
)
}
>
@@ -0,0 +1,14 @@
'use client';
import type { ComponentProps } from 'react';
import { cn } from '@/utils/cn';
export function Container(props: ComponentProps<'main'>) {
return (
<main
id="nd-home-layout"
{...props}
className={cn('flex flex-1 flex-col [--fd-layout-width:1400px]', props.className)}
/>
);
}
@@ -0,0 +1,336 @@
'use client';
import { type ComponentProps, Fragment, useEffect, useEffectEvent, useRef, useState } from 'react';
import { cva } from 'class-variance-authority';
import Link from '@hanzo/docs-core/link';
import { cn } from '@/utils/cn';
import { type LinkItemType, LinkItem } from '@/layouts/shared';
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
NavigationMenuViewport,
} from '@/components/ui/navigation-menu';
import { buttonVariants } from '@/components/ui/button';
import { ChevronDown, Languages } from 'lucide-react';
import { useIsScrollTop } from '@/utils/use-is-scroll-top';
import { useHomeLayout } from '..';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { mergeRefs } from '@/utils/merge-refs';
export const navItemVariants = cva('[&_svg]:size-4', {
variants: {
variant: {
main: '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',
button: buttonVariants({
color: 'secondary',
className: 'gap-1.5',
}),
icon: buttonVariants({
color: 'ghost',
size: 'icon',
}),
},
},
defaultVariants: {
variant: 'main',
},
});
export function Header(props: ComponentProps<'header'>) {
const {
navItems,
menuItems,
slots,
props: { nav },
} = useHomeLayout();
const headerRef = useRef<HTMLElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const [open, setOpen] = useState(false);
const transparentMode = nav?.transparentMode ?? 'none';
const isTop = useIsScrollTop({ enabled: transparentMode === 'top' }) ?? true;
const isNavTransparent = transparentMode === 'top' ? isTop : transparentMode === 'always';
const onClick = useEffectEvent((e: Event) => {
const element = headerRef.current;
if (!open || !element) return;
if (element !== e.target && !element.contains(e.target as HTMLElement)) {
setOpen(false);
}
});
useEffect(() => {
window.addEventListener('click', onClick);
return () => {
window.removeEventListener('click', onClick);
};
}, []);
const list = (
<NavigationMenuList
ref={listRef}
className="flex h-14 w-full mx-auto max-w-(--fd-layout-width) items-center px-4"
>
{slots.navTitle && (
<slots.navTitle className="inline-flex items-center gap-2.5 font-semibold" />
)}
{nav?.children}
<ul className="flex flex-row items-center gap-2 px-6 max-sm:hidden">
{navItems
.filter((item) => !isSecondary(item))
.map((item, i) => (
<NavigationMenuLinkItem key={i} item={item} className="text-sm" />
))}
</ul>
<div className="flex flex-row items-center justify-end gap-1.5 flex-1 max-lg:hidden">
{slots.searchTrigger && (
<slots.searchTrigger.full
hideIfDisabled
className="w-full rounded-full ps-2.5 max-w-[240px]"
/>
)}
{slots.themeSwitch && <slots.themeSwitch />}
{slots.languageSelect && (
<slots.languageSelect.root>
<Languages className="size-5" />
</slots.languageSelect.root>
)}
<ul className="flex flex-row gap-2 items-center empty:hidden">
{navItems.filter(isSecondary).map((item, i) => (
<NavigationMenuLinkItem
key={i}
className={cn(item.type === 'icon' && '-mx-1 first:ms-0 last:me-0')}
item={item}
/>
))}
</ul>
</div>
<div className="flex flex-row items-center ms-auto -me-1.5 lg:hidden">
{slots.searchTrigger && <slots.searchTrigger.sm hideIfDisabled className="p-2" />}
<CollapsibleTrigger
aria-label="Toggle Menu"
className={cn(
buttonVariants({
size: 'icon',
color: 'ghost',
}),
)}
onPointerEnter={
nav?.enableHoverToOpen
? () => {
setOpen(true);
}
: undefined
}
>
<ChevronDown className={cn('transition-transform', open && 'rotate-180')} />
</CollapsibleTrigger>
</div>
</NavigationMenuList>
);
return (
<Collapsible
open={open}
onOpenChange={setOpen}
render={
<header
id="nd-nav"
{...props}
ref={mergeRefs(headerRef, props.ref)}
className={cn('sticky h-14 top-0 z-40', props.className)}
>
<NavigationMenu
className={(s) =>
cn(
'backdrop-blur-lg border-b transition-[box-shadow,background-color,border-radius]',
open && 'max-lg:shadow-lg max-lg:rounded-b-2xl',
(open || !isNavTransparent || s.open) && 'bg-fd-background/80',
)
}
>
{list}
<CollapsibleContent className="mx-auto max-w-(--fd-layout-width) lg:hidden">
<div className="flex flex-col pt-2 p-4 sm:flex-row sm:items-center sm:justify-end">
{menuItems
.filter((item) => !isSecondary(item))
.map((item, i) => (
<MobileNavigationMenuLinkItem key={i} item={item} className="sm:hidden" />
))}
<div className="-ms-1.5 flex flex-row items-center gap-2 max-sm:mt-2">
{menuItems.filter(isSecondary).map((item, i) => (
<MobileNavigationMenuLinkItem
key={i}
item={item}
className={cn(item.type === 'icon' && '-mx-1 first:ms-0')}
/>
))}
<div role="separator" className="flex-1" />
{slots.languageSelect && (
<slots.languageSelect.root>
<Languages className="size-5" />
{slots.languageSelect.text && <slots.languageSelect.text />}
<ChevronDown className="size-3 text-fd-muted-foreground" />
</slots.languageSelect.root>
)}
{slots.themeSwitch && <slots.themeSwitch />}
</div>
</div>
</CollapsibleContent>
<NavigationMenuViewport side="bottom" anchor={listRef} />
</NavigationMenu>
</header>
}
/>
);
}
function isSecondary(item: LinkItemType): boolean {
if ('secondary' in item && item.secondary != null) return item.secondary;
return item.type === 'icon';
}
function NavigationMenuLinkItem({ item, ...props }: { item: LinkItemType; className?: string }) {
if (item.type === 'custom') return item.children;
if (item.type === 'menu') {
const children = item.items.map((child, j) => {
if (child.type === 'custom') {
return <Fragment key={j}>{child.children}</Fragment>;
}
const {
banner = child.icon ? (
<div className="w-fit rounded-md border bg-fd-muted p-1 [&_svg]:size-4">{child.icon}</div>
) : null,
...rest
} = child.menu ?? {};
return (
<NavigationMenuLink
key={`${j}-${child.url}`}
render={
<Link
href={child.url}
external={child.external}
{...rest}
className={cn(
'flex flex-col gap-2 rounded-lg border bg-fd-card p-3 transition-colors hover:bg-fd-accent/80 hover:text-fd-accent-foreground',
rest.className,
)}
>
{rest.children ?? (
<>
{banner}
<p className="text-base font-medium">{child.text}</p>
<p className="text-sm text-fd-muted-foreground empty:hidden">
{child.description}
</p>
</>
)}
</Link>
}
/>
);
});
return (
<NavigationMenuItem {...props}>
<NavigationMenuTrigger className={cn(navItemVariants(), 'rounded-md')}>
{item.url ? (
<Link href={item.url} external={item.external}>
{item.text}
</Link>
) : (
item.text
)}
</NavigationMenuTrigger>
<NavigationMenuContent className="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3">
{children}
</NavigationMenuContent>
</NavigationMenuItem>
);
}
return (
<NavigationMenuItem {...props}>
<NavigationMenuLink
render={
<LinkItem
item={item}
aria-label={item.type === 'icon' ? item.label : undefined}
className={cn(navItemVariants({ variant: item.type }))}
>
{item.type === 'icon' ? item.icon : item.text}
</LinkItem>
}
/>
</NavigationMenuItem>
);
}
function MobileNavigationMenuLinkItem({
item,
...props
}: {
item: LinkItemType;
className?: string;
}) {
if (item.type === 'custom')
return <div className={cn('grid', props.className)}>{item.children}</div>;
if (item.type === 'menu') {
const header = (
<>
{item.icon}
{item.text}
</>
);
return (
<div className={cn('mb-4 flex flex-col', props.className)}>
<p className="mb-1 text-sm text-fd-muted-foreground">
{item.url ? (
<Link href={item.url} external={item.external}>
{header}
</Link>
) : (
header
)}
</p>
{item.items.map((child, i) => (
<MobileNavigationMenuLinkItem key={i} item={child} />
))}
</div>
);
}
return (
<LinkItem
item={item}
className={cn(
{
main: 'inline-flex items-center gap-2 py-1.5 transition-colors hover:text-fd-popover-foreground/50 data-[active=true]:font-medium data-[active=true]:text-fd-primary [&_svg]:size-4',
icon: buttonVariants({
size: 'icon',
color: 'ghost',
}),
button: buttonVariants({
color: 'secondary',
className: 'gap-1.5 [&_svg]:size-4',
}),
}[item.type ?? 'main'],
props.className,
)}
aria-label={item.type === 'icon' ? item.label : undefined}
>
{item.icon}
{item.type === 'icon' ? undefined : item.text}
</LinkItem>
);
}
+106 -229
View File
@@ -1,4 +1,5 @@
'use client';
<<<<<<< HEAD
import { cn } from '@/utils/cn';
import {
type ComponentProps,
@@ -16,18 +17,63 @@ import { useSidebar } from '@/components/sidebar/base';
import { ChevronDown } from 'lucide-react';
import Link from '@hanzo/docs-core/link';
import { usePathname } from '@hanzo/docs-core/framework';
=======
import { type ComponentProps, createContext, type FC, use } from 'react';
import { type DocsLayoutProps } from '.';
>>>>>>> dev
import { useIsScrollTop } from '@/utils/use-is-scroll-top';
import { LinkItem, type LinkItemType, type MenuItemType } from '@/utils/link-item';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { isTabActive, type SidebarTabWithProps } from '@/components/sidebar/tabs/dropdown';
import { renderer, type Renderer } from '@/utils/renderer';
import { type LinkItemType } from '@/layouts/shared';
import {
baseSlots,
type BaseSlots,
type BaseSlotsProps,
type LayoutTab,
useLinkItems,
} from '../shared';
import { TreeContextProvider } from '@/contexts/tree';
import { Container } from './slots/container';
import {
Sidebar,
SidebarCollapseTrigger,
SidebarProvider,
SidebarTrigger,
useSidebar,
type SidebarProps,
type SidebarProviderProps,
} from './slots/sidebar';
import { Header } from './slots/header';
const LayoutContext = createContext<
| (LayoutInfo & {
isNavTransparent: boolean;
})
| null
>(null);
export interface DocsSlots extends BaseSlots {
container: FC<ComponentProps<'div'>>;
header: FC<ComponentProps<'header'>>;
sidebar: {
provider: FC<SidebarProviderProps>;
root: FC<SidebarProps>;
trigger: FC<ComponentProps<'button'>>;
collapseTrigger: FC<ComponentProps<'button'>>;
useSidebar: () => { collapsed: boolean; open: boolean; setOpen: (V: boolean) => void };
};
}
const { useProvider } = baseSlots({
useProps() {
return useNotebookLayout().props;
},
});
interface SlotsProps extends BaseSlotsProps<DocsLayoutProps> {
sidebar: SidebarProps;
tabMode: NonNullable<DocsLayoutProps['tabMode']>;
tabs: LayoutTab[];
}
const LayoutContext = createContext<{
props: SlotsProps;
isNavTransparent: boolean;
navItems: LinkItemType[];
menuItems: LinkItemType[];
slots: DocsSlots;
} | null>(null);
export function useNotebookLayout() {
const context = use(LayoutContext);
@@ -38,230 +84,61 @@ export function useNotebookLayout() {
return context;
}
export interface LayoutInfo {
tabMode: 'sidebar' | 'navbar';
navMode: 'top' | 'auto';
}
export function LayoutContextProvider({
navTransparentMode = 'none',
navMode,
tabMode,
children,
}: LayoutInfo & {
navTransparentMode?: 'always' | 'top' | 'none';
children: ReactNode;
}) {
export function LayoutBody(
props: Omit<DocsLayoutProps, 'tabs'> & {
tabs: LayoutTab[];
},
) {
const {
nav: { enabled: navEnabled = true, transparentMode: navTransparentMode = 'none' } = {},
sidebar: { defaultOpenLevel, prefetch, ...sidebarProps } = {},
slots: defaultSlots,
tabMode = 'sidebar',
tabs,
tree,
containerProps,
children,
} = props;
const isTop = useIsScrollTop({ enabled: navTransparentMode === 'top' }) ?? true;
const isNavTransparent = navTransparentMode === 'top' ? isTop : navTransparentMode === 'always';
const { baseSlots, baseProps } = useProvider(props);
const linkItems = useLinkItems(props);
const slots: DocsSlots = {
...baseSlots,
header: defaultSlots?.header ?? Header,
container: defaultSlots?.container ?? Container,
sidebar: defaultSlots?.sidebar ?? {
provider: SidebarProvider,
root: Sidebar,
trigger: SidebarTrigger,
collapseTrigger: SidebarCollapseTrigger,
useSidebar,
},
};
return (
<LayoutContext
value={useMemo(
() => ({
<TreeContextProvider tree={tree}>
<LayoutContext
value={{
props: {
tabs,
tabMode,
sidebar: sidebarProps,
...baseProps,
},
isNavTransparent,
navMode,
tabMode,
}),
[isNavTransparent, navMode, tabMode],
)}
>
{children}
</LayoutContext>
);
}
export function LayoutHeader(props: ComponentProps<'header'>) {
const { open } = useSidebar();
const { isNavTransparent } = useNotebookLayout();
return (
<header data-transparent={isNavTransparent && !open} {...props}>
{props.children}
</header>
);
}
export function LayoutBody({
_,
children,
}: {
_: Renderer<ComponentProps<'div'>>;
children: ReactNode;
}) {
const { navMode } = useNotebookLayout();
const { collapsed } = useSidebar();
const pageCol =
'calc(var(--fd-layout-width,97rem) - var(--fd-sidebar-col) - var(--fd-toc-width))';
const render = renderer(_, 'div');
return render?.(
(t) =>
({
id: 'nd-notebook-layout',
children,
...t,
className: cn(
'grid overflow-x-clip min-h-(--fd-docs-height) transition-[grid-template-columns] auto-cols-auto auto-rows-auto [--fd-docs-height:100dvh] [--fd-header-height:0px] [--fd-toc-popover-height:0px] [--fd-sidebar-width:0px] [--fd-toc-width:0px]',
t?.className,
),
style: {
gridTemplate:
navMode === 'top'
? `". header header header ."
"sidebar sidebar toc-popover toc-popover ."
"sidebar sidebar main toc ." 1fr / minmax(min-content, 1fr) var(--fd-sidebar-col) minmax(0, ${pageCol}) var(--fd-toc-width) minmax(min-content, 1fr)`
: `"sidebar sidebar header header ."
"sidebar sidebar toc-popover toc-popover ."
"sidebar sidebar main toc ." 1fr / minmax(min-content, 1fr) var(--fd-sidebar-col) minmax(0, ${pageCol}) var(--fd-toc-width) minmax(min-content, 1fr)`,
'--fd-docs-row-1': 'var(--fd-banner-height, 0px)',
'--fd-docs-row-2': 'calc(var(--fd-docs-row-1) + var(--fd-header-height))',
'--fd-docs-row-3': 'calc(var(--fd-docs-row-2) + var(--fd-toc-popover-height))',
'--fd-sidebar-col': collapsed ? '0px' : 'var(--fd-sidebar-width)',
...t?.style,
},
}) as ComponentProps<'div'>,
);
}
export function LayoutHeaderTabs({
options,
className,
...props
}: ComponentProps<'div'> & {
options: SidebarTabWithProps[];
}) {
const pathname = usePathname();
const selectedIdx = useMemo(() => {
return options.findLastIndex((option) => isTabActive(option, pathname));
}, [options, pathname]);
return (
<div className={cn('flex flex-row items-end gap-6', className)} {...props}>
{options.map((option, i) => {
const { title, url, unlisted, props: { className, ...rest } = {} } = option;
const isSelected = selectedIdx === i;
return (
<Link
key={i}
href={url}
className={cn(
'inline-flex border-b-2 border-transparent transition-colors items-center pb-1.5 font-medium gap-2 text-fd-muted-foreground text-sm text-nowrap hover:text-fd-accent-foreground',
unlisted && !isSelected && 'hidden',
isSelected && 'border-fd-primary text-fd-primary',
className,
)}
{...rest}
>
{title}
</Link>
);
})}
</div>
);
}
export function NavbarLinkItem({
item,
className,
...props
}: { item: LinkItemType } & HTMLAttributes<HTMLElement>) {
if (item.type === 'custom') return item.children;
if (item.type === 'menu') {
return <NavbarLinkItemMenu item={item} className={className} {...props} />;
}
return (
<LinkItem
item={item}
className={cn(
'text-sm text-fd-muted-foreground transition-colors hover:text-fd-accent-foreground data-[active=true]:text-fd-primary',
className,
)}
{...props}
>
{item.text}
</LinkItem>
);
}
function NavbarLinkItemMenu({
item,
hoverDelay = 50,
className,
...props
}: { item: MenuItemType; hoverDelay?: number } & HTMLAttributes<HTMLElement>) {
const [open, setOpen] = useState(false);
const timeoutRef = useRef<number>(null);
const freezeUntil = useRef<number>(null);
const delaySetOpen = (value: boolean) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
timeoutRef.current = window.setTimeout(() => {
setOpen(value);
freezeUntil.current = Date.now() + 300;
}, hoverDelay);
};
const onPointerEnter = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
delaySetOpen(true);
};
const onPointerLeave = (e: PointerEvent) => {
if (e.pointerType === 'touch') return;
delaySetOpen(false);
};
function isTouchDevice() {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
}
return (
<Popover
open={open}
onOpenChange={(value) => {
if (freezeUntil.current === null || Date.now() >= freezeUntil.current) setOpen(value);
}}
>
<PopoverTrigger
className={cn(
'inline-flex items-center gap-1.5 p-1 text-sm text-fd-muted-foreground transition-colors has-data-[active=true]:text-fd-primary data-[state=open]:text-fd-accent-foreground focus-visible:outline-none',
className,
)}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
{...props}
slots,
...linkItems,
}}
>
{item.url ? <LinkItem item={item as never}>{item.text}</LinkItem> : item.text}
<ChevronDown className="size-3" />
</PopoverTrigger>
<PopoverContent
className="flex flex-col p-1 text-fd-muted-foreground text-start"
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
>
{item.items.map((child, i) => {
if (child.type === 'custom') return <Fragment key={i}>{child.children}</Fragment>;
return (
<LinkItem
key={i}
item={child}
className="inline-flex items-center gap-2 rounded-md p-2 transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground data-[active=true]:text-fd-primary [&_svg]:size-4"
onClick={() => {
if (isTouchDevice()) setOpen(false);
}}
>
{child.icon}
{child.text}
</LinkItem>
);
})}
</PopoverContent>
</Popover>
<slots.sidebar.provider defaultOpenLevel={defaultOpenLevel} prefetch={prefetch}>
<slots.container {...containerProps}>
{navEnabled && <slots.header />}
<slots.sidebar.root {...sidebarProps} />
{children}
</slots.container>
</slots.sidebar.provider>
</LayoutContext>
</TreeContextProvider>
);
}

Some files were not shown because too many files have changed in this diff Show More