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