From 14f41a95be2ce7c91889a101cc736782b1cf9cec Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Sun, 26 Jul 2026 11:23:55 +1000 Subject: [PATCH] Share one top nav across docs and playground (#279) * Share one top nav across docs, playground, and 404 Docs and the playground carried different chrome: the docs sidebar owned the wordmark, search, nav links, and theme controls, while the playground had a bespoke bar with a plain-text title and one "Docs" link. Nothing carried across but the theme controls. Both surfaces now render one SiteNav: wordmark, Docs/Playground/Storybook, search, and the appearance controls. The 404 renders it too, so no surface is left with its own bar. The docs move from Fumadocs' docs layout to its notebook layout with nav.mode 'top', which spans the header full width and starts the sidebar beneath it. SiteNav is supplied through the layout's header slot, so it also carries the sidebar collapse and drawer triggers, and the sidebar footer no longer renders the theme switch. Below lg the docs hand the destinations to the sidebar, which already lists them there; surfaces with no sidebar keep them on a second nav row below md. Below md the appearance controls move behind a disclosure -- they are 230px of pills that cannot share a 320px row with the wordmark, search, and the sidebar trigger, and letting them wrap made the docs bar 113px tall while the layout still offset content by 56px. Pre-bundle lucide-react: Fumadocs' search trigger and popover import from its barrel, which unbundled is ~1750 module requests. Adding that to the playground, which also loads Monaco, exhausted the browser's connection pool in dev and the page never hydrated. * Cover shared navigation behavior --- apps/docs/src/components/docs-site-nav.tsx | 44 +++++++ apps/docs/src/components/not-found.tsx | 16 +-- .../src/components/site-nav.browser.test.tsx | 100 ++++++++++++++++ apps/docs/src/components/site-nav.tsx | 112 ++++++++++++++++++ apps/docs/src/lib/layout.shared.tsx | 29 ++--- apps/docs/src/lib/site-destinations.test.ts | 17 +++ apps/docs/src/lib/site-destinations.ts | 37 ++++++ apps/docs/src/routes/$.tsx | 4 +- apps/docs/src/routes/playground/index.tsx | 21 +--- apps/docs/src/styles/app.css | 2 +- apps/docs/vite.config.ts | 6 + docs/DOCUMENTATION.md | 20 ++++ 12 files changed, 361 insertions(+), 47 deletions(-) create mode 100644 apps/docs/src/components/docs-site-nav.tsx create mode 100644 apps/docs/src/components/site-nav.browser.test.tsx create mode 100644 apps/docs/src/components/site-nav.tsx create mode 100644 apps/docs/src/lib/site-destinations.test.ts create mode 100644 apps/docs/src/lib/site-destinations.ts diff --git a/apps/docs/src/components/docs-site-nav.tsx b/apps/docs/src/components/docs-site-nav.tsx new file mode 100644 index 00000000..203604d5 --- /dev/null +++ b/apps/docs/src/components/docs-site-nav.tsx @@ -0,0 +1,44 @@ +import { Icon } from '@luke-ui/react/icon'; +import { cx } from '@luke-ui/react/utils'; +import { + SidebarCollapseTrigger, + SidebarTrigger, + useSidebar, +} from 'fumadocs-ui/layouts/notebook/slots/sidebar'; +import { SITE_NAV_BUTTON_CLASS_NAME, SiteNav } from './site-nav.js'; + +const TRIGGER_CLASS_NAME = cx(SITE_NAV_BUTTON_CLASS_NAME, 'w-8 px-0'); + +export function DocsSiteNav() { + return ( + + + + ); +} + +function DocsSidebarTriggers() { + const { collapsed } = useSidebar(); + + return ( + <> + + + + + + + + ); +} diff --git a/apps/docs/src/components/not-found.tsx b/apps/docs/src/components/not-found.tsx index ee379649..582a235c 100644 --- a/apps/docs/src/components/not-found.tsx +++ b/apps/docs/src/components/not-found.tsx @@ -1,15 +1,11 @@ import { Link } from '@tanstack/react-router'; -import { HomeLayout } from 'fumadocs-ui/layouts/home'; +import { SiteNav } from './site-nav.js'; export function NotFound() { return ( - -
+ <> + +

404

Page Not Found

@@ -23,7 +19,7 @@ export function NotFound() { > Back to Home -

-
+ + ); } diff --git a/apps/docs/src/components/site-nav.browser.test.tsx b/apps/docs/src/components/site-nav.browser.test.tsx new file mode 100644 index 00000000..79e69c1f --- /dev/null +++ b/apps/docs/src/components/site-nav.browser.test.tsx @@ -0,0 +1,100 @@ +import '../styles/app.css'; +import '@luke-ui/react/themes/tactile.css'; +import { IconSpritesheetProvider } from '@luke-ui/react/icon'; +import spriteSheetHref from '@luke-ui/react/spritesheet.svg?url&no-inline'; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from '@tanstack/react-router'; +import { RootProvider } from 'fumadocs-ui/provider/tanstack'; +import { act } from 'react'; +import type { ReactNode } from 'react'; +import type { Root } from 'react-dom/client'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, test } from 'vite-plus/test'; +import { page, userEvent } from 'vite-plus/test/context'; +import { NotFound } from './not-found.js'; +import { SiteNav } from './site-nav.js'; +import { DocsThemeRoot } from './theme-controls.js'; + +let container: HTMLElement | undefined; +let root: Root | undefined; + +afterEach(async () => { + if (root) act(() => root?.unmount()); + container?.remove(); + localStorage.clear(); + container = undefined; + root = undefined; + await page.viewport(1024, 800); +}); + +test('marks only the current destination on desktop and keeps search available', async () => { + await page.viewport(1024, 800); + await renderAt('/playground', ); + + await expect + .element(page.getByRole('link', { name: 'Playground' })) + .toHaveAttribute('aria-current', 'page'); + expect(getCurrentLinks()).toHaveLength(1); + await expect.element(page.getByRole('button', { name: /Search/ })).toBeVisible(); +}); + +test('offers search and theme controls from the mobile bar', async () => { + await page.viewport(390, 800); + await renderAt('/', ); + + await expect + .element(page.getByRole('link', { name: 'Docs' })) + .toHaveAttribute('aria-current', 'page'); + expect(getCurrentLinks()).toHaveLength(1); + + const wordmark = page.getByRole('link', { name: 'Luke UI' }).element(); + expect(wordmark.scrollWidth).toBeLessThanOrEqual(wordmark.clientWidth); + await expect.element(page.getByRole('button', { name: 'Open Search' })).toBeVisible(); + + const themeTrigger = page.getByRole('button', { name: 'Theme' }); + await expect.element(themeTrigger).toBeVisible(); + await act(async () => { + await userEvent.click(themeTrigger); + }); + await expect.element(page.getByRole('radiogroup', { name: 'Theme profile' })).toBeVisible(); +}); + +test('leaves every destination inactive on the 404 page', async () => { + await renderAt('/missing', ); + + expect(getCurrentLinks()).toHaveLength(0); +}); + +async function renderAt(pathname: string, children: ReactNode) { + const rootRoute = createRootRoute({ + component: () => ( + + + {children} + + + ), + }); + const router = createRouter({ + history: createMemoryHistory({ initialEntries: [pathname] }), + routeTree: rootRoute, + }); + + container = document.body.appendChild(document.createElement('div')); + root = createRoot(container); + await act(async () => { + root?.render(); + await router.load(); + }); +} + +function getCurrentLinks() { + return page + .getByRole('link') + .elements() + .filter((link) => link.getAttribute('aria-current') === 'page'); +} diff --git a/apps/docs/src/components/site-nav.tsx b/apps/docs/src/components/site-nav.tsx new file mode 100644 index 00000000..dfb38583 --- /dev/null +++ b/apps/docs/src/components/site-nav.tsx @@ -0,0 +1,112 @@ +import { cx } from '@luke-ui/react/utils'; +import { useLinkProps } from '@tanstack/react-router'; +import { usePathname } from 'fumadocs-core/framework'; +import Link from 'fumadocs-core/link'; +import { Popover, PopoverContent, PopoverTrigger } from 'fumadocs-ui/components/ui/popover'; +import { FullSearchTrigger, SearchTrigger } from 'fumadocs-ui/layouts/shared/slots/search-trigger'; +import type { ComponentProps } from 'react'; +import { getActiveSiteDestination, siteDestinations } from '../lib/site-destinations.js'; +import { ThemeControls } from './theme-controls.js'; + +export const SITE_NAV_BUTTON_CLASS_NAME = + 'inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md px-2 text-fd-muted-foreground text-sm transition-colors hover:bg-fd-accent hover:text-fd-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring'; + +interface SiteNavProps extends ComponentProps<'header'> { + hasSidebarNavigation?: boolean; + hideActiveDestination?: boolean; +} + +export function SiteNav({ + children, + className, + hasSidebarNavigation = false, + hideActiveDestination = false, + ...props +}: SiteNavProps) { + const pathname = usePathname(); + const activeDestination = hideActiveDestination ? undefined : getActiveSiteDestination(pathname); + + return ( +
+ + +
+ + +
+ +
+ + {children} +
+
+ ); +} + +function SiteWordmark() { + const linkProps = useLinkProps({ + activeProps: {}, + className: 'flex h-14 shrink-0 items-center truncate font-semibold text-sm', + params: { _splat: '' }, + to: '/$', + }); + + return ( + + Luke UI + + ); +} + +function AppearancePopover() { + return ( + + + Theme + + + + + + ); +} diff --git a/apps/docs/src/lib/layout.shared.tsx b/apps/docs/src/lib/layout.shared.tsx index b1bd75c8..20d90d6e 100644 --- a/apps/docs/src/lib/layout.shared.tsx +++ b/apps/docs/src/lib/layout.shared.tsx @@ -1,25 +1,20 @@ -import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared'; -import { ThemeControls } from '../components/theme-controls'; -import { getStorybookBaseUrl } from './storybook'; +import type { DocsLayoutProps } from 'fumadocs-ui/layouts/notebook'; +import { DocsSiteNav } from '../components/docs-site-nav.js'; +import { siteDestinations } from './site-destinations.js'; -export function baseOptions(): BaseLayoutProps { +export function baseOptions(): Omit { return { - links: [ - { - text: 'Playground', - url: '/playground', - }, - { - external: true, - text: 'Storybook', - url: `${getStorybookBaseUrl(import.meta.env.BASE_URL)}/`, - }, - ], + links: siteDestinations.map((destination) => ({ + external: destination.isExternal ?? false, + text: destination.label, + url: destination.url, + })), nav: { - title: 'Luke UI', + mode: 'top', }, slots: { - themeSwitch: ThemeControls, + header: DocsSiteNav, + themeSwitch: false, }, }; } diff --git a/apps/docs/src/lib/site-destinations.test.ts b/apps/docs/src/lib/site-destinations.test.ts new file mode 100644 index 00000000..f24f76e1 --- /dev/null +++ b/apps/docs/src/lib/site-destinations.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from 'vite-plus/test'; +import { getActiveSiteDestination } from './site-destinations.js'; + +test('the docs destination covers every route the playground does not', () => { + expect(getActiveSiteDestination('/')?.label).toBe('Docs'); + expect(getActiveSiteDestination('/components/actions/button')?.label).toBe('Docs'); + expect(getActiveSiteDestination('/theming')?.label).toBe('Docs'); +}); + +test('the playground destination wins over the docs root it sits under', () => { + expect(getActiveSiteDestination('/playground')?.label).toBe('Playground'); + expect(getActiveSiteDestination('/playground/preview')?.label).toBe('Playground'); +}); + +test('a route that only shares a name prefix with a destination is not active', () => { + expect(getActiveSiteDestination('/playgrounds')?.label).toBe('Docs'); +}); diff --git a/apps/docs/src/lib/site-destinations.ts b/apps/docs/src/lib/site-destinations.ts new file mode 100644 index 00000000..91d472c2 --- /dev/null +++ b/apps/docs/src/lib/site-destinations.ts @@ -0,0 +1,37 @@ +import { getStorybookBaseUrl } from './storybook.js'; + +export interface SiteDestination { + activePath?: string; + isExternal?: boolean; + label: string; + url: string; +} + +export const siteDestinations: ReadonlyArray = [ + { activePath: '/', label: 'Docs', url: '/' }, + { activePath: '/playground', label: 'Playground', url: '/playground' }, + { + isExternal: true, + label: 'Storybook', + url: `${getStorybookBaseUrl(import.meta.env.BASE_URL)}/`, + }, +]; + +export function getActiveSiteDestination(pathname: string): SiteDestination | undefined { + let match: SiteDestination | undefined; + + for (const destination of siteDestinations) { + const { activePath } = destination; + if (activePath === undefined) continue; + if (!isAtOrBelow(pathname, activePath)) continue; + if (match?.activePath !== undefined && match.activePath.length >= activePath.length) continue; + match = destination; + } + + return match; +} + +function isAtOrBelow(pathname: string, activePath: string): boolean { + if (activePath === '/') return true; + return pathname === activePath || pathname.startsWith(`${activePath}/`); +} diff --git a/apps/docs/src/routes/$.tsx b/apps/docs/src/routes/$.tsx index 3e6490f9..786b785c 100644 --- a/apps/docs/src/routes/$.tsx +++ b/apps/docs/src/routes/$.tsx @@ -4,8 +4,8 @@ import { staticFunctionMiddleware } from '@tanstack/start-static-server-function import { useFumadocsLoader } from 'fumadocs-core/source/client'; import { AutoTypeTable } from 'fumadocs-typescript/ui'; import { TypeTable } from 'fumadocs-ui/components/type-table'; -import { DocsLayout } from 'fumadocs-ui/layouts/docs'; -import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page'; +import { DocsLayout } from 'fumadocs-ui/layouts/notebook'; +import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/notebook/page'; import defaultMdxComponents from 'fumadocs-ui/mdx'; import { Suspense } from 'react'; import * as z from 'zod'; diff --git a/apps/docs/src/routes/playground/index.tsx b/apps/docs/src/routes/playground/index.tsx index 4f48b402..eec26c89 100644 --- a/apps/docs/src/routes/playground/index.tsx +++ b/apps/docs/src/routes/playground/index.tsx @@ -1,5 +1,5 @@ import { cx } from '@luke-ui/react/utils'; -import { ClientOnly, createFileRoute, Link } from '@tanstack/react-router'; +import { ClientOnly, createFileRoute } from '@tanstack/react-router'; import { lazy, Suspense, useCallback, useEffect, useReducer, useRef, useState } from 'react'; import { Group, Panel, Separator } from 'react-resizable-panels'; import { useSpinDoctor } from 'spin-doctor'; @@ -12,7 +12,8 @@ import { import { PreviewToolbar } from '../../components/playground/preview-toolbar'; import { useIsDesktop } from '../../components/playground/use-is-desktop'; import type { ViewportWidth } from '../../components/playground/viewport-toggle'; -import { ThemeControls, useDocsThemeIdentity } from '../../components/theme-controls'; +import { SiteNav } from '../../components/site-nav.js'; +import { useDocsThemeIdentity } from '../../components/theme-controls'; import rawDefaultCode from '../../lib/playground-default-code.tsx?raw'; import { decodeCodeHash, encodeCodeHash } from '../../lib/playground-hash'; import type { @@ -137,21 +138,7 @@ function Playground() { return (
-
-
- Luke UI Playground - - Docs - -
-
- -
-
+ { include: [ '@monaco-editor/react', '@react-aria/utils', + '@tanstack/react-router', '@vanilla-extract/recipes', '@vanilla-extract/recipes/createRuntimeFn', + // Fumadocs' chrome (search trigger, popover, sidebar) imports icons + // from lucide-react's barrel, which unbundled is ~1750 separate module + // requests. On the playground, which also loads Monaco, that exhausts + // the browser's connection pool and the page never hydrates. + 'fumadocs-ui > lucide-react', 'lz-string', 'monaco-editor', 'react-aria-components/Breadcrumbs', diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index b4affc4f..71c16bb5 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -57,6 +57,26 @@ update an example in the same change when the feature is easier to understand vi Use short, legible sample values. Do not use lorem ipsum. +## Site chrome + +Every surface shares one top nav, `SiteNav` in `apps/docs/src/components/site-nav.tsx`. It carries +the wordmark, the primary destinations, search, and the appearance controls. The destination list +and its active-route matching live in `apps/docs/src/lib/site-destinations.ts`, so the nav and the +docs layout navigate to the same places. Appearance controls belong to the nav on every surface, not +to the docs sidebar footer. + +The docs routes use Fumadocs' notebook layout with `nav.mode: 'top'`, which spans the header across +the full width and starts the sidebar beneath it. `apps/docs/src/lib/layout.shared.tsx` supplies the +nav through the layout's `header` slot as `DocsSiteNav` +(`apps/docs/src/components/docs-site-nav.tsx`), which adds the sidebar triggers. The playground and +the 404 render `SiteNav` directly. + +`DocsSiteNav` passes `hasSidebarNavigation`, which hides the bar's destinations below `lg` — the +breakpoint where Fumadocs starts listing them in the sidebar and its mobile drawer instead, so they +never appear twice. It also keeps the bar on one row at exactly `h-14`, which the layout's +`--fd-header-height` is declared to match; changing the bar's height means changing both. Surfaces +with no sidebar keep the destinations at every width, moving them to a second nav row below `md`. + ## Playground The docs site has a live playground at `/playground`: a Monaco editor with TypeScript IntelliSense -- 2.51.2