feat(site-builder): menus that follow pages, with dropdowns
- utils/nav-links.ts: shared NavLink type (source/pageId/children),
pageHref, buildPageLinks, mergeLinks, adoptPageLinks, rewriteHeaderLinks
- PageData.nav {show, parentId, label} + updatePageNav; a nav-sync effect in
PageProvider materialises page-following links into the stored header
(or the live header nodes while it is being edited); string/identity
guards keep it a no-op when nothing changed
- Navbar/Menu: followPages prop (default false; the default header seed is
true), one-level dropdowns (hover/focus-within + inline click toggle,
indented in the hamburger layout), hover/CTA colours default to the
primary token
- NavStylePanel: Follow pages toggle (adopts matching manual links, keeps
custom text as the page's menu label), read-only page rows with Hide from
menu, sub-link editor for manual links, LinkPicker records pageId
- Page Settings: Show in menu / Menu label / Nest under; Pages panel badge
- useWhpApi: nav passes through pages_craft_state on save and load
Legacy navs (no followPages) are never rewritten.
Refs #53
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8860349c9b
commit
52477bff45
18 files changed
+1439
-97
No files matched your search
@@ -67,3 +67,43 @@ describe('Menu.toHtml XSS hardening (linkHoverColor into <style>)', () => {
|
||||
expect(html).toMatch(/:hover\s*\{\s*color:\s*#ff0000/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Menu.toHtml dropdowns (#53)', () => {
|
||||
const nested = [
|
||||
{ text: 'Home', href: '/' },
|
||||
{ text: 'Services', href: '/services', children: [
|
||||
{ text: 'Web', href: '/web' },
|
||||
{ text: 'Bad', href: 'javascript:alert(1)' },
|
||||
] },
|
||||
{ text: 'Go', href: '#go', isCta: true },
|
||||
];
|
||||
|
||||
test('a link with children emits the dropdown wrapper, aria-* toggle, inline script and submenu', () => {
|
||||
const { html } = toHtml({ links: nested }, '', 'node-dd');
|
||||
expect(html).toMatch(/<div class="[a-z0-9_-]+-dd"><a href="\/services"[^>]*>Services<\/a><button type="button" class="[a-z0-9_-]+-dd-toggle" aria-haspopup="true" aria-expanded="false" aria-label="Services submenu" onclick="[^"]*aria-expanded[^"]*">/);
|
||||
expect(html).toMatch(/<ul class="[a-z0-9_-]+-sub"><li><a href="\/web"[^>]*>Web<\/a><\/li>/);
|
||||
expect(html).toMatch(/-dd:focus-within > \.[a-z0-9_-]+-sub/);
|
||||
expect(html).toMatch(/-dd:hover > \.[a-z0-9_-]+-sub/);
|
||||
});
|
||||
|
||||
test('safeUrl is applied to child hrefs', () => {
|
||||
const { html } = toHtml({ links: nested }, '', 'node-dd');
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('no children -> no dropdown markup, CSS or script (legacy output unchanged)', () => {
|
||||
const { html } = toHtml({ links: [{ text: 'Home', href: '/' }, { text: 'About', href: '/about' }] }, '', 'node-flat');
|
||||
expect(html).not.toContain('aria-haspopup');
|
||||
expect(html).not.toContain('onclick');
|
||||
expect(html).not.toContain('-sub');
|
||||
});
|
||||
|
||||
test('default hover + CTA colours are the primary token', () => {
|
||||
const { html } = toHtml({}, '', 'node-tok');
|
||||
expect(html).toMatch(/:hover\s*\{\s*color:\s*var\(--wsb-primary\)/);
|
||||
expect(html).toContain('background-color:var(--wsb-primary)');
|
||||
expect((Menu as any).craft.props.linkHoverColor).toBe('var(--wsb-primary)');
|
||||
expect((Menu as any).craft.props.ctaBgColor).toBe('var(--wsb-primary)');
|
||||
expect((Menu as any).craft.props.followPages).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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}">▾</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);
|
||||
});
|
||||
});
|
||||
@@ -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}">▾</button><ul class="navbar-sub">${kids}</ul></div>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user