/** * 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