0cbc58f8d1
M-1: Navbar.toHtml emitted a fixed id="navbar-links" and unscoped .navbar-link/.navbar-cta :hover selectors -- two Navbars on one page collided on the duplicate id and cross-applied each other's hover colors (later <style> block wins in the cascade). Scope both on the Craft node id via scopeId(), matching the Menu/Tabs pattern: the links container gets a unique id, aria-controls/the hamburger toggle script reference it, and the hover rules are prefixed with a per-instance class on the <nav> root. M-2: Gallery lightbox had no focus management -- opening it left focus wherever it was (behind the now-visible overlay) and closing it never restored it. The inline script now stashes document.activeElement on open, moves focus to a new accessible close button, traps Tab on the close button while the dialog is open, and restores the saved focus on close (Escape, backdrop click, or the close button). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
120 lines
5.6 KiB
TypeScript
120 lines
5.6 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 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');
|
|
});
|
|
});
|