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:
@@ -70,6 +70,10 @@ $menu-layer: var(--megaMenuZIndex, 6000);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mega-menu-mobile-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mega-menu-mode-flyout .mega-menu-top-item {
|
||||
position: relative;
|
||||
}
|
||||
@@ -94,20 +98,40 @@ $menu-layer: var(--megaMenuZIndex, 6000);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.menu-item-has-children::after {
|
||||
.menu-item-toggle {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 32px;
|
||||
min-height: var(--megaMenuBarHeight, 44px);
|
||||
margin: 0 8px 0 -10px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $neutral-primary;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-item-toggle > span::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin: -3px 0 0 10px;
|
||||
border-right: 1px solid currentColor;
|
||||
border-bottom: 1px solid currentColor;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.menu-item-toggle[aria-expanded='true'] > span::after {
|
||||
transform: rotate(225deg);
|
||||
}
|
||||
|
||||
.menu-item-link:hover,
|
||||
.menu-item-text:hover,
|
||||
.menu-item-link[aria-expanded='true'],
|
||||
.menu-item-text[aria-expanded='true'] {
|
||||
.menu-item-toggle:hover,
|
||||
.menu-item-toggle[aria-expanded='true'] {
|
||||
background: $neutral-lighter;
|
||||
color: $neutral-primary;
|
||||
text-decoration: none;
|
||||
@@ -134,6 +158,8 @@ $menu-layer: var(--megaMenuZIndex, 6000);
|
||||
|
||||
.menu-item-link:focus,
|
||||
.menu-item-text:focus,
|
||||
.menu-item-toggle:focus,
|
||||
.mega-menu-mobile-toggle:focus,
|
||||
.mega-menu-category a:focus,
|
||||
.flyout-link:focus {
|
||||
outline: 2px solid $theme-primary;
|
||||
@@ -180,6 +206,13 @@ $menu-layer: var(--megaMenuZIndex, 6000);
|
||||
padding: 0 0 8px;
|
||||
}
|
||||
|
||||
.mega-menu-description {
|
||||
margin: -4px 0 12px;
|
||||
color: $neutral-secondary;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mega-menu-category-title {
|
||||
margin: 0 0 10px;
|
||||
padding: 0 0 8px;
|
||||
@@ -289,19 +322,37 @@ $menu-layer: var(--megaMenuZIndex, 6000);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flyout-link-has-children::after {
|
||||
content: '';
|
||||
.flyout-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 18px;
|
||||
top: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: $neutral-primary;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flyout-toggle > span::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-top: -4px;
|
||||
border-top: 1px solid currentColor;
|
||||
border-right: 1px solid currentColor;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.flyout-toggle:focus {
|
||||
outline: 2px solid $theme-primary;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.flyout-level-3 {
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
@@ -314,7 +365,8 @@ $menu-layer: var(--megaMenuZIndex, 6000);
|
||||
}
|
||||
|
||||
.flyout-item:hover > .flyout-level-3,
|
||||
.flyout-item:focus-within > .flyout-level-3 {
|
||||
.flyout-item:focus-within > .flyout-level-3,
|
||||
.flyout-item.is-open > .flyout-level-3 {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -342,9 +394,61 @@ $menu-layer: var(--megaMenuZIndex, 6000);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.mega-menu-mobile-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: var(--megaMenuBarHeight, 44px);
|
||||
padding: 0 16px;
|
||||
border: 0;
|
||||
background: $white;
|
||||
color: $neutral-primary;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mega-menu-mobile-icon,
|
||||
.mega-menu-mobile-icon::before,
|
||||
.mega-menu-mobile-icon::after {
|
||||
display: block;
|
||||
width: 18px;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.mega-menu-mobile-icon {
|
||||
position: relative;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.mega-menu-mobile-icon::before,
|
||||
.mega-menu-mobile-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.mega-menu-mobile-icon::before { top: -6px; }
|
||||
.mega-menu-mobile-icon::after { top: 6px; }
|
||||
|
||||
.mega-menu-top-level {
|
||||
flex-wrap: wrap;
|
||||
padding: 0 8px;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
border-top: 1px solid $neutral-light;
|
||||
}
|
||||
|
||||
.mega-menu-main.is-mobile-open .mega-menu-top-level {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mega-menu-top-item,
|
||||
.mega-menu-mode-flyout .mega-menu-top-item {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 44px;
|
||||
border-bottom: 1px solid $neutral-light;
|
||||
}
|
||||
|
||||
.menu-item-link,
|
||||
@@ -354,6 +458,21 @@ $menu-layer: var(--megaMenuZIndex, 6000);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.menu-item-toggle {
|
||||
width: 44px;
|
||||
margin: 0;
|
||||
border-left: 1px solid $neutral-light;
|
||||
}
|
||||
|
||||
.mega-menu {
|
||||
position: static;
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
border-right: 0;
|
||||
border-left: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.mega-menu-grid {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 16px;
|
||||
@@ -368,12 +487,16 @@ $menu-layer: var(--megaMenuZIndex, 6000);
|
||||
|
||||
.flyout-level-3 {
|
||||
position: static;
|
||||
display: block;
|
||||
display: none;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
padding: 0 0 4px 16px;
|
||||
}
|
||||
|
||||
.flyout-item.is-open > .flyout-level-3 {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface IMegaMenuApplicationCustomizerProperties {
|
||||
cacheMinutes?: number;
|
||||
debug?: boolean;
|
||||
menuMode?: MegaMenuMode;
|
||||
cacheVersion?: string;
|
||||
}
|
||||
|
||||
export default class MegaMenuApplicationCustomizer
|
||||
@@ -42,7 +43,7 @@ export default class MegaMenuApplicationCustomizer
|
||||
const debug: boolean = this._isDebugEnabled();
|
||||
debugLog(debug, LOG_SOURCE, 'onInit started.', {
|
||||
componentId: UserCustomActionMegaMenuId,
|
||||
version: '2.1.0',
|
||||
version: '2.2.0',
|
||||
properties: this.properties || {},
|
||||
pageUrl: window.location.href,
|
||||
webUrl: this.context.pageContext.web.absoluteUrl,
|
||||
@@ -108,13 +109,16 @@ export default class MegaMenuApplicationCustomizer
|
||||
return;
|
||||
}
|
||||
|
||||
this._renderStatus(strings.Loading);
|
||||
try {
|
||||
debugLog(debug, LOG_SOURCE, 'Creating TaxonomyNavigationService.');
|
||||
const taxonomyService: TaxonomyNavigationService = new TaxonomyNavigationService(
|
||||
this.context,
|
||||
termSetIdentifier,
|
||||
debug,
|
||||
this._getCacheMinutes()
|
||||
this._getCacheMinutes(),
|
||||
this._getCacheVersion(),
|
||||
this._getLanguageLcid()
|
||||
);
|
||||
|
||||
debugLog(debug, LOG_SOURCE, 'Requesting navigation items from taxonomy.');
|
||||
@@ -123,6 +127,11 @@ export default class MegaMenuApplicationCustomizer
|
||||
topLevelItemCount: menuItems.length
|
||||
});
|
||||
|
||||
if (!menuItems || menuItems.length === 0) {
|
||||
this._renderStatus(strings.EmptyNavigation);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._renderer) {
|
||||
debugLog(debug, LOG_SOURCE, 'Disposing the previous renderer instance.');
|
||||
this._renderer.dispose();
|
||||
@@ -131,7 +140,18 @@ export default class MegaMenuApplicationCustomizer
|
||||
this._renderer = new MegaMenuRenderer(
|
||||
menuItems,
|
||||
debug,
|
||||
this._getMenuMode()
|
||||
this._getMenuMode(),
|
||||
{
|
||||
navigationLabel: strings.NavigationLabel,
|
||||
openNavigation: strings.OpenNavigation,
|
||||
closeNavigation: strings.CloseNavigation,
|
||||
openSubmenu: strings.OpenSubmenu,
|
||||
closeSubmenu: strings.CloseSubmenu,
|
||||
submenuLabel: strings.SubmenuLabel,
|
||||
externalLink: strings.ExternalLink,
|
||||
opened: strings.Opened,
|
||||
closed: strings.Closed
|
||||
}
|
||||
);
|
||||
|
||||
this._menuContainer = this._getOrCreateContainer('CustomHeader', this._topPlaceholder);
|
||||
@@ -213,6 +233,19 @@ export default class MegaMenuApplicationCustomizer
|
||||
return normalizeMegaMenuMode(this.properties && this.properties.menuMode);
|
||||
}
|
||||
|
||||
private _getCacheVersion(): string {
|
||||
const value: string = this.properties && this.properties.cacheVersion
|
||||
? String(this.properties.cacheVersion).trim()
|
||||
: '1';
|
||||
return value || '1';
|
||||
}
|
||||
|
||||
private _getLanguageLcid(): number {
|
||||
const legacyContext: any = (this.context.pageContext as any).legacyPageContext; // tslint:disable-line:no-any
|
||||
const value: number = legacyContext ? Number(legacyContext.currentLanguage) : 0;
|
||||
return isFinite(value) && value > 0 ? value : 1033;
|
||||
}
|
||||
|
||||
private _renderStatus(message: string): void {
|
||||
if (!this._topPlaceholder) {
|
||||
return;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -2,6 +2,17 @@ define([], function() {
|
||||
return {
|
||||
"Title": "MegaMenuApplicationCustomizer",
|
||||
"ConfigurationMissing": "Die Navigation ist nicht konfiguriert.",
|
||||
"LoadError": "Die Navigation konnte nicht geladen werden."
|
||||
"LoadError": "Die Navigation konnte nicht geladen werden.",
|
||||
"Loading": "Navigation wird geladen…",
|
||||
"EmptyNavigation": "Die Navigation enthält keine sichtbaren Einträge.",
|
||||
"NavigationLabel": "Hauptnavigation",
|
||||
"OpenNavigation": "Hauptnavigation öffnen",
|
||||
"CloseNavigation": "Hauptnavigation schließen",
|
||||
"OpenSubmenu": "Untermenü öffnen für",
|
||||
"CloseSubmenu": "Untermenü schließen für",
|
||||
"SubmenuLabel": "Untermenü",
|
||||
"ExternalLink": "externer Link",
|
||||
"Opened": "geöffnet",
|
||||
"Closed": "geschlossen"
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2,6 +2,17 @@ define([], function() {
|
||||
return {
|
||||
"Title": "MegaMenuApplicationCustomizer",
|
||||
"ConfigurationMissing": "Navigation is not configured.",
|
||||
"LoadError": "Navigation could not be loaded."
|
||||
"LoadError": "Navigation could not be loaded.",
|
||||
"Loading": "Loading navigation…",
|
||||
"EmptyNavigation": "The navigation does not contain any visible entries.",
|
||||
"NavigationLabel": "Main navigation",
|
||||
"OpenNavigation": "Open main navigation",
|
||||
"CloseNavigation": "Close main navigation",
|
||||
"OpenSubmenu": "Open submenu for",
|
||||
"CloseSubmenu": "Close submenu for",
|
||||
"SubmenuLabel": "submenu",
|
||||
"ExternalLink": "external link",
|
||||
"Opened": "opened",
|
||||
"Closed": "closed"
|
||||
}
|
||||
});
|
||||
|
||||
+11
@@ -2,6 +2,17 @@ declare interface IMegaMenuApplicationCustomizerStrings {
|
||||
Title: string;
|
||||
ConfigurationMissing: string;
|
||||
LoadError: string;
|
||||
Loading: string;
|
||||
EmptyNavigation: string;
|
||||
NavigationLabel: string;
|
||||
OpenNavigation: string;
|
||||
CloseNavigation: string;
|
||||
OpenSubmenu: string;
|
||||
CloseSubmenu: string;
|
||||
SubmenuLabel: string;
|
||||
ExternalLink: string;
|
||||
Opened: string;
|
||||
Closed: string;
|
||||
}
|
||||
|
||||
declare module 'MegaMenuApplicationCustomizerStrings' {
|
||||
|
||||
Reference in New Issue
Block a user