From 52eb171566bbc15b4ba7c321dffeceb4db6c6b1c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Tue, 8 Sep 2026 20:26:59 -0700 Subject: [PATCH] refactor(website): render both nav panel surfaces from one body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop hover panel and the mobile drill-in stack each wrote the same shape by hand: map panel.columns to a column wrapper, map column.items through NavPanelItem, then render panel.footer with nav-panel-footer / nav-panel-footer-lead plus one more NavPanelItem. Individual items already funnelled through the shared NavPanelItem, which is what has kept the analytics ids from drifting, but the column and footer shape around them was duplicated and free to diverge. NavPanelBody now renders columns + footer once, parameterised by the two things that genuinely differ: the wrapper class names (the layouts are not the same, so desktop keeps nav-panel-cols / nav-panel-col and mobile keeps nav-mobile-panel / nav-mobile-group) and the optional onNavigate the mobile drawer uses to close itself. The caller still owns the outermost element, because that is the other real difference: desktop needs the panel id and data-columns, mobile needs neither. Desktop passes columnsClassName, mobile omits it, so the mobile stack keeps its columns as direct children and the scoped .nav-mobile-panel .nav-panel-footer override still matches. NavPanelItem and trackNavItem move into the new module with it. NavMobile had been importing NavPanelItem from NavDesktop, which pointed the dependency the wrong way between two sibling surfaces; both now import from a shared leaf and neither imports the other. Pure refactor — no rendered output changes. Verified by dumping the outerHTML of all three panels on both surfaces before and after: identical byte for byte, down to the useId-generated panel id. nx test website 1462 passed, nx build website green, and the nav e2e suites (nav-panels, nav-drawer, nav-surface, nav-height) 32 passed in a real browser. Co-Authored-By: Claude Opus 5 --- .../src/components/shared/NavDesktop.tsx | 97 +----------- .../src/components/shared/NavMobile.tsx | 40 +---- .../src/components/shared/NavPanelBody.tsx | 139 ++++++++++++++++++ 3 files changed, 154 insertions(+), 122 deletions(-) create mode 100644 apps/website/src/components/shared/NavPanelBody.tsx diff --git a/apps/website/src/components/shared/NavDesktop.tsx b/apps/website/src/components/shared/NavDesktop.tsx index 7c1e25513..4f721e2ac 100644 --- a/apps/website/src/components/shared/NavDesktop.tsx +++ b/apps/website/src/components/shared/NavDesktop.tsx @@ -10,103 +10,22 @@ import { import { Button } from '../ui/Button'; import { GitHubIcon } from '../ui/GitHubIcon'; import { GITHUB_REPO_URL } from '../../lib/positioning'; -import { LibraryMark } from '../docs/LibraryMark'; -import { NAV_TRIGGERS, type NavItem, type NavPanel } from './nav-config'; +import { NavPanelBody } from './NavPanelBody'; +import { NAV_TRIGGERS, type NavPanel } from './nav-config'; /** Long enough to cross the gap between trigger and panel diagonally. */ const OPEN_DELAY_MS = 100; const CLOSE_DELAY_MS = 150; -export function trackNavItem(item: NavItem, surface: 'nav' | 'mobile_nav') { - const ctaId: `nav_${string}` | `mobile_nav_${string}` = - surface === 'nav' ? `nav_${item.ctaId}` : `mobile_nav_${item.ctaId}`; - if (item.external) { - trackExternalLinkClick(item.href, { - surface, - cta_id: ctaId, - cta_text: item.label, - }); - return; - } - trackCtaClick({ - surface, - destination_url: item.href, - cta_id: ctaId, - cta_text: item.label, - }); -} - -export function NavPanelItem({ - item, - surface, - onNavigate, -}: { - item: NavItem; - surface: 'nav' | 'mobile_nav'; - onNavigate?: () => void; -}) { - const Icon = item.icon; - const body = ( - <> - - - {item.label} - {item.description} - - - ); - const onClick = () => { - trackNavItem(item, surface); - onNavigate?.(); - }; - - if (item.external) { - return ( - - {body} - - ); - } - return ( - - {body} - - ); -} - function Panel({ panel, id }: { panel: NavPanel; id: string }) { return (
-
- {panel.columns.map((column, index) => ( -
- {column.heading ? ( - {column.heading} - ) : null} - {column.items.map((item) => ( - - ))} -
- ))} -
- {panel.footer ? ( -
- {panel.footer.lead} - -
- ) : null} +
); } diff --git a/apps/website/src/components/shared/NavMobile.tsx b/apps/website/src/components/shared/NavMobile.tsx index cecad1578..524abdb4e 100644 --- a/apps/website/src/components/shared/NavMobile.tsx +++ b/apps/website/src/components/shared/NavMobile.tsx @@ -19,7 +19,7 @@ import { Button } from '../ui/Button'; import { GitHubIcon } from '../ui/GitHubIcon'; import { GITHUB_REPO_URL } from '../../lib/positioning'; import { DocsContextContent } from '../docs/DocsControlPlane'; -import { NavPanelItem } from './NavDesktop'; +import { NavPanelBody } from './NavPanelBody'; import { NAV_TRIGGERS } from './nav-config'; const toAnalyticsLibrary = (library: LibraryId | null): AnalyticsLibrary => { @@ -436,38 +436,12 @@ export function NavMobile({ {panel ? (
- {panel.columns.map((column, index) => ( -
- {column.heading ? ( - - {column.heading} - - ) : null} - {column.items.map((item) => ( - closeMobileMenu()} - /> - ))} -
- ))} - {panel.footer ? ( -
- - {panel.footer.lead} - - closeMobileMenu()} - /> -
- ) : null} + closeMobileMenu()} + />
) : null} diff --git a/apps/website/src/components/shared/NavPanelBody.tsx b/apps/website/src/components/shared/NavPanelBody.tsx new file mode 100644 index 000000000..9ba2c5656 --- /dev/null +++ b/apps/website/src/components/shared/NavPanelBody.tsx @@ -0,0 +1,139 @@ +'use client'; + +import Link from 'next/link'; +import { + trackCtaClick, + trackExternalLinkClick, +} from '../../lib/analytics/client'; +import { LibraryMark } from '../docs/LibraryMark'; +import type { NavItem, NavPanel } from './nav-config'; + +export function trackNavItem(item: NavItem, surface: 'nav' | 'mobile_nav') { + const ctaId: `nav_${string}` | `mobile_nav_${string}` = + surface === 'nav' ? `nav_${item.ctaId}` : `mobile_nav_${item.ctaId}`; + if (item.external) { + trackExternalLinkClick(item.href, { + surface, + cta_id: ctaId, + cta_text: item.label, + }); + return; + } + trackCtaClick({ + surface, + destination_url: item.href, + cta_id: ctaId, + cta_text: item.label, + }); +} + +export function NavPanelItem({ + item, + surface, + onNavigate, +}: { + item: NavItem; + surface: 'nav' | 'mobile_nav'; + onNavigate?: () => void; +}) { + const Icon = item.icon; + const body = ( + <> + + + {item.label} + {item.description} + + + ); + const onClick = () => { + trackNavItem(item, surface); + onNavigate?.(); + }; + + if (item.external) { + return ( + + {body} + + ); + } + return ( + + {body} + + ); +} + +/** + * The contents of one nav panel — its columns, then its footer — shared by the + * desktop hover panel and the mobile drill-in stack. + * + * Only the wrapper class names and the mobile drawer's close callback differ + * between the two surfaces; the column/footer shape itself is the same, and + * writing it twice let the two drift. The caller still owns the outermost + * element, because that is where the surfaces genuinely diverge: desktop needs + * the panel's id and `data-columns`, mobile does not. + */ +export function NavPanelBody({ + panel, + surface, + columnsClassName, + columnClassName, + onNavigate, +}: { + panel: NavPanel; + surface: 'nav' | 'mobile_nav'; + /** Desktop grids its columns inside a wrapper; the mobile stack has none. */ + columnsClassName?: string; + columnClassName: string; + onNavigate?: () => void; +}) { + const columns = panel.columns.map((column, index) => ( +
+ {column.heading ? ( + {column.heading} + ) : null} + {column.items.map((item) => ( + + ))} +
+ )); + + return ( + <> + {columnsClassName ? ( +
{columns}
+ ) : ( + columns + )} + {panel.footer ? ( +
+ {panel.footer.lead} + +
+ ) : null} + + ); +}