Files
site-builder/craft/src/components/basic/Navbar.tsx
T

391 lines
16 KiB
TypeScript

import React, { CSSProperties, useState } from 'react';
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 NavbarProps {
logoType?: 'text' | 'image';
logoText?: string;
logoImage?: string;
logoWidth?: string;
logoUrl?: string;
logoFontFamily?: string;
logoFontSize?: string;
logoColor?: string;
links?: NavLink[];
/** #53: links follow the site's pages (materialised by PageContext). */
followPages?: boolean;
backgroundColor?: string;
textColor?: string;
hoverColor?: string;
ctaColor?: string;
ctaTextColor?: string;
padding?: string;
navAlignment?: 'left' | 'center' | 'right' | 'space-between';
isSticky?: boolean;
showMobileMenu?: boolean;
style?: CSSProperties;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
}
/* ---------- Defaults ---------- */
const defaultLinks: NavLink[] = [
{ text: 'Home', href: '/' },
{ text: 'About', href: '#about' },
{ text: 'Services', href: '#services' },
{ text: 'Contact', href: '#contact', isCta: true },
];
const PADDING_PRESETS = [
{ label: 'Compact', value: '8px 16px' },
{ label: 'Normal', value: '16px 24px' },
{ label: 'Relaxed', value: '20px 32px' },
{ label: 'Spacious', value: '24px 48px' },
];
function isLightNavBg(bg: string): boolean {
const v = (bg || '').trim().toLowerCase();
return v === tokenRef('bg') || v === '#ffffff' || v === '#f8fafc' || v === '#f9fafb';
}
/* ---------- Component ---------- */
export const Navbar: UserComponent<NavbarProps> = ({
logoType = 'text',
logoText = 'MySite',
logoImage = '',
logoWidth = '120px',
logoUrl = '/',
logoFontFamily = 'Inter, sans-serif',
logoFontSize = '20px',
logoColor,
links = defaultLinks,
backgroundColor = tokenRef('bg'),
textColor = tokenRef('text'),
hoverColor = tokenRef('primary'),
ctaColor = tokenRef('primary'),
ctaTextColor = tokenRef('bg'),
padding = '16px 24px',
navAlignment = 'space-between',
isSticky = false,
showMobileMenu = false,
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const { design } = useSiteDesign();
// Light-background heuristic: the bg token counts as light (it is the page
// background); legacy stored hex values keep the old hex test. White/zinc on
// an explicitly dark bar is "text on a dark custom colour" -- no token fits.
const lightBg = isLightNavBg(backgroundColor);
const resolvedLogoColor = logoColor || (lightBg ? design.textColor : '#ffffff');
const resolvedTextColor = textColor || (lightBg ? tokenRef('text') : '#e4e4e7');
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
const [openDropdown, setOpenDropdown] = useState<number | null>(null);
return (
<nav
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: navAlignment,
padding,
backgroundColor,
...(isSticky ? { position: 'sticky' as const, top: 0, zIndex: 1000 } : {}),
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{/* Logo */}
<a
href={logoUrl}
onClick={(e) => e.preventDefault()}
style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}
>
{logoType === 'image' && logoImage ? (
<img
src={logoImage}
alt={logoText || 'Logo'}
style={{ width: logoWidth, height: 'auto', display: 'block' }}
/>
) : (
<span style={{
fontWeight: '700',
fontSize: logoFontSize,
fontFamily: logoFontFamily,
color: resolvedLogoColor,
}}>
{logoText}
</span>
)}
</a>
{/* Links */}
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
{showMobileMenu && (
<div
style={{
display: 'none', /* Hidden in editor, shown via media query in export */
flexDirection: 'column',
gap: '4px',
cursor: 'pointer',
padding: '4px',
}}
className="navbar-hamburger"
>
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
</div>
)}
{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: '14px',
fontWeight: link.isCta ? '600' : '400',
color: link.isCta
? ctaTextColor
: (hoveredLink === i ? hoverColor : resolvedTextColor),
backgroundColor: link.isCta ? ctaColor : '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: '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>
);
};
/* ---------- Craft config ---------- */
Navbar.craft = {
displayName: 'Navbar',
props: {
logoType: 'text',
logoText: 'MySite',
logoImage: '',
logoWidth: '120px',
logoUrl: '/',
logoFontFamily: 'Inter, sans-serif',
logoFontSize: '20px',
logoColor: undefined,
links: defaultLinks,
followPages: false,
backgroundColor: tokenRef('bg'),
textColor: tokenRef('text'),
hoverColor: tokenRef('primary'),
ctaColor: tokenRef('primary'),
ctaTextColor: tokenRef('bg'),
padding: '16px 24px',
navAlignment: 'space-between',
isSticky: false,
showMobileMenu: false,
style: {
borderBottom: `1px solid ${tokenRef('border')}`,
},
hideOnDesktop: false,
hideOnTablet: false,
hideOnMobile: false,
animation: 'none',
animationDelay: '0',
} as NavbarProps,
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
};
/* ---------- HTML export ---------- */
(Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string, nodeId?: string) => {
// Sanitized once here -- these are raw string-interpolation sinks below
// (hoverCol/bgColor go into a <style> block, the worst case: </style>
// breakout -> arbitrary <script>), see task-cssxss-brief.md.
const bgColor = cssValue(props.backgroundColor) || tokenRef('bg');
const textCol = cssValue(props.textColor) || tokenRef('text');
const hoverCol = cssValue(props.hoverColor) || tokenRef('primary');
const ctaCol = cssValue(props.ctaColor) || tokenRef('primary');
const ctaTextCol = cssValue(props.ctaTextColor) || tokenRef('bg');
const pad = cssValue(props.padding) || '16px 24px';
const alignment = props.navAlignment || 'space-between';
const sticky = props.isSticky;
const mobile = props.showMobileMenu;
const logoUrl = props.logoUrl || '/';
const links0 = props.links || defaultLinks;
// M-1: deterministic AND unique per-instance scope, keyed on the Craft
// node id. Two Navbars on the same page previously emitted an identical
// fixed id="navbar-links" (invalid duplicate-id HTML, ambiguous
// aria-controls target) and unscoped `.navbar-link:hover`/`.navbar-cta:hover`
// rules in each instance's own <style> block -- since both blocks target
// the SAME global selector, the later one in the DOM silently overrides
// the earlier one's hover color/behavior for BOTH navbars. Scoping the
// links-container id and adding a per-instance class on the <nav> root
// (used to prefix the hover selectors) eliminates both collisions.
const scope = scopeId(nodeId, JSON.stringify(links0) + alignment + pad, 'nav');
const linksId = `${scope}_links`;
const navStyle = cssPropsToString({
display: 'flex',
alignItems: 'center',
justifyContent: alignment,
padding: pad,
backgroundColor: bgColor,
...(sticky ? { position: 'sticky', top: '0', zIndex: '1000' } : {}),
...props.style,
});
// Logo HTML
let logoHtml: string;
if (props.logoType === 'image' && props.logoImage) {
const imgStyle = cssPropsToString({ width: props.logoWidth || '120px', height: 'auto', display: 'block' });
logoHtml = `<a href="${escapeAttr(safeUrl(logoUrl))}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="${escapeAttr(safeImageUrl(props.logoImage))}" alt="${escapeAttr(props.logoText || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} /></a>`;
} else {
const logoStyle = cssPropsToString({
fontWeight: '700',
fontSize: props.logoFontSize || '20px',
fontFamily: props.logoFontFamily || 'Inter, sans-serif',
color: props.logoColor || textCol,
});
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>`;
}
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
// via aria-expanded, kept in sync with the .navbar-open class by the
// inline onclick handler.
const hamburgerHtml = mobile
? `\n <button class="navbar-hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="${escapeAttr(linksId)}" onclick="var m=document.getElementById('${linksId}');var open=m.classList.toggle('navbar-open');this.setAttribute('aria-expanded', open ? 'true' : 'false');" style="display:none;background:none;border:none;cursor:pointer;padding:4px;flex-direction:column;gap:4px">
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
</button>`
: '';
// Hover CSS -- scoped under `.${scope}` (a class on the <nav> root, added
// below) so it can only ever match THIS instance's links/CTA, never bleed
// 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); }${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; }${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 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';
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 || "#"))}" 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 {
html: `${hoverCss}
<nav class="${scope}"${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}>
${logoHtml}${hamburgerHtml}
<div class="navbar-links" id="${linksId}" style="display:flex;align-items:center;gap:24px">
${linksHtmlWithClass}
</div>
</nav>`,
};
};