9969adca72
- 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>
230 lines
8.2 KiB
TypeScript
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>`,
|
|
};
|
|
};
|