Merge issue-53 (Refs #53)

# Conflicts:
#	craft/src/styles/editor.css
This commit is contained in:
shadowdao committed 2026-10-07 12:09:09 -07:00
commit 1d34d1b3b1
18 files changed
+1500 -98

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 {
+129
View File
@@ -0,0 +1,129 @@
import { describe, test, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { EditorConfigProvider } from '../state/EditorConfigContext';
import { PageProvider, usePages } from '../state/PageContext';
import { SiteDesignProvider, DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
import { ProjectExtrasProvider } from '../state/ProjectExtrasContext';
import { useWhpApi, buildSavePayload } from './useWhpApi';
import { PageData, WhpConfig } from '../types';
/**
* #53: `PageData.nav` round-trips through save (`pages_craft_state[].nav`)
* and load(), and loading a LEGACY project (manual nav links, no
* followPages) leaves its header byte-for-byte untouched. `useEditor` is
* stubbed as in useWhpApi.load.test.tsx -- this drives load(), not the canvas.
*/
vi.mock('@craftjs/core', () => ({
useEditor: () => ({
query: { serialize: () => '{}', getNodes: () => ({}) },
actions: { deserialize: vi.fn(), history: { clear: () => {} } },
}),
}));
const whpConfig: WhpConfig = {
user: 'u', apiUrl: '/panel/api/site-builder', csrfToken: 'tok', siteId: 42,
siteDomain: 'example.com', siteName: 'Test Site', backUrl: '/', isRoot: false,
};
const EMPTY = '{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
const home: PageData = { id: 'home', name: 'Home', slug: 'index', craftState: EMPTY };
const about: PageData = { id: 'about', name: 'About', slug: 'about', craftState: EMPTY, nav: { label: 'Our story', show: true } };
const team: PageData = { id: 'team', name: 'Team', slug: 'team', craftState: EMPTY, nav: { parentId: 'about', show: false } };
function payloadFor(pages: PageData[]) {
const zone = (id: string): PageData => ({ id, name: id, slug: id, craftState: null });
return buildSavePayload({
siteId: 42, siteName: 'Test Site', liveCraftState: EMPTY, pages,
headerPage: zone('__header__'), footerPage: zone('__footer__'),
activePageId: '__header__', isEditingHeader: true, isEditingFooter: false,
headCode: DEFAULT_SITE_DESIGN.headCode, design: DEFAULT_SITE_DESIGN,
});
}
let container: HTMLDivElement;
let root: Root;
type Captured = { load: ReturnType<typeof useWhpApi>['load']; pages: PageData[]; headerPage: PageData };
function render(): () => Captured {
container = document.createElement('div');
document.body.appendChild(container);
let captured: Captured | null = null;
const Consumer: React.FC = () => {
const { load } = useWhpApi();
const { pages, headerPage } = usePages();
captured = { load, pages, headerPage };
return null;
};
act(() => {
root = createRoot(container);
root.render(
<EditorConfigProvider config={whpConfig}>
<ProjectExtrasProvider>
<SiteDesignProvider>
<PageProvider><Consumer /></PageProvider>
</SiteDesignProvider>
</ProjectExtrasProvider>
</EditorConfigProvider>,
);
});
return () => captured!;
}
afterEach(() => {
if (root) act(() => root.unmount());
container?.remove();
vi.unstubAllGlobals();
});
function stubLoad(project: unknown) {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true, project }) }));
}
describe('PageData.nav save/load round-trip (#53)', () => {
test('save writes nav into pages_craft_state; absent nav stays absent', () => {
const payload = payloadFor([home, about, team]);
expect(payload.pages_craft_state.map((p) => p.nav)).toEqual([undefined, about.nav, team.nav]);
expect('nav' in JSON.parse(JSON.stringify(payload.pages_craft_state[0]))).toBe(false);
});
test('load restores nav from the saved payload', async () => {
const saved = JSON.parse(JSON.stringify(payloadFor([home, about, team])));
stubLoad({ design: null, header_craft_state: null, footer_craft_state: null, pages_craft_state: saved.pages_craft_state });
const get = render();
await act(async () => { await get().load(); });
expect(get().pages.map((p) => p.nav)).toEqual([undefined, about.nav, team.nav]);
});
test('a legacy header (manual links, no followPages) loads byte-for-byte unchanged', async () => {
const legacyHeader = JSON.stringify({
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'header' }, displayName: 'Container', custom: {}, hidden: false, nodes: ['n'], linkedNodes: {} },
n: { type: { resolvedName: 'Navbar' }, isCanvas: false, props: { links: [
{ text: 'Home', href: '/' }, { text: 'Menu', href: 'menu' }, { text: 'Call us', href: 'tel:555', isCta: true },
] }, displayName: 'Navbar', parent: 'ROOT', custom: {}, hidden: false, nodes: [], linkedNodes: {} },
});
stubLoad({
design: null, header_craft_state: legacyHeader, footer_craft_state: null,
pages_craft_state: [{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{}}' }, { id: 'p2', name: 'Menu', slug: 'menu', craftState: '{"ROOT":{}}' }],
});
const get = render();
await act(async () => { await get().load(); });
expect(get().headerPage.craftState).toBe(legacyHeader);
});
test('a follow-pages header is materialised against the loaded pages', async () => {
const header = JSON.stringify({
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'header' }, displayName: 'Container', custom: {}, hidden: false, nodes: ['n'], linkedNodes: {} },
n: { type: { resolvedName: 'Navbar' }, isCanvas: false, props: { followPages: true, links: [{ text: 'Home', href: '/', source: 'page', pageId: 'home' }] }, displayName: 'Navbar', parent: 'ROOT', custom: {}, hidden: false, nodes: [], linkedNodes: {} },
});
stubLoad({
design: null, header_craft_state: header, footer_craft_state: null,
pages_craft_state: [{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{}}' }, { id: 'about', name: 'About', slug: 'about', craftState: '{"ROOT":{}}', nav: { label: 'Our story' } }],
});
const get = render();
await act(async () => { await get().load(); });
const links = JSON.parse(get().headerPage.craftState!).n.props.links;
expect(links.map((l: { text: string }) => l.text)).toEqual(['Home', 'Our story']);
});
});
+5 -2
View File
@@ -216,6 +216,9 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
// PKG-H §5: round-trips PageData.seo through save -> load() so the
// editor's SEO fields survive a reload (mirrors craftState above).
seo: page.seo,
// #53: menu settings (show / parentId / label); undefined when unset, so
// legacy-shaped payloads round-trip unchanged.
nav: page.nav,
}));
for (const key of exportCache.keys()) {
@@ -541,8 +544,8 @@ export function useWhpApi() {
}
}
setPagesCraftState(proj.pages_craft_state.map((p: { id: string; name: string; slug: string; craftState: string | null; seo?: PageData['seo'] }) => ({
id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null, seo: p.seo,
setPagesCraftState(proj.pages_craft_state.map((p: { id: string; name: string; slug: string; craftState: string | null; seo?: PageData['seo']; nav?: PageData['nav'] }) => ({
id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null, seo: p.seo, nav: p.nav,
})));
// Load the first page (home) into the canvas. Routed through
@@ -0,0 +1,76 @@
import { describe, test, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { PageSettingsModal } from './PageSettingsModal';
import { PageData } from '../../types';
vi.mock('../../ui/AssetPicker', () => ({ AssetPicker: () => null }));
/** #53: the Menu section of Page Settings (show / label / nest under). */
let root: Root;
let host: HTMLDivElement;
const pages: PageData[] = [
{ id: 'home', name: 'Home', slug: 'index', craftState: null },
{ id: 'services', name: 'Services', slug: 'services', craftState: null },
{ id: 'web', name: 'Web', slug: 'web', craftState: null, nav: { parentId: 'services' } },
{ id: 'about', name: 'About', slug: 'about', craftState: null, nav: { show: false } },
];
function open(page: PageData, updatePageNav = vi.fn()) {
host = document.createElement('div');
document.body.appendChild(host);
act(() => {
root = createRoot(host);
root.render(<PageSettingsModal open onClose={() => {}} page={page} updatePageSeo={() => {}} pages={pages} updatePageNav={updatePageNav} />);
});
return updatePageNav;
}
afterEach(() => {
act(() => root.unmount());
host.remove();
});
const q = <T extends Element>(id: string) => document.body.querySelector(`[data-testid="${id}"]`) as unknown as T;
function change(el: HTMLInputElement | HTMLSelectElement, value: string) {
const proto = el instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype;
act(() => {
Object.getOwnPropertyDescriptor(proto, 'value')!.set!.call(el, value);
el.dispatchEvent(new Event('change', { bubbles: true }));
});
}
describe('PageSettingsModal Menu section (#53)', () => {
test('"Show in menu" reflects nav.show and writes it', () => {
const spy = open(pages[3]);
const box = q<HTMLInputElement>('page-nav-show');
expect(box.checked).toBe(false);
act(() => { box.click(); });
expect(spy).toHaveBeenCalledWith('about', { show: true });
});
test('"Menu label" writes nav.label', () => {
const spy = open(pages[1]);
change(q<HTMLInputElement>('page-nav-label'), 'What we do');
expect(spy).toHaveBeenCalledWith('services', { label: 'What we do' });
});
test('"Nest under" lists other top-level pages only (not itself, not nested pages) and writes parentId', () => {
const spy = open(pages[3]);
const select = q<HTMLSelectElement>('page-nav-parent');
const values = Array.from(select.options).map((o) => o.value);
expect(values).toEqual(['', 'home', 'services']);
change(select, 'services');
expect(spy).toHaveBeenCalledWith('about', { parentId: 'services' });
change(select, '');
expect(spy).toHaveBeenLastCalledWith('about', { parentId: null });
});
test('the landing page cannot be nested', () => {
open(pages[0]);
expect(q<HTMLSelectElement>('page-nav-parent').disabled).toBe(true);
});
});
+71 -4
View File
@@ -2,13 +2,17 @@ import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Modal } from '../../ui/Modal';
import { AssetPicker } from '../../ui/AssetPicker';
import { PageData, PageSeo } from '../../types';
import { PageData, PageSeo, PageNav } from '../../types';
interface PageSettingsModalProps {
open: boolean;
onClose: () => void;
page: PageData | null;
updatePageSeo: (pageId: string, seo: Partial<PageSeo>) => void;
/** #53: all pages (for "Nest under") + the nav updater. The Menu section
* is shown only when both are given. */
pages?: PageData[];
updatePageNav?: (pageId: string, nav: Partial<PageNav>) => void;
}
const EMPTY_SEO: PageSeo = {
@@ -34,8 +38,9 @@ const EMPTY_SEO: PageSeo = {
* buffering to a "Save" button -- consistent with HeadCodeModal's live-edit
* pattern for `design.headCode`.
*/
export const PageSettingsModal: React.FC<PageSettingsModalProps> = ({ open, onClose, page, updatePageSeo }) => {
export const PageSettingsModal: React.FC<PageSettingsModalProps> = ({ open, onClose, page, updatePageSeo, pages, updatePageNav }) => {
const [seo, setSeo] = useState<PageSeo>(EMPTY_SEO);
const [nav, setNav] = useState<PageNav>({});
// Re-sync local form state from the target page's stored seo whenever the
// modal opens (or the target page changes while open) -- mirrors the
@@ -45,7 +50,8 @@ export const PageSettingsModal: React.FC<PageSettingsModalProps> = ({ open, onCl
useEffect(() => {
if (!open) return;
setSeo({ ...EMPTY_SEO, ...(page?.seo || {}) });
}, [open, page?.id, page?.seo]);
setNav({ ...(page?.nav || {}) });
}, [open, page?.id, page?.seo, page?.nav]);
if (!page) return null;
@@ -54,6 +60,17 @@ export const PageSettingsModal: React.FC<PageSettingsModalProps> = ({ open, onCl
updatePageSeo(page.id, patch);
};
const updateNav = (patch: Partial<PageNav>) => {
setNav((prev) => ({ ...prev, ...patch }));
updatePageNav?.(page.id, patch);
};
// #53 "Nest under": one level only -- candidates are the other top-level
// pages; the landing page itself never nests (it is always top-level).
const isLanding = !!pages && pages[0]?.id === page.id;
const parentOptions = (pages || []).filter((p) => p.id !== page.id && !p.nav?.parentId);
const hasChildren = (pages || []).some((p) => p.nav?.parentId === page.id);
return createPortal(
<Modal open={open} onClose={onClose}>
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
@@ -66,7 +83,7 @@ export const PageSettingsModal: React.FC<PageSettingsModalProps> = ({ open, onCl
Page Settings — {page.name}
</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
SEO and social-sharing metadata for this page only.
Menu, SEO and social-sharing settings for this page only.
</div>
</div>
</div>
@@ -77,6 +94,56 @@ export const PageSettingsModal: React.FC<PageSettingsModalProps> = ({ open, onCl
{/* Body */}
<div style={{ padding: 20, flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }}>
{pages && updatePageNav && (
<fieldset className="page-menu-settings" data-testid="page-menu-settings">
<legend style={fieldLabelStyle}>Menu</legend>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--color-text)', cursor: 'pointer' }}>
<input
type="checkbox"
checked={nav.show !== false}
onChange={(e) => updateNav({ show: e.target.checked })}
data-testid="page-nav-show"
/>
Show in menu
</label>
<div>
<label style={fieldLabelStyle} htmlFor="page-nav-label">Menu label</label>
<input
id="page-nav-label"
type="text"
className="control-input"
value={nav.label || ''}
onChange={(e) => updateNav({ label: e.target.value })}
placeholder={page.name}
data-testid="page-nav-label"
/>
</div>
<div>
<label style={fieldLabelStyle} htmlFor="page-nav-parent">Nest under</label>
<select
id="page-nav-parent"
className="control-select"
value={isLanding ? '' : (nav.parentId || '')}
disabled={isLanding || hasChildren}
onChange={(e) => updateNav({ parentId: e.target.value || null })}
data-testid="page-nav-parent"
>
<option value="">Top level (no dropdown)</option>
{parentOptions.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<p style={hintStyle}>
{isLanding
? 'The landing page is always a top-level menu item.'
: hasChildren
? 'Other pages are nested under this one, so it stays top-level.'
: 'Shows this page in a dropdown under the chosen page. Applies to menus with "Follow pages" on.'}
</p>
</div>
</fieldset>
)}
<div>
<label style={fieldLabelStyle}>Meta Title</label>
<input
+12
View File
@@ -20,6 +20,7 @@ export const PagesPanel: React.FC = () => {
movePage,
setLandingPage,
updatePageSeo,
updatePageNav,
} = usePages();
const { actions: editorActions } = useEditor();
const [isAdding, setIsAdding] = useState(false);
@@ -440,6 +441,15 @@ export const PagesPanel: React.FC = () => {
Landing
</span>
)}
{page.nav?.show === false && (
<i
className="fa fa-eye-slash page-menu-hidden-badge"
title="Hidden from menu"
aria-label="Hidden from menu"
role="img"
data-testid="page-menu-hidden"
/>
)}
</div>
<div
style={{
@@ -652,6 +662,8 @@ export const PagesPanel: React.FC = () => {
onClose={() => setSeoSettingsPageId(null)}
page={seoSettingsPage}
updatePageSeo={updatePageSeo}
pages={pages}
updatePageNav={updatePageNav}
/>
</div>
);
@@ -13,14 +13,20 @@ const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => {
updater(lastProps);
});
let lastProps: any;
/* history.merge() is a spy so undo-burst behaviour is observable: a call to
it means the setProp folded into the previous undo entry. */
const mergeSpy = vi.fn(() => ({ setProp: setPropSpy }));
vi.mock('@craftjs/core', () => ({
useEditor: () => ({ actions: { setProp: setPropSpy, history: { merge: () => ({ setProp: setPropSpy }) } } }),
useEditor: () => ({ actions: { setProp: setPropSpy, history: { merge: mergeSpy } } }),
}));
let mockPages: { id: string; name: string; slug: string; craftState: string | null }[] = [];
let mockPages: { id: string; name: string; slug: string; craftState: string | null; nav?: any }[] = [];
/* #53: extra PageContext API for the Follow-pages tests (absent by default,
i.e. the panel is not on the header and has no updatePageNav). */
let mockPageApi: Record<string, unknown> = {};
vi.mock('../../../state/PageContext', () => ({
usePages: () => ({ pages: mockPages }),
usePages: () => ({ pages: mockPages, ...mockPageApi }),
}));
vi.mock('../../../ui/AssetPicker', () => ({
@@ -28,6 +34,7 @@ vi.mock('../../../ui/AssetPicker', () => ({
}));
import { NavStylePanel, LinkPicker, pageHref } from './NavStylePanel';
import { closeContinuousBurst } from './shared';
let container: HTMLDivElement;
let root: Root;
@@ -67,6 +74,9 @@ function click(el: Element | null) {
beforeEach(() => {
try { window.localStorage.clear(); } catch { /* ignore */ }
setPropSpy.mockClear();
mergeSpy.mockClear();
closeContinuousBurst();
mockPageApi = {};
mockPages = [
{ id: 'home', name: 'Home', slug: 'index', craftState: null },
{ id: 'about', name: 'About', slug: 'about', craftState: null },
@@ -279,3 +289,142 @@ describe('NavStylePanel: box-model + animation + visibility controls always pres
expect(lastProps.animation).toBe('fade-in');
});
});
describe('NavStylePanel: Follow pages (#53)', () => {
const updatePageNav = vi.fn();
beforeEach(() => {
updatePageNav.mockClear();
mockPageApi = { updatePageNav, isEditingHeader: true };
});
const toggle = () => container.querySelector('[data-testid="nav-follow-pages"] input') as HTMLInputElement;
test('switching it on converts matching manual links, keeps CTA + externals, carries custom text as the page label', () => {
// A legacy (pre-#53) nav: manual links only, no followPages prop at all.
lastProps = {
links: [
{ text: 'Home', href: '/' },
{ text: 'About us', href: '/about' },
{ text: 'Blog', href: 'https://blog.example.com', isExternal: true },
{ text: 'Book now', href: '#book', isCta: true },
],
};
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
expect(toggle().checked).toBe(false);
act(() => { toggle().click(); });
expect(lastProps.followPages).toBe(true);
expect(lastProps.links).toEqual([
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
{ text: 'About us', href: '/about', source: 'page', pageId: 'about' },
{ text: 'Blog', href: 'https://blog.example.com', isExternal: true },
{ text: 'Book now', href: '#book', isCta: true },
]);
expect(updatePageNav).toHaveBeenCalledWith('about', { label: 'About us' });
expect(updatePageNav).toHaveBeenCalledTimes(1);
});
test('page-sourced rows are read-only and "Hide from menu" calls updatePageNav(show:false)', () => {
lastProps = {
followPages: true,
links: [
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
{ text: 'About', href: '/about', source: 'page', pageId: 'about' },
{ text: 'Go', href: '#', isCta: true },
],
};
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
const rows = container.querySelectorAll('[data-testid="nav-page-link"]');
expect(rows).toHaveLength(2);
expect(rows[1].textContent).toContain('From page: About');
expect(rows[1].querySelector('input')).toBeNull();
// The one-shot sync button is replaced by the toggle.
expect(Array.from(container.querySelectorAll('button')).some((b) => b.textContent?.includes('Sync links with Pages'))).toBe(false);
click(rows[1].querySelector('button[aria-label="Hide About from menu"]'));
expect(updatePageNav).toHaveBeenCalledWith('about', { show: false });
});
test('the toggle is not offered outside the header (one-shot sync remains)', () => {
mockPageApi = { updatePageNav, isEditingHeader: false };
lastProps = { links: [{ text: 'Home', href: '/' }] };
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
expect(toggle()).toBeNull();
expect(Array.from(container.querySelectorAll('button')).some((b) => b.textContent?.includes('Sync links with Pages'))).toBe(true);
});
test('LinkPicker page mode records source:"page" + pageId on a manual link', () => {
mockPageApi = { updatePageNav, isEditingHeader: false };
lastProps = { links: [{ text: 'X', href: 'https://x.test' }] };
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
const modeSelect = container.querySelector('[data-testid="link-picker"] select') as HTMLSelectElement;
setValue(modeSelect, 'page');
rerender(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
const pageSelect = container.querySelectorAll('[data-testid="link-picker"] select')[1] as HTMLSelectElement;
setValue(pageSelect, '/about');
expect(lastProps.links[0]).toEqual({ text: 'X', href: '/about', source: 'page', pageId: 'about' });
// Back to a custom URL: manual again.
setValue(modeSelect, 'url');
expect(lastProps.links[0]).toEqual({ text: 'X', href: '' });
});
test('manual links get a sub-link (dropdown) editor', () => {
lastProps = { links: [{ text: 'More', href: '#' }] };
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
const add = Array.from(container.querySelectorAll('button')).find((b) => b.textContent?.includes('Add sub link'))!;
click(add);
expect(lastProps.links[0].children).toEqual([{ text: 'Sub link', href: '#' }]);
rerender(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
click(container.querySelector('button[aria-label="Remove sub link Sub link"]'));
expect(lastProps.links[0].children).toBeUndefined();
});
});
describe('NavStylePanel: link href typing is one undo burst (#53 fix round 1)', () => {
const typeInto = (el: HTMLInputElement, v: string) => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
act(() => { setter.call(el, v); el.dispatchEvent(new Event('input', { bubbles: true })); });
};
const hrefInput = () => container.querySelector('[data-testid="link-picker"] input[type="text"]') as HTMLInputElement;
test('consecutive URL keystrokes fold into one history entry via history.merge()', () => {
lastProps = { links: [{ text: 'Docs', href: 'https://e' }] };
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
for (const v of ['https://ex', 'https://exa', 'https://exam', 'https://example.com']) typeInto(hrefInput(), v);
expect(setPropSpy).toHaveBeenCalledTimes(4);
expect(mergeSpy).toHaveBeenCalledTimes(3); // first keystroke opens the entry, the rest merge
expect(lastProps.links[0]).toEqual({ text: 'Docs', href: 'https://example.com' });
});
test('tel typing on a Follow-pages nav merges too, strips source/pageId and keeps the page merge', () => {
lastProps = {
followPages: true,
links: [
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
{ text: 'About', href: '/about', source: 'page', pageId: 'about' },
{ text: 'Call', href: 'tel:1' },
],
};
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
const telInput = Array.from(container.querySelectorAll('[data-testid="link-picker"] input[type="text"]'))
.find((el) => (el as HTMLInputElement).placeholder === '+15551234567') as HTMLInputElement;
for (const v of ['15', '155', '1555']) typeInto(telInput, v);
expect(mergeSpy).toHaveBeenCalledTimes(2);
const call = lastProps.links.find((l: any) => l.text === 'Call');
expect(call).toEqual({ text: 'Call', href: 'tel:1555' });
// page links are still present (mergeLinks ran inside the updater)
expect(lastProps.links.filter((l: any) => l.source === 'page').map((l: any) => l.pageId)).toEqual(['home', 'about']);
});
test('a page pick after typing is its own undo step (no merge)', () => {
lastProps = { links: [{ text: 'Go', href: 'https://a' }] };
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
typeInto(hrefInput(), 'https://ab');
typeInto(hrefInput(), 'https://abc');
expect(mergeSpy).toHaveBeenCalledTimes(1);
const modeSelect = container.querySelector('[data-testid="link-picker"] select') as HTMLSelectElement;
setValue(modeSelect, 'page');
expect(mergeSpy).toHaveBeenCalledTimes(1);
expect(lastProps.links[0]).toEqual({ text: 'Go', href: '/', source: 'page', pageId: 'home' });
});
});
+159 -22
View File
@@ -24,19 +24,17 @@ import {
AnimationControl,
VisibilityControl,
continuousSetProp,
discreteSetProp,
} from './shared';
import { AssetPicker } from '../../../ui/AssetPicker';
import { usePages } from '../../../state/PageContext';
import { PageData } from '../../../types';
import { pageHref, adoptPageLinks, mergeLinks, NavLink } from '../../../utils/nav-links';
/* ---------- Link-to-page helpers (F1/F2: link picker + Sync with Pages) ----------
Mirrors the export convention used elsewhere: the first page is always the
landing page and publishes to '/', every other page publishes to '/{slug}'.
See PageContext.tsx's uniqueSlug/slugify and the landing-page-lock comment
in renamePage() for why index 0 is special-cased this way. */
export function pageHref(page: PageData, index: number): string {
return index === 0 ? '/' : `/${page.slug}`;
}
`pageHref` lives in utils/nav-links.ts (#53: single source shared with the
page-following menus); re-exported here for existing callers. */
export { pageHref };
type LinkMode = 'page' | 'url' | 'anchor' | 'tel' | 'mailto';
@@ -59,19 +57,30 @@ function detectLinkMode(value: string, pageHrefs: string[]): LinkMode {
interface LinkPickerProps {
value: string;
onChange: (value: string) => void;
/** #53: when given, used INSTEAD of `onChange` and also reports which page
* was picked (null for every non-page mode), so a nav link can record
* `source: 'page'` + `pageId`. */
onChangeLink?: (href: string, pageId: string | null) => void;
}
export const LinkPicker: React.FC<LinkPickerProps> = ({ value, onChange }) => {
export const LinkPicker: React.FC<LinkPickerProps> = ({ value, onChange, onChangeLink }) => {
const { pages } = usePages();
const pageOptions = pages.map((p, i) => ({ id: p.id, name: p.name, href: pageHref(p, i) }));
const mode = detectLinkMode(value || '', pageOptions.map((p) => p.href));
const emit = (href: string) => {
if (onChangeLink) onChangeLink(href, pageOptions.find((p) => p.href === href)?.id ?? null);
else onChange(href);
};
// Non-page modes never carry a pageId, even if the typed text matches a page href.
const emitManual = (href: string) => (onChangeLink ? onChangeLink(href, null) : onChange(href));
const switchMode = (next: LinkMode) => {
if (next === mode) return;
if (next === 'page') onChange(pageOptions[0]?.href || '/');
else if (next === 'anchor') onChange('#');
else if (next === 'tel') onChange('tel:');
else if (next === 'mailto') onChange('mailto:');
else onChange('');
if (next === 'page') emit(pageOptions[0]?.href || '/');
else if (next === 'anchor') emitManual('#');
else if (next === 'tel') emitManual('tel:');
else if (next === 'mailto') emitManual('mailto:');
else emitManual('');
};
return (
@@ -90,7 +99,7 @@ export const LinkPicker: React.FC<LinkPickerProps> = ({ value, onChange }) => {
</select>
{mode === 'page' && (
pageOptions.length > 0 ? (
<select value={value} onChange={(e) => onChange(e.target.value)} style={inputStyle}>
<select value={value} onChange={(e) => emit(e.target.value)} style={inputStyle}>
{pageOptions.map((p) => (
<option key={p.id} value={p.href}>{p.name}</option>
))}
@@ -103,7 +112,7 @@ export const LinkPicker: React.FC<LinkPickerProps> = ({ value, onChange }) => {
<input
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value.startsWith('#') ? e.target.value : `#${e.target.value}`)}
onChange={(e) => emitManual(e.target.value.startsWith('#') ? e.target.value : `#${e.target.value}`)}
placeholder="#section-id"
style={inputStyle}
/>
@@ -112,7 +121,7 @@ export const LinkPicker: React.FC<LinkPickerProps> = ({ value, onChange }) => {
<input
type="text"
value={(value || '').replace(/^tel:/, '')}
onChange={(e) => onChange(`tel:${e.target.value}`)}
onChange={(e) => emitManual(`tel:${e.target.value}`)}
placeholder="+15551234567"
style={inputStyle}
/>
@@ -121,7 +130,7 @@ export const LinkPicker: React.FC<LinkPickerProps> = ({ value, onChange }) => {
<input
type="text"
value={(value || '').replace(/^mailto:/, '')}
onChange={(e) => onChange(`mailto:${e.target.value}`)}
onChange={(e) => emitManual(`mailto:${e.target.value}`)}
placeholder="name@example.com"
style={inputStyle}
/>
@@ -130,7 +139,7 @@ export const LinkPicker: React.FC<LinkPickerProps> = ({ value, onChange }) => {
<input
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
onChange={(e) => emitManual(e.target.value)}
placeholder="https://example.com or /page"
style={inputStyle}
/>
@@ -152,13 +161,38 @@ function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/* #53: a link owned by a page (Follow pages on) -- read-only here; its text,
href and nesting are edited in the page's Page Settings. */
const PageLinkRow: React.FC<{ link: NavLink; pages: PageData[]; onHide: (pageId: string) => void }> = ({ link, pages, onHide }) => {
const pageName = (id?: string) => pages.find((p) => p.id === id)?.name || link.text;
const row = (l: NavLink, nested: boolean) => (
<div key={l.pageId} className={nested ? 'nav-page-link nav-page-link--child' : 'nav-page-link'} data-testid="nav-page-link">
<i className="fa fa-file-o" aria-hidden="true" />
<span className="nav-page-link__label">
{l.text}
<span className="nav-page-link__source">From page: {pageName(l.pageId)}</span>
</span>
<button type="button" className="nav-page-link__hide" onClick={() => l.pageId && onHide(l.pageId)} aria-label={`Hide ${l.text} from menu`} title="Hide from menu">
<i className="fa fa-eye-slash" aria-hidden="true" />
</button>
</div>
);
return (
<div>
{row(link, false)}
{(link.children || []).map((c) => row(c, true))}
</div>
);
};
/* ---------- NAV / MENU / LOGO / FOOTER ---------- */
export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
const { actions } = useEditor();
const { setProp, setPropStyle, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
const { pages } = usePages();
const { pages, updatePageNav, isEditingHeader } = usePages();
const links: any[] = nodeProps.links || [];
const links: NavLink[] = nodeProps.links || [];
const followPages = !!nodeProps.followPages;
const updateLink = useCallback((index: number, field: string, value: any) => {
continuousSetProp(actions, selectedId, `link:${index}:${field}`, (props: any) => {
@@ -168,6 +202,80 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
});
}, [actions, selectedId]);
/* #53: LinkPicker reports the picked page; a page pick records
source:'page' + pageId, any other mode makes the link manual again. */
const updateLinkHref = useCallback((index: number, href: string, pageId: string | null) => {
const updater = (props: any) => {
const updated = [...(props.links || [])];
const { source: _s, pageId: _p, ...rest } = updated[index] || {};
updated[index] = pageId ? { ...rest, href, source: 'page', pageId } : { ...rest, href };
// On a page-following nav the write is re-merged, so a link re-pointed
// at a page is materialised (and deduped) at once.
props.links = props.followPages ? mergeLinks(updated, pages) : updated;
};
// A page pick is a discrete choice (its own undo step); URL / anchor /
// tel / mailto typing is one undo burst per control, as before #53.
if (pageId) discreteSetProp(actions, selectedId, updater);
else continuousSetProp(actions, selectedId, `link:${index}:href`, updater);
}, [actions, selectedId, pages]);
/* Nested (dropdown) links of a MANUAL link. Page nesting is set per page in
Page Settings instead, so sub-links stay plain manual links. */
const updateChild = useCallback((index: number, childIndex: number, field: string, value: any) => {
continuousSetProp(actions, selectedId, `link:${index}:child:${childIndex}:${field}`, (props: any) => {
const updated = [...(props.links || [])];
const children = [...(updated[index].children || [])];
children[childIndex] = { ...children[childIndex], [field]: value };
updated[index] = { ...updated[index], children };
props.links = updated;
});
}, [actions, selectedId]);
const addChild = useCallback((index: number) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props.links || [])];
updated[index] = { ...updated[index], children: [...(updated[index].children || []), { text: 'Sub link', href: '#' }] };
props.links = updated;
});
}, [actions, selectedId]);
const removeChild = useCallback((index: number, childIndex: number) => {
actions.setProp(selectedId, (props: any) => {
const updated = [...(props.links || [])];
const children = [...(updated[index].children || [])];
children.splice(childIndex, 1);
const { children: _c, ...rest } = updated[index];
updated[index] = children.length ? { ...rest, children } : rest;
props.links = updated;
});
}, [actions, selectedId]);
/* #53 "Follow pages": switching on adopts every manual link whose href is
a page's href (so existing menus keep their entries), carries a custom
link text over as that page's menu label (so the owner's wording
survives), then materialises the page links once. Later page changes
are kept in sync by PageContext. Switching off freezes the links as-is. */
const toggleFollowPages = useCallback((on: boolean) => {
if (!on) {
actions.setProp(selectedId, (props: any) => { props.followPages = false; });
return;
}
const adopted = adoptPageLinks(links, pages);
const labels = new Map<string, string>();
adopted.forEach((link) => {
if (link.source !== 'page' || !link.pageId || labels.has(link.pageId)) return;
const page = pages.find((p) => p.id === link.pageId);
if (page && !page.nav?.label && link.text && link.text !== page.name) labels.set(page.id, link.text);
});
const labelled = pages.map((p) => (labels.has(p.id) ? { ...p, nav: { ...p.nav, label: labels.get(p.id) } } : p));
const merged = mergeLinks(adopted, labelled);
labels.forEach((label, pageId) => updatePageNav?.(pageId, { label }));
actions.setProp(selectedId, (props: any) => {
props.followPages = true;
props.links = merged;
});
}, [actions, selectedId, links, pages, updatePageNav]);
const addLink = useCallback(() => {
actions.setProp(selectedId, (props: any) => {
props.links = [...(props.links || []), { text: 'New Link', href: '#' }];
@@ -312,6 +420,16 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
Footer -- that don't carry a `links` array at all). */}
{!isStandaloneLogo && nodeProps.links !== undefined && (
<CollapsibleSection title="Links">
{/* #53: only the site header's navs are kept in sync with pages
(PageContext rewrites the header state), so the toggle is
offered there; elsewhere the one-shot sync below applies. */}
{(isEditingHeader || followPages) && (
<label data-testid="nav-follow-pages" style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#e4e4e7', cursor: 'pointer', marginBottom: 8 }}>
<input type="checkbox" checked={followPages} onChange={(e) => toggleFollowPages(e.target.checked)} />
Follow pages (new pages appear automatically)
</label>
)}
{!followPages && (
<button
onClick={syncWithPages}
title="Replace these links with one per page (preserves any CTA link)"
@@ -319,8 +437,11 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
>
<i className="fa fa-refresh" /> Sync links with Pages
</button>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{links.map((link, i) => (
{links.map((link, i) => followPages && link.source === 'page' ? (
<PageLinkRow key={i} link={link} pages={pages} onHide={(pageId) => updatePageNav?.(pageId, { show: false })} />
) : (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={link.text || ''} onChange={(e) => updateLink(i, 'text', e.target.value)} placeholder="Text" style={{ ...smallInputStyle, flex: 1 }} />
@@ -328,11 +449,27 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
<i className="fa fa-times" />
</button>
</div>
<LinkPicker value={link.href || ''} onChange={(v) => updateLink(i, 'href', v)} />
<LinkPicker value={link.href || ''} onChange={(v) => updateLink(i, 'href', v)} onChangeLink={(href, pageId) => updateLinkHref(i, href, pageId)} />
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10, color: '#a1a1aa', cursor: 'pointer', marginTop: -6 }}>
<input type="checkbox" checked={!!link.download} onChange={(e) => updateLink(i, 'download', e.target.checked)} />
Download (points at a file)
</label>
{!link.isCta && (
<div className="nav-sublinks" data-testid="nav-sublinks">
{(link.children || []).map((child, j) => (
<div key={j} className="nav-sublink">
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={child.text || ''} onChange={(e) => updateChild(i, j, 'text', e.target.value)} placeholder="Sub link text" aria-label="Sub link text" style={{ ...smallInputStyle, flex: 1 }} />
<button onClick={() => removeChild(i, j)} aria-label={`Remove sub link ${child.text}`} style={{ padding: '2px 6px', fontSize: 10, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}>
<i className="fa fa-times" />
</button>
</div>
<LinkPicker value={child.href || ''} onChange={(v) => updateChild(i, j, 'href', v)} />
</div>
))}
<button className="nav-sublink-add" onClick={() => addChild(i)}>+ Add sub link (dropdown)</button>
</div>
)}
</div>
))}
</div>
+6 -5
View File
@@ -10,7 +10,7 @@ import {
import { uploadAsset } from '../../../utils/assets';
import { getPref, setPref } from '../../../utils/local-prefs';
import { useSiteDesign, DEFAULT_SITE_DESIGN, SiteDesign } from '../../../state/SiteDesignContext';
import { COLOR_TOKENS, resolveTokenValue, tokenKeyOf } from '../../../utils/tokens';
import { COLOR_TOKENS, resolveTokenValue, tokenKeyOf, tokenRef } from '../../../utils/tokens';
import { TokenSwatchRow } from './TokenSwatchRow';
import { useDevice } from '../../../state/DeviceContext';
import { RESPONSIVE_STYLE_KEYS } from '../../../utils/responsive-style';
@@ -414,15 +414,16 @@ export interface NavColorField {
fallback: string;
}
const NAV_COLOR_FIELDS: NavColorField[] = [
// #53: hover/CTA fallbacks mirror the Navbar/Menu defaults (primary token).
// Navbar / Logo / Footer schema
{ key: 'backgroundColor', label: 'Background', fallback: '#ffffff' },
{ key: 'textColor', label: 'Text Color', fallback: '#18181b' },
{ key: 'hoverColor', label: 'Hover Color', fallback: '#3b82f6' },
{ key: 'ctaColor', label: 'CTA Color', fallback: '#3b82f6' },
{ key: 'hoverColor', label: 'Hover Color', fallback: tokenRef('primary') },
{ key: 'ctaColor', label: 'CTA Color', fallback: tokenRef('primary') },
// Menu schema (distinct prop names)
{ key: 'linkColor', label: 'Link Color', fallback: '#3f3f46' },
{ key: 'linkHoverColor', label: 'Hover Color', fallback: '#3b82f6' },
{ key: 'ctaBgColor', label: 'CTA Background', fallback: '#3b82f6' },
{ key: 'linkHoverColor', label: 'Hover Color', fallback: tokenRef('primary') },
{ key: 'ctaBgColor', label: 'CTA Background', fallback: tokenRef('primary') },
// ctaTextColor is shared: Menu's CTA text AND Navbar's CTA text
{ key: 'ctaTextColor', label: 'CTA Text', fallback: '#ffffff' },
];
+155
View File
@@ -0,0 +1,155 @@
import { describe, test, expect, afterEach } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
import { PageProvider, usePages, DEFAULT_HEADER_STATE } from './PageContext';
import type { NavLink } from '../utils/nav-links';
/**
* #53 NavSyncEffect, against the REAL Craft editor + the real PageProvider:
* page operations materialise page-following links into the header's
* Navbar props -- in the stored header string when another page is on the
* canvas, and on the live node while the header itself is being edited.
*/
type Api = ReturnType<typeof usePages>;
let api: Api;
let harness: EditorHarness | null = null;
const Probe: React.FC = () => {
api = usePages();
return null;
};
afterEach(() => {
harness?.unmount();
harness = null;
});
/** Flush PageContext.loadState's setTimeout(0) deserialize. */
async function flush(h: EditorHarness) {
await h.act(async () => { await new Promise((r) => setTimeout(r, 0)); });
}
function mount(): EditorHarness {
const h = renderEditorHarness();
h.mountChild(<PageProvider><Probe /></PageProvider>);
harness = h;
return h;
}
function storedNavLinks(): NavLink[] {
return JSON.parse(api.headerPage.craftState!)['header-navbar'].props.links;
}
const hrefs = (links: NavLink[]) => links.map((l) => l.href);
describe('NavSyncEffect: stored header (a page is on the canvas)', () => {
test('the default header follows pages: add / rename / hide / delete', async () => {
const h = mount();
expect(api.headerPage.craftState).toBe(DEFAULT_HEADER_STATE);
h.act(() => { api.addPage('Services', 'services'); });
await flush(h);
expect(hrefs(storedNavLinks())).toEqual(['/', '/services', '#']);
const servicesId = api.pages[1].id;
h.act(() => { api.renamePage(servicesId, 'What we do', 'what-we-do'); });
expect(storedNavLinks()[1]).toEqual({ text: 'What we do', href: '/what-we-do', source: 'page', pageId: servicesId });
h.act(() => { api.updatePageNav(servicesId, { show: false }); });
expect(hrefs(storedNavLinks())).toEqual(['/', '#']);
h.act(() => { api.updatePageNav(servicesId, { show: true, label: 'Services' }); });
expect(storedNavLinks()[1].text).toBe('Services');
h.act(() => { api.deletePage(servicesId); });
await flush(h);
expect(hrefs(storedNavLinks())).toEqual(['/', '#']);
});
test('nesting via updatePageNav produces children; moving pages reorders', async () => {
const h = mount();
h.act(() => { api.addPage('Services', 'services'); });
h.act(() => { api.addPage('Web', 'web'); });
await flush(h);
const [, services, web] = api.pages;
h.act(() => { api.updatePageNav(web.id, { parentId: services.id }); });
const links = storedNavLinks();
expect(hrefs(links)).toEqual(['/', '/services', '#']);
expect(links[1].children).toEqual([{ text: 'Web', href: '/web', source: 'page', pageId: web.id }]);
h.act(() => { api.updatePageNav(web.id, { parentId: null }); });
h.act(() => { api.movePage(web.id, 'up'); });
expect(hrefs(storedNavLinks())).toEqual(['/', '/web', '/services', '#']);
});
test('a legacy header (no followPages) is never rewritten -- manual links survive exactly', async () => {
const h = mount();
const legacy = JSON.parse(DEFAULT_HEADER_STATE);
delete legacy['header-navbar'].props.followPages;
legacy['header-navbar'].props.links = [
{ text: 'Home', href: '/' },
{ text: 'About us', href: '/about' },
{ text: 'Book', href: '#book', isCta: true },
];
const legacyState = JSON.stringify(legacy);
h.act(() => { api.setHeaderCraftState(legacyState); });
h.act(() => { api.addPage('Services', 'services'); });
await flush(h);
expect(api.headerPage.craftState).toBe(legacyState);
});
});
describe('NavSyncEffect: live header on the canvas', () => {
test('rename / updatePageNav while editing the header patch the live Navbar node', async () => {
const h = mount();
h.act(() => { api.addPage('Services', 'services'); });
await flush(h);
const servicesId = api.pages[1].id;
h.act(() => { api.editHeader(); });
await flush(h);
expect(api.isEditingHeader).toBe(true);
const liveLinks = (): NavLink[] => h.query.node('header-navbar').get().data.props.links;
expect(hrefs(liveLinks())).toEqual(['/', '/services', '#']);
h.act(() => { api.renamePage(servicesId, 'Offers', 'offers'); });
expect(liveLinks()[1]).toEqual({ text: 'Offers', href: '/offers', source: 'page', pageId: servicesId });
h.act(() => { api.updatePageNav(servicesId, { show: false }); });
expect(hrefs(liveLinks())).toEqual(['/', '#']);
});
test('addPage from the header switches away; the serialized header still gains the page', async () => {
const h = mount();
h.act(() => { api.editHeader(); });
await flush(h);
h.act(() => { api.addPage('Services', 'services'); });
await flush(h);
expect(api.isEditingHeader).toBe(false);
expect(hrefs(storedNavLinks())).toEqual(['/', '/services', '#']);
});
});
describe('NavSyncEffect: no-op when the menu would not change (nothing marked dirty)', () => {
// DirtyTracker marks dirty on any new headerPage object, and Craft's
// onNodesChange on any setProp -- so "nothing changed" must mean the SAME
// headerPage object and the SAME live links array.
test('stored path: a nav-signature change that leaves links identical keeps the headerPage object', async () => {
const h = mount();
await flush(h);
const before = api.headerPage;
h.act(() => { api.updatePageNav('home', { show: true }); });
expect(api.pages[0].nav).toEqual({ show: true });
expect(api.headerPage).toBe(before);
});
test('live path: no setProp when the merged links equal the current ones', async () => {
const h = mount();
h.act(() => { api.editHeader(); });
await flush(h);
const before = h.query.node('header-navbar').get().data.props.links;
h.act(() => { api.updatePageNav('home', { show: true }); });
expect(h.query.node('header-navbar').get().data.props.links).toBe(before);
});
});
+75 -8
View File
@@ -1,10 +1,12 @@
import React, { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
import React, { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo, ReactNode } from 'react';
import { useEditor } from '@craftjs/core';
import { PageData, PageSeo } from '../types';
import { PageData, PageSeo, PageNav } from '../types';
import { SerializedTreeNode } from '../types/sitesmith';
import { useSiteDesign, SiteDesign } from './SiteDesignContext';
import { sanitizeAiTree, flattenTreeForCraft, FlatCraftNode } from '../utils/craft-tree';
import { repairOrphanNodes } from '../utils/orphan-repair';
import { mergeLinks, rewriteHeaderLinks, isFollowPagesNav } from '../utils/nav-links';
import { tokenRef } from '../utils/tokens';
interface PageContextValue {
pages: PageData[];
@@ -43,9 +45,11 @@ interface PageContextValue {
setLandingPage: (pageId: string) => void;
/** Merges `seo` fields onto the target page's existing `seo` (creating it if absent). */
updatePageSeo: (pageId: string, seo: Partial<PageSeo>) => void;
/** Merges `nav` fields onto the target page's existing `nav` (#53: show / parentId / label). */
updatePageNav: (pageId: string, nav: Partial<PageNav>) => void;
setHeaderCraftState: (craftState: string) => void;
setFooterCraftState: (craftState: string) => void;
setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null; seo?: PageSeo }[]) => void;
setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null; seo?: PageSeo; nav?: PageNav }[]) => void;
/**
* Bookkeeping-only: point `activePageId` at an already-loaded page without
* re-serializing/deserializing the canvas (the caller -- e.g. useWhpApi's
@@ -152,6 +156,12 @@ export function treeToCraftState(tree: SerializedTreeNode): string {
// plus a CTA button, which is inert (`href: '#'`) rather than pointing at a
// page that doesn't exist. Users add pages/links as their site grows.
// Node shape matches treeToCraftState() / Craft's actions.deserialize().
//
// #53: the seed nav follows pages (`followPages: true`), so pages added later
// appear in it automatically. Its Home link is already materialised exactly
// as `mergeLinks` builds it for the default Home page, so the nav-sync effect
// is a no-op on a fresh site (nothing marked dirty). Hover/CTA colours are
// token refs so they track the site's primary colour.
export const DEFAULT_HEADER_STATE = JSON.stringify({
ROOT: {
type: { resolvedName: 'Container' },
@@ -175,13 +185,14 @@ export const DEFAULT_HEADER_STATE = JSON.stringify({
logoFontFamily: 'Inter, sans-serif',
logoFontSize: '20px',
links: [
{ text: 'Home', href: '/' },
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
{ text: 'Get Started', href: '#', isCta: true },
],
followPages: true,
backgroundColor: '#ffffff',
textColor: '#3f3f46',
hoverColor: '#3b82f6',
ctaColor: '#3b82f6',
hoverColor: tokenRef('primary'),
ctaColor: tokenRef('primary'),
ctaTextColor: '#ffffff',
padding: '16px 24px',
navAlignment: 'space-between',
@@ -215,6 +226,7 @@ const PageContext = createContext<PageContextValue>({
movePage: () => {},
setLandingPage: () => {},
updatePageSeo: () => {},
updatePageNav: () => {},
setHeaderCraftState: () => {},
setFooterCraftState: () => {},
setPagesCraftState: () => {},
@@ -509,7 +521,10 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const prev = pagesRef.current;
if (prev.length <= 1) return;
const filtered = prev.filter((p) => p.id !== pageId);
// #53: pages nested under the deleted page become top-level again.
const filtered = prev
.filter((p) => p.id !== pageId)
.map((p) => (p.nav?.parentId === pageId ? { ...p, nav: { ...p.nav, parentId: null } } : p));
setPages(filtered);
// If deleting the active page, switch to the first remaining. This
@@ -567,6 +582,9 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
slug: copySlug,
craftState: sourceCraftState,
seo: source.seo ? { ...source.seo } : undefined,
// #53: the copy sits where the source sits in the menu, but keeps its
// own "<name> copy" label rather than the source's label override.
nav: source.nav ? { show: source.nav.show, parentId: source.nav.parentId } : undefined,
};
setPages((prev) => {
@@ -643,6 +661,13 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
);
}, []);
/** Merges `nav` fields onto the target page's existing `nav` (creating it if absent). */
const updatePageNav = useCallback((pageId: string, nav: Partial<PageNav>) => {
setPages((prev) =>
prev.map((p) => (p.id === pageId ? { ...p, nav: { ...p.nav, ...nav } } : p)),
);
}, []);
/** Allow external code (e.g., load from API) to set the header craft state */
const setHeaderCraftState = useCallback((craftState: string) => {
setHeaderPage((prev) => ({ ...prev, craftState }));
@@ -664,7 +689,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}, []);
/** Allow external code (e.g., load from API) to restore pages with craft states */
const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null; seo?: PageSeo }[]) => {
const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null; seo?: PageSeo; nav?: PageNav }[]) => {
setPages(pagesData.map((p, i) => ({
id: p.id,
name: p.name,
@@ -674,6 +699,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
slug: i === 0 ? 'index' : p.slug,
craftState: p.craftState,
seo: p.seo,
nav: p.nav,
})));
}, []);
@@ -753,6 +779,46 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}
}, [loadState]);
/* ---------- NavSyncEffect (#53) ----------
`toHtml` is pure and never sees `pages`, so page-following nav links are
MATERIALISED into the stored Navbar/Menu props whenever anything a menu
shows about a page changes (order, id, name, slug, nav). Every page
operation (add/rename/delete/move/setLanding/updatePageNav/load) flows
through here. While the header is on the canvas the live nodes are
patched with setProp (a full deserialize would reset selection/history);
otherwise the stored header string is rewritten. Both paths are no-ops
when nothing changed, so nothing is marked dirty. */
const navSignature = useMemo(
() => JSON.stringify(pages.map((p) => [p.id, p.name, p.slug, p.nav ?? null])),
[pages],
);
// useEditor() hands back fresh query/actions wrappers on every render, so
// they are read through a ref: as effect deps they would re-run it each render.
const editorRef = useRef({ query, actions });
editorRef.current = { query, actions };
useEffect(() => {
const currentPages = pagesRef.current;
const { query, actions } = editorRef.current;
if (activePageIdRef.current === HEADER_ID) {
const nodes = query.getNodes();
for (const [id, node] of Object.entries<any>(nodes)) {
if (!isFollowPagesNav(node.data?.name, node.data?.props)) continue;
const current = node.data.props.links || [];
const merged = mergeLinks(current, currentPages);
if (JSON.stringify(merged) === JSON.stringify(current)) continue;
// Not an undo step of its own: undoing would only un-sync the menu.
const target = typeof actions.history?.ignore === 'function' ? actions.history.ignore() : actions;
target.setProp(id, (p: any) => { p.links = merged; });
}
return;
}
setHeaderPage((prev) => {
if (!prev.craftState) return prev;
const next = rewriteHeaderLinks(prev.craftState, currentPages);
return next === prev.craftState ? prev : { ...prev, craftState: next };
});
}, [navSignature, headerPage.craftState]);
return (
<PageContext.Provider
value={{
@@ -772,6 +838,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
movePage,
setLandingPage,
updatePageSeo,
updatePageNav,
setHeaderCraftState,
setFooterCraftState,
setPagesCraftState,
+79
View File
@@ -3012,3 +3012,82 @@ body {
pointer-events: none;
white-space: nowrap;
}
/* #53 menus that follow pages: read-only page-link rows, sub-link editor,
page-settings Menu section, hidden-from-menu badge */
.nav-page-link {
display: flex;
align-items: center;
gap: 6px;
padding: 6px;
background: var(--color-bg-elevated);
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
font-size: 11px;
}
.nav-page-link--child { margin: 4px 0 0 16px; }
.nav-page-link > .fa { color: var(--color-text-muted); font-size: 10px; }
.nav-page-link__label {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-page-link__source { font-size: 10px; color: var(--color-text-muted); }
.nav-page-link__hide {
padding: 2px 6px;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
cursor: pointer;
font-size: 10px;
}
.nav-page-link__hide:hover { color: var(--color-text); border-color: var(--color-text-muted); }
.nav-sublinks {
display: flex;
flex-direction: column;
gap: 4px;
margin-left: 12px;
padding-left: 8px;
border-left: 2px solid var(--color-border);
}
.nav-sublink { display: flex; flex-direction: column; gap: 3px; }
.nav-sublink-add {
padding: 4px;
background: none;
border: 1px dashed var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
cursor: pointer;
font-size: 10px;
}
.nav-sublink-add:hover { color: var(--color-text); }
.page-menu-settings {
display: flex;
flex-direction: column;
gap: 10px;
margin: 0;
padding: 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
.page-menu-settings > legend { padding: 0 4px; margin-bottom: 0; }
.page-menu-hidden-badge { font-size: 10px; color: var(--color-text-muted); flex-shrink: 0; }
+16
View File
@@ -32,6 +32,20 @@ export interface PageSeo {
noindex?: boolean;
}
/**
* Per-page menu settings (#53). All optional -- an absent `nav` means "shown,
* top-level, labelled with the page name", which is how every page saved
* before this field existed behaves.
*/
export interface PageNav {
/** Default true; false hides the page from page-following menus. */
show?: boolean;
/** Nest under this (top-level) page -- rendered as a dropdown. */
parentId?: string | null;
/** Menu label override; falls back to the page name. */
label?: string;
}
export interface PageData {
id: string;
name: string;
@@ -39,6 +53,8 @@ export interface PageData {
craftState: string | null;
/** NEW -- optional; absent === no SEO overrides (back-compat). */
seo?: PageSeo;
/** NEW (#53) -- optional; absent === shown, top-level, page name. */
nav?: PageNav;
}
export interface AssetData {
+170
View File
@@ -0,0 +1,170 @@
import { describe, test, expect } from 'vitest';
import { buildPageLinks, mergeLinks, adoptPageLinks, rewriteHeaderLinks, pageHref, NavLink } from './nav-links';
import { PageData } from '../types';
import { DEFAULT_HEADER_STATE } from '../state/PageContext';
const pg = (id: string, name: string, slug: string, nav?: PageData['nav']): PageData =>
({ id, name, slug, craftState: null, ...(nav ? { nav } : {}) });
const home = pg('home', 'Home', 'index');
const about = pg('about', 'About', 'about');
const services = pg('services', 'Services', 'services');
describe('pageHref', () => {
test('landing page is "/", others "/slug"', () => {
expect(pageHref(home, 0)).toBe('/');
expect(pageHref(about, 1)).toBe('/about');
});
});
describe('buildPageLinks', () => {
test('landing first, page order, label override, source/pageId', () => {
const links = buildPageLinks([home, pg('about', 'About', 'about', { label: 'Our story' }), services]);
expect(links).toEqual([
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
{ text: 'Our story', href: '/about', source: 'page', pageId: 'about' },
{ text: 'Services', href: '/services', source: 'page', pageId: 'services' },
]);
});
test('hidden pages are left out', () => {
const links = buildPageLinks([home, pg('about', 'About', 'about', { show: false }), services]);
expect(links.map((l) => l.pageId)).toEqual(['home', 'services']);
});
test('nests by parentId (one level), children in page order', () => {
const pages = [
home,
services,
pg('web', 'Web', 'web', { parentId: 'services' }),
about,
pg('seo', 'SEO', 'seo', { parentId: 'services' }),
];
const links = buildPageLinks(pages);
expect(links.map((l) => l.pageId)).toEqual(['home', 'services', 'about']);
expect(links[1].children).toEqual([
{ text: 'Web', href: '/web', source: 'page', pageId: 'web' },
{ text: 'SEO', href: '/seo', source: 'page', pageId: 'seo' },
]);
});
test('a child of a hidden / missing / nested parent, and the landing page, stay top-level', () => {
const pages = [
pg('home', 'Home', 'index', { parentId: 'about' }),
pg('about', 'About', 'about', { show: false }),
pg('a', 'A', 'a', { parentId: 'about' }),
pg('b', 'B', 'b', { parentId: 'gone' }),
pg('c', 'C', 'c', { parentId: 'd' }),
pg('d', 'D', 'd', { parentId: 'c' }),
];
const links = buildPageLinks(pages);
expect(links.map((l) => l.pageId)).toEqual(['home', 'a', 'b', 'c', 'd']);
expect(links.every((l) => !l.children)).toBe(true);
});
});
describe('mergeLinks', () => {
const cta: NavLink = { text: 'Get Started', href: '#signup', isCta: true };
const blog: NavLink = { text: 'Blog', href: 'https://blog.example.com', isExternal: true };
test('keeps manual links and the CTA (CTA last), rebuilds page links', () => {
const existing: NavLink[] = [
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
cta,
blog,
];
expect(mergeLinks(existing, [home, about])).toEqual([
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
{ text: 'About', href: '/about', source: 'page', pageId: 'about' },
blog,
cta,
]);
});
test('manual links that sat before the page links stay before them', () => {
const shop: NavLink = { text: 'Shop', href: 'https://shop.example.com' };
const existing: NavLink[] = [shop, { text: 'Home', href: '/', source: 'page', pageId: 'home' }, blog];
expect(mergeLinks(existing, [home]).map((l) => l.text)).toEqual(['Shop', 'Home', 'Blog']);
});
test("drops a deleted page's link", () => {
const existing: NavLink[] = [
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
{ text: 'About', href: '/about', source: 'page', pageId: 'about' },
cta,
];
expect(mergeLinks(existing, [home])).toEqual([
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
cta,
]);
});
test('dedupes page links by pageId', () => {
const existing: NavLink[] = [
{ text: 'About', href: '/about', source: 'page', pageId: 'about' },
{ text: 'About again', href: '/about', source: 'page', pageId: 'about' },
];
expect(mergeLinks(existing, [home, about]).filter((l) => l.pageId === 'about')).toHaveLength(1);
});
});
describe('adoptPageLinks', () => {
test('converts a manual "/about" (and "/") into page links; leaves CTA and externals', () => {
const links: NavLink[] = [
{ text: 'Home', href: '/' },
{ text: 'About', href: '/about' },
{ text: 'Blog', href: 'https://blog.example.com' },
{ text: 'About CTA', href: '/about', isCta: true },
];
expect(adoptPageLinks(links, [home, about])).toEqual([
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
{ text: 'About', href: '/about', source: 'page', pageId: 'about' },
{ text: 'Blog', href: 'https://blog.example.com' },
{ text: 'About CTA', href: '/about', isCta: true },
]);
});
});
/** A serialized header holding a follow-pages Navbar, a non-following Menu and a Container. */
function headerState(navProps: Record<string, unknown>, menuLinks: NavLink[] = [{ text: 'Legacy', href: '/about' }]): string {
return JSON.stringify({
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'header' }, displayName: 'Container', custom: {}, hidden: false, nodes: ['nav', 'menu'], linkedNodes: {} },
nav: { type: { resolvedName: 'Navbar' }, isCanvas: false, props: navProps, displayName: 'Navbar', parent: 'ROOT', custom: {}, hidden: false, nodes: [], linkedNodes: {} },
menu: { type: { resolvedName: 'Menu' }, isCanvas: false, props: { links: menuLinks }, displayName: 'Menu', parent: 'ROOT', custom: {}, hidden: false, nodes: [], linkedNodes: {} },
});
}
describe('rewriteHeaderLinks', () => {
test('returns the IDENTICAL string when nothing changed', () => {
const pages = [home, about];
const state = headerState({ followPages: true, links: mergeLinks([], pages) });
expect(rewriteHeaderLinks(state, pages)).toBe(state);
});
test('the default header seed is already materialised for the default Home page', () => {
expect(rewriteHeaderLinks(DEFAULT_HEADER_STATE, [home])).toBe(DEFAULT_HEADER_STATE);
});
test('renaming a page updates href AND text of the follow-pages nav only', () => {
const before = [home, about];
const state = headerState({ followPages: true, links: [...mergeLinks([], before), { text: 'Go', href: '#', isCta: true }] });
const after = [home, pg('about', 'Team', 'team')];
const next = JSON.parse(rewriteHeaderLinks(state, after));
expect(next.nav.props.links).toEqual([
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
{ text: 'Team', href: '/team', source: 'page', pageId: 'about' },
{ text: 'Go', href: '#', isCta: true },
]);
// The non-following Menu is never touched -- legacy manual links survive exactly.
expect(next.menu.props.links).toEqual([{ text: 'Legacy', href: '/about' }]);
});
test('a legacy nav without followPages is returned unchanged even when pages differ', () => {
const legacy = headerState({ links: [{ text: 'Home', href: '/' }, { text: 'Old', href: '/old' }] });
expect(rewriteHeaderLinks(legacy, [home, about, services])).toBe(legacy);
});
test('unreadable state is returned as-is', () => {
expect(rewriteHeaderLinks('not json', [home])).toBe('not json');
});
});
+167
View File
@@ -0,0 +1,167 @@
import type { PageData } from '../types';
/**
* Shared link shape for Navbar and Menu (#53). `source` absent = a manual
* link (every link saved before #53 is manual and is never rewritten). A
* `source: 'page'` link is owned by its page: on a nav with `followPages`
* on, its text/href/children are rebuilt from the pages list whenever pages
* change (see `mergeLinks` / `rewriteHeaderLinks`).
*/
export interface NavLink {
text: string;
href: string;
isExternal?: boolean;
isCta?: boolean;
/** Adds the `download` attribute to the exported anchor (F3: links to files). */
download?: boolean;
source?: 'page' | 'manual';
pageId?: string;
/** One level of nesting, rendered as a dropdown. */
children?: NavLink[];
}
/** Resolver names of the components that carry a `links` array + `followPages`. */
const NAV_COMPONENTS = new Set(['Navbar', 'Menu']);
/* Mirrors the export convention: the first page is always the landing page
and publishes to '/', every other page publishes to '/{slug}' (the
published .htaccess rewrites /slug -> slug.html). Single source of truth
for page hrefs -- NavStylePanel re-exports it. */
export function pageHref(page: PageData, index: number): string {
return index === 0 ? '/' : `/${page.slug}`;
}
function isShown(page: PageData): boolean {
return page.nav?.show !== false;
}
/**
* The parent a page nests under, or null when it is top-level. One level
* only: a parent that is itself nested makes the child top-level (this also
* breaks A<->B cycles). The landing page never nests; a missing, hidden or
* self parent is ignored.
*/
function parentOf(pages: PageData[], index: number): PageData | null {
const raw = (i: number): PageData | null => {
if (i <= 0) return null;
const pid = pages[i].nav?.parentId;
if (!pid || pid === pages[i].id) return null;
const parent = pages.find((p) => p.id === pid);
return parent && isShown(parent) ? parent : null;
};
const parent = raw(index);
if (!parent) return null;
return raw(pages.indexOf(parent)) ? null : parent;
}
function pageLink(page: PageData, index: number): NavLink {
return {
text: page.nav?.label || page.name,
href: pageHref(page, index),
source: 'page',
pageId: page.id,
};
}
/** One link per shown page, in page order (landing first), children nested by `nav.parentId`. */
export function buildPageLinks(pages: PageData[]): NavLink[] {
const top: NavLink[] = [];
const byId = new Map<string, NavLink>();
pages.forEach((page, i) => {
if (!isShown(page) || parentOf(pages, i)) return;
const link = pageLink(page, i);
byId.set(page.id, link);
top.push(link);
});
pages.forEach((page, i) => {
if (!isShown(page)) return;
const parent = parentOf(pages, i);
if (!parent) return;
const host = byId.get(parent.id);
if (!host) return;
host.children = [...(host.children || []), pageLink(page, i)];
});
return top;
}
/**
* Rebuilds the page-sourced links of `existing` from `pages`, keeping every
* manual link. Order: manual links that sat before the first page link,
* then the page links (page order), then the remaining manual links, then
* every CTA. Page links are deduped by pageId simply by being rebuilt -- any
* stale page link (deleted/hidden page) drops out.
*/
export function mergeLinks(existing: NavLink[], pages: PageData[]): NavLink[] {
const leading: NavLink[] = [];
const trailing: NavLink[] = [];
const ctas: NavLink[] = [];
const hasPageLinks = existing.some((l) => l.source === 'page' && !l.isCta);
let seenPage = false;
for (const link of existing) {
if (link.isCta) { ctas.push(link); continue; }
if (link.source === 'page') { seenPage = true; continue; }
(hasPageLinks && !seenPage ? leading : trailing).push(link);
}
return [...leading, ...buildPageLinks(pages), ...trailing, ...ctas];
}
/**
* Converts manual links whose href is exactly a page's href into page links
* (used once, when the owner switches Follow pages on). CTAs stay manual.
*/
export function adoptPageLinks(links: NavLink[], pages: PageData[]): NavLink[] {
const byHref = new Map<string, PageData>();
pages.forEach((p, i) => { if (!byHref.has(pageHref(p, i))) byHref.set(pageHref(p, i), p); });
return links.map((link) => {
if (link.isCta || link.source === 'page') return link;
const page = byHref.get(link.href);
return page ? { ...link, source: 'page', pageId: page.id } : link;
});
}
/**
* Materialises page-following links into a serialized Craft state: every
* Navbar/Menu node with `followPages` gets `props.links = mergeLinks(...)`.
* Returns the IDENTICAL string when nothing changed (callers use string
* equality to avoid marking the editor dirty) or when the state is unreadable.
*/
export function rewriteHeaderLinks(serializedState: string, pages: PageData[]): string {
let nodes: Record<string, any>;
try {
nodes = JSON.parse(serializedState);
} catch {
return serializedState;
}
if (!nodes || typeof nodes !== 'object') return serializedState;
let changed = false;
for (const node of Object.values(nodes)) {
if (!node || !NAV_COMPONENTS.has(node.type?.resolvedName) || !node.props?.followPages) continue;
const current: NavLink[] = Array.isArray(node.props.links) ? node.props.links : [];
const merged = mergeLinks(current, pages);
if (JSON.stringify(merged) !== JSON.stringify(current)) {
node.props.links = merged;
changed = true;
}
}
return changed ? JSON.stringify(nodes) : serializedState;
}
/** True when the node (Craft resolver name) is a page-following nav. */
export function isFollowPagesNav(resolvedName: string | undefined, props: any): boolean {
return !!resolvedName && NAV_COMPONENTS.has(resolvedName) && !!props?.followPages;
}
/**
* Inline click handler for an exported dropdown toggle button (same inline
* pattern as the Navbar hamburger): flips `is-open` on the wrapper and keeps
* `aria-expanded` in sync. Self-contained per button, so several navs on one
* page never interfere and nothing global is installed. No double quotes --
* it sits inside a double-quoted attribute.
*/
export const DROPDOWN_TOGGLE_ONCLICK =
"var o=this.parentNode.classList.toggle('is-open');this.setAttribute('aria-expanded',o?'true':'false')";
/** True when some non-CTA link has children (i.e. the dropdown CSS is needed). */
export function hasDropdown(links: NavLink[]): boolean {
return links.some((l) => !l.isCta && !!l.children?.length);
}