223 lines
5.6 KiB
JavaScript
223 lines
5.6 KiB
JavaScript
document.addEventListener("DOMContentLoaded", () => {
|
|
setupMobileNavigation();
|
|
setupScrollAnimations();
|
|
setupProjectFiltering();
|
|
setupContactForm();
|
|
setupSmoothScrollClose();
|
|
});
|
|
|
|
function setupMobileNavigation() {
|
|
const toggle = document.querySelector("[data-menu-toggle]");
|
|
const mobileNav = document.querySelector("[data-mobile-nav]");
|
|
|
|
if (!toggle || !mobileNav) {
|
|
return;
|
|
}
|
|
|
|
const updateState = (isOpen) => {
|
|
toggle.classList.toggle("is-open", isOpen);
|
|
toggle.setAttribute("aria-expanded", String(isOpen));
|
|
mobileNav.classList.toggle("is-open", isOpen);
|
|
document.body.classList.toggle("menu-open", isOpen);
|
|
};
|
|
|
|
toggle.addEventListener("click", () => {
|
|
const isOpen = toggle.getAttribute("aria-expanded") === "true";
|
|
updateState(!isOpen);
|
|
});
|
|
|
|
mobileNav.querySelectorAll("a").forEach((link) => {
|
|
link.addEventListener("click", () => updateState(false));
|
|
});
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") {
|
|
updateState(false);
|
|
}
|
|
});
|
|
|
|
document.addEventListener("click", (event) => {
|
|
const clickedInsideMenu = mobileNav.contains(event.target);
|
|
const clickedToggle = toggle.contains(event.target);
|
|
|
|
if (!clickedInsideMenu && !clickedToggle) {
|
|
updateState(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
function setupSmoothScrollClose() {
|
|
const samePageLinks = document.querySelectorAll('a[href^="#"]');
|
|
|
|
samePageLinks.forEach((link) => {
|
|
link.addEventListener("click", (event) => {
|
|
const targetId = link.getAttribute("href");
|
|
|
|
if (!targetId || targetId === "#") {
|
|
return;
|
|
}
|
|
|
|
const target = document.querySelector(targetId);
|
|
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
});
|
|
});
|
|
}
|
|
|
|
function setupScrollAnimations() {
|
|
const animatedItems = document.querySelectorAll("[data-animate]");
|
|
|
|
if (!animatedItems.length) {
|
|
return;
|
|
}
|
|
|
|
if (!("IntersectionObserver" in window)) {
|
|
animatedItems.forEach((item) => item.classList.add("is-visible"));
|
|
return;
|
|
}
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries, currentObserver) => {
|
|
entries.forEach((entry) => {
|
|
if (!entry.isIntersecting) {
|
|
return;
|
|
}
|
|
|
|
entry.target.classList.add("is-visible");
|
|
currentObserver.unobserve(entry.target);
|
|
});
|
|
},
|
|
{
|
|
threshold: 0.12,
|
|
rootMargin: "0px 0px -40px 0px"
|
|
}
|
|
);
|
|
|
|
animatedItems.forEach((item) => observer.observe(item));
|
|
}
|
|
|
|
function setupProjectFiltering() {
|
|
const filterButtons = document.querySelectorAll("[data-filter]");
|
|
const filterItems = document.querySelectorAll("[data-category]");
|
|
|
|
if (!filterButtons.length || !filterItems.length) {
|
|
return;
|
|
}
|
|
|
|
filterButtons.forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const filterValue = button.dataset.filter;
|
|
|
|
filterButtons.forEach((otherButton) => {
|
|
otherButton.classList.toggle("is-active", otherButton === button);
|
|
otherButton.setAttribute("aria-pressed", String(otherButton === button));
|
|
});
|
|
|
|
filterItems.forEach((item) => {
|
|
const categories = (item.dataset.category || "").split(" ");
|
|
const shouldShow = filterValue === "all" || categories.includes(filterValue);
|
|
item.classList.toggle("is-hidden", !shouldShow);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function setupContactForm() {
|
|
const form = document.querySelector("[data-contact-form]");
|
|
|
|
if (!form) {
|
|
return;
|
|
}
|
|
|
|
const fields = Array.from(form.querySelectorAll("[data-required], [type='email']"));
|
|
const status = form.querySelector("[data-form-status]");
|
|
|
|
const validators = {
|
|
email: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
|
};
|
|
|
|
const setFieldError = (field, message) => {
|
|
const wrapper = field.closest(".form-field");
|
|
const error = wrapper ? wrapper.querySelector(".form-field__error") : null;
|
|
|
|
field.setAttribute("aria-invalid", String(Boolean(message)));
|
|
|
|
if (wrapper) {
|
|
wrapper.classList.toggle("is-invalid", Boolean(message));
|
|
}
|
|
|
|
if (error) {
|
|
error.textContent = message || "";
|
|
}
|
|
};
|
|
|
|
const validateField = (field) => {
|
|
const rawValue = field.value.trim();
|
|
const label = field.dataset.label || "This field";
|
|
|
|
if (field.hasAttribute("data-required") && !rawValue) {
|
|
setFieldError(field, `${label} is required.`);
|
|
return false;
|
|
}
|
|
|
|
if (field.type === "email" && rawValue && !validators.email(rawValue)) {
|
|
setFieldError(field, "Enter a valid email address.");
|
|
return false;
|
|
}
|
|
|
|
setFieldError(field, "");
|
|
return true;
|
|
};
|
|
|
|
fields.forEach((field) => {
|
|
field.addEventListener("blur", () => validateField(field));
|
|
field.addEventListener("input", () => {
|
|
if (field.getAttribute("aria-invalid") === "true") {
|
|
validateField(field);
|
|
}
|
|
});
|
|
});
|
|
|
|
form.addEventListener("submit", (event) => {
|
|
let isValid = true;
|
|
|
|
fields.forEach((field) => {
|
|
if (!validateField(field)) {
|
|
isValid = false;
|
|
}
|
|
});
|
|
|
|
if (!isValid) {
|
|
event.preventDefault();
|
|
|
|
if (status) {
|
|
status.textContent = "Please review the highlighted fields and try again.";
|
|
status.classList.remove("is-success");
|
|
}
|
|
|
|
const firstInvalid = form.querySelector("[aria-invalid='true']");
|
|
|
|
if (firstInvalid) {
|
|
firstInvalid.focus();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
if (status) {
|
|
status.textContent = "Thanks. Your message structure is validated and ready for backend integration.";
|
|
status.classList.add("is-success");
|
|
}
|
|
|
|
form.reset();
|
|
fields.forEach((field) => setFieldError(field, ""));
|
|
});
|
|
}
|