feature: Add sliding viewport mobile menu support

This commit is contained in:
Keith Solomon
2025-09-29 15:18:34 -05:00
parent b1559b2ce9
commit 89a1a48382
5 changed files with 560 additions and 45 deletions

View File

@@ -36,6 +36,24 @@ class Navigation {
*/ */
#subMenuElementIdentifier; #subMenuElementIdentifier;
/**
* Current navigation level in sliding viewport
* @type {number}
*/
#currentLevel = 0;
/**
* Navigation stack for breadcrumb functionality
* @type {Array}
*/
#navigationStack = [];
/**
* Whether sliding viewport mode is enabled
* @type {boolean}
*/
#slidingViewportEnabled = false;
constructor(mobileMenuButtonId, dropDownClass, subMenuLinkClass = "sub-menu-item") { constructor(mobileMenuButtonId, dropDownClass, subMenuLinkClass = "sub-menu-item") {
@@ -44,6 +62,9 @@ class Navigation {
this.#subMenuElementIdentifier = subMenuLinkClass; this.#subMenuElementIdentifier = subMenuLinkClass;
this.handleEscapeKey(); this.handleEscapeKey();
// Initialize sliding viewport immediately if styles are detected
this.initializeSlidingViewport();
} }
/** /**
@@ -93,7 +114,16 @@ class Navigation {
* @param {HTMLButtonElement} button * @param {HTMLButtonElement} button
*/ */
toggleMobileMenu(event, button) { toggleMobileMenu(event, button) {
this.toggleAriaExpanded(event, button) this.toggleAriaExpanded(event, button);
const isExpanded = button.getAttribute("aria-expanded") === "true";
if (!isExpanded) {
// Reset sliding navigation when menu is closed
if (this.#slidingViewportEnabled) {
this.resetSlidingNavigation();
}
}
} }
/** /**
@@ -138,7 +168,9 @@ class Navigation {
// Note: cannot group negation -1*-1*-1 != -(1*1*1) // Note: cannot group negation -1*-1*-1 != -(1*1*1)
const isNull = event.relatedTarget == null; const isNull = event.relatedTarget == null;
const isNotMenuItem = !isNull && !event.relatedTarget.classList.contains("menu-vdi__toggle") const isNotMenuItem = !isNull && !event.relatedTarget.classList.contains("menu-vdi__toggle")
&& !event.relatedTarget.classList.contains("sub-menu-item") && !event.relatedTarget.classList.contains("menu-vdi__link"); && !event.relatedTarget.classList.contains("sub-menu-item")
&& !event.relatedTarget.classList.contains("menu-vdi__link")
&& !event.relatedTarget.classList.contains("menu-vdi__back"); // Don't close on back button click
if (isNull || isNotMenuItem) if (isNull || isNotMenuItem)
button.setAttribute("aria-expanded", false); button.setAttribute("aria-expanded", false);
@@ -158,6 +190,11 @@ class Navigation {
if (event.key === "Escape") { if (event.key === "Escape") {
this.#mobileMenuButton.setAttribute("aria-expanded", false); this.#mobileMenuButton.setAttribute("aria-expanded", false);
this.closeAllDropDowns(); this.closeAllDropDowns();
// Reset sliding navigation on escape
if (this.#slidingViewportEnabled) {
this.resetSlidingNavigation();
}
} }
}, { passive: true }); }, { passive: true });
} }
@@ -220,6 +257,320 @@ class Navigation {
} }
}); });
} }
/**
* Initialize sliding viewport navigation
* Detects if sliding viewport should be enabled and sets up the structure
*/
initializeSlidingViewport() {
// Check if we should enable sliding viewport (could be based on screen size, user preference, etc.)
this.#slidingViewportEnabled = this.shouldEnableSlidingViewport();
if (this.#slidingViewportEnabled) {
console.log('Sliding viewport enabled, setting up structure');
this.setupSlidingViewportStructure();
} else {
console.log('Sliding viewport not enabled');
}
}
/**
* Determine if sliding viewport should be enabled
* @returns {boolean}
*/
shouldEnableSlidingViewport() {
// Check if sliding viewport styles are loaded by testing if the CSS rule exists
// This allows CSS-based switching between navigation styles
const isMobile = window.innerWidth <= 1000; // 62.5rem converted to px
if (!isMobile) return false;
// Check if sliding viewport CSS is loaded by testing a specific rule
// We need to test the element within the proper context (.nav-main)
try {
const navMain = document.querySelector('.nav-main');
if (!navMain) return false;
const testElement = document.createElement('div');
testElement.className = 'menu-vdi--sliding';
testElement.style.position = 'absolute';
testElement.style.visibility = 'hidden';
testElement.style.height = '1px';
testElement.style.width = '1px';
navMain.appendChild(testElement);
// Get computed styles to check if sliding viewport CSS is active
const computedStyle = window.getComputedStyle(testElement);
const hasOverflowHidden = computedStyle.overflow === 'hidden';
navMain.removeChild(testElement);
return hasOverflowHidden;
} catch (error) {
console.warn('Error detecting sliding viewport styles:', error);
return false;
}
}
/**
* Setup the HTML structure for sliding viewport navigation
*/
setupSlidingViewportStructure() {
const menuContainer = document.getElementById('menu-container');
if (!menuContainer) {
console.warn('Menu container not found');
return;
}
// Don't set up if already configured
if (menuContainer.classList.contains('menu-vdi--sliding')) {
console.log('Sliding viewport already configured');
return;
}
console.log('Setting up sliding viewport structure');
// Add sliding class to enable sliding styles
menuContainer.classList.add('menu-vdi--sliding');
// Create viewport container
const viewport = document.createElement('div');
viewport.className = 'menu-vdi__viewport';
viewport.setAttribute('data-current-level', '0');
// Create main level
const mainLevel = document.createElement('div');
mainLevel.className = 'menu-vdi__level menu-vdi__level--main';
mainLevel.setAttribute('data-level', '0');
// Move existing menu items to main level
const existingItems = Array.from(menuContainer.children);
console.log('Moving', existingItems.length, 'existing items to main level');
existingItems.forEach(item => {
mainLevel.appendChild(item);
});
// Setup click handlers for parent items in sliding mode
this.setupSlidingClickHandlers(mainLevel);
viewport.appendChild(mainLevel);
menuContainer.appendChild(viewport);
console.log('Sliding viewport structure complete');
}
/**
* Setup click handlers for parent menu items in sliding mode
* @param {HTMLElement} level
*/
setupSlidingClickHandlers(level) {
const parentButtons = level.querySelectorAll('.menu-vdi__toggle');
parentButtons.forEach(button => {
button.addEventListener('click', (event) => {
if (this.#slidingViewportEnabled) {
event.preventDefault();
event.stopPropagation();
const parentItem = button.closest('.menu-vdi__item--parent');
const submenu = parentItem.querySelector('.menu-vdi__submenu');
if (submenu) {
this.navigateToLevel(button.textContent.trim(), submenu);
}
}
}, true); // Use capture phase to intercept before other handlers
});
}
/**
* Navigate to a specific navigation level
* @param {string} levelTitle
* @param {HTMLElement} submenuElement
*/
navigateToLevel(levelTitle, submenuElement) {
const viewport = document.querySelector('.menu-vdi__viewport');
if (!viewport) return;
this.#currentLevel++;
this.#navigationStack.push({ title: levelTitle, level: this.#currentLevel });
// Create new level
const newLevel = document.createElement('div');
newLevel.className = 'menu-vdi__level';
newLevel.setAttribute('data-level', this.#currentLevel);
// Add back button
const backButton = this.createBackButton();
newLevel.appendChild(backButton);
// Add level title
const levelTitleEl = document.createElement('div');
levelTitleEl.className = 'menu-vdi__level-title';
levelTitleEl.textContent = levelTitle;
newLevel.appendChild(levelTitleEl);
// Clone and add submenu items
const submenuItems = submenuElement.cloneNode(true);
submenuItems.className = 'menu-vdi__level-items';
// Convert submenu items to level items and ensure proper visibility
const items = submenuItems.querySelectorAll('.menu-vdi__item');
items.forEach(item => {
// Remove any nested classes that don't apply to sliding mode
item.classList.remove('menu-vdi__item--child', 'menu-vdi__item--grandchild');
// Handle nested items if they exist
const nestedToggle = item.querySelector('.menu-vdi__toggle');
if (nestedToggle) {
nestedToggle.addEventListener('click', (event) => {
event.preventDefault();
// Could implement deeper nesting here if needed
});
}
});
// Remove any hidden submenu classes from nested elements to ensure visibility
const nestedSubmenus = submenuItems.querySelectorAll('.menu-vdi__submenu');
nestedSubmenus.forEach(nestedSubmenu => {
nestedSubmenu.classList.remove('menu-vdi__submenu');
nestedSubmenu.classList.add('menu-vdi__nested-items');
});
// Ensure all list items are visible in sliding mode
const allListItems = submenuItems.querySelectorAll('li');
allListItems.forEach(li => {
if (!li.classList.contains('menu-vdi__item')) {
li.classList.add('menu-vdi__item');
}
});
newLevel.appendChild(submenuItems);
viewport.appendChild(newLevel);
// Animate to new level
this.animateToLevel(this.#currentLevel);
}
/**
* Create a back button for navigation levels
* @returns {HTMLElement}
*/
createBackButton() {
const backButton = document.createElement('button');
backButton.className = 'menu-vdi__back';
backButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
Back
`;
backButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
this.navigateBack();
});
return backButton;
}
/**
* Navigate back to previous level
*/
navigateBack() {
if (this.#currentLevel > 0) {
this.#currentLevel--;
this.#navigationStack.pop();
// Remove the current level element
const viewport = document.querySelector('.menu-vdi__viewport');
const currentLevelEl = viewport.querySelector(`[data-level="${this.#currentLevel + 1}"]`);
if (currentLevelEl) {
currentLevelEl.remove();
}
// Animate back to previous level
this.animateToLevel(this.#currentLevel);
// Ensure menu stays open after back navigation
setTimeout(() => {
if (this.#mobileMenuButton.getAttribute("aria-expanded") === "false") {
this.#mobileMenuButton.setAttribute("aria-expanded", "true");
}
}, 50);
}
}
/**
* Animate viewport to specific level
* @param {number} level
*/
animateToLevel(level) {
const viewport = document.querySelector('.menu-vdi__viewport');
if (!viewport) return;
const translateX = -level * 100;
viewport.style.transform = `translateX(${translateX}%)`;
viewport.setAttribute('data-current-level', level);
}
/**
* Reset sliding navigation to main level
*/
resetSlidingNavigation() {
const menuContainer = document.getElementById('menu-container');
if (!menuContainer) return;
if (this.#slidingViewportEnabled) {
this.#currentLevel = 0;
this.#navigationStack = [];
const viewport = document.querySelector('.menu-vdi__viewport');
if (viewport) {
// Remove all levels except main
const levels = viewport.querySelectorAll('.menu-vdi__level:not(.menu-vdi__level--main)');
levels.forEach(level => level.remove());
// Reset position
this.animateToLevel(0);
}
} else {
// Clean up sliding structure and restore normal menu
this.cleanupSlidingStructure();
}
}
/**
* Clean up sliding viewport structure and restore normal menu
*/
cleanupSlidingStructure() {
const menuContainer = document.getElementById('menu-container');
if (!menuContainer) return;
// Remove sliding class
menuContainer.classList.remove('menu-vdi--sliding');
// Find viewport and move items back to container
const viewport = menuContainer.querySelector('.menu-vdi__viewport');
if (viewport) {
const mainLevel = viewport.querySelector('.menu-vdi__level--main');
if (mainLevel) {
// Move all items back to menu container
const items = Array.from(mainLevel.children);
items.forEach(item => {
menuContainer.appendChild(item);
});
}
// Remove viewport structure
viewport.remove();
}
console.log('Sliding structure cleaned up');
}
} }
export default Navigation; export default Navigation;

View File

@@ -7,13 +7,30 @@
* - index.php * - index.php
* - nav-functional.php * - nav-functional.php
* *
* Desktop Navigation Styles:
* Choose one or the other for the main navigation, based on theme needs. * Choose one or the other for the main navigation, based on theme needs.
* @import 'nav-main-default'; (standard dropdown)
* @import 'nav-main-mega'; (mega menu style)
* *
* @import 'nav-main-default'; * Mobile Navigation Styles:
* @import 'nav-main-mega'; * Choose one for mobile navigation behavior:
* @import 'nav-mobile-accordion'; (traditional dropdown/accordion style)
* @import 'nav-mobile-sliding'; (sliding viewport style)
*/ */
@import "./nav-functional.css"; @import "./nav-functional.css";
@import "./nav-aux.css"; @import "./nav-aux.css";
/* Mobile Navigation Style - Choose one of the following: */
/* Traditional dropdown style */
@import "./nav-main-default.css"; @import "./nav-main-default.css";
/* Mega menu style */
/* @import "./nav-main-mega.css"; */
/* Mobile Navigation Style - Choose one of the following: */
/* Accordion/dropdown style */
/* @import "./nav-mobile-accordion.css"; */
/* Sliding viewport style */
@import "./nav-mobile-sliding.css";
@import "./nav-footer.css"; @import "./nav-footer.css";

View File

@@ -1,5 +1,10 @@
/** /**
* VDI Navs & Menu - Main Navigation + Default Dropdown Menu Styles * VDI Navs & Menu - Main Navigation + Default Dropdown Menu Styles (Desktop Only)
*
* This file contains only desktop navigation styles.
* For mobile navigation, choose one of:
* - nav-mobile-accordion.css (traditional dropdown/accordion style)
* - nav-mobile-sliding.css (sliding viewport style)
* *
* Please review documentation upon first use, and, as-needed: * Please review documentation upon first use, and, as-needed:
* TODO: Add documenation link here * TODO: Add documenation link here
@@ -22,46 +27,6 @@
} }
} }
/* mobile */
@media screen and (max-width: 62.5rem) {
.nav-main {
.nav-main__toggle {
/* display */
@apply text-white p-3;
}
.menu-vdi {
@apply flex-col bg-white w-[95%] right-0 z-10 py-6;
top: var(--hgtHeader);
min-height: calc(100vh - var(--hgtHeader));
.menu-vdi__submenu {
@apply py-2 px-7 flex-col;
}
.menu-vdi__item {
a,
button {
/* text */
@apply font-bold text-20px text-black hover:text-light no-underline leading-snug;
/* spacing & display */
@apply block w-full p-4;
/* interaction */
@apply focus-visible:bg-secondary-200 hover:bg-secondary-200;
}
a {
@apply block w-full;
}
button {
@apply flex w-full justify-between;
}
}
}
}
}
@media screen and (min-width: 62.5rem) { @media screen and (min-width: 62.5rem) {
.menu-vdi { .menu-vdi {
.menu-vdi__toggle { .menu-vdi__toggle {

View File

@@ -0,0 +1,52 @@
/**
* VDI Navs & Menu - Mobile Accordion Navigation
*
* Traditional mobile accordion/dropdown style navigation
* Include this file for accordion-style mobile navigation
*/
/* Mobile accordion navigation */
@media screen and (max-width: 62.5rem) {
.nav-main {
.nav-main__toggle {
/* display */
@apply text-white p-3;
}
.menu-vdi {
@apply flex-col bg-white w-[95%] right-0 z-10 py-6;
@apply absolute hidden; /* Hidden by default */
top: var(--hgtHeader);
min-height: calc(100vh - var(--hgtHeader));
.menu-vdi__submenu {
@apply py-2 px-7 flex-col;
}
.menu-vdi__item {
a,
button {
/* text */
@apply font-bold text-20px text-black hover:text-light no-underline leading-snug;
/* spacing & display */
@apply block w-full p-4;
/* interaction */
@apply focus-visible:bg-secondary-200 hover:bg-secondary-200;
}
a {
@apply block w-full;
}
button {
@apply flex w-full justify-between;
}
}
}
/* Show menu when toggle button is expanded */
.nav-main__toggle[aria-expanded="true"] ~ .menu-vdi:not(.menu-vdi--sliding) {
@apply !flex; /* Use !important to override hidden */
}
}
}

View File

@@ -0,0 +1,130 @@
/**
* VDI Navs & Menu - Mobile Sliding Viewport Navigation
*
* Sliding viewport style for mobile navigation where
* users can navigate through menu levels by sliding between views
* Include this file for sliding-style mobile navigation
*/
/* Mobile sliding viewport navigation */
@media screen and (max-width: 62.5rem) {
.nav-main {
.nav-main__toggle {
/* display */
@apply text-white p-3;
}
.menu-vdi--sliding {
@apply relative overflow-hidden bg-white w-[95%] right-0 z-10 py-6;
@apply absolute hidden; /* Hidden by default */
top: var(--hgtHeader);
min-height: calc(100vh - var(--hgtHeader));
/* Container for all navigation levels */
.menu-vdi__viewport {
@apply flex transition-transform duration-300 ease-in-out;
width: 100%;
}
/* Each navigation level */
.menu-vdi__level {
@apply w-full flex-shrink-0 flex-col;
min-width: 100%;
}
/* Back button for secondary levels */
.menu-vdi__back {
@apply flex items-center gap-2 p-4 border-b border-gray-200 font-bold text-black hover:bg-secondary-200 focus-visible:bg-secondary-200 cursor-pointer;
svg {
@apply w-5 h-5;
}
}
/* Level indicator for context */
.menu-vdi__level-title {
@apply p-4 border-b border-gray-200 font-bold text-lg text-center bg-gray-50;
}
/* Navigation items in sliding mode */
.menu-vdi__item {
a,
button {
/* text */
@apply font-bold text-20px text-black hover:text-light no-underline leading-snug;
/* spacing & display */
@apply block w-full p-4;
/* interaction */
@apply focus-visible:bg-secondary-200 hover:bg-secondary-200;
}
a {
@apply block w-full;
}
button {
@apply flex w-full justify-between items-center;
/* Arrow indicator for items with children */
svg {
@apply w-5 h-5;
}
}
}
/* Hide submenu toggles in sliding mode as they become navigation buttons */
.menu-vdi__toggle {
/* Override the accordion toggle behavior */
&[aria-expanded="true"] {
svg {
@apply rotate-0; /* Don't rotate arrow */
}
+.menu-vdi__submenu {
@apply hidden; /* Don't show dropdown */
}
}
}
/* Hide traditional submenus in sliding mode */
.menu-vdi__submenu {
@apply hidden;
}
/* Ensure menu items in sliding viewport have proper styling */
.menu-vdi__level-items {
@apply flex-col;
.menu-vdi__item {
@apply w-full;
a {
@apply p-4 block w-full no-underline;
}
span {
@apply p-4 block w-full font-bold;
}
}
/* Style nested items that were converted from submenus */
.menu-vdi__nested-items {
@apply block;
.menu-vdi__item {
@apply w-full;
a {
@apply p-4 pl-8 block w-full no-underline; /* Add indent for nested items */
}
}
}
}
}
/* Show sliding menu when toggle button is expanded and menu has sliding class */
.nav-main__toggle[aria-expanded="true"] + .menu-vdi--sliding {
@apply !block; /* Use !important to override hidden */
}
}
}