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:
@@ -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(/<label for="([^"]+)"/g)].map((m) => m[1]);
|
||||
const controlIds = [...html.matchAll(/<(?:input|textarea|select) id="([^"]+)"/g)].map((m) => m[1]);
|
||||
expect(labelIds.length).toBe(2);
|
||||
expect(controlIds.length).toBe(2);
|
||||
expect(labelIds).toEqual(controlIds);
|
||||
expect(new Set(controlIds).size).toBe(2);
|
||||
});
|
||||
|
||||
test('ids are deterministic across repeated calls with the same fields', () => {
|
||||
const { html: html1 } = toHtml({ fields }, '');
|
||||
const { html: html2 } = toHtml({ fields }, '');
|
||||
const ids1 = [...html1.matchAll(/<input id="([^"]+)"/g)].map((m) => m[1]);
|
||||
const ids2 = [...html2.matchAll(/<input id="([^"]+)"/g)].map((m) => m[1]);
|
||||
expect(ids1).toEqual(ids2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||
import { escapeHtml, escapeAttr, slugId } from '../../utils/escape';
|
||||
|
||||
interface ContactFormField {
|
||||
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
|
||||
@@ -180,18 +180,21 @@ ContactForm.craft = {
|
||||
const inputBorder = props.inputBorder || '#d1d5db';
|
||||
const inputStyleStr = `width:100%;padding:10px 14px;font-size:14px;font-family:Inter,sans-serif;border:1px solid ${inputBorder};border-radius:6px;background-color:${inputBg};color:#1f2937;box-sizing:border-box;outline:none`;
|
||||
|
||||
const fieldsHtml = (props.fields || defaultFields).map((field) => {
|
||||
const fieldsHtml = (props.fields || defaultFields).map((field, i) => {
|
||||
const reqStar = field.required ? '<span style="color:#ef4444;margin-left:2px">*</span>' : '';
|
||||
const labelHtml = `<label style="font-size:14px;font-weight:500;color:${labelColor}">${escapeHtml(field.label)}${reqStar}</label>`;
|
||||
// Deterministic id: index + slugified name, so repeated fields with the
|
||||
// same name (or no name) still get unique, stable ids -- no Math.random.
|
||||
const fieldId = `field-${i}-${slugId(field.name)}`;
|
||||
const labelHtml = `<label for="${escapeAttr(fieldId)}" style="font-size:14px;font-weight:500;color:${labelColor}">${escapeHtml(field.label)}${reqStar}</label>`;
|
||||
const reqAttr = field.required ? ' required' : '';
|
||||
let inputHtml = '';
|
||||
if (field.type === 'textarea') {
|
||||
inputHtml = `<textarea name="${escapeAttr(field.name)}" placeholder="${escapeAttr(field.placeholder)}" rows="4" style="${inputStyleStr};resize:vertical"${reqAttr}></textarea>`;
|
||||
inputHtml = `<textarea id="${escapeAttr(fieldId)}" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(field.placeholder)}" rows="4" style="${inputStyleStr};resize:vertical"${reqAttr}></textarea>`;
|
||||
} else if (field.type === 'select') {
|
||||
const opts = (field.options || []).map((o) => `<option value="${escapeAttr(o)}">${escapeHtml(o)}</option>`).join('');
|
||||
inputHtml = `<select name="${escapeAttr(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${escapeHtml(field.placeholder || 'Select...')}</option>${opts}</select>`;
|
||||
inputHtml = `<select id="${escapeAttr(fieldId)}" name="${escapeAttr(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${escapeHtml(field.placeholder || 'Select...')}</option>${opts}</select>`;
|
||||
} else {
|
||||
inputHtml = `<input type="${field.type}" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(field.placeholder)}" style="${inputStyleStr}"${reqAttr} />`;
|
||||
inputHtml = `<input id="${escapeAttr(fieldId)}" type="${field.type}" name="${escapeAttr(field.name)}" placeholder="${escapeAttr(field.placeholder)}" style="${inputStyleStr}"${reqAttr} />`;
|
||||
}
|
||||
return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}${inputHtml}</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { InputField } from './InputField';
|
||||
|
||||
const toHtml = (InputField as any).toHtml;
|
||||
|
||||
describe('InputField.toHtml accessibility (F2.1)', () => {
|
||||
test('label for= matches input id=', () => {
|
||||
const { html } = toHtml({ label: 'Your Name', name: 'name' }, '');
|
||||
const forMatch = html.match(/<label for="([^"]+)"/);
|
||||
const idMatch = html.match(/<input id="([^"]+)"/);
|
||||
expect(forMatch).toBeTruthy();
|
||||
expect(idMatch).toBeTruthy();
|
||||
expect(forMatch![1]).toBe(idMatch![1]);
|
||||
});
|
||||
|
||||
test('id is deterministic (derived from name, not random) -- stable across calls', () => {
|
||||
const { html: html1 } = toHtml({ label: 'Email', name: 'email' }, '');
|
||||
const { html: html2 } = toHtml({ label: 'Email', name: 'email' }, '');
|
||||
const id1 = html1.match(/<input id="([^"]+)"/)![1];
|
||||
const id2 = html2.match(/<input id="([^"]+)"/)![1];
|
||||
expect(id1).toBe(id2);
|
||||
});
|
||||
|
||||
test('no visible label: input gets aria-label from placeholder', () => {
|
||||
const { html } = toHtml({ label: '', name: 'phone', placeholder: 'Phone number' }, '');
|
||||
expect(html).not.toContain('<label');
|
||||
expect(html).toContain('aria-label="Phone number"');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||
import { escapeHtml, escapeAttr, slugId } from '../../utils/escape';
|
||||
|
||||
interface InputFieldProps {
|
||||
label?: string;
|
||||
@@ -95,13 +95,19 @@ InputField.craft = {
|
||||
...props.style,
|
||||
});
|
||||
const reqAttr = props.required ? ' required' : '';
|
||||
// Deterministic id derived from the field name (pure function of props,
|
||||
// no Math.random) so the <label for> always matches the <input id>.
|
||||
const fieldId = 'field-' + slugId(props.name, 'field');
|
||||
const labelHtml = props.label
|
||||
? `<label style="font-size:14px;font-weight:500;color:#18181b">${escapeHtml(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
|
||||
? `<label for="${escapeAttr(fieldId)}" style="font-size:14px;font-weight:500;color:#18181b">${escapeHtml(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
|
||||
: '';
|
||||
const ariaLabelAttr = !props.label
|
||||
? ` aria-label="${escapeAttr(props.placeholder || props.name || 'Input field')}"`
|
||||
: '';
|
||||
return {
|
||||
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
|
||||
${labelHtml}
|
||||
<input type="${props.type || 'text'}" name="${escapeAttr(props.name || 'field')}" placeholder="${escapeAttr(props.placeholder || '')}"${reqAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box" />
|
||||
<input id="${escapeAttr(fieldId)}" type="${props.type || 'text'}" name="${escapeAttr(props.name || 'field')}" placeholder="${escapeAttr(props.placeholder || '')}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box" />
|
||||
</div>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { TextareaField } from './TextareaField';
|
||||
|
||||
const toHtml = (TextareaField as any).toHtml;
|
||||
|
||||
describe('TextareaField.toHtml accessibility (F2.1)', () => {
|
||||
test('label for= matches textarea id=', () => {
|
||||
const { html } = toHtml({ label: 'Message', name: 'message' }, '');
|
||||
const forMatch = html.match(/<label for="([^"]+)"/);
|
||||
const idMatch = html.match(/<textarea id="([^"]+)"/);
|
||||
expect(forMatch).toBeTruthy();
|
||||
expect(idMatch).toBeTruthy();
|
||||
expect(forMatch![1]).toBe(idMatch![1]);
|
||||
});
|
||||
|
||||
test('no visible label: textarea gets aria-label from placeholder', () => {
|
||||
const { html } = toHtml({ label: '', name: 'notes', placeholder: 'Anything else?' }, '');
|
||||
expect(html).not.toContain('<label');
|
||||
expect(html).toContain('aria-label="Anything else?"');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr } from '../../utils/escape';
|
||||
import { escapeHtml, escapeAttr, slugId } from '../../utils/escape';
|
||||
|
||||
interface TextareaFieldProps {
|
||||
label?: string;
|
||||
@@ -97,13 +97,19 @@ TextareaField.craft = {
|
||||
...props.style,
|
||||
});
|
||||
const reqAttr = props.required ? ' required' : '';
|
||||
// Deterministic id derived from the field name (pure function of props,
|
||||
// no Math.random) so the <label for> always matches the <textarea id>.
|
||||
const fieldId = 'field-' + slugId(props.name, 'message');
|
||||
const labelHtml = props.label
|
||||
? `<label style="font-size:14px;font-weight:500;color:#18181b">${escapeHtml(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
|
||||
? `<label for="${escapeAttr(fieldId)}" style="font-size:14px;font-weight:500;color:#18181b">${escapeHtml(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
|
||||
: '';
|
||||
const ariaLabelAttr = !props.label
|
||||
? ` aria-label="${escapeAttr(props.placeholder || props.name || 'Textarea field')}"`
|
||||
: '';
|
||||
return {
|
||||
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
|
||||
${labelHtml}
|
||||
<textarea name="${escapeAttr(props.name || 'message')}" placeholder="${escapeAttr(props.placeholder || '')}" rows="${props.rows || 4}"${reqAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box;resize:vertical;font-family:inherit"></textarea>
|
||||
<textarea id="${escapeAttr(fieldId)}" name="${escapeAttr(props.name || 'message')}" placeholder="${escapeAttr(props.placeholder || '')}" rows="${props.rows || 4}"${reqAttr}${ariaLabelAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box;resize:vertical;font-family:inherit"></textarea>
|
||||
</div>`,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user