mirror of
https://git.lindalindsay.org/admin/linda.lindsay.changemaker.git
synced 2026-05-18 23:36:24 -06:00
463 lines
14 KiB
JavaScript
463 lines
14 KiB
JavaScript
/* Linda Lindsay for Ward B School Trustee - Modern Website JavaScript */
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
// DOM Content Loaded Event
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
initMobileNavigation();
|
|
initSmoothScrolling();
|
|
initFormValidation();
|
|
initImageLazyLoading();
|
|
initAccessibility();
|
|
initAnalytics();
|
|
});
|
|
|
|
/**
|
|
* Mobile Navigation
|
|
*/
|
|
function initMobileNavigation() {
|
|
const mobileToggle = document.querySelector('.mobile-nav-toggle');
|
|
const nav = document.querySelector('.nav');
|
|
|
|
if (mobileToggle && nav) {
|
|
mobileToggle.addEventListener('click', function() {
|
|
nav.classList.toggle('active');
|
|
mobileToggle.setAttribute('aria-expanded',
|
|
nav.classList.contains('active') ? 'true' : 'false'
|
|
);
|
|
});
|
|
|
|
// Close mobile nav when clicking outside
|
|
document.addEventListener('click', function(e) {
|
|
if (!mobileToggle.contains(e.target) && !nav.contains(e.target)) {
|
|
nav.classList.remove('active');
|
|
mobileToggle.setAttribute('aria-expanded', 'false');
|
|
}
|
|
});
|
|
|
|
// Close mobile nav on escape key
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape' && nav.classList.contains('active')) {
|
|
nav.classList.remove('active');
|
|
mobileToggle.setAttribute('aria-expanded', 'false');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Smooth Scrolling for Anchor Links
|
|
*/
|
|
function initSmoothScrolling() {
|
|
const anchorLinks = document.querySelectorAll('a[href^="#"]');
|
|
|
|
anchorLinks.forEach(link => {
|
|
link.addEventListener('click', function(e) {
|
|
const href = this.getAttribute('href');
|
|
|
|
if (href === '#') return;
|
|
|
|
const target = document.querySelector(href);
|
|
if (target) {
|
|
e.preventDefault();
|
|
|
|
const headerHeight = document.querySelector('.header')?.offsetHeight || 0;
|
|
const targetPosition = target.offsetTop - headerHeight - 20;
|
|
|
|
window.scrollTo({
|
|
top: targetPosition,
|
|
behavior: 'smooth'
|
|
});
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Form Validation
|
|
*/
|
|
function initFormValidation() {
|
|
const forms = document.querySelectorAll('form');
|
|
|
|
forms.forEach(form => {
|
|
form.addEventListener('submit', function(e) {
|
|
if (!validateForm(this)) {
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
|
|
// Real-time validation
|
|
const inputs = form.querySelectorAll('input, textarea, select');
|
|
inputs.forEach(input => {
|
|
input.addEventListener('blur', function() {
|
|
validateField(this);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Validate Form
|
|
*/
|
|
function validateForm(form) {
|
|
let isValid = true;
|
|
const inputs = form.querySelectorAll('input[required], textarea[required], select[required]');
|
|
|
|
inputs.forEach(input => {
|
|
if (!validateField(input)) {
|
|
isValid = false;
|
|
}
|
|
});
|
|
|
|
return isValid;
|
|
}
|
|
|
|
/**
|
|
* Validate Individual Field
|
|
*/
|
|
function validateField(field) {
|
|
const value = field.value.trim();
|
|
const type = field.type;
|
|
let isValid = true;
|
|
let message = '';
|
|
|
|
// Clear previous errors
|
|
clearFieldError(field);
|
|
|
|
// Required field validation
|
|
if (field.hasAttribute('required') && !value) {
|
|
isValid = false;
|
|
message = 'This field is required.';
|
|
}
|
|
// Email validation
|
|
else if (type === 'email' && value) {
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailRegex.test(value)) {
|
|
isValid = false;
|
|
message = 'Please enter a valid email address.';
|
|
}
|
|
}
|
|
// Phone validation
|
|
else if (type === 'tel' && value) {
|
|
const phoneRegex = /^[\+]?[1-9]?[\d\s\-\(\)]{7,15}$/;
|
|
if (!phoneRegex.test(value)) {
|
|
isValid = false;
|
|
message = 'Please enter a valid phone number.';
|
|
}
|
|
}
|
|
|
|
if (!isValid) {
|
|
showFieldError(field, message);
|
|
}
|
|
|
|
return isValid;
|
|
}
|
|
|
|
/**
|
|
* Show Field Error
|
|
*/
|
|
function showFieldError(field, message) {
|
|
field.classList.add('error');
|
|
|
|
let errorElement = field.parentNode.querySelector('.field-error');
|
|
if (!errorElement) {
|
|
errorElement = document.createElement('div');
|
|
errorElement.className = 'field-error';
|
|
errorElement.style.color = '#dc3545';
|
|
errorElement.style.fontSize = '0.875rem';
|
|
errorElement.style.marginTop = '0.25rem';
|
|
field.parentNode.appendChild(errorElement);
|
|
}
|
|
|
|
errorElement.textContent = message;
|
|
}
|
|
|
|
/**
|
|
* Clear Field Error
|
|
*/
|
|
function clearFieldError(field) {
|
|
field.classList.remove('error');
|
|
const errorElement = field.parentNode.querySelector('.field-error');
|
|
if (errorElement) {
|
|
errorElement.remove();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Image Lazy Loading
|
|
*/
|
|
function initImageLazyLoading() {
|
|
const images = document.querySelectorAll('img[data-src]');
|
|
|
|
if ('IntersectionObserver' in window) {
|
|
const imageObserver = new IntersectionObserver((entries, observer) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
const img = entry.target;
|
|
img.src = img.dataset.src;
|
|
img.classList.remove('lazy');
|
|
imageObserver.unobserve(img);
|
|
}
|
|
});
|
|
});
|
|
|
|
images.forEach(img => imageObserver.observe(img));
|
|
} else {
|
|
// Fallback for browsers without IntersectionObserver
|
|
images.forEach(img => {
|
|
img.src = img.dataset.src;
|
|
img.classList.remove('lazy');
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Accessibility Enhancements
|
|
*/
|
|
function initAccessibility() {
|
|
// Skip to main content link
|
|
const skipLink = document.querySelector('.skip-link');
|
|
if (skipLink) {
|
|
skipLink.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
const main = document.querySelector('main') || document.querySelector('#main');
|
|
if (main) {
|
|
main.focus();
|
|
main.scrollIntoView();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Keyboard navigation for buttons
|
|
const buttons = document.querySelectorAll('button, .btn');
|
|
buttons.forEach(button => {
|
|
button.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
this.click();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Focus management for modals
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Tab') {
|
|
trapFocus(e);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Trap Focus for Modals
|
|
*/
|
|
function trapFocus(e) {
|
|
const modal = document.querySelector('.modal.active');
|
|
if (!modal) return;
|
|
|
|
const focusableElements = modal.querySelectorAll(
|
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
);
|
|
|
|
const firstElement = focusableElements[0];
|
|
const lastElement = focusableElements[focusableElements.length - 1];
|
|
|
|
if (e.shiftKey) {
|
|
if (document.activeElement === firstElement) {
|
|
lastElement.focus();
|
|
e.preventDefault();
|
|
}
|
|
} else {
|
|
if (document.activeElement === lastElement) {
|
|
firstElement.focus();
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Analytics and Tracking
|
|
*/
|
|
function initAnalytics() {
|
|
// Track page views
|
|
trackPageView();
|
|
|
|
// Track button clicks
|
|
const trackingButtons = document.querySelectorAll('[data-track]');
|
|
trackingButtons.forEach(button => {
|
|
button.addEventListener('click', function() {
|
|
const action = this.dataset.track;
|
|
trackEvent('button_click', action);
|
|
});
|
|
});
|
|
|
|
// Track form submissions
|
|
const forms = document.querySelectorAll('form');
|
|
forms.forEach(form => {
|
|
form.addEventListener('submit', function() {
|
|
const formName = this.name || this.id || 'unknown_form';
|
|
trackEvent('form_submit', formName);
|
|
});
|
|
});
|
|
|
|
// Track scroll depth
|
|
let scrollDepth = 0;
|
|
window.addEventListener('scroll', throttle(function() {
|
|
const currentScroll = window.scrollY + window.innerHeight;
|
|
const totalHeight = document.documentElement.scrollHeight;
|
|
const currentDepth = Math.round((currentScroll / totalHeight) * 100);
|
|
|
|
if (currentDepth > scrollDepth && currentDepth % 25 === 0) {
|
|
scrollDepth = currentDepth;
|
|
trackEvent('scroll_depth', `${currentDepth}%`);
|
|
}
|
|
}, 1000));
|
|
}
|
|
|
|
/**
|
|
* Track Page View
|
|
*/
|
|
function trackPageView() {
|
|
const page = window.location.pathname + window.location.search;
|
|
trackEvent('page_view', page);
|
|
}
|
|
|
|
/**
|
|
* Track Event
|
|
*/
|
|
function trackEvent(action, label) {
|
|
// Google Analytics 4 tracking
|
|
if (typeof gtag !== 'undefined') {
|
|
gtag('event', action, {
|
|
event_label: label,
|
|
event_category: 'website_interaction'
|
|
});
|
|
}
|
|
|
|
// Console log for development
|
|
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
|
console.log('Track Event:', action, label);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Throttle Function
|
|
*/
|
|
function throttle(func, limit) {
|
|
let inThrottle;
|
|
return function() {
|
|
const args = arguments;
|
|
const context = this;
|
|
if (!inThrottle) {
|
|
func.apply(context, args);
|
|
inThrottle = true;
|
|
setTimeout(() => inThrottle = false, limit);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Debounce Function
|
|
*/
|
|
function debounce(func, wait, immediate) {
|
|
let timeout;
|
|
return function() {
|
|
const context = this;
|
|
const args = arguments;
|
|
const later = function() {
|
|
timeout = null;
|
|
if (!immediate) func.apply(context, args);
|
|
};
|
|
const callNow = immediate && !timeout;
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
if (callNow) func.apply(context, args);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Utility: Get URL Parameters
|
|
*/
|
|
function getURLParam(param) {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
return urlParams.get(param);
|
|
}
|
|
|
|
/**
|
|
* Utility: Format Phone Number
|
|
*/
|
|
function formatPhoneNumber(phoneNumberString) {
|
|
const cleaned = ('' + phoneNumberString).replace(/\D/g, '');
|
|
const match = cleaned.match(/^(\d{3})(\d{3})(\d{4})$/);
|
|
if (match) {
|
|
return '(' + match[1] + ') ' + match[2] + '-' + match[3];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Utility: Copy to Clipboard
|
|
*/
|
|
function copyToClipboard(text) {
|
|
if (navigator.clipboard) {
|
|
navigator.clipboard.writeText(text).then(function() {
|
|
showNotification('Copied to clipboard!');
|
|
});
|
|
} else {
|
|
// Fallback for older browsers
|
|
const textArea = document.createElement('textarea');
|
|
textArea.value = text;
|
|
document.body.appendChild(textArea);
|
|
textArea.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(textArea);
|
|
showNotification('Copied to clipboard!');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show Notification
|
|
*/
|
|
function showNotification(message, type = 'success') {
|
|
const notification = document.createElement('div');
|
|
notification.className = `notification notification-${type}`;
|
|
notification.textContent = message;
|
|
notification.style.cssText = `
|
|
position: fixed;
|
|
top: 20px;
|
|
right: 20px;
|
|
background: var(--primary-green);
|
|
color: white;
|
|
padding: 1rem 2rem;
|
|
border-radius: var(--border-radius);
|
|
box-shadow: var(--shadow-medium);
|
|
z-index: 10000;
|
|
opacity: 0;
|
|
transition: opacity 0.3s ease;
|
|
`;
|
|
|
|
document.body.appendChild(notification);
|
|
|
|
setTimeout(() => {
|
|
notification.style.opacity = '1';
|
|
}, 10);
|
|
|
|
setTimeout(() => {
|
|
notification.style.opacity = '0';
|
|
setTimeout(() => {
|
|
document.body.removeChild(notification);
|
|
}, 300);
|
|
}, 3000);
|
|
}
|
|
|
|
// Export utility functions to global scope for external use
|
|
window.LindaLindsayUtils = {
|
|
trackEvent,
|
|
getURLParam,
|
|
formatPhoneNumber,
|
|
copyToClipboard,
|
|
showNotification
|
|
};
|
|
|
|
})();
|