feat(site-builder): menus that follow pages, with dropdowns

- utils/nav-links.ts: shared NavLink type (source/pageId/children),
  pageHref, buildPageLinks, mergeLinks, adoptPageLinks, rewriteHeaderLinks
- PageData.nav {show, parentId, label} + updatePageNav; a nav-sync effect in
  PageProvider materialises page-following links into the stored header
  (or the live header nodes while it is being edited); string/identity
  guards keep it a no-op when nothing changed
- Navbar/Menu: followPages prop (default false; the default header seed is
  true), one-level dropdowns (hover/focus-within + inline click toggle,
  indented in the hamburger layout), hover/CTA colours default to the
  primary token
- NavStylePanel: Follow pages toggle (adopts matching manual links, keeps
  custom text as the page's menu label), read-only page rows with Hide from
  menu, sub-link editor for manual links, LinkPicker records pageId
- Page Settings: Show in menu / Menu label / Nest under; Pages panel badge
- useWhpApi: nav passes through pages_craft_state on save and load

Legacy navs (no followPages) are never rewritten.

Refs #53

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 12:03:16 -07:00
1 parent 8860349c9b
commit 52477bff45
18 files changed
+1439 -97

No files matched your search

@@ -67,3 +67,43 @@ describe('Menu.toHtml XSS hardening (linkHoverColor into <style>)', () => {
expect(html).toMatch(/:hover\s*\{\s*color:\s*#ff0000/);
});
});
describe('Menu.toHtml dropdowns (#53)', () => {
const nested = [
{ text: 'Home', href: '/' },
{ text: 'Services', href: '/services', children: [
{ text: 'Web', href: '/web' },
{ text: 'Bad', href: 'javascript:alert(1)' },
] },
{ text: 'Go', href: '#go', isCta: true },
];
test('a link with children emits the dropdown wrapper, aria-* toggle, inline script and submenu', () => {
const { html } = toHtml({ links: nested }, '', 'node-dd');
expect(html).toMatch(/<div class="[a-z0-9_-]+-dd"><a href="\/services"[^>]*>Services<\/a><button type="button" class="[a-z0-9_-]+-dd-toggle" aria-haspopup="true" aria-expanded="false" aria-label="Services submenu" onclick="[^"]*aria-expanded[^"]*">/);
expect(html).toMatch(/<ul class="[a-z0-9_-]+-sub"><li><a href="\/web"[^>]*>Web<\/a><\/li>/);
expect(html).toMatch(/-dd:focus-within > \.[a-z0-9_-]+-sub/);
expect(html).toMatch(/-dd:hover > \.[a-z0-9_-]+-sub/);
});
test('safeUrl is applied to child hrefs', () => {
const { html } = toHtml({ links: nested }, '', 'node-dd');
expect(html).not.toContain('javascript:');
});
test('no children -> no dropdown markup, CSS or script (legacy output unchanged)', () => {
const { html } = toHtml({ links: [{ text: 'Home', href: '/' }, { text: 'About', href: '/about' }] }, '', 'node-flat');
expect(html).not.toContain('aria-haspopup');
expect(html).not.toContain('onclick');
expect(html).not.toContain('-sub');
});
test('default hover + CTA colours are the primary token', () => {
const { html } = toHtml({}, '', 'node-tok');
expect(html).toMatch(/:hover\s*\{\s*color:\s*var\(--wsb-primary\)/);
expect(html).toContain('background-color:var(--wsb-primary)');
expect((Menu as any).craft.props.linkHoverColor).toBe('var(--wsb-primary)');
expect((Menu as any).craft.props.ctaBgColor).toBe('var(--wsb-primary)');
expect((Menu as any).craft.props.followPages).toBe(false);
});
});
+74 -18
View File
@@ -2,20 +2,17 @@ import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { escapeHtml, escapeAttr, safeUrl, scopeId, cssValue } from '../../utils/escape';
import { tokenRef } from '../../utils/tokens';
import { NavLink, DROPDOWN_TOGGLE_ONCLICK, hasDropdown } from '../../utils/nav-links';
/* ---------- Types ---------- */
interface MenuLink {
text: string;
href: string;
isExternal?: boolean;
isCta?: boolean;
/** Adds the `download` attribute to the exported anchor (F3: links to files). */
download?: boolean;
}
type MenuLink = NavLink;
interface MenuProps {
links?: MenuLink[];
/** #53: links follow the site's pages (materialised by PageContext). */
followPages?: boolean;
alignment?: 'left' | 'center' | 'right';
linkColor?: string;
linkHoverColor?: string;
@@ -47,8 +44,8 @@ export const Menu: UserComponent<MenuProps> = ({
links = defaultLinks,
alignment = 'right',
linkColor = '#3f3f46',
linkHoverColor = '#3b82f6',
ctaBgColor = '#3b82f6',
linkHoverColor = tokenRef('primary'),
ctaBgColor = tokenRef('primary'),
ctaTextColor = '#ffffff',
gap = '24px',
orientation = 'horizontal',
@@ -60,6 +57,7 @@ export const Menu: UserComponent<MenuProps> = ({
} = useNode();
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
const [openDropdown, setOpenDropdown] = useState<number | null>(null);
const justifyMap = { left: 'flex-start', center: 'center', right: 'flex-end' };
@@ -76,7 +74,9 @@ export const Menu: UserComponent<MenuProps> = ({
...style,
}}
>
{links.map((link, i) => (
{links.map((link, i) => {
const kids = !link.isCta && link.children?.length ? link.children : null;
const anchor = (
<a
key={i}
href={link.href}
@@ -102,21 +102,58 @@ export const Menu: UserComponent<MenuProps> = ({
>
{link.text}
</a>
))}
);
if (!kids) return anchor;
// #53: one-level dropdown, opened on hover in the editor (export: hover,
// focus-within and a click toggle -- see toHtml).
return (
<span
key={i}
style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 4 }}
onMouseEnter={() => setOpenDropdown(i)}
onMouseLeave={() => setOpenDropdown(null)}
>
{anchor}
<span aria-hidden="true" style={{ fontSize, color: linkColor }}>▾</span>
{(openDropdown === i || orientation === 'vertical') && (
<ul style={orientation === 'vertical' ? SUB_STATIC_STYLE : SUB_STYLE}>
{kids.map((child, j) => (
<li key={j}>
<a
href={child.href}
onClick={(e) => e.preventDefault()}
style={{ display: 'block', padding: '6px 16px', whiteSpace: 'nowrap', textDecoration: 'none', fontSize, color: linkColor }}
>
{child.text}
</a>
</li>
))}
</ul>
)}
</span>
);
})}
</nav>
);
};
const SUB_STYLE: CSSProperties = {
position: 'absolute', top: '100%', left: 0, zIndex: 1001, minWidth: 180, margin: 0, padding: '8px 0',
listStyle: 'none', background: 'var(--wsb-bg, #ffffff)', borderRadius: 6, boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
};
const SUB_STATIC_STYLE: CSSProperties = { margin: 0, padding: '4px 0 0 12px', listStyle: 'none' };
/* ---------- Craft config ---------- */
Menu.craft = {
displayName: 'Menu',
props: {
links: defaultLinks,
followPages: false,
alignment: 'right',
linkColor: '#3f3f46',
linkHoverColor: '#3b82f6',
ctaBgColor: '#3b82f6',
linkHoverColor: tokenRef('primary'),
ctaBgColor: tokenRef('primary'),
ctaTextColor: '#ffffff',
gap: '24px',
orientation: 'horizontal',
@@ -143,8 +180,8 @@ Menu.craft = {
// the worst case: </style> breakout -> arbitrary <script>), see
// task-cssxss-brief.md.
const linkCol = cssValue(props.linkColor) || '#3f3f46';
const hoverCol = cssValue(props.linkHoverColor) || '#3b82f6';
const ctaBg = cssValue(props.ctaBgColor) || '#3b82f6';
const hoverCol = cssValue(props.linkHoverColor) || tokenRef('primary');
const ctaBg = cssValue(props.ctaBgColor) || tokenRef('primary');
const ctaText = cssValue(props.ctaTextColor) || '#ffffff';
const gap = cssValue(props.gap) || '24px';
const orientation = props.orientation || 'horizontal';
@@ -173,7 +210,7 @@ Menu.craft = {
// would let one instance's hover styling bleed into the other's).
const scope = scopeId(nodeId, JSON.stringify(links) + orientation + alignment, 'menu');
const linksHtml = links.map((link) => {
const anchorHtml = (link: MenuLink) => {
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const downloadAttr = link.download ? ' download' : '';
const cls = link.isCta ? `${scope}-cta` : `${scope}-link`;
@@ -188,11 +225,30 @@ Menu.craft = {
transition: 'color 0.15s, background-color 0.15s',
});
return `<a href="${escapeAttr(safeUrl(link.href || '#'))}" class="${cls}"${target}${downloadAttr}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
};
// #53: a (non-CTA) link with children becomes a dropdown: the parent link
// still navigates; a separate toggle button opens the submenu on touch /
// click (hover and focus-within open it on desktop, see dropdownCss).
const linksHtml = links.map((link) => {
if (link.isCta || !link.children?.length) return anchorHtml(link);
const kids = link.children.map((child) => `<li>${anchorHtml({ ...child, isCta: false })}</li>`).join('');
return `<div class="${scope}-dd">${anchorHtml(link)}<button type="button" class="${scope}-dd-toggle" aria-haspopup="true" aria-expanded="false" aria-label="${escapeAttr(`${link.text} submenu`)}" onclick="${DROPDOWN_TOGGLE_ONCLICK}">&#9662;</button><ul class="${scope}-sub">${kids}</ul></div>`;
}).join('\n ');
const dropdownCss = hasDropdown(links) ? `
.${scope}-dd { position: relative; display: inline-flex; align-items: center; gap: 2px; }
.${scope}-dd-toggle { background: none; border: 0; margin: 0; padding: 2px 4px; cursor: pointer; color: ${linkCol}; font-size: ${fSize}; line-height: 1; }
.${scope}-sub { display: none; position: absolute; top: 100%; left: 0; z-index: 1001; min-width: 180px; margin: 0; padding: 8px 0; list-style: none; background: ${tokenRef('bg')}; border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.12); }
.${scope}-dd:hover > .${scope}-sub, .${scope}-dd:focus-within > .${scope}-sub, .${scope}-dd.is-open > .${scope}-sub { display: block; }
.${scope}-sub a { display: block !important; padding: 6px 16px !important; white-space: nowrap; }${orientation === 'vertical' ? `
.${scope}-dd { flex-wrap: wrap; }
.${scope}-sub { display: block; position: static; box-shadow: none; background: transparent; min-width: 0; padding: 4px 0 0 12px; flex-basis: 100%; }
.${scope}-dd-toggle { display: none; }` : ''}` : '';
const hoverCss = `<style>
.${scope}-link:hover { color: ${hoverCol} !important; }
.${scope}-cta:hover { filter: brightness(1.1); }
.${scope}-cta:hover { filter: brightness(1.1); }${dropdownCss}
</style>`;
return {
@@ -140,3 +140,48 @@ describe('Navbar.toHtml XSS hardening (hoverColor/backgroundColor/ctaColor into
expect(html).toContain('background-color:#123456');
});
});
describe('Navbar.toHtml dropdowns (#53)', () => {
const nested = [
{ text: 'Home', href: '/' },
{ text: 'Services', href: '/services', source: 'page', pageId: 's', children: [
{ text: 'Web', href: '/web', source: 'page', pageId: 'w' },
{ text: 'Bad', href: 'javascript:alert(1)' },
] },
{ text: 'Go', href: '#go', isCta: true },
];
test('a link with children emits the dropdown wrapper, aria-* toggle, inline script and submenu', () => {
const { html } = toHtml({ links: nested }, '', 'node-dd');
expect(html).toMatch(/<div class="navbar-dd"><a href="\/services" class="navbar-link"[^>]*>Services<\/a><button type="button" class="navbar-dd-toggle" aria-haspopup="true" aria-expanded="false" aria-label="Services submenu" onclick="[^"]*is-open[^"]*aria-expanded[^"]*">/);
expect(html).toMatch(/<ul class="navbar-sub"><li><a href="\/web" class="navbar-link"[^>]*>Web<\/a><\/li>/);
expect(html).toContain('.navbar-dd:focus-within > .navbar-sub');
});
test('safeUrl is applied to child hrefs', () => {
const { html } = toHtml({ links: nested }, '', 'node-dd');
expect(html).not.toContain('javascript:');
});
test('hamburger layout lists children indented (static submenu in the mobile media query)', () => {
const { html } = toHtml({ links: nested, showMobileMenu: true }, '', 'node-dd-m');
const media = html.slice(html.indexOf('@media'));
expect(media).toMatch(/\.navbar-sub \{ display: block; position: static;[^}]*padding: 4px 0 0 16px/);
});
test('no children -> no dropdown markup, CSS or script', () => {
const { html } = toHtml({ links: [{ text: 'Home', href: '/' }] }, '', 'node-flat');
expect(html).not.toContain('aria-haspopup');
expect(html).not.toContain('navbar-dd');
expect(html).not.toContain('onclick');
});
test('default hover + CTA colours are the primary token', () => {
const { html } = toHtml({}, '', 'node-tok');
expect(html).toMatch(/\.navbar-link:hover \{ color: var\(--wsb-primary\)/);
expect(html).toContain('background-color:var(--wsb-primary)');
expect((Navbar as any).craft.props.hoverColor).toBe('var(--wsb-primary)');
expect((Navbar as any).craft.props.ctaColor).toBe('var(--wsb-primary)');
expect((Navbar as any).craft.props.followPages).toBe(false);
});
});
+69 -36
View File
@@ -3,18 +3,11 @@ import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { useSiteDesign } from '../../state/SiteDesignContext';
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, cssValue, scopeId } from '../../utils/escape';
import { tokenRef } from '../../utils/tokens';
import { NavLink, DROPDOWN_TOGGLE_ONCLICK, hasDropdown } from '../../utils/nav-links';
/* ---------- Types ---------- */
interface NavLink {
text: string;
href: string;
isExternal?: boolean;
isCta?: boolean;
/** Adds the `download` attribute to the exported anchor (F3: links to files). */
download?: boolean;
}
interface NavbarProps {
logoType?: 'text' | 'image';
logoText?: string;
@@ -25,6 +18,8 @@ interface NavbarProps {
logoFontSize?: string;
logoColor?: string;
links?: NavLink[];
/** #53: links follow the site's pages (materialised by PageContext). */
followPages?: boolean;
backgroundColor?: string;
textColor?: string;
hoverColor?: string;
@@ -72,8 +67,8 @@ export const Navbar: UserComponent<NavbarProps> = ({
links = defaultLinks,
backgroundColor = '#ffffff',
textColor = '#3f3f46',
hoverColor = '#3b82f6',
ctaColor = '#3b82f6',
hoverColor = tokenRef('primary'),
ctaColor = tokenRef('primary'),
ctaTextColor = '#ffffff',
padding = '16px 24px',
navAlignment = 'space-between',
@@ -93,6 +88,7 @@ export const Navbar: UserComponent<NavbarProps> = ({
const resolvedTextColor = textColor || (backgroundColor === '#ffffff' || backgroundColor === '#f8fafc' || backgroundColor === '#f9fafb' ? '#3f3f46' : '#e4e4e7');
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
const [openDropdown, setOpenDropdown] = useState<number | null>(null);
return (
<nav
@@ -150,7 +146,9 @@ export const Navbar: UserComponent<NavbarProps> = ({
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
</div>
)}
{links.map((link, i) => (
{links.map((link, i) => {
const kids = !link.isCta && link.children?.length ? link.children : null;
const anchor = (
<a
key={i}
href={link.href}
@@ -176,7 +174,37 @@ export const Navbar: UserComponent<NavbarProps> = ({
>
{link.text}
</a>
))}
);
if (!kids) return anchor;
// #53: one-level dropdown, opened on hover in the editor (export:
// hover, focus-within and a click toggle -- see toHtml).
return (
<span
key={i}
style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 4 }}
onMouseEnter={() => setOpenDropdown(i)}
onMouseLeave={() => setOpenDropdown(null)}
>
{anchor}
<span aria-hidden="true" style={{ fontSize: '14px', color: resolvedTextColor }}>▾</span>
{openDropdown === i && (
<ul style={{ position: 'absolute', top: '100%', left: 0, zIndex: 1001, minWidth: 180, margin: 0, padding: '8px 0', listStyle: 'none', background: backgroundColor, borderRadius: 6, boxShadow: '0 8px 24px rgba(0,0,0,0.12)' }}>
{kids.map((child, j) => (
<li key={j}>
<a
href={child.href}
onClick={(e) => e.preventDefault()}
style={{ display: 'block', padding: '6px 16px', whiteSpace: 'nowrap', textDecoration: 'none', fontSize: '14px', color: resolvedTextColor }}
>
{child.text}
</a>
</li>
))}
</ul>
)}
</span>
);
})}
</div>
</nav>
);
@@ -196,10 +224,11 @@ Navbar.craft = {
logoFontSize: '20px',
logoColor: undefined,
links: defaultLinks,
followPages: false,
backgroundColor: '#ffffff',
textColor: '#3f3f46',
hoverColor: '#3b82f6',
ctaColor: '#3b82f6',
hoverColor: tokenRef('primary'),
ctaColor: tokenRef('primary'),
ctaTextColor: '#ffffff',
padding: '16px 24px',
navAlignment: 'space-between',
@@ -229,8 +258,8 @@ Navbar.craft = {
// breakout -> arbitrary <script>), see task-cssxss-brief.md.
const bgColor = cssValue(props.backgroundColor) || '#ffffff';
const textCol = cssValue(props.textColor) || '#3f3f46';
const hoverCol = cssValue(props.hoverColor) || '#3b82f6';
const ctaCol = cssValue(props.ctaColor) || '#3b82f6';
const hoverCol = cssValue(props.hoverColor) || tokenRef('primary');
const ctaCol = cssValue(props.ctaColor) || tokenRef('primary');
const ctaTextCol = cssValue(props.ctaTextColor) || '#ffffff';
const pad = cssValue(props.padding) || '16px 24px';
const alignment = props.navAlignment || 'space-between';
@@ -276,22 +305,8 @@ Navbar.craft = {
logoHtml = `<a href="${escapeAttr(safeUrl(logoUrl))}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><span${logoStyle ? ` style="${logoStyle}"` : ''}>${escapeHtml(props.logoText || 'MySite')}</span></a>`;
}
// Links HTML
const links = props.links || defaultLinks;
const linksHtml = links.map((link) => {
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const linkStyle = cssPropsToString({
textDecoration: 'none',
fontSize: '14px',
fontWeight: link.isCta ? '600' : '400',
color: link.isCta ? ctaTextCol : textCol,
backgroundColor: link.isCta ? ctaCol : 'transparent',
padding: link.isCta ? '8px 20px' : '0',
borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s',
});
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
}).join('\n ');
const links = links0;
const dropdown = hasDropdown(links);
// Hamburger HTML for mobile. The toggle needs an accessible name (there's
// no visible text, just three bars) and must report its open/closed state
@@ -310,16 +325,24 @@ Navbar.craft = {
// into or get overridden by another Navbar instance's rules.
const hoverCss = `<style>
.${scope} .navbar-link:hover { color: ${hoverCol} !important; }
.${scope} .navbar-cta:hover { filter: brightness(1.1); }${mobile ? `
.${scope} .navbar-cta:hover { filter: brightness(1.1); }${dropdown ? `
.${scope} .navbar-dd { position: relative; display: inline-flex; align-items: center; gap: 2px; }
.${scope} .navbar-dd-toggle { background: none; border: 0; margin: 0; padding: 2px 4px; cursor: pointer; color: ${textCol}; font-size: 14px; line-height: 1; }
.${scope} .navbar-sub { display: none; position: absolute; top: 100%; left: 0; z-index: 1001; min-width: 180px; margin: 0; padding: 8px 0; list-style: none; background-color: ${bgColor}; border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.12); }
.${scope} .navbar-dd:hover > .navbar-sub, .${scope} .navbar-dd:focus-within > .navbar-sub, .${scope} .navbar-dd.is-open > .navbar-sub { display: block; }
.${scope} .navbar-sub a { display: block; padding: 6px 16px !important; white-space: nowrap; }` : ''}${mobile ? `
@media (max-width: 768px) {
.${scope} .navbar-hamburger { display: flex !important; }
.${scope} .navbar-links { display: none !important; position: absolute; top: 100%; left: 0; right: 0; flex-direction: column !important; background-color: ${bgColor}; padding: 12px 24px; gap: 12px !important; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.${scope} .navbar-links.navbar-open { display: flex !important; }
.${scope} .navbar-links.navbar-open { display: flex !important; }${dropdown ? `
.${scope} .navbar-dd { flex-direction: column; align-items: flex-start; }
.${scope} .navbar-dd-toggle { display: none; }
.${scope} .navbar-sub { display: block; position: static; box-shadow: none; min-width: 0; padding: 4px 0 0 16px; }` : ''}
}` : ''}
</style>`;
// Add CSS class to each link for hover
const linksHtmlWithClass = links.map((link) => {
const anchorHtml = (link: NavLink) => {
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const downloadAttr = link.download ? ' download' : '';
const cls = link.isCta ? 'navbar-cta' : 'navbar-link';
@@ -334,6 +357,16 @@ Navbar.craft = {
transition: 'color 0.15s, background-color 0.15s',
});
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}" class="${cls}"${target}${downloadAttr}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
};
// #53: a (non-CTA) link with children becomes a dropdown: the parent link
// still navigates; a separate toggle button opens the submenu on touch /
// click (hover and focus-within open it on desktop). In the hamburger
// layout the children are listed indented (see the media query above).
const linksHtmlWithClass = links.map((link) => {
if (link.isCta || !link.children?.length) return anchorHtml(link);
const kids = link.children.map((child) => `<li>${anchorHtml({ ...child, isCta: false })}</li>`).join('');
return `<div class="navbar-dd">${anchorHtml(link)}<button type="button" class="navbar-dd-toggle" aria-haspopup="true" aria-expanded="false" aria-label="${escapeAttr(`${link.text} submenu`)}" onclick="${DROPDOWN_TOGGLE_ONCLICK}">&#9662;</button><ul class="navbar-sub">${kids}</ul></div>`;
}).join('\n ');
return {