Files
site-builder/craft/src/components/basic/Menu.tsx
T
shadowdaoandClaude Opus 5.5 52477bff45 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>
2026-10-07 12:03:16 -07:00

261 lines
10 KiB
TypeScript

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 ---------- */
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;
ctaBgColor?: string;
ctaTextColor?: string;
gap?: string;
orientation?: 'horizontal' | 'vertical';
fontSize?: string;
style?: CSSProperties;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
}
/* ---------- Defaults ---------- */
const defaultLinks: MenuLink[] = [
{ text: 'Home', href: '/' },
{ text: 'About', href: '#about' },
{ text: 'Services', href: '#services' },
{ text: 'Contact', href: '#contact', isCta: true },
];
/* ---------- Component ---------- */
export const Menu: UserComponent<MenuProps> = ({
links = defaultLinks,
alignment = 'right',
linkColor = '#3f3f46',
linkHoverColor = tokenRef('primary'),
ctaBgColor = tokenRef('primary'),
ctaTextColor = '#ffffff',
gap = '24px',
orientation = 'horizontal',
fontSize = '14px',
style = {},
}) => {
const {
connectors: { connect, drag },
} = 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' };
return (
<nav
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'flex',
flexDirection: orientation === 'vertical' ? 'column' : 'row',
alignItems: orientation === 'vertical' ? (alignment === 'center' ? 'center' : alignment === 'right' ? 'flex-end' : 'flex-start') : 'center',
justifyContent: orientation === 'horizontal' ? justifyMap[alignment] : undefined,
gap,
flexWrap: 'wrap',
...style,
}}
>
{links.map((link, i) => {
const kids = !link.isCta && link.children?.length ? link.children : null;
const anchor = (
<a
key={i}
href={link.href}
target={link.isExternal ? '_blank' : undefined}
rel={link.isExternal ? 'noopener noreferrer' : undefined}
download={link.download || undefined}
onClick={(e) => e.preventDefault()}
onMouseEnter={() => setHoveredLink(i)}
onMouseLeave={() => setHoveredLink(null)}
style={{
textDecoration: 'none',
fontSize,
fontWeight: link.isCta ? '600' : '400',
color: link.isCta
? ctaTextColor
: (hoveredLink === i ? linkHoverColor : linkColor),
backgroundColor: link.isCta ? ctaBgColor : 'transparent',
padding: link.isCta ? '8px 20px' : '0',
borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s',
...(link.isCta && hoveredLink === i ? { filter: 'brightness(1.1)' } : {}),
}}
>
{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: tokenRef('primary'),
ctaBgColor: tokenRef('primary'),
ctaTextColor: '#ffffff',
gap: '24px',
orientation: 'horizontal',
fontSize: '14px',
style: {},
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
animation: 'none',
animationDelay: '0',
} as MenuProps,
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
};
/* ---------- HTML export ---------- */
(Menu as any).toHtml = (props: MenuProps, _childrenHtml: string, nodeId?: string) => {
// Sanitized once here -- linkCol/hoverCol/ctaBg/ctaText/gap/fSize are raw
// string-interpolation sinks below (hoverCol goes into a <style> block,
// the worst case: </style> breakout -> arbitrary <script>), see
// task-cssxss-brief.md.
const linkCol = cssValue(props.linkColor) || '#3f3f46';
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';
const alignment = props.alignment || 'right';
const fSize = cssValue(props.fontSize) || '14px';
const justifyMap: Record<string, string> = { left: 'flex-start', center: 'center', right: 'flex-end' };
const navStyle = cssPropsToString({
display: 'flex',
flexDirection: orientation === 'vertical' ? 'column' : 'row',
alignItems: orientation === 'vertical'
? (alignment === 'center' ? 'center' : alignment === 'right' ? 'flex-end' : 'flex-start')
: 'center',
justifyContent: orientation === 'horizontal' ? justifyMap[alignment] : undefined,
gap,
flexWrap: 'wrap',
...props.style,
});
const links = props.links || defaultLinks;
// Scope for the hover CSS classes below. Deterministic AND unique: scoped
// on the Craft node id so two Menu instances with identical/default links
// don't collide on the same `.menu-link`/`.menu-cta` class names (which
// would let one instance's hover styling bleed into the other's).
const scope = scopeId(nodeId, JSON.stringify(links) + orientation + alignment, 'menu');
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`;
const linkStyle = cssPropsToString({
textDecoration: 'none',
fontSize: fSize,
fontWeight: link.isCta ? '600' : '400',
color: link.isCta ? ctaText : linkCol,
backgroundColor: link.isCta ? ctaBg : '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 || '#'))}" 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); }${dropdownCss}
</style>`;
return {
html: `${hoverCss}
<nav${navStyle ? ` style="${navStyle}"` : ''}>
${linksHtml}
</nav>`,
};
};