/* ========================================================================== Lagos MiND — main.js No dependencies. Everything degrades to a working page if JS fails. ========================================================================== */ (function () { 'use strict'; var mqDesktop = window.matchMedia('(min-width: 901px)'); var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); /* ------------------------------------------------------------------------ Header shadow on scroll ---------------------------------------------------------------------- */ var header = document.getElementById('header'); if (header) { var onScroll = function () { header.classList.toggle('is-stuck', window.scrollY > 8); }; onScroll(); window.addEventListener('scroll', onScroll, { passive: true }); } /* ------------------------------------------------------------------------ Mobile navigation ---------------------------------------------------------------------- */ var nav = document.getElementById('nav'); var navToggle = document.getElementById('navToggle'); function closeNav() { if (!nav || !navToggle) return; nav.classList.remove('is-open'); navToggle.setAttribute('aria-expanded', 'false'); navToggle.setAttribute('aria-label', 'Open menu'); document.body.classList.remove('is-locked'); } if (nav && navToggle) { navToggle.addEventListener('click', function () { var open = nav.classList.toggle('is-open'); navToggle.setAttribute('aria-expanded', String(open)); navToggle.setAttribute('aria-label', open ? 'Close menu' : 'Open menu'); document.body.classList.toggle('is-locked', open); }); } /* ------------------------------------------------------------------------ Mega menus Desktop: CSS handles hover and focus-within. JS only manages the aria-expanded state and the Escape key. Mobile: the trigger becomes an accordion toggle. ---------------------------------------------------------------------- */ var navItems = Array.prototype.slice.call(document.querySelectorAll('.nav__item')); navItems.forEach(function (item) { var trigger = item.querySelector('.nav__link'); var panel = item.querySelector('.mega'); if (!trigger || !panel) return; trigger.addEventListener('click', function (e) { e.preventDefault(); var isOpen = item.classList.contains('is-open'); navItems.forEach(function (other) { if (other !== item) { other.classList.remove('is-open'); var t = other.querySelector('.nav__link'); if (t) t.setAttribute('aria-expanded', 'false'); } }); item.classList.toggle('is-open', !isOpen); trigger.setAttribute('aria-expanded', String(!isOpen)); }); // Desktop hover should keep aria in sync for assistive tech item.addEventListener('mouseenter', function () { if (mqDesktop.matches) trigger.setAttribute('aria-expanded', 'true'); }); item.addEventListener('mouseleave', function () { if (mqDesktop.matches) { trigger.setAttribute('aria-expanded', 'false'); item.classList.remove('is-open'); } }); }); document.addEventListener('keydown', function (e) { if (e.key !== 'Escape') return; navItems.forEach(function (item) { if (!item.classList.contains('is-open')) return; item.classList.remove('is-open'); var t = item.querySelector('.nav__link'); if (t) { t.setAttribute('aria-expanded', 'false'); t.focus(); } }); }); // Clicking outside closes any open mega menu on desktop document.addEventListener('click', function (e) { if (!mqDesktop.matches) return; if (e.target.closest('.nav__item')) return; navItems.forEach(function (item) { item.classList.remove('is-open'); var t = item.querySelector('.nav__link'); if (t) t.setAttribute('aria-expanded', 'false'); }); }); // Reset state when crossing the breakpoint var handleBreakpoint = function () { closeNav(); navItems.forEach(function (item) { item.classList.remove('is-open'); var t = item.querySelector('.nav__link'); if (t) t.setAttribute('aria-expanded', 'false'); }); }; if (mqDesktop.addEventListener) mqDesktop.addEventListener('change', handleBreakpoint); else if (mqDesktop.addListener) mqDesktop.addListener(handleBreakpoint); /* ------------------------------------------------------------------------ Help dialog ---------------------------------------------------------------------- */ var dialog = document.getElementById('helpDialog'); var lastFocused = null; var FOCUSABLE = 'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])'; function openDialog() { if (!dialog) return; lastFocused = document.activeElement; dialog.hidden = false; dialog.classList.add('is-open'); document.body.classList.add('is-locked'); closeNav(); var first = dialog.querySelector(FOCUSABLE); if (first) first.focus(); } function closeDialog() { if (!dialog) return; dialog.classList.remove('is-open'); dialog.hidden = true; document.body.classList.remove('is-locked'); if (lastFocused && lastFocused.focus) lastFocused.focus(); } document.addEventListener('click', function (e) { var opener = e.target.closest('[data-help-open]'); if (opener) { e.preventDefault(); openDialog(); return; } if (e.target.closest('[data-help-close]')) { closeDialog(); return; } if (dialog && e.target === dialog) closeDialog(); }); document.addEventListener('keydown', function (e) { if (!dialog || dialog.hidden) return; if (e.key === 'Escape') { closeDialog(); return; } if (e.key === 'Tab') { var items = Array.prototype.slice.call(dialog.querySelectorAll(FOCUSABLE)) .filter(function (el) { return el.offsetParent !== null; }); if (!items.length) return; var first = items[0]; var last = items[items.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } }); /* ------------------------------------------------------------------------ Scroll reveal ---------------------------------------------------------------------- */ var revealables = document.querySelectorAll('.reveal'); var canReveal = !reduceMotion.matches && 'IntersectionObserver' in window; if (canReveal && revealables.length) { // Hide them only now that we know we can bring them back. document.documentElement.classList.add('js-reveal'); var io = new IntersectionObserver(function (entries) { entries.forEach(function (entry, i) { if (!entry.isIntersecting) return; var el = entry.target; setTimeout(function () { el.classList.add('is-in'); }, i * 70); io.unobserve(el); }); }, { rootMargin: '0px 0px -8% 0px', threshold: 0.08 }); revealables.forEach(function (el) { io.observe(el); }); // Belt and braces: if anything is still hidden after 3s, show it. setTimeout(function () { revealables.forEach(function (el) { el.classList.add('is-in'); }); }, 3000); } /* ------------------------------------------------------------------------ Tabs Used on the facilities page. Tabs whose panels are declared with aria-controls are handled here; the provider directory has its own data-driven tabs in providers.js and is skipped. ---------------------------------------------------------------------- */ var tabLists = document.querySelectorAll('[role="tablist"]'); tabLists.forEach(function (list) { var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"][aria-controls]')); if (!tabs.length) return; function select(tab) { tabs.forEach(function (t) { var on = t === tab; t.setAttribute('aria-selected', String(on)); t.tabIndex = on ? 0 : -1; var panel = document.getElementById(t.getAttribute('aria-controls')); if (panel) panel.hidden = !on; }); } tabs.forEach(function (tab, i) { tab.tabIndex = tab.getAttribute('aria-selected') === 'true' ? 0 : -1; tab.addEventListener('click', function () { select(tab); }); // Arrow keys move between tabs, as expected of a tablist tab.addEventListener('keydown', function (e) { var next = null; if (e.key === 'ArrowRight') next = tabs[(i + 1) % tabs.length]; else if (e.key === 'ArrowLeft') next = tabs[(i - 1 + tabs.length) % tabs.length]; else if (e.key === 'Home') next = tabs[0]; else if (e.key === 'End') next = tabs[tabs.length - 1]; if (!next) return; e.preventDefault(); select(next); next.focus(); }); }); }); /* ------------------------------------------------------------------------ Forms Replace ENDPOINT with your handler (Formspree, Zoho Forms, a Netlify function, or a small PHP script). Until then the form validates and reports success locally without sending anything. ---------------------------------------------------------------------- */ var ENDPOINT = ''; // e.g. 'https://formspree.io/f/xxxxxxx' function setStatus(form, message, state) { var box = form.querySelector('[data-status]'); if (!box) return; box.textContent = message; box.setAttribute('data-state', state); box.classList.add('is-visible'); } function handleForm(form, successMessage) { if (!form) return; form.addEventListener('submit', function (e) { e.preventDefault(); if (!form.checkValidity()) { setStatus(form, 'Check the highlighted fields and try again.', 'error'); var invalid = form.querySelector(':invalid'); if (invalid) invalid.focus(); return; } var button = form.querySelector('button[type="submit"]'); var original = button ? button.textContent : ''; if (button) { button.disabled = true; button.textContent = 'Sending…'; } if (!ENDPOINT) { setStatus(form, successMessage, 'ok'); form.reset(); if (button) { button.disabled = false; button.textContent = original; } return; } fetch(ENDPOINT, { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } }) .then(function (res) { if (!res.ok) throw new Error('Request failed'); setStatus(form, successMessage, 'ok'); form.reset(); }) .catch(function () { setStatus(form, 'That didn\'t send. Email help@lagosmind.org or call 070 0000 6463 instead.', 'error'); }) .finally(function () { if (button) { button.disabled = false; button.textContent = original; } }); }); } handleForm(document.getElementById('newsletterForm'), 'You\'re subscribed. Look out for our next update.'); handleForm(document.getElementById('partnerForm'), 'Proposal received. We reply to partnership enquiries within five working days.'); handleForm(document.getElementById('contactForm'), 'Message received. We\'ll reply within one working day. If it\'s urgent, please call 070 0000 6463.'); /* ------------------------------------------------------------------------ Footer year ---------------------------------------------------------------------- */ var yearEl = document.querySelector('[data-year]'); if (yearEl) yearEl.textContent = new Date().getFullYear(); })(); /* -------------------------------------------------------------------------- Brand logo Tries logo.svg, then logo.png, then falls back to the initials mark, so you can drop in whichever format you have without editing any HTML. The same applies to logo-light.svg / logo-light.png in the footer. Checking for the absence of the doesn't work here: the error fires asynchronously, so a broken image is often still in the DOM at this point and both would render at once. Bind to the image's own load/error instead. -------------------------------------------------------------------------- */ (function () { 'use strict'; document.querySelectorAll('.brand').forEach(function (brand) { var fallback = brand.querySelector('.brand__fallback'); var logo = brand.querySelector('img'); if (!fallback) return; if (!logo) { fallback.hidden = false; return; } var tried = false; function onFail() { // First failure: try the .png of the same name. if (!tried) { tried = true; var png = logo.getAttribute('src').replace(/\.svg$/i, '.png'); if (png !== logo.getAttribute('src')) { logo.setAttribute('src', png); return; } } // Out of options — show the initials. logo.style.display = 'none'; fallback.hidden = false; } logo.addEventListener('error', onFail); if (logo.complete && !logo.naturalWidth) onFail(); }); })(); /* -------------------------------------------------------------------------- Live site configuration -------------------------------------------------------------------------- Edit assets/data/site.json to change the Lagos MiND logo/brand text without editing every HTML page. The HTML image remains a safe fallback if the config cannot be fetched. -------------------------------------------------------------------------- */ (function () { 'use strict'; fetch('assets/data/site.json', { cache: 'no-store' }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (cfg) { if (!cfg || !cfg.brand) return; document.querySelectorAll('.brand').forEach(function (brand) { var img = brand.querySelector('img'); var fallbackName = brand.querySelector('.brand__name'); var fallbackSub = brand.querySelector('.brand__sub'); if (img) { var src = img.src || ''; var isDark = /blackbg|dark|footer/i.test(src) || brand.closest('.footer'); var configured = isDark ? cfg.brand.logoDark : cfg.brand.logo; if (configured) img.setAttribute('src', configured); if (cfg.brand.name) img.setAttribute('alt', cfg.brand.name); } if (fallbackName && cfg.brand.name) fallbackName.textContent = cfg.brand.name; if (fallbackSub && cfg.brand.subtitle) fallbackSub.textContent = cfg.brand.subtitle; }); }) .catch(function () {}); })();