Skip to content

Commit aa661d0

Browse files
bloveclaude
andauthored
refactor(website): render both nav panel surfaces from one body (#1084)
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 <noreply@anthropic.com>
1 parent 9d4cfd6 commit aa661d0

3 files changed

Lines changed: 154 additions & 122 deletions

File tree

apps/website/src/components/shared/NavDesktop.tsx

Lines changed: 8 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -10,103 +10,22 @@ import {
1010
import { Button } from '../ui/Button';
1111
import { GitHubIcon } from '../ui/GitHubIcon';
1212
import { GITHUB_REPO_URL } from '../../lib/positioning';
13-
import { LibraryMark } from '../docs/LibraryMark';
14-
import { NAV_TRIGGERS, type NavItem, type NavPanel } from './nav-config';
13+
import { NavPanelBody } from './NavPanelBody';
14+
import { NAV_TRIGGERS, type NavPanel } from './nav-config';
1515

1616
/** Long enough to cross the gap between trigger and panel diagonally. */
1717
const OPEN_DELAY_MS = 100;
1818
const CLOSE_DELAY_MS = 150;
1919

20-
export function trackNavItem(item: NavItem, surface: 'nav' | 'mobile_nav') {
21-
const ctaId: `nav_${string}` | `mobile_nav_${string}` =
22-
surface === 'nav' ? `nav_${item.ctaId}` : `mobile_nav_${item.ctaId}`;
23-
if (item.external) {
24-
trackExternalLinkClick(item.href, {
25-
surface,
26-
cta_id: ctaId,
27-
cta_text: item.label,
28-
});
29-
return;
30-
}
31-
trackCtaClick({
32-
surface,
33-
destination_url: item.href,
34-
cta_id: ctaId,
35-
cta_text: item.label,
36-
});
37-
}
38-
39-
export function NavPanelItem({
40-
item,
41-
surface,
42-
onNavigate,
43-
}: {
44-
item: NavItem;
45-
surface: 'nav' | 'mobile_nav';
46-
onNavigate?: () => void;
47-
}) {
48-
const Icon = item.icon;
49-
const body = (
50-
<>
51-
<span className="nav-panel-item-chip" aria-hidden="true">
52-
{item.library ? (
53-
<LibraryMark library={item.library} size={20} />
54-
) : Icon ? (
55-
<Icon size={16} aria-hidden={true} />
56-
) : null}
57-
</span>
58-
<span className="nav-panel-item-text">
59-
<span className="nav-panel-item-label">{item.label}</span>
60-
<span className="nav-panel-item-desc">{item.description}</span>
61-
</span>
62-
</>
63-
);
64-
const onClick = () => {
65-
trackNavItem(item, surface);
66-
onNavigate?.();
67-
};
68-
69-
if (item.external) {
70-
return (
71-
<a
72-
href={item.href}
73-
target="_blank"
74-
rel="noopener noreferrer"
75-
onClick={onClick}
76-
className="nav-panel-item"
77-
>
78-
{body}
79-
</a>
80-
);
81-
}
82-
return (
83-
<Link href={item.href} onClick={onClick} className="nav-panel-item">
84-
{body}
85-
</Link>
86-
);
87-
}
88-
8920
function Panel({ panel, id }: { panel: NavPanel; id: string }) {
9021
return (
9122
<div id={id} className="nav-panel" data-columns={panel.columns.length}>
92-
<div className="nav-panel-cols">
93-
{panel.columns.map((column, index) => (
94-
<div key={column.heading ?? index} className="nav-panel-col">
95-
{column.heading ? (
96-
<span className="nav-panel-col-head">{column.heading}</span>
97-
) : null}
98-
{column.items.map((item) => (
99-
<NavPanelItem key={item.ctaId} item={item} surface="nav" />
100-
))}
101-
</div>
102-
))}
103-
</div>
104-
{panel.footer ? (
105-
<div className="nav-panel-footer">
106-
<span className="nav-panel-footer-lead">{panel.footer.lead}</span>
107-
<NavPanelItem item={panel.footer} surface="nav" />
108-
</div>
109-
) : null}
23+
<NavPanelBody
24+
panel={panel}
25+
surface="nav"
26+
columnsClassName="nav-panel-cols"
27+
columnClassName="nav-panel-col"
28+
/>
11029
</div>
11130
);
11231
}

apps/website/src/components/shared/NavMobile.tsx

Lines changed: 7 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { Button } from '../ui/Button';
1919
import { GitHubIcon } from '../ui/GitHubIcon';
2020
import { GITHUB_REPO_URL } from '../../lib/positioning';
2121
import { DocsContextContent } from '../docs/DocsControlPlane';
22-
import { NavPanelItem } from './NavDesktop';
22+
import { NavPanelBody } from './NavPanelBody';
2323
import { NAV_TRIGGERS } from './nav-config';
2424

2525
const toAnalyticsLibrary = (library: LibraryId | null): AnalyticsLibrary => {
@@ -436,38 +436,12 @@ export function NavMobile({
436436

437437
{panel ? (
438438
<div className="nav-mobile-panel">
439-
{panel.columns.map((column, index) => (
440-
<div
441-
key={column.heading ?? index}
442-
className="nav-mobile-group"
443-
>
444-
{column.heading ? (
445-
<span className="nav-panel-col-head">
446-
{column.heading}
447-
</span>
448-
) : null}
449-
{column.items.map((item) => (
450-
<NavPanelItem
451-
key={item.ctaId}
452-
item={item}
453-
surface="mobile_nav"
454-
onNavigate={() => closeMobileMenu()}
455-
/>
456-
))}
457-
</div>
458-
))}
459-
{panel.footer ? (
460-
<div className="nav-panel-footer">
461-
<span className="nav-panel-footer-lead">
462-
{panel.footer.lead}
463-
</span>
464-
<NavPanelItem
465-
item={panel.footer}
466-
surface="mobile_nav"
467-
onNavigate={() => closeMobileMenu()}
468-
/>
469-
</div>
470-
) : null}
439+
<NavPanelBody
440+
panel={panel}
441+
surface="mobile_nav"
442+
columnClassName="nav-mobile-group"
443+
onNavigate={() => closeMobileMenu()}
444+
/>
471445
</div>
472446
) : null}
473447
</div>
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
'use client';
2+
3+
import Link from 'next/link';
4+
import {
5+
trackCtaClick,
6+
trackExternalLinkClick,
7+
} from '../../lib/analytics/client';
8+
import { LibraryMark } from '../docs/LibraryMark';
9+
import type { NavItem, NavPanel } from './nav-config';
10+
11+
export function trackNavItem(item: NavItem, surface: 'nav' | 'mobile_nav') {
12+
const ctaId: `nav_${string}` | `mobile_nav_${string}` =
13+
surface === 'nav' ? `nav_${item.ctaId}` : `mobile_nav_${item.ctaId}`;
14+
if (item.external) {
15+
trackExternalLinkClick(item.href, {
16+
surface,
17+
cta_id: ctaId,
18+
cta_text: item.label,
19+
});
20+
return;
21+
}
22+
trackCtaClick({
23+
surface,
24+
destination_url: item.href,
25+
cta_id: ctaId,
26+
cta_text: item.label,
27+
});
28+
}
29+
30+
export function NavPanelItem({
31+
item,
32+
surface,
33+
onNavigate,
34+
}: {
35+
item: NavItem;
36+
surface: 'nav' | 'mobile_nav';
37+
onNavigate?: () => void;
38+
}) {
39+
const Icon = item.icon;
40+
const body = (
41+
<>
42+
<span className="nav-panel-item-chip" aria-hidden="true">
43+
{item.library ? (
44+
<LibraryMark library={item.library} size={20} />
45+
) : Icon ? (
46+
<Icon size={16} aria-hidden={true} />
47+
) : null}
48+
</span>
49+
<span className="nav-panel-item-text">
50+
<span className="nav-panel-item-label">{item.label}</span>
51+
<span className="nav-panel-item-desc">{item.description}</span>
52+
</span>
53+
</>
54+
);
55+
const onClick = () => {
56+
trackNavItem(item, surface);
57+
onNavigate?.();
58+
};
59+
60+
if (item.external) {
61+
return (
62+
<a
63+
href={item.href}
64+
target="_blank"
65+
rel="noopener noreferrer"
66+
onClick={onClick}
67+
className="nav-panel-item"
68+
>
69+
{body}
70+
</a>
71+
);
72+
}
73+
return (
74+
<Link href={item.href} onClick={onClick} className="nav-panel-item">
75+
{body}
76+
</Link>
77+
);
78+
}
79+
80+
/**
81+
* The contents of one nav panel — its columns, then its footer — shared by the
82+
* desktop hover panel and the mobile drill-in stack.
83+
*
84+
* Only the wrapper class names and the mobile drawer's close callback differ
85+
* between the two surfaces; the column/footer shape itself is the same, and
86+
* writing it twice let the two drift. The caller still owns the outermost
87+
* element, because that is where the surfaces genuinely diverge: desktop needs
88+
* the panel's id and `data-columns`, mobile does not.
89+
*/
90+
export function NavPanelBody({
91+
panel,
92+
surface,
93+
columnsClassName,
94+
columnClassName,
95+
onNavigate,
96+
}: {
97+
panel: NavPanel;
98+
surface: 'nav' | 'mobile_nav';
99+
/** Desktop grids its columns inside a wrapper; the mobile stack has none. */
100+
columnsClassName?: string;
101+
columnClassName: string;
102+
onNavigate?: () => void;
103+
}) {
104+
const columns = panel.columns.map((column, index) => (
105+
<div key={column.heading ?? index} className={columnClassName}>
106+
{column.heading ? (
107+
<span className="nav-panel-col-head">{column.heading}</span>
108+
) : null}
109+
{column.items.map((item) => (
110+
<NavPanelItem
111+
key={item.ctaId}
112+
item={item}
113+
surface={surface}
114+
onNavigate={onNavigate}
115+
/>
116+
))}
117+
</div>
118+
));
119+
120+
return (
121+
<>
122+
{columnsClassName ? (
123+
<div className={columnsClassName}>{columns}</div>
124+
) : (
125+
columns
126+
)}
127+
{panel.footer ? (
128+
<div className="nav-panel-footer">
129+
<span className="nav-panel-footer-lead">{panel.footer.lead}</span>
130+
<NavPanelItem
131+
item={panel.footer}
132+
surface={surface}
133+
onNavigate={onNavigate}
134+
/>
135+
</div>
136+
) : null}
137+
</>
138+
);
139+
}

0 commit comments

Comments
 (0)