/** * main.js — HER Home Enhancement and Renovation * * Sections: * 1. Navigation — mobile hamburger (focus trap, Escape, ARIA) * 2. Navigation — compact + CTA visibility on scroll * 3. Scroll-triggered fade-up animation (IntersectionObserver) * 4. Quote form — validation, character counter, submission */ (function () { 'use strict'; /* ============================================================ 1. Navigation — Mobile Hamburger ============================================================ */ var navToggle = document.querySelector('.nav__toggle'); var navOverlay = document.querySelector('.nav__overlay'); var navOverlayClose = document.querySelector('.nav__overlay-close'); /** * Returns an array of focusable elements within a container, * in DOM order, skipping anything with tabindex="-1". */ function getFocusable(container) { return Array.from( container.querySelectorAll( 'a[href], button:not([disabled]), input:not([disabled]), ' + 'select:not([disabled]), textarea:not([disabled]), ' + '[tabindex]:not([tabindex="-1"])' ) ); } function openNav() { if (!navOverlay || !navToggle) return; navOverlay.removeAttribute('hidden'); navToggle.setAttribute('aria-expanded', 'true'); navToggle.setAttribute('aria-label', 'Close menu'); document.body.classList.add('nav-open'); // Move focus into overlay — first focusable item var focusable = getFocusable(navOverlay); if (focusable.length) focusable[0].focus(); } function closeNav() { if (!navOverlay || !navToggle) return; navOverlay.setAttribute('hidden', ''); navToggle.setAttribute('aria-expanded', 'false'); navToggle.setAttribute('aria-label', 'Open menu'); document.body.classList.remove('nav-open'); navToggle.focus(); } if (navToggle) { navToggle.addEventListener('click', function () { if (navToggle.getAttribute('aria-expanded') === 'true') { closeNav(); } else { openNav(); } }); } if (navOverlayClose) { navOverlayClose.addEventListener('click', closeNav); } if (navOverlay) { // Close when a nav link inside the overlay is clicked navOverlay.addEventListener('click', function (e) { if (e.target && e.target.tagName === 'A') { closeNav(); } }); } // Keyboard handling: focus trap (Tab/Shift-Tab) and Escape close document.addEventListener('keydown', function (e) { if (!navOverlay || navOverlay.hasAttribute('hidden')) return; if (e.key === 'Escape') { closeNav(); return; } if (e.key === 'Tab') { var focusable = getFocusable(navOverlay); if (!focusable.length) { e.preventDefault(); return; } var first = focusable[0]; var last = focusable[focusable.length - 1]; if (e.shiftKey) { // Shift-Tab on first → wrap to last if (document.activeElement === first) { e.preventDefault(); last.focus(); } } else { // Tab on last → wrap to first if (document.activeElement === last) { e.preventDefault(); first.focus(); } } } }); /* ============================================================ 2. Navigation — Compact on Scroll ============================================================ */ var navEl = document.querySelector('.nav'); // #nav-cta starts with style="display:none" in HTML; we manage via style.display var navCta = document.getElementById('nav-cta'); function handleNavScroll() { var y = window.scrollY; if (navEl) { if (y > 80) { navEl.classList.add('nav--compact'); } else { navEl.classList.remove('nav--compact'); } } if (navCta) { // Remove the inline display:none to show; restore it to hide navCta.style.display = y > 400 ? '' : 'none'; } } window.addEventListener('scroll', handleNavScroll, { passive: true }); // Apply immediately so state is correct before any scroll occurs handleNavScroll(); /* ============================================================ 3. Scroll-triggered Fade-Up Animation ============================================================ */ (function initFadeUp() { // Respect user's motion preference — CSS already handles the fallback if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; if (!('IntersectionObserver' in window)) return; var fadeEls = document.querySelectorAll('.fade-up'); if (!fadeEls.length) return; var observer = new IntersectionObserver(function (entries) { entries.forEach(function (entry) { if (entry.isIntersecting) { entry.target.classList.add('is-visible'); observer.unobserve(entry.target); // one-shot — never toggle back } }); }, { threshold: 0.15 }); fadeEls.forEach(function (el) { observer.observe(el); }); }()); /* ============================================================ 4. Quote Form ============================================================ */ (function initQuoteForm() { /** POST destination — replace with your API Gateway invoke URL */ var FORM_ENDPOINT = 'REPLACE_WITH_API_GATEWAY_URL'; var form = document.getElementById('quote-form'); if (!form) return; // form not present on this page — bail out // ── Element references ──────────────────────────────────── var honeypot = form.querySelector('input[name="website"]'); var errorBanner = form.querySelector('.form-error-banner'); var successPanel = form.querySelector('.form-success'); var submitBtn = form.querySelector('.form__submit'); var charCountEl = document.getElementById('description-char-count'); var descriptionEl = document.getElementById('description'); var FIELD_IDS = ['name', 'address', 'phone', 'email', 'description']; // Save original submit button label so we can restore it after an error var originalBtnText = submitBtn ? (submitBtn.textContent.trim() || 'Send Request') : 'Send Request'; // Ensure the error banner is focusable (needed for focus management) if (errorBanner && !errorBanner.hasAttribute('tabindex')) { errorBanner.setAttribute('tabindex', '-1'); } // ── Validation rules ────────────────────────────────────── var RULES = { name: function (val) { return val.trim().length >= 2 ? null : 'Please enter your name.'; }, address: function (val) { return val.trim().length >= 3 ? null : 'Please enter the service address or city.'; }, phone: function (val) { return /^[+\d][\d\s\-().]{6,}$/.test(val.trim()) ? null : 'Please enter a valid phone number.'; }, email: function (val) { var v = val.trim(); return (v.indexOf('@') !== -1 && v.indexOf('.') !== -1) ? null : 'Please enter a valid email address.'; }, description: function (val) { return val.trim().length >= 10 ? null : 'Please describe your project (at least 10 characters).'; } }; // ── Helper: show / clear individual field errors ────────── /** * Mark a field as invalid and display its error message. * @param {string} fieldId - The field's id attribute value * @param {string} message - The error text to display */ function showError(fieldId, message) { var field = document.getElementById(fieldId); var errorEl = document.getElementById(fieldId + '-error'); if (field) field.setAttribute('aria-invalid', 'true'); if (errorEl) { errorEl.textContent = message; errorEl.removeAttribute('hidden'); } } /** * Mark a field as valid and hide its error message. * @param {string} fieldId - The field's id attribute value */ function clearError(fieldId) { var field = document.getElementById(fieldId); var errorEl = document.getElementById(fieldId + '-error'); if (field) field.setAttribute('aria-invalid', 'false'); if (errorEl) { errorEl.textContent = ''; errorEl.setAttribute('hidden', ''); } } // ── Helper: validate one field ──────────────────────────── /** * Run validation for a single field. Shows or clears its error. * @param {string} fieldId * @returns {boolean} true if valid */ function validateField(fieldId) { var field = document.getElementById(fieldId); if (!field) return true; // element absent — treat as valid var rule = RULES[fieldId]; if (!rule) return true; var errorMsg = rule(field.value); if (errorMsg) { showError(fieldId, errorMsg); return false; } clearError(fieldId); return true; } // ── Helper: validate all fields ─────────────────────────── /** * Run validation on every form field. Does NOT short-circuit — * all fields are checked so every error is visible at once. * @returns {boolean} true only if every field is valid */ function validateAll() { var allValid = true; FIELD_IDS.forEach(function (id) { if (!validateField(id)) allValid = false; }); return allValid; } // ── Blur-time validation ────────────────────────────────── FIELD_IDS.forEach(function (id) { var field = document.getElementById(id); if (field) { field.addEventListener('blur', function () { validateField(id); }); } }); // ── Character counter ───────────────────────────────────── if (descriptionEl && charCountEl) { // Set initial display charCountEl.textContent = '0 / 2000'; descriptionEl.addEventListener('input', function () { var len = descriptionEl.value.length; charCountEl.textContent = len + ' / 2000'; // classList.toggle(cls, force) is supported in all modern browsers charCountEl.classList.toggle('warn', len > 1800); }); } // ── Banner helpers ──────────────────────────────────────── /** * Display the error banner with a given message and move focus to it. * @param {string} message */ function showBannerError(message) { if (!errorBanner) return; // Update the inner

if present to preserve markup; else use textContent var bannerP = errorBanner.querySelector('p'); if (bannerP) { bannerP.textContent = message; } else { errorBanner.textContent = message; } errorBanner.removeAttribute('hidden'); errorBanner.focus(); } function hideBanner() { if (!errorBanner) return; errorBanner.setAttribute('hidden', ''); var bannerP = errorBanner.querySelector('p'); if (bannerP) { bannerP.textContent = ''; } else { errorBanner.textContent = ''; } } // ── Success display ─────────────────────────────────────── /** * Hide all form content except the success panel, then show the * success panel and move focus to its title. * (.form-success lives inside

in the HTML, so we cannot * simply hide the element — instead we hide its siblings.) */ function showSuccess() { // Hide every direct child of the form except .form-success Array.from(form.children).forEach(function (child) { if (!child.classList.contains('form-success')) { child.setAttribute('hidden', ''); } }); if (successPanel) { successPanel.removeAttribute('hidden'); var title = successPanel.querySelector('.form-success__title'); if (title) { // Ensure the heading is programmatically focusable if (!title.hasAttribute('tabindex')) { title.setAttribute('tabindex', '-1'); } title.focus(); } } } // ── Form state helpers ──────────────────────────────────── var allInputs; // captured at submit time, used in onError function disableForm() { allInputs = form.querySelectorAll('input, textarea, select, button'); allInputs.forEach(function (el) { el.disabled = true; }); if (submitBtn) submitBtn.textContent = 'Sending\u2026'; // "Sending…" form.setAttribute('aria-busy', 'true'); } function enableForm() { if (allInputs) { allInputs.forEach(function (el) { el.disabled = false; }); } if (submitBtn) submitBtn.textContent = originalBtnText; form.removeAttribute('aria-busy'); } // ── Submit handler ──────────────────────────────────────── form.addEventListener('submit', function (e) { e.preventDefault(); // Reset error banner from any previous attempt hideBanner(); // 1. Validate — show all errors simultaneously var isValid = validateAll(); if (!isValid) { showBannerError('Please correct the errors above before submitting.'); return; } // 2. Honeypot check — silently fake success so bots see no difference if (honeypot && honeypot.value) { showSuccess(); return; } // 3. Lock the form while the request is in flight disableForm(); // 4. Build the JSON payload var payload = { name: (document.getElementById('name') || { value: '' }).value, address: (document.getElementById('address') || { value: '' }).value, phone: (document.getElementById('phone') || { value: '' }).value, email: (document.getElementById('email') || { value: '' }).value, description: (document.getElementById('description') || { value: '' }).value, honeypot: honeypot ? honeypot.value : '' }; // 5. 10-second hard timeout via AbortController var controller = new AbortController(); var abortTimer = setTimeout(function () { controller.abort(); }, 10000); // 6. POST to the API fetch(FORM_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), signal: controller.signal }) .then(function (response) { // Treat any non-2xx HTTP status as an error if (!response.ok) { throw new Error('HTTP error ' + response.status); } return response.json(); }) .then(function (data) { clearTimeout(abortTimer); // Some API Gateway patterns return { ok: false } with HTTP 200 if (data && data.ok === false) { throw new Error(data.message || 'Server returned an error.'); } // 7. Success path form.removeAttribute('aria-busy'); // clear before showSuccess hides children showSuccess(); }) .catch(function () { clearTimeout(abortTimer); // 8. Error path — re-enable form and surface the error enableForm(); showBannerError( 'Something went wrong sending your request. ' + 'Please try again, or contact Diana directly.' ); }); }); }()); // end initQuoteForm }()); // end top-level IIFE