Accessibility fixes and form session expiration fix
- Fix color contrast: change #179e83 to #148069 for WCAG AA compliance - Add ARIA attributes to mobile nav toggle (aria-expanded, aria-controls) - Implement focus trap on mobile menu with Escape key support - Add aria-hidden to decorative hero SVG - Add ARIA validation to contact form (aria-invalid, aria-describedby) - Fix touch target sizes (notification close button 48x48px) - Fix form session expiration by relaxing timestamp validation - Add cache busting (v1.1.0) to JS/CSS files - Update service worker cache version to force refresh Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -60,24 +60,85 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('Rotating text elements not found');
|
||||
}
|
||||
|
||||
// Mobile Navigation Toggle
|
||||
// Mobile Navigation Toggle with ARIA and Focus Trap
|
||||
const navToggle = document.getElementById('nav-toggle');
|
||||
const navMenu = document.getElementById('nav-menu');
|
||||
|
||||
|
||||
if (navToggle && navMenu) {
|
||||
// Get focusable elements in the menu
|
||||
const getFocusableElements = () => {
|
||||
return navMenu.querySelectorAll('a[href], button:not([disabled])');
|
||||
};
|
||||
|
||||
// Focus trap handler
|
||||
const handleFocusTrap = (e) => {
|
||||
if (!navMenu.classList.contains('active')) return;
|
||||
|
||||
const focusableElements = getFocusableElements();
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
if (e.shiftKey && document.activeElement === firstElement) {
|
||||
e.preventDefault();
|
||||
lastElement.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === lastElement) {
|
||||
e.preventDefault();
|
||||
firstElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Close menu on Escape key
|
||||
if (e.key === 'Escape') {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const openMenu = () => {
|
||||
navMenu.classList.add('active');
|
||||
navToggle.classList.add('active');
|
||||
navToggle.setAttribute('aria-expanded', 'true');
|
||||
document.addEventListener('keydown', handleFocusTrap);
|
||||
// Focus first menu item
|
||||
const firstFocusable = getFocusableElements()[0];
|
||||
if (firstFocusable) {
|
||||
setTimeout(() => firstFocusable.focus(), 100);
|
||||
}
|
||||
};
|
||||
|
||||
const closeMenu = () => {
|
||||
navMenu.classList.remove('active');
|
||||
navToggle.classList.remove('active');
|
||||
navToggle.setAttribute('aria-expanded', 'false');
|
||||
document.removeEventListener('keydown', handleFocusTrap);
|
||||
navToggle.focus();
|
||||
};
|
||||
|
||||
navToggle.addEventListener('click', function() {
|
||||
navMenu.classList.toggle('active');
|
||||
navToggle.classList.toggle('active');
|
||||
const isExpanded = navToggle.getAttribute('aria-expanded') === 'true';
|
||||
if (isExpanded) {
|
||||
closeMenu();
|
||||
} else {
|
||||
openMenu();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Close mobile menu when clicking on a link
|
||||
const navLinks = document.querySelectorAll('.nav-link');
|
||||
navLinks.forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
navMenu.classList.remove('active');
|
||||
navToggle.classList.remove('active');
|
||||
closeMenu();
|
||||
});
|
||||
});
|
||||
|
||||
// Close menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (navMenu.classList.contains('active') &&
|
||||
!navMenu.contains(e.target) &&
|
||||
!navToggle.contains(e.target)) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Navbar Scroll Effect
|
||||
@@ -186,52 +247,137 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
timestampField.value = formStartTime;
|
||||
}
|
||||
|
||||
// Form Validation and Enhancement
|
||||
// Form Validation and Enhancement with ARIA support
|
||||
const contactForm = document.querySelector('.contact-form form');
|
||||
|
||||
|
||||
// Helper function to set field error state
|
||||
function setFieldError(fieldId, errorMessage) {
|
||||
const field = document.getElementById(fieldId);
|
||||
const errorSpan = document.getElementById(fieldId + '-error');
|
||||
if (field && errorSpan) {
|
||||
field.setAttribute('aria-invalid', 'true');
|
||||
errorSpan.textContent = errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to clear field error state
|
||||
function clearFieldError(fieldId) {
|
||||
const field = document.getElementById(fieldId);
|
||||
const errorSpan = document.getElementById(fieldId + '-error');
|
||||
if (field && errorSpan) {
|
||||
field.setAttribute('aria-invalid', 'false');
|
||||
errorSpan.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all form errors
|
||||
function clearAllErrors() {
|
||||
['name', 'email', 'company', 'message'].forEach(clearFieldError);
|
||||
}
|
||||
|
||||
if (contactForm) {
|
||||
// Real-time validation on blur
|
||||
const requiredFields = contactForm.querySelectorAll('[required]');
|
||||
requiredFields.forEach(field => {
|
||||
field.addEventListener('blur', function() {
|
||||
validateField(this);
|
||||
});
|
||||
field.addEventListener('input', function() {
|
||||
if (this.getAttribute('aria-invalid') === 'true') {
|
||||
validateField(this);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function validateField(field) {
|
||||
const value = field.value.trim();
|
||||
const fieldId = field.id;
|
||||
|
||||
if (fieldId === 'name' && value.length < 2) {
|
||||
setFieldError(fieldId, 'Please enter a valid name (at least 2 characters)');
|
||||
return false;
|
||||
} else if (fieldId === 'email' && !isValidEmail(value)) {
|
||||
setFieldError(fieldId, 'Please enter a valid email address');
|
||||
return false;
|
||||
} else if (fieldId === 'company' && value.length < 2) {
|
||||
setFieldError(fieldId, 'Please enter your organisation name');
|
||||
return false;
|
||||
} else if (fieldId === 'message' && value.length < 10) {
|
||||
setFieldError(fieldId, 'Please provide more details (at least 10 characters)');
|
||||
return false;
|
||||
}
|
||||
|
||||
clearFieldError(fieldId);
|
||||
return true;
|
||||
}
|
||||
|
||||
contactForm.addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
clearAllErrors();
|
||||
|
||||
// Basic form validation
|
||||
const formData = new FormData(this);
|
||||
const name = formData.get('name');
|
||||
const email = formData.get('email');
|
||||
const company = formData.get('company');
|
||||
const message = formData.get('message');
|
||||
|
||||
|
||||
// Validation
|
||||
let isValid = true;
|
||||
const errors = [];
|
||||
|
||||
let firstErrorField = null;
|
||||
|
||||
if (!name || name.trim().length < 2) {
|
||||
errors.push('Please enter a valid name');
|
||||
setFieldError('name', 'Please enter a valid name (at least 2 characters)');
|
||||
isValid = false;
|
||||
if (!firstErrorField) firstErrorField = document.getElementById('name');
|
||||
}
|
||||
|
||||
|
||||
if (!email || !isValidEmail(email)) {
|
||||
errors.push('Please enter a valid email address');
|
||||
setFieldError('email', 'Please enter a valid email address');
|
||||
isValid = false;
|
||||
if (!firstErrorField) firstErrorField = document.getElementById('email');
|
||||
}
|
||||
|
||||
|
||||
if (!company || company.trim().length < 2) {
|
||||
setFieldError('company', 'Please enter your organisation name');
|
||||
isValid = false;
|
||||
if (!firstErrorField) firstErrorField = document.getElementById('company');
|
||||
}
|
||||
|
||||
if (!message || message.trim().length < 10) {
|
||||
errors.push('Please provide more details about your project (minimum 10 characters)');
|
||||
setFieldError('message', 'Please provide more details (at least 10 characters)');
|
||||
isValid = false;
|
||||
if (!firstErrorField) firstErrorField = document.getElementById('message');
|
||||
}
|
||||
|
||||
|
||||
// Focus first error field for accessibility
|
||||
if (!isValid && firstErrorField) {
|
||||
firstErrorField.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
// Show loading state
|
||||
const submitButton = this.querySelector('button[type="submit"]');
|
||||
const originalText = submitButton.textContent;
|
||||
submitButton.textContent = 'Sending...';
|
||||
submitButton.disabled = true;
|
||||
|
||||
|
||||
// Update form timestamp to current time (ensures it's fresh at submission)
|
||||
const timestampField = document.getElementById('form_timestamp');
|
||||
if (timestampField) {
|
||||
timestampField.value = Date.now();
|
||||
}
|
||||
// Recreate formData after updating timestamp
|
||||
const freshFormData = new FormData(this);
|
||||
|
||||
// Execute reCAPTCHA and submit form
|
||||
if (typeof grecaptcha !== 'undefined') {
|
||||
grecaptcha.ready(() => {
|
||||
grecaptcha.execute(window.recaptchaSiteKey, {action: 'contact_form'}).then((token) => {
|
||||
// Add reCAPTCHA token and interaction data
|
||||
formData.set('recaptcha_response', token);
|
||||
formData.set('interaction_token', btoa(JSON.stringify({score: Math.min(interactionScore, 100), time: Date.now() - formStartTime})));
|
||||
freshFormData.set('recaptcha_response', token);
|
||||
freshFormData.set('interaction_token', btoa(JSON.stringify({score: Math.min(interactionScore, 100), time: Date.now() - formStartTime})));
|
||||
|
||||
// Submit form
|
||||
fetch('contact-handler.php', {
|
||||
@@ -239,7 +385,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: formData
|
||||
body: freshFormData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
|
||||
2
assets/js/main.min.js
vendored
2
assets/js/main.min.js
vendored
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user