feat: Enhance MegaMenu with localization, caching, and new properties

- Added localization support for loading states, empty navigation, and submenu actions in both German and English.
- Introduced new properties in IMenuItem for description, openInNewWindow, and external links.
- Updated SPTermStorePickerService to handle cache versioning and language-specific caching.
- Implemented a new MegaMenuCore service for handling term properties, cache management, and navigation URL resolution.
- Refactored TaxonomyNavigationService to build menu hierarchy and filter hidden terms.
- Added automated tests for MegaMenu core functionalities and static asset validation.
- Removed unused ItemDictionary service.
- Created a comprehensive ToDo document outlining the implementation status and future tasks.
This commit is contained in:
Torsten Brendgen
2026-07-20 21:34:24 +02:00
parent bc61166267
commit 50b85468f1
30 changed files with 1394 additions and 294 deletions
+198 -69
View File
@@ -6,9 +6,22 @@
import { IMenuItem } from '../../services/IMenuItem';
import { debugLog } from '../../services/MegaMenuDebug';
import { MegaMenuMode } from './MegaMenuMode';
import { isNavigationUrlActive } from '../../services/MegaMenuCore';
const LOG_SOURCE: string = 'MegaMenuRenderer';
export interface IMegaMenuRendererStrings {
navigationLabel: string;
openNavigation: string;
closeNavigation: string;
openSubmenu: string;
closeSubmenu: string;
submenuLabel: string;
externalLink: string;
opened: string;
closed: string;
}
export class MegaMenuRenderer {
private static readonly hoverOpenDelayMs: number = 140;
private static readonly hoverCloseDelayMs: number = 180;
@@ -16,11 +29,13 @@ export class MegaMenuRenderer {
private _documentKeydownHandler: ((event: KeyboardEvent) => void) | undefined;
private _documentClickHandler: ((event: MouseEvent) => void) | undefined;
private _announcer: HTMLElement | undefined;
private _mobileToggle: HTMLButtonElement | undefined;
constructor(
private menuItems: IMenuItem[],
private debug: boolean = false,
private menuMode: MegaMenuMode = 'megaMenu'
private menuMode: MegaMenuMode = 'megaMenu',
private strings: IMegaMenuRendererStrings
) { }
public render(container: HTMLElement): void {
@@ -38,10 +53,22 @@ export class MegaMenuRenderer {
nav.id = 'Mega-Menu';
nav.className = this.joinClasses('mega-menu-main', 'mega-menu-mode-' + this.menuMode);
nav.setAttribute('data-menu-mode', this.menuMode);
nav.setAttribute('aria-label', 'Hauptnavigation');
nav.setAttribute('aria-label', this.strings.navigationLabel);
this._mobileToggle = document.createElement('button');
this._mobileToggle.type = 'button';
this._mobileToggle.className = 'mega-menu-mobile-toggle';
this._mobileToggle.setAttribute('aria-expanded', 'false');
this._mobileToggle.setAttribute('aria-controls', 'Mega-Menu-Items');
this._mobileToggle.setAttribute('aria-label', this.strings.openNavigation);
this._mobileToggle.innerHTML = '<span class="mega-menu-mobile-icon" aria-hidden="true"></span><span class="mega-menu-mobile-label"></span>';
const mobileLabel = this._mobileToggle.querySelector('.mega-menu-mobile-label') as HTMLElement;
mobileLabel.textContent = this.strings.navigationLabel;
nav.appendChild(this._mobileToggle);
const topLevelUl = document.createElement('ul');
topLevelUl.className = 'mega-menu-top-level';
topLevelUl.id = 'Mega-Menu-Items';
this.menuItems.forEach(topLevelItem => {
const topLevelLi = this.createTopLevelItem(topLevelItem);
@@ -76,6 +103,7 @@ export class MegaMenuRenderer {
this._announcer.parentElement.removeChild(this._announcer);
}
this._announcer = undefined;
this._mobileToggle = undefined;
if (this._container) {
this._container.innerHTML = '';
}
@@ -100,9 +128,20 @@ export class MegaMenuRenderer {
li.appendChild(topElement);
if (hasChildren) {
const toggle: HTMLButtonElement = document.createElement('button');
const menuId: string = 'Mega-Menu-Panel-' + item.id.replace(/[^a-z0-9_-]/gi, '');
toggle.type = 'button';
toggle.className = 'menu-item-toggle';
toggle.setAttribute('aria-haspopup', 'true');
toggle.setAttribute('aria-expanded', 'false');
toggle.setAttribute('aria-controls', menuId);
toggle.setAttribute('aria-label', this.strings.openSubmenu + ' ' + item.label);
toggle.innerHTML = '<span aria-hidden="true"></span>';
li.appendChild(toggle);
const childMenu = this.menuMode === 'flyout'
? this.createFlyoutMenu(item)
: this.createMegaMenu(item);
childMenu.id = menuId;
li.appendChild(childMenu);
}
@@ -119,7 +158,9 @@ export class MegaMenuRenderer {
} else {
element = document.createElement('span');
element.className = 'menu-item-text';
element.setAttribute('tabindex', '0');
if (!hasChildren) {
element.setAttribute('tabindex', '0');
}
}
element.textContent = item.label;
@@ -130,11 +171,6 @@ export class MegaMenuRenderer {
isCurrent ? 'is-current' : undefined
);
if (hasChildren) {
element.setAttribute('aria-haspopup', 'true');
element.setAttribute('aria-expanded', 'false');
}
if (isCurrent && item.url) {
element.setAttribute('aria-current', 'page');
}
@@ -143,6 +179,10 @@ export class MegaMenuRenderer {
element.title = item.hoverText;
}
if (item.url) {
this.applyLinkBehavior(element as HTMLAnchorElement, item);
}
return element;
}
@@ -150,7 +190,7 @@ export class MegaMenuRenderer {
const megaMenuDiv = document.createElement('div');
megaMenuDiv.className = 'mega-menu';
megaMenuDiv.setAttribute('aria-expanded', 'false');
megaMenuDiv.setAttribute('aria-label', parentItem.label + ' Unterkategorien');
megaMenuDiv.setAttribute('aria-label', parentItem.label + ' ' + this.strings.submenuLabel);
const gridDiv = document.createElement('div');
gridDiv.className = 'mega-menu-grid';
@@ -184,6 +224,7 @@ export class MegaMenuRenderer {
const link = document.createElement('a');
link.href = item.url;
link.textContent = item.label;
this.applyLinkBehavior(link, item);
link.className = this.joinClasses(
isActive ? 'is-active' : undefined,
isCurrent ? 'is-current' : undefined
@@ -207,6 +248,13 @@ export class MegaMenuRenderer {
h3.appendChild(span);
}
if (item.description) {
const description: HTMLParagraphElement = document.createElement('p');
description.className = 'mega-menu-description';
description.textContent = item.description;
categoryDiv.appendChild(description);
}
categoryDiv.appendChild(h3);
if (item.hasChildren() && item.items && item.items.length > 0) {
@@ -226,6 +274,7 @@ export class MegaMenuRenderer {
link.href = thirdLevelItem.url || '#';
link.textContent = thirdLevelItem.label;
this.applyLinkBehavior(link, thirdLevelItem);
link.className = this.joinClasses(
isLinkActive ? 'is-active' : undefined,
isLinkCurrent ? 'is-current' : undefined
@@ -253,7 +302,7 @@ export class MegaMenuRenderer {
const flyout = document.createElement('div');
flyout.className = 'mega-menu mega-menu-flyout';
flyout.setAttribute('aria-expanded', 'false');
flyout.setAttribute('aria-label', parentItem.label + ' Untermenü');
flyout.setAttribute('aria-label', parentItem.label + ' ' + this.strings.submenuLabel);
const list = document.createElement('ul');
list.className = 'flyout-list flyout-level-2';
@@ -285,9 +334,19 @@ export class MegaMenuRenderer {
li.appendChild(element);
if (hasChildren && item.items) {
const toggle: HTMLButtonElement = document.createElement('button');
const childId: string = 'Mega-Menu-Flyout-' + item.id.replace(/[^a-z0-9_-]/gi, '');
toggle.type = 'button';
toggle.className = 'flyout-toggle';
toggle.setAttribute('aria-expanded', 'false');
toggle.setAttribute('aria-controls', childId);
toggle.setAttribute('aria-label', this.strings.openSubmenu + ' ' + item.label);
toggle.innerHTML = '<span aria-hidden="true"></span>';
li.appendChild(toggle);
const childList = document.createElement('ul');
childList.className = 'flyout-list flyout-level-3';
childList.setAttribute('aria-label', item.label + ' Untermenü');
childList.id = childId;
childList.setAttribute('aria-label', item.label + ' ' + this.strings.submenuLabel);
item.items.forEach(thirdLevelItem => {
childList.appendChild(this.createFlyoutItem(thirdLevelItem));
@@ -306,6 +365,7 @@ export class MegaMenuRenderer {
if (item.url) {
(element as HTMLAnchorElement).href = item.url;
this.applyLinkBehavior(element as HTMLAnchorElement, item);
} else {
element.setAttribute('tabindex', '0');
}
@@ -318,9 +378,6 @@ export class MegaMenuRenderer {
isCurrent ? 'is-current' : undefined
);
if (hasChildren) {
element.setAttribute('aria-haspopup', 'true');
}
if (isCurrent && item.url) {
element.setAttribute('aria-current', 'page');
}
@@ -332,7 +389,7 @@ export class MegaMenuRenderer {
}
private attachEventListeners(): void {
const headings = document.querySelectorAll('#Mega-Menu > ul > li > a, #Mega-Menu > ul > li > .menu-item-text');
const headings = document.querySelectorAll('#Mega-Menu > ul > li > .menu-item-toggle');
for (let i = 0; i < headings.length; i++) {
const heading = headings[i] as HTMLElement;
@@ -347,21 +404,17 @@ export class MegaMenuRenderer {
this.attachGlobalKeyboardNavigation();
this.attachGlobalClickOutsideHandler();
this.attachMobileToggle();
this.attachFlyoutToggles();
}
private attachKeyboardNavigation(heading: HTMLElement, megaMenu: HTMLElement): void {
heading.addEventListener('click', (e: MouseEvent) => {
e.preventDefault();
this.toggleMegaMenu(heading, megaMenu);
});
heading.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter') {
if (heading.tagName === 'A') {
return;
}
e.preventDefault();
this.toggleMegaMenu(heading, megaMenu);
} else if (e.key === ' ') {
e.preventDefault();
this.toggleMegaMenu(heading, megaMenu);
} else if (e.key === 'ArrowDown') {
if (e.key === 'ArrowDown') {
e.preventDefault();
this.openMegaMenu(heading, megaMenu);
this.focusFirstLink(megaMenu);
@@ -375,12 +428,6 @@ export class MegaMenuRenderer {
}
});
if (heading.tagName === 'A') {
heading.addEventListener('click', () => {
debugLog(this.debug, LOG_SOURCE, 'Link clicked:', (heading as HTMLAnchorElement).href);
});
}
heading.addEventListener('focus', () => {
debugLog(this.debug, LOG_SOURCE, 'Focus moved to:', heading.textContent);
});
@@ -444,7 +491,7 @@ export class MegaMenuRenderer {
const isInsideThisMenu = megaMenu.contains(focusedElement);
const isOnThisTrigger = focusedElement === heading;
const isInAnyMegaMenu = focusedElement.closest('.mega-menu');
const isOnAnyTopLevel = focusedElement.closest('#Mega-Menu > ul > li > a, #Mega-Menu > ul > li > span');
const isOnAnyTopLevel = focusedElement.closest('.mega-menu-top-item');
if (!isInsideThisMenu && !isOnThisTrigger && !isInAnyMegaMenu && !isOnAnyTopLevel) {
debugLog(this.debug, LOG_SOURCE, 'Closing menu because focus left the navigation.');
@@ -467,9 +514,30 @@ export class MegaMenuRenderer {
}
}
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && activeElement.closest('.mega-menu-top-item')) {
const currentItem = activeElement.closest('.mega-menu-top-item') as HTMLElement;
const items = document.querySelectorAll('#Mega-Menu > ul > .mega-menu-top-item');
let currentIndex: number = -1;
for (let index: number = 0; index < items.length; index++) {
if (items[index] === currentItem) {
currentIndex = index;
break;
}
}
if (currentIndex >= 0) {
e.preventDefault();
const offset: number = e.key === 'ArrowRight' ? 1 : -1;
const targetIndex: number = (currentIndex + offset + items.length) % items.length;
const target = (items[targetIndex] as HTMLElement).querySelector('a, .menu-item-toggle') as HTMLElement;
if (target) {
target.focus();
}
}
}
if (e.key === 'Tab') {
if (!e.shiftKey) {
const currentTopLevel = activeElement.closest('#Mega-Menu > ul > li > a, #Mega-Menu > ul > li > span') as HTMLElement;
const currentTopLevel = activeElement.closest('.menu-item-toggle') as HTMLElement;
if (currentTopLevel) {
const parentLi = currentTopLevel.closest('li') as HTMLElement;
const megaMenu = parentLi.querySelector('.mega-menu.js-open') as HTMLElement;
@@ -523,21 +591,86 @@ export class MegaMenuRenderer {
document.addEventListener('click', this._documentClickHandler);
}
private attachMobileToggle(): void {
if (!this._mobileToggle) {
return;
}
this._mobileToggle.addEventListener('click', (): void => {
const nav: HTMLElement = this._mobileToggle.closest('#Mega-Menu') as HTMLElement;
const isOpen: boolean = this._mobileToggle.getAttribute('aria-expanded') === 'true';
this._mobileToggle.setAttribute('aria-expanded', String(!isOpen));
this._mobileToggle.setAttribute('aria-label', isOpen ? this.strings.openNavigation : this.strings.closeNavigation);
nav.classList.toggle('is-mobile-open', !isOpen);
if (isOpen) {
this.closeAllMegaMenus();
}
});
const nav: HTMLElement = this._mobileToggle.closest('#Mega-Menu') as HTMLElement;
nav.addEventListener('click', (event: MouseEvent): void => {
const target: Element = event.target as Element;
if (target && target.closest('a') && window.innerWidth <= 767) {
this._mobileToggle.setAttribute('aria-expanded', 'false');
this._mobileToggle.setAttribute('aria-label', this.strings.openNavigation);
nav.classList.remove('is-mobile-open');
}
});
}
private attachFlyoutToggles(): void {
const toggles = document.querySelectorAll('#Mega-Menu .flyout-toggle');
for (let i: number = 0; i < toggles.length; i++) {
const toggle: HTMLButtonElement = toggles[i] as HTMLButtonElement;
const parent: HTMLElement = toggle.parentElement as HTMLElement;
const setOpen = (open: boolean): void => {
toggle.setAttribute('aria-expanded', String(open));
toggle.setAttribute('aria-label', (open ? this.strings.closeSubmenu : this.strings.openSubmenu)
+ ' ' + this.getFlyoutToggleLabel(toggle));
parent.classList.toggle('is-open', open);
};
toggle.addEventListener('click', (event: MouseEvent): void => {
event.preventDefault();
setOpen(toggle.getAttribute('aria-expanded') !== 'true');
});
toggle.addEventListener('keydown', (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
event.preventDefault();
setOpen(false);
toggle.focus();
}
});
parent.addEventListener('mouseenter', (): void => setOpen(true));
parent.addEventListener('mouseleave', (): void => setOpen(false));
parent.addEventListener('focusin', (): void => setOpen(true));
parent.addEventListener('focusout', (): void => {
window.setTimeout((): void => {
if (!parent.contains(document.activeElement)) {
setOpen(false);
}
}, 0);
});
}
}
private openMegaMenu(trigger: HTMLElement, menu: HTMLElement): void {
this.closeAllMegaMenus(menu);
const label: string = this.getTriggerLabel(trigger);
trigger.setAttribute('aria-expanded', 'true');
trigger.setAttribute('aria-label', this.strings.closeSubmenu + ' ' + label);
menu.setAttribute('aria-expanded', 'true');
menu.classList.add('js-open');
this.announce((trigger.textContent || '') + ' geöffnet');
debugLog(this.debug, LOG_SOURCE, 'Menu opened:', trigger.textContent);
this.announce(label + ' ' + this.strings.opened);
debugLog(this.debug, LOG_SOURCE, 'Menu opened:', label);
}
private closeMegaMenu(trigger: HTMLElement, menu: HTMLElement): void {
const label: string = this.getTriggerLabel(trigger);
trigger.setAttribute('aria-expanded', 'false');
trigger.setAttribute('aria-label', this.strings.openSubmenu + ' ' + label);
menu.setAttribute('aria-expanded', 'false');
menu.classList.remove('js-open');
this.announce((trigger.textContent || '') + ' geschlossen');
debugLog(this.debug, LOG_SOURCE, 'Menu closed:', trigger.textContent);
this.announce(label + ' ' + this.strings.closed);
debugLog(this.debug, LOG_SOURCE, 'Menu closed:', label);
}
private toggleMegaMenu(trigger: HTMLElement, menu: HTMLElement): void {
@@ -550,7 +683,7 @@ export class MegaMenuRenderer {
}
private closeAllMegaMenus(exceptMenu?: HTMLElement): void {
const allTriggers = document.querySelectorAll('#Mega-Menu > ul > li > a[aria-expanded="true"], #Mega-Menu > ul > li > span[aria-expanded="true"]');
const allTriggers = document.querySelectorAll('#Mega-Menu > ul > li > .menu-item-toggle[aria-expanded="true"]');
for (let i = 0; i < allTriggers.length; i++) {
const trigger = allTriggers[i] as HTMLElement;
const menu = trigger.nextElementSibling as HTMLElement;
@@ -586,36 +719,7 @@ export class MegaMenuRenderer {
}
private isCurrentUrl(url?: string): boolean {
const currentPath = this.normalizePath(window.location.href);
const itemPath = this.normalizePath(url);
if (!currentPath || !itemPath) {
return false;
}
if (itemPath === '/') {
return currentPath === '/';
}
if (currentPath === itemPath) {
return true;
}
return currentPath.indexOf(itemPath + '/') === 0;
}
private normalizePath(url?: string): string {
if (!url) {
return '';
}
try {
const parsedUrl = new URL(url, window.location.origin);
const pathname = (parsedUrl.pathname || '/').replace(/\/+$/, '');
return (pathname || '/').toLowerCase();
} catch (error) {
return '';
}
return isNavigationUrlActive(window.location.href, url || '', window.location.origin);
}
private getColumnCount(items?: IMenuItem[]): string {
@@ -624,6 +728,31 @@ export class MegaMenuRenderer {
return columnCount.toString();
}
private applyLinkBehavior(link: HTMLAnchorElement, item: IMenuItem): void {
if (item.openInNewWindow) {
link.target = '_blank';
link.rel = 'noopener noreferrer';
}
if (item.external) {
const currentLabel: string = link.getAttribute('aria-label') || item.label;
link.setAttribute('aria-label', currentLabel + ' (' + this.strings.externalLink + ')');
}
}
private getTriggerLabel(trigger: HTMLElement): string {
const labelElement: HTMLElement = trigger.parentElement
? trigger.parentElement.querySelector('.menu-item-link, .menu-item-text') as HTMLElement
: undefined;
return labelElement ? labelElement.textContent || '' : '';
}
private getFlyoutToggleLabel(toggle: HTMLElement): string {
const labelElement: HTMLElement = toggle.parentElement
? toggle.parentElement.querySelector('.flyout-link') as HTMLElement
: undefined;
return labelElement ? labelElement.textContent || '' : '';
}
private joinClasses(...classNames: Array<string | undefined>): string {
const filteredClassNames: string[] = [];