a11y: exported semantics (forms/rating/nav/iframe/icons)

- InputField/TextareaField/ContactForm: every control gets a
  deterministic id (slugId() in utils/escape.ts, derived from the
  field's name/label + index for ContactForm's looped fields -- no
  Math.random) with a matching <label for=>; fields with no visible
  label get an aria-label from the placeholder/name instead.
- StarRating: wrapped in role="img" aria-label="Rating: N out of M",
  individual star glyphs marked aria-hidden.
- Navbar: the mobile hamburger toggle gets aria-label="Toggle
  navigation menu", aria-controls="navbar-links", and aria-expanded
  wired to flip true/false in the inline onclick handler.
- VideoBlock and MapEmbed: every exported <iframe> gets a title
  (generic "Embedded video", or "Map of {address}" for MapEmbed).
- Decorative Font Awesome icons (ContentSlider arrows already covered
  in the prior commit; SocialLinks, SearchBar, Testimonials stars) are
  aria-hidden; SocialLinks' icon-only links get an aria-label naming
  the platform alongside the existing title tooltip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 14:19:01 -07:00
co-authored by Claude Opus 4.8
parent 9b532e36e8
commit 9969adca72
21 changed files with 255 additions and 27 deletions
@@ -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');
});
});
+6 -3
View File
@@ -264,9 +264,12 @@ Navbar.craft = {
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
}).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 <button class="navbar-hamburger" onclick="this.parentElement.querySelector('.navbar-links').classList.toggle('navbar-open')" style="display:none;background:none;border:none;cursor:pointer;padding:4px;flex-direction:column;gap:4px">
? `\n <button class="navbar-hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="navbar-links" onclick="var m=this.parentElement.querySelector('.navbar-links');var open=m.classList.toggle('navbar-open');this.setAttribute('aria-expanded', open ? 'true' : 'false');" style="display:none;background:none;border:none;cursor:pointer;padding:4px;flex-direction:column;gap:4px">
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
@@ -305,7 +308,7 @@ Navbar.craft = {
html: `${hoverCss}
<nav${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}>
${logoHtml}${hamburgerHtml}
<div class="navbar-links" style="display:flex;align-items:center;gap:24px">
<div class="navbar-links" id="navbar-links" style="display:flex;align-items:center;gap:24px">
${linksHtmlWithClass}
</div>
</nav>`,
@@ -0,0 +1,13 @@
import { describe, test, expect } from 'vitest';
import { SearchBar } from './SearchBar';
const toHtml = (SearchBar as any).toHtml;
describe('SearchBar.toHtml decorative icons (F2.5)', () => {
test('the input-adjacent search icon is aria-hidden', () => {
const { html } = toHtml({}, '');
const icons = html.match(/<i class="fa fa-search"[^>]*>/g) || [];
expect(icons.length).toBeGreaterThan(0);
icons.forEach((tag: string) => expect(tag).toContain('aria-hidden="true"'));
});
});
+2 -2
View File
@@ -130,13 +130,13 @@ SearchBar.craft = {
const inputStyleStr = `width:100%;padding:12px 16px 12px 40px;font-size:15px;font-family:Inter,sans-serif;border:1px solid #d1d5db;border-radius:${showButton ? '8px 0 0 8px' : '8px'};background-color:#ffffff;color:#1f2937;outline:none;box-sizing:border-box`;
const btnHtml = showButton
? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:#ffffff;background-color:#3b82f6;border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa fa-search" style="font-size:13px"></i>${escapeHtml(buttonText)}</button>`
? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:#ffffff;background-color:#3b82f6;border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa fa-search" style="font-size:13px" aria-hidden="true"></i>${escapeHtml(buttonText)}</button>`
: '';
return {
html: `<form role="search"${formStyle ? ` style="${formStyle}"` : ''}>
<div style="position:relative;flex:1">
<i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:14px;pointer-events:none"></i>
<i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:14px;pointer-events:none" aria-hidden="true"></i>
<input type="search" placeholder="${escapeAttr(placeholder)}" style="${inputStyleStr}" />
</div>
${btnHtml}
@@ -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(/<a[^>]*aria-label="Facebook"/);
});
test('the icon glyph itself is aria-hidden', () => {
const { html } = toHtml({ links: [{ platform: 'twitter', url: '#' }] }, '');
expect(html).toMatch(/<i class="fa fa-twitter"[^>]*aria-hidden="true"/);
});
});
+5 -1
View File
@@ -204,7 +204,11 @@ SocialLinks.craft = {
if (hasBg) {
aStyle += `;${getShapeStr()}`;
}
return `<a href="${escapeAttr(safeUrl(link.url || '#'))}" target="_blank" rel="noopener noreferrer" title="${escapeAttr(title)}" style="${aStyle}"><i class="fa ${escapeAttr(iconClass)}" style="font-size:${iconSize}"></i></a>`;
// 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 `<a href="${escapeAttr(safeUrl(link.url || '#'))}" target="_blank" rel="noopener noreferrer" title="${escapeAttr(title)}" aria-label="${escapeAttr(title)}" style="${aStyle}"><i class="fa ${escapeAttr(iconClass)}" style="font-size:${iconSize}" aria-hidden="true"></i></a>`;
}).join('\n ');
return {
@@ -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(/<span role="img" aria-label="Rating: 4\.5 out of 5"/);
});
test('individual star glyphs are aria-hidden', () => {
const { html } = toHtml({ rating: 3, maxStars: 5 }, '');
const glyphs = html.match(/<i class="fa fa-star"[^>]*>/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"');
});
});
+7 -4
View File
@@ -112,15 +112,18 @@ StarRating.craft = {
let starsHtml = '';
for (let i = 1; i <= maxStars; i++) {
if (i <= Math.floor(rating)) {
starsHtml += `<i class="fa fa-star" style="color:${filledColor};font-size:${size}"></i>`;
starsHtml += `<i class="fa fa-star" style="color:${filledColor};font-size:${size}" aria-hidden="true"></i>`;
} else if (i === Math.ceil(rating) && rating % 1 !== 0) {
starsHtml += `<span style="position:relative;display:inline-block;font-size:${size}"><i class="fa fa-star" style="color:${emptyColor}"></i><span style="position:absolute;left:0;top:0;overflow:hidden;width:50%"><i class="fa fa-star" style="color:${filledColor}"></i></span></span>`;
starsHtml += `<span style="position:relative;display:inline-block;font-size:${size}" aria-hidden="true"><i class="fa fa-star" style="color:${emptyColor}"></i><span style="position:absolute;left:0;top:0;overflow:hidden;width:50%"><i class="fa fa-star" style="color:${filledColor}"></i></span></span>`;
} else {
starsHtml += `<i class="fa fa-star" style="color:${emptyColor};font-size:${size}"></i>`;
starsHtml += `<i class="fa fa-star" style="color:${emptyColor};font-size:${size}" aria-hidden="true"></i>`;
}
}
// 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: `<span${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`,
html: `<span role="img" aria-label="Rating: ${rating} out of ${maxStars}"${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`,
};
};