@devmansam/forms
Version:
Professional, customizable web form components for contact and inquiry forms
799 lines (686 loc) • 23.8 kB
JavaScript
class ContactForm extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.googleFontLoaded = false;
}
static get observedAttributes() {
return [
'endpoint', 'theme', 'primary-color', 'background-color', 'text-color', 'border-color',
'border-radius', 'font-family', 'font-size', 'google-font', 'success-message',
'error-message', 'input-background-color', 'input-text-color', 'input-border-color',
'heading', 'dark-primary-color', 'dark-background-color', 'dark-text-color', 'dark-border-color',
'dark-input-background-color', 'dark-input-text-color', 'dark-input-border-color',
'button-text-color', 'dark-button-text-color', 'heading-color', 'dark-heading-color',
'asterisk-color', 'dark-asterisk-color'
];
}
connectedCallback() {
// Render the initial structure immediately
this.render(); // This applies initial styles with current fontFamily (might be fallback)
// Load Google Font and then update styles
this.loadGoogleFont().then(() => {
// After Google Font is loaded and googleFontLoaded is true,
// call updateStyles to re-render CSS with the new font.
this.updateStyles();
});
this.loadCleavejs().then(() => {
this.initializePhoneFormatting();
});
this.setupEventListeners();
this.updateTheme();
this.setupThemeWatchers();
}
disconnectedCallback() {
if (this.themeMediaQuery) {
this.themeMediaQuery.removeEventListener('change', this.handleSystemThemeChange);
}
if (this.themeObserver) {
this.themeObserver.disconnect();
}
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
if (name === "theme") {
this.updateTheme();
} else if (name === "google-font") {
// When google-font changes, reset the loaded flag and re-load/update
this.googleFontLoaded = false; // Important: Reset the flag
this.loadGoogleFont().then(() => {
this.updateStyles(); // Re-apply styles after the new font loads
});
} else {
// Re-render styles for other attribute changes
this.updateStyles();
}
}
loadGoogleFont() {
return new Promise((resolve) => {
const googleFont = this.getAttribute('google-font');
if (!googleFont) {
this.googleFontLoaded = false;
resolve();
return;
}
const existingLink = document.head.querySelector(`link[href*="fonts.googleapis.com"][href*="${googleFont.replace(/\s+/g, '+')}"]`);
if (existingLink) {
this.googleFontLoaded = true;
resolve();
return;
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `https://fonts.googleapis.com/css2?family=${googleFont.replace(/\s+/g, '+')}:wght@400;500;600;700&display=swap`;
link.onload = () => {
this.googleFontLoaded = true;
resolve();
};
link.onerror = () => {
console.warn(`Failed to load Google Font: ${googleFont}`);
this.googleFontLoaded = false;
resolve();
};
document.head.appendChild(link);
});
}
setupThemeWatchers() {
this.themeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
this.handleSystemThemeChange = () => {
if (!this.getAttribute('theme')) {
this.updateTheme();
}
};
this.themeMediaQuery.addEventListener('change', this.handleSystemThemeChange);
this.themeObserver = new MutationObserver(() => {
if (!this.getAttribute('theme')) {
this.updateTheme();
}
});
this.themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'class']
});
this.themeObserver.observe(document.body, {
attributes: true,
attributeFilter: ['data-theme', 'class']
});
}
updateTheme() {
const container = this.shadowRoot.querySelector(".contact-form");
if (!container) return;
const explicitTheme = this.getAttribute("theme");
let isDark = false;
if (explicitTheme) {
isDark = explicitTheme === "dark";
} else {
isDark = this.detectDarkMode();
}
if (isDark) {
container.classList.add("dark-mode");
} else {
container.classList.remove("dark-mode");
}
}
detectDarkMode() {
const html = document.documentElement;
const body = document.body;
const dataTheme = html.getAttribute('data-theme') || body.getAttribute('data-theme');
if (dataTheme === 'dark') return true;
if (dataTheme === 'light') return false;
if (html.classList.contains('dark') || body.classList.contains('dark')) return true;
if (html.classList.contains('dark-mode') || body.classList.contains('dark-mode')) return true;
if (html.classList.contains('theme-dark') || body.classList.contains('theme-dark')) return true;
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
getFontFamily() {
const googleFont = this.getAttribute('google-font');
const customFontFamily = this.getAttribute('font-family');
if (googleFont && this.googleFontLoaded) {
return `"${googleFont}", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
} else if (customFontFamily) {
return customFontFamily;
} else {
return '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
}
}
get styles() {
const primaryColor = this.getAttribute('primary-color') || '#3b82f6';
const backgroundColor = this.getAttribute('background-color') || '#ffffff';
const textColor = this.getAttribute('text-color') || '#374151';
const borderColor = this.getAttribute('border-color') || '#d1d5db';
const borderRadius = this.getAttribute('border-radius') || '6px';
const fontFamily = this.getFontFamily();
const fontSize = this.getAttribute('font-size') || '14px';
const inputBackgroundColor = this.getAttribute('input-background-color') || backgroundColor;
const inputTextColor = this.getAttribute('input-text-color') || textColor;
const inputBorderColor = this.getAttribute('input-border-color') || borderColor;
const buttonTextColor = this.getAttribute('button-text-color') || '#ffffff';
const headingColor = this.getAttribute('heading-color') || textColor;
const asteriskColor = this.getAttribute('asterisk-color') || '#ef4444';
const darkPrimaryColor = this.getAttribute('dark-primary-color') || '#60a5fa';
const darkBackgroundColor = this.getAttribute('dark-background-color') || '#1f2937';
const darkTextColor = this.getAttribute('dark-text-color') || '#f9fafb';
const darkBorderColor = this.getAttribute('dark-border-color') || '#4b5563';
const darkInputBackgroundColor = this.getAttribute('dark-input-background-color') || darkBackgroundColor;
const darkInputTextColor = this.getAttribute('dark-input-text-color') || darkTextColor;
const darkInputBorderColor = this.getAttribute('dark-input-border-color') || darkBorderColor;
const darkButtonTextColor = this.getAttribute('dark-button-text-color') || '#ffffff';
const darkHeadingColor = this.getAttribute('dark-heading-color') || darkTextColor;
const darkAsteriskColor = this.getAttribute('dark-asterisk-color') || '#f87171';
return `
:host {
display: block;
font-family: ${fontFamily};
font-size: ${fontSize};
}
.contact-form {
background: ${backgroundColor};
padding: 2rem;
border-radius: ${borderRadius};
max-width: 600px;
margin: 0 auto;
/* Updated: Softer box shadow values */
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
font-family: ${fontFamily};
box-sizing: border-box;
}
.form-heading {
margin: 0 0 1.5rem 0;
padding: 0;
font-size: 1.5em;
font-weight: bold;
color: ${headingColor};
font-family: ${fontFamily};
text-align: center;
letter-spacing: 1px;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: ${textColor};
font-family: ${fontFamily};
letter-spacing: 0.75px;
}
.required::after {
content: " *";
color: ${asteriskColor};
font-weight: bold;
}
input, textarea {
width: 100%;
padding: 0.5rem;
border: 2px solid ${inputBorderColor};
border-radius: ${borderRadius};
font-size: ${fontSize};
font-family: ${fontFamily};
color: ${inputTextColor};
background: ${inputBackgroundColor};
transition: border-color 0.2s ease;
box-sizing: border-box;
letter-spacing: 0.75px;
}
input:focus, textarea:focus {
outline: none;
border-color: ${primaryColor};
}
textarea {
resize: vertical;
min-height: 120px;
}
.name-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.submit-btn {
background: ${primaryColor};
color: ${buttonTextColor};
border: none;
padding: 0.875rem 2rem;
border-radius: ${borderRadius};
font-size: ${fontSize};
font-weight: 500;
font-family: ${fontFamily};
cursor: pointer;
transition: opacity 0.2s ease;
width: 100%;
letter-spacing: 0.75px;
}
.submit-btn:hover:not(:disabled) {
opacity: 0.9;
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.message {
padding: 1rem;
border-radius: ${borderRadius};
margin-bottom: 1rem;
text-align: center;
font-weight: 500;
font-family: ${fontFamily};
letter-spacing: 0.75px;
}
.success {
background: #d1fae5;
color: #065f46;
border: 1px solid #a7f3d0;
}
.error {
background: #fee2e2;
color: #dc2626;
border: 1px solid #fca5a5;
}
.invalid {
border-color: #dc2626 !important;
background-color: rgba(220, 38, 38, 0.05);
}
.valid {
border-color: #16a34a;
background-color: rgba(22, 163, 74, 0.05);
}
[role="alert"] {
color: #dc2626;
font-size: 12px;
margin-top: 4px;
font-weight: 500;
font-family: ${fontFamily};
letter-spacing: 0.75px;
display: none;
}
[role="alert"]:not(:empty) {
display: block;
}
.error-message {
color: #dc2626;
font-size: 12px;
margin-top: 4px;
font-weight: 500;
font-family: ${fontFamily};
letter-spacing: 0.75px;
}
/* Dark Mode */
.contact-form.dark-mode {
background: ${darkBackgroundColor};
color: ${darkTextColor};
}
.dark-mode label {
color: ${darkTextColor};
}
.dark-mode .form-heading {
color: ${darkHeadingColor};
}
.dark-mode .required::after {
color: ${darkAsteriskColor};
}
.dark-mode input,
.dark-mode textarea {
background: ${darkInputBackgroundColor};
color: ${darkInputTextColor};
border-color: ${darkInputBorderColor};
}
.dark-mode input:focus,
.dark-mode textarea:focus {
border-color: ${darkPrimaryColor};
}
.dark-mode .submit-btn {
background: ${darkPrimaryColor};
color: ${darkButtonTextColor};
}
.dark-mode .success {
background: rgba(34, 197, 94, 0.1);
color: #4ade80;
border: 1px solid rgba(34, 197, 94, 0.3);
}
.dark-mode .error {
background: rgba(239, 68, 68, 0.1);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.dark-mode [role="alert"] {
color: #f87171;
}
/* Responsive */
(max-width: 480px) {
.contact-form {
padding: 1.25rem;
}
.form-heading {
font-size: 1.25em;
margin-bottom: 1rem;
}
label {
font-size: 0.875em;
}
input, textarea {
padding: 0.625rem;
font-size: 0.875em;
}
.submit-btn {
padding: 0.75rem 1.5rem;
font-size: 0.875em;
}
.name-row {
grid-template-columns: 1fr;
gap: 0;
}
.form-group {
margin-bottom: 1.25rem;
}
}
`;
}
updateStyles() {
const styleTag = this.shadowRoot.querySelector('style');
if (styleTag) {
styleTag.textContent = this.styles;
}
}
render() {
if (!this.shadowRoot.querySelector('style')) {
const styleTag = document.createElement('style');
this.shadowRoot.appendChild(styleTag);
}
const heading = this.getAttribute('heading');
const headingHtml = heading ? `<h2 class="form-heading" id="form-heading">${heading}</h2>` : '';
this.shadowRoot.innerHTML = `
${this.shadowRoot.querySelector('style').outerHTML}
<form class="contact-form" role="form" aria-label="Contact form${heading ? '' : ''}" ${heading ? 'aria-labelledby="form-heading"' : ''}>
${headingHtml}
<div class="name-row">
<div class="form-group">
<label for="firstName" id="firstName-label" class="required">First Name</label>
<input
type="text"
id="firstName"
name="firstName"
required
aria-required="true"
aria-invalid="false"
aria-labelledby="firstName-label"
aria-describedby="firstName-error">
<div id="firstName-error" role="alert" aria-live="assertive"></div>
</div>
<div class="form-group">
<label for="lastName" id="lastName-label" class="required">Last Name</label>
<input
type="text"
id="lastName"
name="lastName"
required
aria-required="true"
aria-invalid="false"
aria-labelledby="lastName-label"
aria-describedby="lastName-error">
<div id="lastName-error" role="alert" aria-live="assertive"></div>
</div>
</div>
<div class="form-group">
<label for="email" id="email-label" class="required">Email</label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-invalid="false"
aria-labelledby="email-label"
aria-describedby="email-error">
<div id="email-error" role="alert" aria-live="assertive"></div>
</div>
<div class="form-group">
<label for="phone" id="phone-label">Phone</label>
<input
type="tel"
id="phone"
name="phone"
aria-required="false"
aria-invalid="false"
aria-labelledby="phone-label"
aria-describedby="phone-error">
<div id="phone-error" role="alert" aria-live="assertive"></div>
</div>
<div class="form-group">
<label for="message" id="message-label" class="required">Message</label>
<textarea
id="message"
name="message"
required
aria-required="true"
aria-invalid="false"
aria-labelledby="message-label"
aria-describedby="message-error"></textarea>
<div id="message-error" role="alert" aria-live="assertive"></div>
</div>
<div id="message-container" role="status" aria-live="polite" aria-atomic="true"></div>
<button type="submit" class="submit-btn" aria-label="Send message">Send Message</button>
</form>
`;
this.updateStyles();
}
setupEventListeners() {
const form = this.shadowRoot.querySelector('form');
const submitBtn = this.shadowRoot.querySelector('.submit-btn');
this.initializeValidation();
submitBtn.addEventListener('click', (e) => {
e.preventDefault();
this.handleSubmit();
});
}
loadCleavejs() {
return new Promise((resolve, reject) => {
if (window.Cleave) {
resolve();
return;
}
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/cleave.js@1.6.0/dist/cleave.min.js';
script.onload = () => resolve();
script.onerror = () => reject(new Error('Failed to load Cleave.js'));
document.head.appendChild(script);
});
}
initializePhoneFormatting() {
const phoneInput = this.shadowRoot.querySelector('#phone');
if (window.Cleave && phoneInput) {
new window.Cleave(phoneInput, {
numericOnly: true,
blocks: [3, 3, 4],
delimiters: ['-', '-'],
});
}
}
initializeValidation() {
const form = this.shadowRoot.querySelector('form');
// Only select required inputs for initial validation
const inputs = form.querySelectorAll('input, textarea');
inputs.forEach(input => {
input.addEventListener('blur', () => this.validateField(input));
input.addEventListener('input', () => {
if (input.classList.contains('invalid')) {
this.validateField(input);
}
});
});
}
validateField(field) {
this.removeError(field);
const value = field.value.trim();
// Check required fields (phone is no longer required here)
if (field.required && !value) {
this.showError(field, 'This field is required');
return false;
}
if ((field.name === 'firstName' || field.name === 'lastName') && value && value.length < 1) {
this.showError(field, 'Name must be at least 1 character');
return false;
}
if (field.name === 'message' && value && value.length < 1) {
this.showError(field, 'Message must be at least 1 character');
return false;
}
if (field.type === 'email' && value) {
if (!this.isValidEmail(value)) {
this.showError(field, 'Please enter a valid email address');
return false;
}
}
// Phone validation only if a value is provided
if (field.type === 'tel' && value) {
if (!this.isValidPhone(value)) {
this.showError(field, 'Please enter a valid phone number (xxx-xxx-xxxx)');
return false;
}
}
field.classList.add('valid');
field.classList.remove('invalid');
return true;
}
showError(input, message) {
this.removeError(input);
// Update ARIA attributes
input.setAttribute('aria-invalid', 'true');
// Use the pre-existing error div with role="alert"
const errorId = `${input.id}-error`;
const errorDiv = this.shadowRoot.getElementById(errorId);
if (errorDiv) {
errorDiv.textContent = message;
}
input.classList.add('invalid');
input.classList.remove('valid');
}
removeError(input) {
// Update ARIA attributes
input.setAttribute('aria-invalid', 'false');
// Clear the error div
const errorId = `${input.id}-error`;
const errorDiv = this.shadowRoot.getElementById(errorId);
if (errorDiv) {
errorDiv.textContent = '';
}
input.classList.remove('invalid');
}
isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
isValidPhone(phone) {
// This regex will now only apply if a phone number IS entered and needs validation
return /^\d{3}-\d{3}-\d{4}$/.test(phone);
}
validateForm() {
const form = this.shadowRoot.querySelector('form');
// Select only explicitly required inputs for form-wide validation
const inputsToValidate = form.querySelectorAll('input[required], textarea[required]');
let isValid = true;
inputsToValidate.forEach(input => {
if (!this.validateField(input)) {
isValid = false;
}
});
// Manually validate phone if a value is present, even though it's not required
const phoneInput = form.querySelector('#phone');
if (phoneInput && phoneInput.value.trim() !== '') {
if (!this.validateField(phoneInput)) {
isValid = false;
}
}
return isValid;
}
async handleSubmit() {
const endpoint = this.getAttribute('endpoint');
if (!endpoint) {
this.showMessage('No endpoint specified', 'error');
return;
}
if (!this.validateForm()) {
this.showMessage('Please fix the errors above', 'error');
return;
}
const form = this.shadowRoot.querySelector('form');
const formData = new FormData(form);
const submitBtn = this.shadowRoot.querySelector('.submit-btn');
const originalText = submitBtn.textContent;
submitBtn.textContent = 'Sending...';
submitBtn.disabled = true;
const data = {
firstName: formData.get('firstName'),
lastName: formData.get('lastName'),
email: formData.get('email'),
// Set phone to '000-000-0000' if it's empty
phone: formData.get('phone') || '000-000-0000',
message: formData.get('message'),
businessName: `${formData.get('firstName')} ${formData.get('lastName')}`,
businessPhone: '',
businessPhoneExt: '',
businessEmail: '',
businessServices: '',
phoneExt: '',
preferredContact: 'email',
serviceDesired: 'Web Development',
hasWebsite: 'no',
websiteAddress: '',
billingStreet: 'N/A',
billingAptUnit: '',
billingCity: 'N/A',
billingState: 'N/A',
billingZipCode: '00000',
billingCountry: 'USA',
billingAddress: {
street: 'N/A',
aptUnit: '',
city: 'N/A',
state: 'N/A',
zipCode: '00000',
country: 'USA'
},
isFormSubmission: true
};
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
if (response.ok) {
const successMessage = this.getAttribute('success-message') || 'Message sent successfully!';
this.showMessage(successMessage, 'success');
form.reset();
const inputs = form.querySelectorAll('input, textarea');
inputs.forEach(input => {
input.classList.remove('valid', 'invalid');
input.setAttribute('aria-invalid', 'false');
this.removeError(input); // Also remove any error messages
});
} else {
const errorText = await response.text();
console.error('Server response:', response.status, errorText);
throw new Error(`${response.status}: ${errorText}`);
}
} catch (error) {
console.error('Full error:', error);
const errorMessage = this.getAttribute('error-message') || 'Failed to send message. Please try again.';
this.showMessage(errorMessage, 'error');
} finally {
submitBtn.textContent = originalText;
submitBtn.disabled = false;
}
}
showMessage(text, type) {
const container = this.shadowRoot.querySelector('#message-container');
container.innerHTML = `<div class="message ${type}">${text}</div>`;
if (type === 'success') {
setTimeout(() => {
container.innerHTML = '';
}, 5000);
}
}
}
customElements.define('contact-form', ContactForm);