From cf30510eaee4e8ebd1af1a43ab8a7d130b7a8b3e Mon Sep 17 00:00:00 2001
From: Rito Rhymes <83614463+ritorhymes@users.noreply.github.com>
Date: Fri, 19 Sep 2025 09:00:13 -0400
Subject: [PATCH] feat(mobile-menu): add smooth open/close animations for main
and submenus on mobile
- Height transition based on measured scrollHeight
- Link and language selector fade-in on expand
- Collapse while submenu expanded temporarily forces dark bg/border to avoid color artifacts
- Cleans up inline styles post-animation so CSS remains source of truth
- Scoped to mobile-menu only; desktop not affected
- Implemented as an IIFE to encapsulate helpers and avoid global-scope pollution
---
_includes/layout/base/html-head.html | 1 +
js/mobile-menu-animate.js | 325 +++++++++++++++++++++++++++
2 files changed, 326 insertions(+)
create mode 100644 js/mobile-menu-animate.js
diff --git a/_includes/layout/base/html-head.html b/_includes/layout/base/html-head.html
index ed85f58972..f27ee34b62 100644
--- a/_includes/layout/base/html-head.html
+++ b/_includes/layout/base/html-head.html
@@ -15,6 +15,7 @@
{% if page.lang == 'ar' or page.lang == 'he' or page.lang == 'fa' %}{% endif %}
{% if page.lang == 'bg' or page.lang == 'el' or page.lang == 'ko' or page.lang == 'hi' or page.lang == 'pl' or page.lang == 'sl' or page.lang == 'ro' or page.lang == 'ru' or page.lang == 'tr' or page.lang == 'uk' or page.lang == 'zh_CN' or page.lang == 'zh_TW' %}{% endif %}
+
diff --git a/js/mobile-menu-animate.js b/js/mobile-menu-animate.js
new file mode 100644
index 0000000000..2ec10645d8
--- /dev/null
+++ b/js/mobile-menu-animate.js
@@ -0,0 +1,325 @@
+// Mobile menu animation with smooth height transitions
+
+// Note: Padding values (20px top/bottom) are hardcoded here rather than dynamically detected.
+// This is intentional - using getComputedStyle() to detect padding may cause noticeable animation stuttering.
+// Hardcoded values ensure perfectly smooth animations. Update these if CSS padding changes.
+
+// Color changes made in the mobile menu may need to also be updated here if they effect the animation blending with the BG
+
+(function() {
+ 'use strict';
+
+ // Main hamburger menu animation
+ window.animateMainMenu = function(menuElement, expand, duration) {
+ duration = duration || 300;
+
+ // Only animate on mobile
+ if (window.innerWidth > 750) {
+ menuElement.style.display = expand ? 'block' : 'none';
+ return;
+ }
+
+ if (expand) {
+ // Expanding - measure and animate
+ menuElement.style.display = 'block';
+ menuElement.style.overflow = 'hidden';
+
+ // Fade in main menu links and language selector text
+ var mainLinks = menuElement.querySelectorAll('li > a');
+ for (var i = 0; i < mainLinks.length; i++) {
+ mainLinks[i].style.opacity = '0';
+ mainLinks[i].style.transition = 'opacity 250ms ease-out';
+ }
+
+ var langSelectText = document.querySelector('.center-select__text');
+ if (langSelectText) {
+ langSelectText.style.opacity = '0';
+ langSelectText.style.transition = 'opacity 250ms ease-out';
+ }
+
+ // Measure full height accounting for expanded submenus
+ menuElement.style.height = 'auto';
+
+ // Check for expanded submenus and ensure their padding is included
+ var expandedSubmenus = menuElement.querySelectorAll('li.hover > ul');
+ for (var i = 0; i < expandedSubmenus.length; i++) {
+ if (!expandedSubmenus[i].style.padding) {
+ expandedSubmenus[i].style.padding = '20px 0';
+ }
+ }
+
+ var targetHeight = menuElement.scrollHeight;
+
+ // Start from 0
+ menuElement.style.height = '0px';
+ void menuElement.offsetHeight; // Force reflow
+
+ // Animate to full height and fade in links
+ menuElement.style.transition = 'height ' + duration + 'ms ease-out';
+ menuElement.style.height = targetHeight + 'px';
+
+ // Trigger link fade in
+ setTimeout(function() {
+ for (var i = 0; i < mainLinks.length; i++) {
+ mainLinks[i].style.opacity = '1';
+ }
+ if (langSelectText) {
+ langSelectText.style.opacity = '1';
+ }
+ }, 10);
+
+ // Clean up after animation
+ setTimeout(function() {
+ menuElement.style.height = '';
+ menuElement.style.overflow = '';
+ menuElement.style.transition = '';
+ for (var i = 0; i < mainLinks.length; i++) {
+ mainLinks[i].style.opacity = '';
+ mainLinks[i].style.transition = '';
+ }
+ if (langSelectText) {
+ langSelectText.style.opacity = '';
+ langSelectText.style.transition = '';
+ }
+ }, duration);
+
+ } else {
+ // Collapsing - animate to 0
+ menuElement.style.overflow = 'hidden';
+ menuElement.style.height = menuElement.scrollHeight + 'px';
+ void menuElement.offsetHeight; // Force reflow
+
+ // Fade submenu links to black during collapse animation
+ var submenus = menuElement.querySelectorAll('li ul');
+ var submenuLinks = menuElement.querySelectorAll('li ul a');
+
+ for (var i = 0; i < submenus.length; i++) {
+ if (submenus[i].style.display === 'block' || submenus[i].offsetParent !== null) {
+ submenus[i].style.backgroundColor = '#000';
+ submenus[i].style.borderColor = '#000';
+ }
+ }
+
+ for (var j = 0; j < submenuLinks.length; j++) {
+ submenuLinks[j].style.transition = 'color 300ms ease-out';
+ submenuLinks[j].style.color = '#000';
+ }
+
+ menuElement.style.transition = 'height ' + duration + 'ms ease-out';
+ menuElement.style.height = '0px';
+
+ // Hide after animation and reset styles
+ setTimeout(function() {
+ menuElement.style.display = 'none';
+ menuElement.style.height = '';
+ menuElement.style.overflow = '';
+ menuElement.style.transition = '';
+
+ // Reset submenu styles
+ for (var i = 0; i < submenus.length; i++) {
+ submenus[i].style.backgroundColor = '';
+ submenus[i].style.borderColor = '';
+ }
+ for (var j = 0; j < submenuLinks.length; j++) {
+ submenuLinks[j].style.color = '';
+ submenuLinks[j].style.transition = '';
+ }
+ }, duration);
+ }
+ };
+
+ // Updated mobileMenuShow to use animation
+ window.mobileMenuShow = function(e) {
+ var show = function() {
+ var mm = document.getElementById('menusimple');
+ var ml = document.getElementById('langselect');
+ var isExpanding = mm.style.display !== 'block';
+
+ // Animate main menu
+ window.animateMainMenu(mm, isExpanding, 300);
+
+ // Handle language selector
+ if (ml) {
+ ml.style.display = isExpanding ? 'block' : 'none';
+ }
+
+ if (isExpanding) {
+ addClass(mm, 'menutap');
+ } else {
+ removeClass(mm, 'menutap');
+ }
+
+ cancelEvent(e);
+ };
+ onTouchClick(e, show);
+ };
+
+ // Submenu animation (your existing working code)
+ window.animateMenuHeight = function(element, expand, duration) {
+ // Skip animation on desktop
+ if (window.innerWidth > 750) {
+ if (expand) {
+ element.classList.add('hover');
+ } else {
+ element.classList.remove('hover');
+ }
+ return;
+ }
+
+ duration = duration || 300;
+
+ var submenu = element.querySelector('ul');
+ if (!submenu) return;
+
+ // Measure height function
+ var measureCleanHeight = function() {
+ var originalTransition = submenu.style.transition;
+ var originalPadding = submenu.style.padding;
+ submenu.style.transition = 'none';
+
+ var wasVisible = submenu.style.display !== 'none';
+ var originalHeight = submenu.style.height;
+ var originalOverflow = submenu.style.overflow;
+ var originalPosition = submenu.style.position;
+ var originalVisibility = submenu.style.visibility;
+ var hadHoverClass = element.classList.contains('hover');
+
+ element.classList.add('hover');
+ submenu.style.display = 'block';
+ submenu.style.position = 'absolute';
+ submenu.style.visibility = 'hidden';
+ submenu.style.height = 'auto';
+ submenu.style.overflow = 'visible';
+ submenu.style.padding = '0';
+
+ void submenu.offsetHeight;
+ var contentHeight = submenu.scrollHeight;
+
+ // Add CSS padding back (20px top + 20px bottom = 40px total)
+ var totalHeight = contentHeight + 40;
+
+ // Restore original state
+ if (!hadHoverClass) element.classList.remove('hover');
+ submenu.style.display = wasVisible ? '' : 'none';
+ submenu.style.height = originalHeight;
+ submenu.style.overflow = originalOverflow;
+ submenu.style.position = originalPosition;
+ submenu.style.visibility = originalVisibility;
+ submenu.style.transition = originalTransition;
+ submenu.style.padding = originalPadding;
+
+ return totalHeight;
+ };
+
+ if (expand) {
+ // Expanding
+ element.classList.add('hover');
+
+ var targetHeight = measureCleanHeight();
+
+ submenu.style.display = 'block';
+ submenu.style.height = '0px';
+ submenu.style.padding = '0';
+ submenu.style.overflow = 'hidden';
+
+ void submenu.offsetHeight;
+
+ submenu.style.transition = 'height ' + duration + 'ms ease-out, padding ' + duration + 'ms ease-out';
+ submenu.style.height = targetHeight + 'px';
+ submenu.style.padding = ''; // Let CSS padding take over
+
+ setTimeout(function() {
+ if (submenu && element.classList.contains('hover')) {
+ submenu.style.transition = '';
+ submenu.style.overflow = '';
+ submenu.style.padding = '';
+ }
+ }, duration);
+
+ } else {
+ // Collapsing
+ var currentHeight = measureCleanHeight();
+
+ submenu.style.height = currentHeight + 'px';
+ submenu.style.padding = '';
+ submenu.style.overflow = 'hidden';
+ submenu.style.transition = 'none';
+
+ void submenu.offsetHeight;
+
+ submenu.style.transition = 'height ' + duration + 'ms ease-out, padding ' + duration + 'ms ease-out';
+ submenu.style.height = '0px';
+ submenu.style.padding = '0';
+
+ setTimeout(function() {
+ element.classList.remove('hover');
+ submenu.style.display = '';
+ submenu.style.height = '';
+ submenu.style.overflow = '';
+ submenu.style.transition = '';
+ submenu.style.padding = '';
+ }, duration);
+ }
+ };
+
+ // Submenu hover handler (your existing working code)
+ window.mobileMenuHover = function(e) {
+ var t = getEvent(e, 'target');
+
+ var initHover = function() {
+ if (t.nodeName !== 'A') return;
+
+ var targetLi = t.parentNode;
+ var isExpanding = targetLi.className.indexOf('hover') === -1;
+
+ var hasSubItemsCheck = function(el) {
+ var checkEl = el;
+ while (checkEl && checkEl.nodeName !== 'LI') checkEl = checkEl.parentNode;
+ if (!checkEl) return false;
+ return (checkEl.getElementsByTagName('UL').length > 0);
+ };
+
+ var hasSubItems = hasSubItemsCheck(t);
+ if (!isExpanding && !hasSubItems) return;
+
+ var p = targetLi;
+ while (p.parentNode.nodeName === 'UL' || p.parentNode.nodeName === 'LI') p = p.parentNode;
+
+ for (var i = 0, nds = p.getElementsByTagName('LI'), n = nds.length; i < n; i++) {
+ if (nds[i] === targetLi) continue;
+ if (hasSubItemsCheck(nds[i])) continue;
+
+ if (nds[i].className.indexOf('hover') !== -1) {
+ window.animateMenuHeight(nds[i], false, 300);
+ }
+ }
+
+ if (hasSubItems) {
+ window.animateMenuHeight(targetLi, isExpanding, 300);
+ }
+
+ var parentEl = t.parentNode;
+ while (parentEl !== p) {
+ if (parentEl.nodeName === 'LI' && parentEl !== targetLi) {
+ if (isExpanding && parentEl.className.indexOf('hover') === -1) {
+ parentEl.classList.add('hover');
+ }
+ }
+ parentEl = parentEl.parentNode;
+ }
+ };
+
+ var filterClick = function(e) {
+ var t = getEvent(e, 'target');
+ if (t.nodeName !== 'A') return;
+ var checkEl = t;
+ while (checkEl && checkEl.nodeName !== 'LI') checkEl = checkEl.parentNode;
+ if (checkEl && checkEl.getElementsByTagName('UL').length > 0) {
+ e.preventDefault();
+ }
+ };
+
+ onTouchClick(e, initHover, filterClick);
+ };
+
+})();
\ No newline at end of file