2026-04-05 18:31:16 -07:00
|
|
|
import React, { CSSProperties, useState } from 'react';
|
|
|
|
|
import { useNode, UserComponent } from '@craftjs/core';
|
|
|
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
2026-07-12 14:35:12 -07:00
|
|
|
import { escapeHtml, escapeAttr, safeUrl, scopeId } from '../../utils/escape';
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
/* ---------- Types ---------- */
|
|
|
|
|
|
|
|
|
|
interface MenuLink {
|
|
|
|
|
text: string;
|
|
|
|
|
href: string;
|
|
|
|
|
isExternal?: boolean;
|
|
|
|
|
isCta?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface MenuProps {
|
|
|
|
|
links?: MenuLink[];
|
|
|
|
|
alignment?: 'left' | 'center' | 'right';
|
|
|
|
|
linkColor?: string;
|
|
|
|
|
linkHoverColor?: string;
|
|
|
|
|
ctaBgColor?: string;
|
|
|
|
|
ctaTextColor?: string;
|
|
|
|
|
gap?: string;
|
|
|
|
|
orientation?: 'horizontal' | 'vertical';
|
|
|
|
|
fontSize?: string;
|
|
|
|
|
style?: CSSProperties;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------- 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 = '#3b82f6',
|
|
|
|
|
ctaBgColor = '#3b82f6',
|
|
|
|
|
ctaTextColor = '#ffffff',
|
|
|
|
|
gap = '24px',
|
|
|
|
|
orientation = 'horizontal',
|
|
|
|
|
fontSize = '14px',
|
|
|
|
|
style = {},
|
|
|
|
|
}) => {
|
|
|
|
|
const {
|
|
|
|
|
connectors: { connect, drag },
|
|
|
|
|
} = useNode();
|
|
|
|
|
|
|
|
|
|
const [hoveredLink, setHoveredLink] = 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) => (
|
|
|
|
|
<a
|
|
|
|
|
key={i}
|
|
|
|
|
href={link.href}
|
|
|
|
|
target={link.isExternal ? '_blank' : undefined}
|
|
|
|
|
rel={link.isExternal ? 'noopener noreferrer' : 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>
|
|
|
|
|
))}
|
|
|
|
|
</nav>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- Craft config ---------- */
|
|
|
|
|
|
|
|
|
|
Menu.craft = {
|
|
|
|
|
displayName: 'Menu',
|
|
|
|
|
props: {
|
|
|
|
|
links: defaultLinks,
|
|
|
|
|
alignment: 'right',
|
|
|
|
|
linkColor: '#3f3f46',
|
|
|
|
|
linkHoverColor: '#3b82f6',
|
|
|
|
|
ctaBgColor: '#3b82f6',
|
|
|
|
|
ctaTextColor: '#ffffff',
|
|
|
|
|
gap: '24px',
|
|
|
|
|
orientation: 'horizontal',
|
|
|
|
|
fontSize: '14px',
|
|
|
|
|
style: {},
|
|
|
|
|
} as MenuProps,
|
|
|
|
|
rules: {
|
|
|
|
|
canDrag: () => true,
|
|
|
|
|
canMoveIn: () => false,
|
|
|
|
|
canMoveOut: () => true,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- HTML export ---------- */
|
|
|
|
|
|
2026-07-12 14:35:12 -07:00
|
|
|
(Menu as any).toHtml = (props: MenuProps, _childrenHtml: string, nodeId?: string) => {
|
2026-04-05 18:31:16 -07:00
|
|
|
const linkCol = props.linkColor || '#3f3f46';
|
|
|
|
|
const hoverCol = props.linkHoverColor || '#3b82f6';
|
|
|
|
|
const ctaBg = props.ctaBgColor || '#3b82f6';
|
|
|
|
|
const ctaText = props.ctaTextColor || '#ffffff';
|
|
|
|
|
const gap = props.gap || '24px';
|
|
|
|
|
const orientation = props.orientation || 'horizontal';
|
|
|
|
|
const alignment = props.alignment || 'right';
|
|
|
|
|
const fSize = 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;
|
|
|
|
|
|
2026-07-12 14:35:12 -07:00
|
|
|
// 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');
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
const linksHtml = links.map((link) => {
|
|
|
|
|
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
2026-07-12 14:35:12 -07:00
|
|
|
const cls = link.isCta ? `${scope}-cta` : `${scope}-link`;
|
2026-04-05 18:31:16 -07:00
|
|
|
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',
|
|
|
|
|
});
|
2026-07-12 12:05:57 -07:00
|
|
|
return `<a href="${escapeAttr(safeUrl(link.href || '#'))}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
2026-04-05 18:31:16 -07:00
|
|
|
}).join('\n ');
|
|
|
|
|
|
|
|
|
|
const hoverCss = `<style>
|
2026-07-12 14:35:12 -07:00
|
|
|
.${scope}-link:hover { color: ${hoverCol} !important; }
|
|
|
|
|
.${scope}-cta:hover { filter: brightness(1.1); }
|
2026-04-05 18:31:16 -07:00
|
|
|
</style>`;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
html: `${hoverCss}
|
|
|
|
|
<nav${navStyle ? ` style="${navStyle}"` : ''}>
|
|
|
|
|
${linksHtml}
|
|
|
|
|
</nav>`,
|
|
|
|
|
};
|
|
|
|
|
};
|