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
parent 9b532e36e8
commit 9969adca72
21 changed files with 255 additions and 27 deletions
@@ -0,0 +1,16 @@
import { describe, test, expect } from 'vitest';
import { MapEmbed } from './MapEmbed';
const toHtml = (MapEmbed as any).toHtml;
describe('MapEmbed.toHtml iframe accessibility (F2.4)', () => {
test('iframe has a non-empty title attribute', () => {
const { html } = toHtml({ address: 'New York, NY' }, '');
expect(html).toMatch(/<iframe[^>]*title="[^"]+"/);
});
test('title reflects the configured address', () => {
const { html } = toHtml({ address: 'Golden Gate Bridge' }, '');
expect(html).toContain('title="Map of Golden Gate Bridge"');
});
});
+1 -1
View File
@@ -93,6 +93,6 @@ MapEmbed.craft = {
const src = buildMapUrl(address, zoom);
return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><iframe src="${escapeAttr(safeUrl(src))}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div>`,
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><iframe src="${escapeAttr(safeUrl(src))}" title="${escapeAttr(`Map of ${address}`)}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div>`,
};
};
@@ -59,3 +59,15 @@ describe('VideoBlock URL parsing (D4)', () => {
expect(html).not.toContain('javascript:');
});
});
describe('VideoBlock.toHtml iframe accessibility (F2.4)', () => {
test('normal-mode YouTube/Vimeo iframe has a title attribute', () => {
const { html } = toHtml({ videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' }, '');
expect(html).toMatch(/<iframe[^>]*title="[^"]+"/);
});
test('background-mode YouTube/Vimeo iframe has a title attribute', () => {
const { html } = toHtml({ videoUrl: 'https://vimeo.com/123456789', isBackground: true }, '');
expect(html).toMatch(/<iframe[^>]*title="[^"]+"/);
});
});
+2 -2
View File
@@ -403,7 +403,7 @@ VideoBlock.craft = {
zIndex: '0',
pointerEvents: 'none',
});
videoHtml = `<iframe src="${escapeAttr(safeUrl(iframeSrc))}" allow="autoplay; encrypted-media" allowfullscreen${ifrStyle ? ` style="${ifrStyle}"` : ''}></iframe>`;
videoHtml = `<iframe src="${escapeAttr(safeUrl(iframeSrc))}" title="Embedded video" allow="autoplay; encrypted-media" allowfullscreen${ifrStyle ? ` style="${ifrStyle}"` : ''}></iframe>`;
}
return {
@@ -436,7 +436,7 @@ VideoBlock.craft = {
border: 'none',
});
return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><div${containerStyle ? ` style="${containerStyle}"` : ''}><iframe src="${escapeAttr(safeUrl(iframeSrc))}" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div></div>`,
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><div${containerStyle ? ` style="${containerStyle}"` : ''}><iframe src="${escapeAttr(safeUrl(iframeSrc))}" title="Embedded video" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div></div>`,
};
}