site-builder: fix Menu guided panel (empty Colors) + surface layout/nav colors

The shared NavStylePanel gated its Colors controls on the Navbar's prop
names (backgroundColor/textColor/ctaColor), so a Menu -- whose color props
are linkColor/linkHoverColor/ctaBgColor/ctaTextColor -- rendered an empty
Colors section (customer report: 'nothing to select').

- Add navColorFields() helper: derives the visible color controls from the
  props actually present, covering both the Navbar and Menu schemas
  (+ Navbar's hoverColor, which render/toHtml consume but had no control).
- Add a Menu Layout section (alignment/orientation/gap/font size), guarded
  on Menu's own props so it never leaks into Navbar or a standalone Logo.
- Unit test (navColorFields.test.ts) locks each component to its real props.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 13:10:46 -07:00
parent d20b77e66d
commit 97cb439508
3 changed files with 153 additions and 8 deletions
@@ -9,6 +9,8 @@ import {
PresetButtonGrid, PresetButtonGrid,
CollapsibleSection, CollapsibleSection,
ColorPickerField, ColorPickerField,
navColorFields,
btnActiveStyle,
labelStyle, labelStyle,
inputStyle, inputStyle,
smallInputStyle, smallInputStyle,
@@ -56,6 +58,21 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
/* Detect standalone Logo vs Navbar/Menu */ /* Detect standalone Logo vs Navbar/Menu */
const isStandaloneLogo = nodeProps.type !== undefined && (nodeProps.type === 'text' || nodeProps.type === 'image') && nodeProps.logoText === undefined; const isStandaloneLogo = nodeProps.type !== undefined && (nodeProps.type === 'text' || nodeProps.type === 'image') && nodeProps.logoText === undefined;
/* Color controls, derived from the props the selected component actually has */
const colorFields = navColorFields(nodeProps);
/* Menu layout controls (alignment/orientation/gap/font size). These props are
unique to the Menu component — Navbar uses navAlignment and has no
orientation/gap/fontSize — so guarding on their presence scopes this section
to the Menu without leaking into Navbar or a standalone Logo. */
const hasMenuLayout = !isStandaloneLogo && (
nodeProps.alignment !== undefined ||
nodeProps.orientation !== undefined ||
nodeProps.gap !== undefined ||
nodeProps.fontSize !== undefined
);
const GAP_PRESETS = ['8px', '16px', '24px', '32px', '40px'].map((g) => ({ label: g, value: g }));
return ( return (
<> <>
{/* Standalone Logo component settings */} {/* Standalone Logo component settings */}
@@ -165,17 +182,61 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
</CollapsibleSection> </CollapsibleSection>
)} )}
{/* Colors (not shown for standalone Logo - it has its own color picker) */} {/* Colors (not shown for standalone Logo - it has its own color picker).
{!isStandaloneLogo && ( Fields are derived from whichever color props the selected component
actually has, so Menu (linkColor/ctaBg/…) and Navbar (backgroundColor/…)
each get the right controls instead of an empty section. */}
{!isStandaloneLogo && colorFields.length > 0 && (
<CollapsibleSection title="Colors"> <CollapsibleSection title="Colors">
{nodeProps.backgroundColor !== undefined && ( {colorFields.map((f) => (
<ColorPickerField label="Background" value={nodeProps.backgroundColor || '#ffffff'} onChange={(v) => setProp('backgroundColor', v)} /> <ColorPickerField
key={f.key}
label={f.label}
value={nodeProps[f.key] || f.fallback}
onChange={(v) => setProp(f.key, v)}
/>
))}
</CollapsibleSection>
)} )}
{nodeProps.textColor !== undefined && (
<ColorPickerField label="Text Color" value={nodeProps.textColor || '#18181b'} onChange={(v) => setProp('textColor', v)} /> {/* Menu layout (alignment / orientation / gap / font size) */}
{hasMenuLayout && (
<CollapsibleSection title="Layout">
{nodeProps.alignment !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Alignment</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['left', 'center', 'right'] as const).map((a) => (
<button key={a} onClick={() => setProp('alignment', a)} style={btnActiveStyle((nodeProps.alignment || 'right') === a)}>
{a.charAt(0).toUpperCase() + a.slice(1)}
</button>
))}
</div>
</div>
)} )}
{nodeProps.ctaColor !== undefined && ( {nodeProps.orientation !== undefined && (
<ColorPickerField label="CTA Color" value={nodeProps.ctaColor || '#3b82f6'} onChange={(v) => setProp('ctaColor', v)} /> <div style={sectionGap}>
<label style={labelStyle}>Orientation</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['horizontal', 'vertical'] as const).map((o) => (
<button key={o} onClick={() => setProp('orientation', o)} style={btnActiveStyle((nodeProps.orientation || 'horizontal') === o)}>
{o.charAt(0).toUpperCase() + o.slice(1)}
</button>
))}
</div>
</div>
)}
{nodeProps.gap !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Gap</label>
<PresetButtonGrid presets={GAP_PRESETS} activeValue={nodeProps.gap} onSelect={(v) => setProp('gap', v)} />
</div>
)}
{nodeProps.fontSize !== undefined && (
<div style={sectionGap}>
<label style={labelStyle}>Font Size</label>
<input type="text" value={nodeProps.fontSize || '14px'} onChange={(e) => setProp('fontSize', e.target.value)} placeholder="14px" style={inputStyle} />
</div>
)} )}
</CollapsibleSection> </CollapsibleSection>
)} )}
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { navColorFields } from './shared';
import { Menu } from '../../../components/basic/Menu';
import { Navbar } from '../../../components/basic/Navbar';
/*
* Regression: the Menu component's color props (linkColor/linkHoverColor/
* ctaBgColor/ctaTextColor) differ from the Navbar's (backgroundColor/textColor/
* ctaColor). NavStylePanel's "Colors" section is shared across both, so it must
* derive its fields from the actual props present — otherwise a Menu shows an
* empty "Colors" dropdown with nothing to select.
*/
describe('navColorFields', () => {
it('surfaces the Menu color props (bug: was empty for Menu)', () => {
const menuProps = (Menu as any).craft.props;
const keys = navColorFields(menuProps).map((f) => f.key);
expect(keys).toContain('linkColor');
expect(keys).toContain('linkHoverColor');
expect(keys).toContain('ctaBgColor');
expect(keys).toContain('ctaTextColor');
});
it('never returns an empty list for a Menu (no empty Colors dropdown)', () => {
const menuProps = (Menu as any).craft.props;
expect(navColorFields(menuProps).length).toBeGreaterThan(0);
});
it('surfaces the Navbar color props (incl. hover/ctaText the panel never exposed before)', () => {
const navProps = (Navbar as any).craft.props;
const keys = navColorFields(navProps).map((f) => f.key);
// every field surfaced must be a real, consumed prop on the Navbar
expect(keys).toContain('backgroundColor');
expect(keys).toContain('textColor');
expect(keys).toContain('hoverColor');
expect(keys).toContain('ctaColor');
expect(keys).toContain('ctaTextColor');
// must NOT invent Menu-only props on a Navbar
expect(keys).not.toContain('linkColor');
expect(keys).not.toContain('ctaBgColor');
});
it('does not surface Navbar-only props on a Menu', () => {
const menuProps = (Menu as any).craft.props;
const keys = navColorFields(menuProps).map((f) => f.key);
expect(keys).not.toContain('backgroundColor');
expect(keys).not.toContain('hoverColor'); // Menu uses linkHoverColor
expect(keys).not.toContain('ctaColor');
});
it('only returns fields whose prop is actually present', () => {
// A bare Logo (text logo) has no link/cta color props -> should not invent them
const keys = navColorFields({ backgroundColor: '#fff' }).map((f) => f.key);
expect(keys).toEqual(['backgroundColor']);
});
});
+29
View File
@@ -247,6 +247,35 @@ export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value
</div> </div>
); );
/* ---------- Nav-family color fields ----------
The Colors section is shared across the whole nav family (Navbar / Logo /
Footer / Menu). Those components DON'T share a color-prop schema:
Navbar/Logo/Footer use backgroundColor/textColor/ctaColor, while Menu uses
linkColor/linkHoverColor/ctaBgColor/ctaTextColor. Deriving the visible fields
from whichever props actually exist keeps the section from rendering empty
(the "Colors dropdown with nothing to select" bug on Menu). */
export interface NavColorField {
key: string;
label: string;
fallback: string;
}
const NAV_COLOR_FIELDS: NavColorField[] = [
// 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' },
// 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' },
// ctaTextColor is shared: Menu's CTA text AND Navbar's CTA text
{ key: 'ctaTextColor', label: 'CTA Text', fallback: '#ffffff' },
];
export function navColorFields(nodeProps: Record<string, any>): NavColorField[] {
return NAV_COLOR_FIELDS.filter((f) => nodeProps[f.key] !== undefined);
}
/* ---------- Collapsible section ---------- */ /* ---------- Collapsible section ---------- */
export const CollapsibleSection: React.FC<{ title: string; defaultOpen?: boolean; children: React.ReactNode }> = ({ title, defaultOpen = true, children }) => { export const CollapsibleSection: React.FC<{ title: string; defaultOpen?: boolean; children: React.ReactNode }> = ({ title, defaultOpen = true, children }) => {
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);