Track _site/ in repo for direct Caddy deployment
This commit is contained in:
479
_site/js/main.js
Normal file
479
_site/js/main.js
Normal file
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* 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 <p> 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 <form> in the HTML, so we cannot
|
||||
* simply hide the <form> 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
|
||||
Reference in New Issue
Block a user