feat: bootstrap Community Works Collaborative theme from starter
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
<svg width="83px" height="83px" viewBox="0 0 83 83" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Copyright (c) 2025 Kevinleary.net, LLC. All rights reserved. -->
|
||||
<title>kevinleary.net</title>
|
||||
<circle fill="#FFFFFF" cx="41.5" cy="41.5" r="41.5"></circle>
|
||||
<path d="M41.4990862,5 C21.3735072,5 5,21.3753349 5,41.5009138 C5,61.6264928 21.3735072,78 41.5009138,78 C61.6264928,78 78,61.6264928 78,41.5009138 C78,21.3753349 61.6264928,5 41.4990862,5 Z M50.341036,59.1902962 L34.0314949,42.9703077 L33.9218386,43.0799639 L33.9218386,59.1902962 L30.0893523,59.1902962 L30.0893523,22.8904188 L33.9218386,22.8904188 L33.9218386,37.9517062 L49.2389906,22.8904188 L54.5006634,22.8904188 L36.7016248,40.400696 L55.5387427,59.1902962 L50.341036,59.1902962 Z" fill="#000000" fill-rule="nonzero"></path>
|
||||
<path d="M41.5,0 C18.5798958,0 0,18.5798958 0,41.5 C0,64.418375 18.5798958,83 41.5,83 C64.418375,83 83,64.4201042 83,41.5 C83,18.5798958 64.4201042,0 41.5,0 Z M41.5,80.2934783 C20.1078009,80.2934783 2.70652174,62.8907964 2.70652174,41.5009041 C2.70652174,20.1092036 20.1096092,2.70652174 41.5,2.70652174 C62.8903908,2.70652174 80.2934783,20.1092036 80.2934783,41.5009041 C80.2934783,62.8907964 62.8903908,80.2934783 41.5,80.2934783 Z" fill="#000000" fill-rule="nonzero"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 976 B |
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Admin JS
|
||||
*/
|
||||
import { registerButtonComponent } from './components/button.js';
|
||||
|
||||
const app = () => {
|
||||
registerButtonComponent();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', app);
|
||||
|
||||
console.log(`admin.js loaded.`);
|
||||
@@ -0,0 +1,33 @@
|
||||
// Back to Top Button Component
|
||||
class BackToTopButton extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.innerHTML = `
|
||||
<button id="backToTopBtn" aria-label="Back to top" class="back-to-top" style="">
|
||||
↑ Top
|
||||
</button>
|
||||
`;
|
||||
|
||||
const btn = this.querySelector('#backToTopBtn');
|
||||
|
||||
let previousScrollY = window.scrollY;
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
const currentScrollY = window.scrollY;
|
||||
const isScrollingUp = currentScrollY < previousScrollY;
|
||||
const shouldShowButton = currentScrollY > 300 && isScrollingUp;
|
||||
|
||||
btn.style.display = shouldShowButton ? 'block' : 'none';
|
||||
previousScrollY = currentScrollY;
|
||||
});
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function registerBackToTopButton() {
|
||||
if (!customElements.get('back-to-top')) {
|
||||
customElements.define('back-to-top', BackToTopButton);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
class ButtonComponent extends HTMLElement {
|
||||
/**
|
||||
* Parameters
|
||||
* - btnClasses: Additional classes to add to the block.
|
||||
* - element: The element to use for the button. Defaults to 'a'.
|
||||
* - url: The URL to link to.
|
||||
* - target: The target for the link.
|
||||
* - title: The text to display on the button.
|
||||
* - ariaLabel: The ARIA label for the button.
|
||||
* - color: The color of the button.
|
||||
* - variant: The variant of the button.
|
||||
* - size: The size of the button.
|
||||
* - width: The width of the button.
|
||||
*
|
||||
*/
|
||||
|
||||
connectedCallback() {
|
||||
if (!this.querySelector(this.getAttribute('element'))) {
|
||||
this.append(document.createElement(this.getAttribute('element')));
|
||||
}
|
||||
|
||||
this.update();
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return [
|
||||
'btnClasses',
|
||||
'el',
|
||||
'element',
|
||||
'type',
|
||||
'url',
|
||||
'target',
|
||||
'title',
|
||||
'ariaLabel',
|
||||
'color',
|
||||
'variant',
|
||||
'size',
|
||||
'width',
|
||||
]
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
this.update();
|
||||
}
|
||||
|
||||
update() {
|
||||
const btn = this.querySelector(this.getAttribute('element'));
|
||||
|
||||
// console.log('[ButtonComponent] attributes', {
|
||||
// btnClasses: this.getAttribute('btnClasses'),
|
||||
// element: this.getAttribute('element'),
|
||||
// type: this.getAttribute('type'),
|
||||
// url: this.getAttribute('url'),
|
||||
// target: this.getAttribute('target'),
|
||||
// title: this.getAttribute('title'),
|
||||
// ariaLabel: this.getAttribute('ariaLabel'),
|
||||
// color: this.getAttribute('color'),
|
||||
// variant: this.getAttribute('variant'),
|
||||
// size: this.getAttribute('size'),
|
||||
// width: this.getAttribute('width'),
|
||||
// });
|
||||
|
||||
if (btn) {
|
||||
btn.classList = this.getAttribute('btnClasses') || '';
|
||||
|
||||
if (this.getAttribute('element') == 'a') {
|
||||
btn.href = this.getAttribute('url') || '#';
|
||||
|
||||
if (btn.target) {
|
||||
btn.target = 'target="${this.getAttribute(target)}"';
|
||||
}
|
||||
}
|
||||
|
||||
const type = this.getAttribute('type');
|
||||
if (type && this.getAttribute('element') !== 'a') {
|
||||
btn.type = type;
|
||||
}
|
||||
|
||||
btn.title = this.getAttribute('title') || '';
|
||||
btn.textContent = this.getAttribute('title') || '';
|
||||
|
||||
if (!this.getAttribute('ariaLabel') && this.getAttribute('url')) {
|
||||
btn.setAttribute('aria-label', `Link to ${this.getAttribute('url')}`);
|
||||
} else {
|
||||
btn.setAttribute('aria-label', this.getAttribute('ariaLabel'));
|
||||
}
|
||||
|
||||
btn.setAttribute('aria-label', this.getAttribute('ariaLabel'));
|
||||
btn.setAttribute('data-button-color', this.getAttribute('color'));
|
||||
btn.setAttribute('data-button-variant', this.getAttribute('variant'));
|
||||
btn.setAttribute('data-button-size', this.getAttribute('size'));
|
||||
btn.setAttribute('data-button-width', this.getAttribute('width'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const registerButtonComponent = () => {
|
||||
customElements.define('x-button', ButtonComponent);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Get Header Height
|
||||
* -
|
||||
* Get header height and set CSS variable "--hgtHeader" to the header height.
|
||||
*/
|
||||
|
||||
function getHeaderHeight() {
|
||||
const headerHeight = document.querySelector('.header__nav-main').getBoundingClientRect().height;
|
||||
document.documentElement.style.setProperty('--hgtHeader', `${headerHeight}px`);
|
||||
}
|
||||
|
||||
export default getHeaderHeight;
|
||||
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* VDI Main Nav - All Main Navigation Functionality
|
||||
*
|
||||
* Please review documentation upon first use, and, as-needed:
|
||||
* https://docs.vincentdevelopment.ca/docs/starter-v3-enhancements/navigation/
|
||||
*/
|
||||
|
||||
/**
|
||||
* Navigation
|
||||
* Handles navigation logic
|
||||
*
|
||||
* @param {string} mobileMenuButtonId
|
||||
* @param {string} dropDownClass
|
||||
* @param {string} subMenuLinkClass
|
||||
*/
|
||||
class Navigation {
|
||||
/**
|
||||
* The main toggle element
|
||||
*
|
||||
* @type {HTMLElement}
|
||||
* @link {https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties}
|
||||
*/
|
||||
#mobileMenuButton;
|
||||
|
||||
/**
|
||||
* List of sub dropdown buttons
|
||||
*
|
||||
* @type {NodeList}
|
||||
* @link {https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties}
|
||||
*/
|
||||
#dropdownButtons;
|
||||
|
||||
/**
|
||||
* Class name to identify sub menu items (ul>li>a)
|
||||
* @type {string}
|
||||
*/
|
||||
#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") {
|
||||
this.#mobileMenuButton = document.getElementById(mobileMenuButtonId);
|
||||
this.#dropdownButtons = document.querySelectorAll(dropDownClass); // Do not change this to getElementsByClassName, logic is iterating over non-live node list
|
||||
this.#subMenuElementIdentifier = subMenuLinkClass;
|
||||
|
||||
this.handleEscapeKey();
|
||||
|
||||
// Initialize sliding viewport immediately if styles are detected
|
||||
this.initializeSlidingViewport();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles main mobile toggling
|
||||
* Adds an event listener to mobile menu dropdown
|
||||
* toggle button
|
||||
*/
|
||||
mobileMenuToggle() {
|
||||
this.#mobileMenuButton.addEventListener("click", (event) => {
|
||||
this.toggleMobileMenu(event, this.#mobileMenuButton); // toggle submenu on mobile
|
||||
});
|
||||
|
||||
this.closeOnBlur(this.#mobileMenuButton, { "navType": "desktop" }); // close submenu when user clicks outside
|
||||
this.closeOnBlur(this.#mobileMenuButton, { "navType": "mobile" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles dropdowns on desktop navigation
|
||||
* Loops over list of navigation dropdown buttons
|
||||
* and adds eventlisteners to toggle view
|
||||
*/
|
||||
desktopMenuDropdowns() {
|
||||
this.#dropdownButtons.forEach((button) => {
|
||||
button.addEventListener("click", (event) => {
|
||||
this.toggleDropdown(event, button);
|
||||
});
|
||||
|
||||
this.closeOnBlur(button); // close menu when user clicks outside
|
||||
this.handleFocus(button); // close dropdown when user finishes tabbing submenu elements
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles desktop dropdowns
|
||||
*
|
||||
* @param {EventTarget} event
|
||||
* @param {HTMLButtonElement} button
|
||||
*/
|
||||
toggleDropdown(event, button) {
|
||||
this.toggleAriaExpanded(event, button);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles mobile menu
|
||||
*
|
||||
* @param {EventTarget} event
|
||||
* @param {HTMLButtonElement} button
|
||||
*/
|
||||
toggleMobileMenu(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles aria-expanded attribute
|
||||
*
|
||||
* @param {EventTarget} event
|
||||
* @param {HTMLButtonElement} button
|
||||
*/
|
||||
toggleAriaExpanded(event, button) {
|
||||
let isExpanded = event.currentTarget.getAttribute("aria-expanded") === "true"; // true is returned as string
|
||||
|
||||
// ... but open the targeted secondary nav
|
||||
if (!isExpanded) {
|
||||
// close all dropdowns...
|
||||
this.closeAllDropDowns();
|
||||
// then toggle targeted dropdown...
|
||||
button.setAttribute("aria-expanded", true);
|
||||
} else {
|
||||
button.setAttribute("aria-expanded", false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close dropdown when user clicks anywhere else on the screen
|
||||
*
|
||||
* @param {HTMLButtonElement} button
|
||||
* @link {https://developer.mozilla.org/en-US/docs/Web/API/Element/blur_event}
|
||||
* @link {https://developer.mozilla.org/en-US/docs/Web/API/FocusEvent/relatedTarget}
|
||||
*/
|
||||
closeOnBlur(button, args) {
|
||||
if (args === undefined || args?.navType === "desktop") {
|
||||
button.addEventListener("blur", (event) => {
|
||||
if (event.relatedTarget == null) {
|
||||
event.currentTarget.setAttribute("aria-expanded", false);
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
//FIXME: Remove hardcoded literal
|
||||
if (args?.navType === "mobile") {
|
||||
document.getElementById("menu-container").addEventListener("blur", (event) => {
|
||||
// Note: cannot group negation -1*-1*-1 != -(1*1*1)
|
||||
const isNull = event.relatedTarget == null;
|
||||
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("menu-vdi__back"); // Don't close on back button click
|
||||
|
||||
if (isNull || isNotMenuItem)
|
||||
button.setAttribute("aria-expanded", false);
|
||||
}, true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles escape behaviour
|
||||
* Closes all dropdown when user hits
|
||||
* escape key
|
||||
*
|
||||
* @link {https://developer.mozilla.org/en-US/docs/Web/API/Element/keyup_event}
|
||||
*/
|
||||
handleEscapeKey() {
|
||||
window.addEventListener("keyup", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
this.#mobileMenuButton.setAttribute("aria-expanded", false);
|
||||
this.closeAllDropDowns();
|
||||
|
||||
// Reset sliding navigation on escape
|
||||
if (this.#slidingViewportEnabled) {
|
||||
this.resetSlidingNavigation();
|
||||
}
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all dropdown menus
|
||||
* Sets aria expanded property by looping
|
||||
* over the list of dropdown button elements
|
||||
*
|
||||
* @link {https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-expanded}
|
||||
*/
|
||||
closeAllDropDowns() {
|
||||
this.#dropdownButtons.forEach((button) => {
|
||||
button.setAttribute("aria-expanded", false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle focus
|
||||
* Watches blur event on submenu list container, if the next
|
||||
* focus element is not a submenu list item, closes the
|
||||
* dropdown.
|
||||
*
|
||||
* Implemented for WCAG 2.2 compliance
|
||||
*
|
||||
* @param {htmlButtonElement} button
|
||||
*/
|
||||
handleFocus(button) {
|
||||
const subMenuListElement = button.closest("ul");
|
||||
|
||||
/**
|
||||
* Ducking JavaScript, I am not sure why the blur event on submenu(ul)
|
||||
* would bubble up to the button since the button and sub menu list element
|
||||
* are siblings and not nested. This is a mystery to me.
|
||||
*
|
||||
* Here we are stopping any bubbling event that may be propagated to the
|
||||
* top level buttons. This includes the bubbling event from sub menu list element.
|
||||
*
|
||||
* If anyone finds a better solution to this or can explain bubbling of focus event
|
||||
* to next sibling, you time and effort would be much appreciated.
|
||||
*/
|
||||
button.addEventListener("focusout", (event) => {
|
||||
event.stopImmediatePropagation();
|
||||
})
|
||||
|
||||
subMenuListElement.addEventListener("focusout", (event) => {
|
||||
// blur event triggers when user clicks outside
|
||||
const nextFocusElement = event.relatedTarget;
|
||||
|
||||
let isSubMenuElement;
|
||||
|
||||
if (nextFocusElement !== null) {
|
||||
isSubMenuElement = nextFocusElement.classList.contains(this.#subMenuElementIdentifier);
|
||||
} else {
|
||||
isSubMenuElement = false; // close when user clicks outside
|
||||
}
|
||||
|
||||
if (!isSubMenuElement) {
|
||||
this.closeAllDropDowns();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -0,0 +1,39 @@
|
||||
|
||||
/**
|
||||
* Tags external links in the document with appropriate attributes and styling.
|
||||
*
|
||||
* This function identifies all anchor elements with href attributes and determines
|
||||
* if they point to external domains. External links are enhanced with:
|
||||
* - Accessibility label indicating they open in a new tab
|
||||
* - target="_blank" to open in new tab
|
||||
* - rel="noopener noreferrer" for security
|
||||
* - Custom CSS class "extLink" for styling
|
||||
*
|
||||
* Links are considered external if their host differs from the current page's host.
|
||||
* Malformed URLs are silently ignored.
|
||||
*
|
||||
* @function tagExternalLinks
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
function tagExternalLinks() {
|
||||
const currentHost = window.location.host;
|
||||
|
||||
document.querySelectorAll('a[href]').forEach(link => {
|
||||
try {
|
||||
const url = new URL(link.href, window.location.href);
|
||||
|
||||
// If the link's host is different from the current host, treat as external
|
||||
if (url.host !== currentHost) {
|
||||
link.setAttribute('aria-label', 'External link, opens in a new tab');
|
||||
link.setAttribute('target', '_blank');
|
||||
link.setAttribute('rel', 'noopener noreferrer'); // Security best practice
|
||||
link.classList.add('extLink'); // Add a custom class for icons or other styling
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore malformed URLs
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default tagExternalLinks;
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Theme JS
|
||||
*/
|
||||
|
||||
import { registerButtonComponent } from './components/button.js';
|
||||
import { registerBackToTopButton } from './components/backToTop.js';
|
||||
import GetHeaderHeight from './modules/GetHeaderHeight.js';
|
||||
import tagExternalLinks from './modules/TagExternalLinks.js';
|
||||
import Navigation from './modules/Navigation.js';
|
||||
|
||||
// Add passive event listeners
|
||||
! function (e) {
|
||||
"function" == typeof define && define.amd ? define(e) : e()
|
||||
}(function () {
|
||||
let e;
|
||||
const t = ["scroll", "wheel", "touchstart", "touchmove", "touchenter", "touchend", "touchleave", "mouseout", "mouseleave", "mouseup", "mousedown", "mousemove", "mouseenter", "mousewheel", "mouseover"];
|
||||
if (function () {
|
||||
let e = !1;
|
||||
try {
|
||||
const t = Object.defineProperty({}, "passive", {
|
||||
get: function () {
|
||||
e = !0
|
||||
}
|
||||
});
|
||||
window.addEventListener("test", null, t);
|
||||
window.removeEventListener("test", null, t);
|
||||
} catch (e) { }
|
||||
return e
|
||||
}()) {
|
||||
const n = EventTarget.prototype.addEventListener;
|
||||
e = n;
|
||||
EventTarget.prototype.addEventListener = function (n, o, r) {
|
||||
let i;
|
||||
const s = "object" == typeof r && null !== r,
|
||||
u = s ? r.capture : r;
|
||||
if (s) {
|
||||
const t = Object.getOwnPropertyDescriptor(r, "passive");
|
||||
r = t && !0 !== t.writable && void 0 === t.set ? { ...r } : r;
|
||||
} else {
|
||||
r = {};
|
||||
}
|
||||
r.passive = void 0 !== (i = r.passive) ? i : -1 !== t.indexOf(n) && !0;
|
||||
r.capture = void 0 !== u && u;
|
||||
e.call(this, n, o, r);
|
||||
};
|
||||
EventTarget.prototype.addEventListener._original = e
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Application entrypoint
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Tag external links
|
||||
tagExternalLinks();
|
||||
|
||||
// Register button component
|
||||
registerButtonComponent();
|
||||
registerBackToTopButton();
|
||||
|
||||
// Initialize Navigation
|
||||
const navigation = new Navigation('navMainToggle', '.menu-vdi__toggle');
|
||||
|
||||
// Initialize Navigation
|
||||
navigation.desktopMenuDropdowns();
|
||||
navigation.mobileMenuToggle();
|
||||
|
||||
// Initialize Header Height
|
||||
GetHeaderHeight();
|
||||
|
||||
// Add Back to Top button to body
|
||||
const backToTop = document.createElement('back-to-top');
|
||||
document.body.appendChild(backToTop);
|
||||
});
|
||||
|
||||
console.log(`theme.js loaded.`);
|
||||
Reference in New Issue
Block a user