diff --git a/craft/src/components/basic/Navbar.toHtml.test.ts b/craft/src/components/basic/Navbar.toHtml.test.ts new file mode 100644 index 0000000..ed46235 --- /dev/null +++ b/craft/src/components/basic/Navbar.toHtml.test.ts @@ -0,0 +1,28 @@ +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 }, ''); + expect(html).toMatch(/class="navbar-hamburger"[^>]*aria-label="Toggle navigation menu"/); + expect(html).toMatch(/aria-expanded="false"/); + expect(html).toMatch(/aria-controls="navbar-links"/); + }); + + test('aria-controls target id exists on the links container', () => { + const { html } = toHtml({ showMobileMenu: true }, ''); + expect(html).toContain('id="navbar-links"'); + }); + + test('toggle script flips aria-expanded on click', () => { + const { html } = toHtml({ showMobileMenu: true }, ''); + expect(html).toMatch(/setAttribute\(['"]aria-expanded['"]/); + }); + + test('no mobile menu: no hamburger button emitted', () => { + const { html } = toHtml({ showMobileMenu: false }, ''); + expect(html).not.toContain('navbar-hamburger'); + }); +}); diff --git a/craft/src/components/basic/Navbar.tsx b/craft/src/components/basic/Navbar.tsx index 77c1e49..17b510a 100644 --- a/craft/src/components/basic/Navbar.tsx +++ b/craft/src/components/basic/Navbar.tsx @@ -264,9 +264,12 @@ Navbar.craft = { return `${escapeHtml(link.text)}`; }).join('\n '); - // Hamburger HTML for mobile + // 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 + // via aria-expanded, kept in sync with the .navbar-open class by the + // inline onclick handler. const hamburgerHtml = mobile - ? `\n ` + ? `` : ''; return { html: `
- +
${btnHtml} diff --git a/craft/src/components/basic/SocialLinks.toHtml.test.ts b/craft/src/components/basic/SocialLinks.toHtml.test.ts new file mode 100644 index 0000000..a415453 --- /dev/null +++ b/craft/src/components/basic/SocialLinks.toHtml.test.ts @@ -0,0 +1,16 @@ +import { describe, test, expect } from 'vitest'; +import { SocialLinks } from './SocialLinks'; + +const toHtml = (SocialLinks as any).toHtml; + +describe('SocialLinks.toHtml accessibility (F2.5)', () => { + test('icon-only links get an aria-label naming the platform', () => { + const { html } = toHtml({ links: [{ platform: 'facebook', url: 'https://fb.example/x' }] }, ''); + expect(html).toMatch(/]*aria-label="Facebook"/); + }); + + test('the icon glyph itself is aria-hidden', () => { + const { html } = toHtml({ links: [{ platform: 'twitter', url: '#' }] }, ''); + expect(html).toMatch(/]*aria-hidden="true"/); + }); +}); diff --git a/craft/src/components/basic/SocialLinks.tsx b/craft/src/components/basic/SocialLinks.tsx index 61e7670..09cd212 100644 --- a/craft/src/components/basic/SocialLinks.tsx +++ b/craft/src/components/basic/SocialLinks.tsx @@ -204,7 +204,11 @@ SocialLinks.craft = { if (hasBg) { aStyle += `;${getShapeStr()}`; } - return ``; + // The link's only content is the icon glyph, so the glyph itself is + // aria-hidden and the accessible name lives on the link (aria-label, + // mirroring the existing `title` tooltip since title support in + // screen readers is inconsistent). + return ``; }).join('\n '); return { diff --git a/craft/src/components/basic/StarRating.toHtml.test.ts b/craft/src/components/basic/StarRating.toHtml.test.ts new file mode 100644 index 0000000..405c42d --- /dev/null +++ b/craft/src/components/basic/StarRating.toHtml.test.ts @@ -0,0 +1,23 @@ +import { describe, test, expect } from 'vitest'; +import { StarRating } from './StarRating'; + +const toHtml = (StarRating as any).toHtml; + +describe('StarRating.toHtml accessibility (F2.2)', () => { + test('wrapper has role="img" and a "Rating: N out of maxStars" aria-label', () => { + const { html } = toHtml({ rating: 4.5, maxStars: 5 }, ''); + expect(html).toMatch(/ { + const { html } = toHtml({ rating: 3, maxStars: 5 }, ''); + const glyphs = html.match(/]*>/g) || []; + expect(glyphs.length).toBeGreaterThan(0); + glyphs.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"')); + }); + + test('respects custom maxStars in the aria-label', () => { + const { html } = toHtml({ rating: 2, maxStars: 10 }, ''); + expect(html).toContain('aria-label="Rating: 2 out of 10"'); + }); +}); diff --git a/craft/src/components/basic/StarRating.tsx b/craft/src/components/basic/StarRating.tsx index 8159369..6afc986 100644 --- a/craft/src/components/basic/StarRating.tsx +++ b/craft/src/components/basic/StarRating.tsx @@ -112,15 +112,18 @@ StarRating.craft = { let starsHtml = ''; for (let i = 1; i <= maxStars; i++) { if (i <= Math.floor(rating)) { - starsHtml += ``; + starsHtml += ``; } else if (i === Math.ceil(rating) && rating % 1 !== 0) { - starsHtml += ``; + starsHtml += ``; } else { - starsHtml += ``; + starsHtml += ``; } } + // The star glyphs convey nothing to assistive tech on their own -- wrap + // in role="img" with a textual equivalent, and hide the decorative glyphs + // themselves (aria-hidden above) so AT doesn't announce each icon. return { - html: `${starsHtml}`, + html: `${starsHtml}`, }; }; diff --git a/craft/src/components/forms/ContactForm.toHtml.test.ts b/craft/src/components/forms/ContactForm.toHtml.test.ts index e64dd22..8f0f7be 100644 --- a/craft/src/components/forms/ContactForm.toHtml.test.ts +++ b/craft/src/components/forms/ContactForm.toHtml.test.ts @@ -63,3 +63,28 @@ describe('ContactForm.toHtml successMessage', () => { expect(html).not.toContain('onerror="alert(1)"'); }); }); + +describe('ContactForm.toHtml accessibility (F2.1)', () => { + const fields = [ + { type: 'text' as const, label: 'Name', name: 'name', placeholder: 'Your name', required: true }, + { type: 'email' as const, label: 'Email', name: 'email', placeholder: 'you@example.com', required: true }, + ]; + + test('each field label for= matches its control id=, and ids are unique', () => { + const { html } = toHtml({ fields }, ''); + const labelIds = [...html.matchAll(/