Files
site-builder/craft/src/components/basic/Navbar.toHtml.test.ts
T
shadowdao 54572f648a feat(builder): nav/menu link-to-page picker, page sync, download attr, box-model rollout
NavStylePanel (Navbar/Menu/Logo/Footer):
- LinkPicker: dropdown of the site's pages (read-only via usePages()) plus
  manual URL / #anchor / tel: / mailto: entry, wired into every link-href
  field (standalone Logo href, Navbar logoUrl, Navbar/Menu link items).
- "Sync links with Pages" button in the Links section: repopulates the
  links array from the current pages list (label = page name, href = '/'
  for the landing page else '/{slug}'), preserving any existing CTA link.
  Regression-fix vs the legacy GrapesJS builder, which had this.
- `download` checkbox per link (Navbar/Menu links, standalone Logo href)
  emits the `download` attribute on export for links to files.
- Links/Colors sections now gate on the component actually carrying a
  `links`/color prop, so Footer (no links array) no longer shows a dead
  "Add Link" editor.
- Box-model (Margin/Padding via SpacingControl, Border & Effects via
  BorderControl + box-shadow presets + opacity), AnimationControl, and
  VisibilityControl added for all four owned components, backed by new
  animation/animationDelay/hideOnDesktop/hideOnTablet/hideOnMobile props
  (with blank/default values in each component's .craft.props).

Tests: NavStylePanel.test.tsx (new, 17 tests: LinkPicker modes, sync
preserves CTA, download toggle, box-model/animation/visibility wiring) +
extended Navbar/Menu/Logo/Footer .toHtml.test.ts (download attribute,
craft.props defaults). Full suite: 683/683 passing. `npm run build` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 06:47:59 -07:00

143 lines
6.7 KiB
TypeScript

import { describe, test, expect } from 'vitest';
import { Navbar } from './Navbar';
const toHtml = (Navbar as any).toHtml;
describe('Navbar.toHtml hamburger accessibility (F2.3)', () => {
test('mobile toggle button has an accessible name, aria-expanded, and aria-controls', () => {
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
expect(html).toMatch(/class="navbar-hamburger"[^>]*aria-label="Toggle navigation menu"/);
expect(html).toMatch(/aria-expanded="false"/);
expect(html).toMatch(/aria-controls="[^"]+"/);
});
test('aria-controls target id exists on the links container', () => {
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
const controls = html.match(/aria-controls="([^"]+)"/)![1];
expect(html).toContain(`id="${controls}"`);
});
test('toggle script flips aria-expanded on click', () => {
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
expect(html).toMatch(/setAttribute\(['"]aria-expanded['"]/);
});
test('no mobile menu: no hamburger button emitted', () => {
const { html } = toHtml({ showMobileMenu: false }, '', 'node-nav1');
expect(html).not.toContain('navbar-hamburger');
});
});
describe('Navbar.toHtml node-scoped ids/hover styles (M-1: two navbars must not collide)', () => {
test('no bare unscoped id="navbar-links" is emitted', () => {
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
expect(html).not.toContain('id="navbar-links"');
});
test('two different node ids produce different links-container ids', () => {
const { html: html1 } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
const { html: html2 } = toHtml({ showMobileMenu: true }, '', 'node-nav2');
const id1 = html1.match(/id="([^"]+)"/)![1];
const id2 = html2.match(/id="([^"]+)"/)![1];
expect(id1).not.toBe(id2);
});
test('aria-controls always equals the actual links-container id', () => {
const { html } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
const controls = html.match(/aria-controls="([^"]+)"/)![1];
const linksId = html.match(/id="([^"]+)"/)![1];
expect(controls).toBe(linksId);
});
test('hover style selectors are scoped per-instance, not bare .navbar-link/.navbar-cta', () => {
const { html } = toHtml({ hoverColor: '#ff0000' }, '', 'node-nav1');
// A selector rule that STARTS the line with .navbar-link:hover (i.e. not
// preceded by a per-instance ancestor class) would be the old, unscoped,
// globally-colliding form.
expect(html).not.toMatch(/^\s*\.navbar-link:hover/m);
expect(html).not.toMatch(/^\s*\.navbar-cta:hover/m);
// still present, just scoped under a per-instance ancestor class
expect(html).toMatch(/\.navbar-link:hover/);
expect(html).toMatch(/\.[\w-]+ \.navbar-link:hover/);
});
test('two navbars with different hoverColor do not leak style onto each other (scoped selectors differ)', () => {
const { html: html1 } = toHtml({ hoverColor: '#ff0000' }, '', 'node-nav1');
const { html: html2 } = toHtml({ hoverColor: '#00ff00' }, '', 'node-nav2');
const scope1 = html1.match(/<style>\s*\.([\w-]+)\s/)![1];
const scope2 = html2.match(/<style>\s*\.([\w-]+)\s/)![1];
expect(scope1).not.toBe(scope2);
expect(html1).toContain(`.${scope1} .navbar-link:hover`);
expect(html2).toContain(`.${scope2} .navbar-link:hover`);
});
test('a normal single navbar still renders its hover style (visual output preserved)', () => {
const { html } = toHtml({ hoverColor: '#ff0000' }, '', 'node-nav1');
expect(html).toMatch(/:hover\s*\{\s*color:\s*#ff0000/);
});
test('same node id -> identical output across calls (deterministic)', () => {
const { html: html1 } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
const { html: html2 } = toHtml({ showMobileMenu: true }, '', 'node-nav1');
expect(html1).toBe(html2);
});
});
describe('Navbar.toHtml download attribute (F3: link-to-file toggle)', () => {
test('a link with download:true emits the download attribute', () => {
const { html } = toHtml({ links: [{ text: 'Brochure', href: '/brochure.pdf', download: true }] }, '', 'node-dl1');
expect(html).toMatch(/<a href="\/brochure\.pdf"[^>]* download[^>]*>Brochure<\/a>/);
});
test('a link without download does not emit the attribute', () => {
const { html } = toHtml({ links: [{ text: 'Home', href: '/' }] }, '', 'node-dl2');
expect(html).not.toContain(' download');
});
});
describe('Navbar (F4: box-model + animation + visibility props on craft.props)', () => {
test('craft.props includes animation/visibility defaults so the panel controls always render', () => {
const craftProps = (Navbar as any).craft.props;
expect(craftProps).toHaveProperty('animation', 'none');
expect(craftProps).toHaveProperty('animationDelay', '0');
expect(craftProps).toHaveProperty('hideOnDesktop', false);
expect(craftProps).toHaveProperty('hideOnTablet', false);
expect(craftProps).toHaveProperty('hideOnMobile', false);
});
});
describe('Navbar.toHtml XSS hardening (hoverColor/backgroundColor/ctaColor into <style>)', () => {
test('a hoverColor value containing </style><script> is neutralized in the hover <style> block', () => {
const malicious = '#fff}</style><script>alert(1)</script><style>{';
const { html } = toHtml({ hoverColor: malicious }, '');
expect(html).not.toContain('</style><script');
expect(html).not.toContain('<script>alert(1)</script>');
});
test('a backgroundColor value containing </style><script> is neutralized (mobile media-query rule)', () => {
const malicious = '#fff}</style><script>alert(2)</script><style>{';
const { html } = toHtml({ backgroundColor: malicious, showMobileMenu: true }, '');
expect(html).not.toContain('</style><script');
expect(html).not.toContain('<script>alert(2)</script>');
});
test('a ctaColor value containing </style><script> is neutralized', () => {
const malicious = '#fff}</style><script>alert(3)</script><style>{';
const { html } = toHtml({ ctaColor: malicious }, '');
expect(html).not.toContain('</style><script');
expect(html).not.toContain('<script>alert(3)</script>');
});
test('a textColor value containing a quote breakout does not escape the hamburger span style attribute', () => {
const malicious = '#333" onmouseover="alert(1)';
const { html } = toHtml({ textColor: malicious, showMobileMenu: true }, '');
expect(html).not.toMatch(/style="[^"]*"[^>]*onmouseover/);
});
test('normal colors still render correctly', () => {
const { html } = toHtml({ hoverColor: '#ff0000', backgroundColor: '#123456', ctaColor: '#00ff00' }, '');
expect(html).toMatch(/:hover\s*\{\s*color:\s*#ff0000/);
expect(html).toContain('background-color:#123456');
});
});