Files
site-builder/craft/src/components/forms/ContactForm.tsx
T
shadowdao 9969adca72 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>
2026-07-12 14:19:01 -07:00

230 lines
8.2 KiB
TypeScript

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, slugId } from '../../utils/escape';
interface ContactFormField {
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
label: string;
name: string;
placeholder: string;
required: boolean;
options?: string[];
}
interface ContactFormProps {
fields?: ContactFormField[];
submitText?: string;
submitColor?: string;
formAction?: string;
successMessage?: string;
style?: CSSProperties;
labelColor?: string;
inputBg?: string;
inputBorder?: string;
recipientEmail?: string;
thankYouUrl?: string;
}
const defaultFields: ContactFormField[] = [
{ type: 'text', label: 'Name', name: 'name', placeholder: 'Your name', required: true },
{ type: 'email', label: 'Email', name: 'email', placeholder: 'your@email.com', required: true },
{ type: 'tel', label: 'Phone', name: 'phone', placeholder: '(555) 123-4567', required: false },
{ type: 'textarea', label: 'Message', name: 'message', placeholder: 'How can we help you?', required: true },
];
export const ContactForm: UserComponent<ContactFormProps> = ({
fields = defaultFields,
submitText = 'Send Message',
submitColor = '#3b82f6',
formAction = '#',
successMessage = 'Thank you! We\'ll get back to you soon.',
style = {},
labelColor = '#374151',
inputBg = '#ffffff',
inputBorder = '#d1d5db',
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const inputBaseStyle: CSSProperties = {
width: '100%',
padding: '10px 14px',
fontSize: '14px',
fontFamily: 'Inter, sans-serif',
border: `1px solid ${inputBorder}`,
borderRadius: '6px',
backgroundColor: inputBg,
color: '#1f2937',
boxSizing: 'border-box',
outline: 'none',
};
return (
<form
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
action={formAction}
method="POST"
onSubmit={(e) => e.preventDefault()}
style={{
padding: '32px',
display: 'flex',
flexDirection: 'column',
gap: '20px',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{fields.map((field, i) => (
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<label style={{ fontSize: '14px', fontWeight: '500', color: labelColor }}>
{field.label}
{field.required && <span style={{ color: '#ef4444', marginLeft: '2px' }}>*</span>}
</label>
{field.type === 'textarea' ? (
<textarea
name={field.name}
placeholder={field.placeholder}
required={field.required}
rows={4}
style={{ ...inputBaseStyle, resize: 'vertical' }}
/>
) : field.type === 'select' ? (
<select
name={field.name}
required={field.required}
style={{ ...inputBaseStyle, cursor: 'pointer' }}
>
<option value="">{field.placeholder || 'Select...'}</option>
{(field.options || []).map((opt, j) => (
<option key={j} value={opt}>{opt}</option>
))}
</select>
) : (
<input
type={field.type}
name={field.name}
placeholder={field.placeholder}
required={field.required}
style={inputBaseStyle}
/>
)}
</div>
))}
<button
type="submit"
style={{
padding: '12px 32px',
fontSize: '16px',
fontWeight: '600',
fontFamily: 'Inter, sans-serif',
color: '#ffffff',
backgroundColor: submitColor,
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
alignSelf: 'flex-start',
}}
>
{submitText}
</button>
</form>
);
};
/* ---------- Craft config ---------- */
ContactForm.craft = {
displayName: 'Contact Form',
props: {
fields: defaultFields,
submitText: 'Send Message',
submitColor: '#3b82f6',
formAction: '#',
successMessage: 'Thank you! We\'ll get back to you soon.',
style: {
backgroundColor: '#ffffff',
borderRadius: '12px',
border: '1px solid #e5e7eb',
},
labelColor: '#374151',
inputBg: '#ffffff',
inputBorder: '#d1d5db',
recipientEmail: '',
thankYouUrl: '',
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
};
/* ---------- HTML export ---------- */
(ContactForm as any).toHtml = (props: ContactFormProps, _childrenHtml: string) => {
const formStyle = cssPropsToString({
padding: '32px',
display: 'flex',
flexDirection: 'column',
gap: '20px',
...props.style,
});
const labelColor = props.labelColor || '#374151';
const inputBg = props.inputBg || '#ffffff';
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, i) => {
const reqStar = field.required ? '<span style="color:#ef4444;margin-left:2px">*</span>' : '';
// 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 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 id="${escapeAttr(fieldId)}" name="${escapeAttr(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${escapeHtml(field.placeholder || 'Select...')}</option>${opts}</select>`;
} else {
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 ');
const btnStyle = cssPropsToString({
padding: '12px 32px',
fontSize: '16px',
fontWeight: '600',
fontFamily: 'Inter, sans-serif',
color: '#ffffff',
backgroundColor: props.submitColor || '#3b82f6',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
alignSelf: 'flex-start',
});
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.formAction);
// The form-sender relay delivers success via a full-page 303 redirect
// (to thankYouUrl or a hosted thanks.php page) -- there is no in-page JS
// that reveals an inline success element today. Emit successMessage as a
// forward-compatible data attribute so a future AJAX/JS submission mode
// can read it, without implying a live mechanism that doesn't exist yet.
const successAttr = props.successMessage ? ` data-whp-success-message="${escapeAttr(props.successMessage)}"` : '';
return {
html: `${marker}<form action="${actionAttr}" method="POST"${successAttr}${formStyle ? ` style="${formStyle}"` : ''}>
${honeypot ? ` ${honeypot}\n` : ''}${fieldsHtml ? ` ${fieldsHtml}\n` : ''} <button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${escapeHtml(props.submitText || 'Send Message')}</button>
</form>`,
};
};