Initial commit
This commit is contained in:
@@ -0,0 +1,510 @@
|
||||
// tslint:disable:max-line-length
|
||||
// tslint:disable:match-default-export-name
|
||||
// tslint:disable:typedef
|
||||
// tslint:disable:variable-name
|
||||
|
||||
import { IMenuItem } from '../../services/IMenuItem';
|
||||
import { debugLog } from '../../services/MegaMenuDebug';
|
||||
|
||||
import styles from './MegaMenu.module.scss'; // tslint:disable-line:no-unused-variable
|
||||
|
||||
const LOG_SOURCE: string = 'MegaMenuRenderer';
|
||||
|
||||
export class MegaMenuRenderer {
|
||||
private static readonly hoverOpenDelayMs: number = 140;
|
||||
private static readonly hoverCloseDelayMs: number = 180;
|
||||
|
||||
constructor(
|
||||
private menuItems: IMenuItem[],
|
||||
private debug: boolean = false
|
||||
) { }
|
||||
|
||||
public render(container: HTMLElement) {
|
||||
container.innerHTML = '';
|
||||
container.id = 'CustomNavigation';
|
||||
|
||||
const nav = document.createElement('nav');
|
||||
nav.id = 'Mega-Menu';
|
||||
nav.className = 'mega-menu-main';
|
||||
nav.setAttribute('role', 'navigation');
|
||||
nav.setAttribute('aria-label', 'Hauptnavigation');
|
||||
|
||||
const topLevelUl = document.createElement('ul');
|
||||
topLevelUl.className = 'mega-menu-top-level';
|
||||
topLevelUl.setAttribute('role', 'menubar');
|
||||
|
||||
this.menuItems.forEach(topLevelItem => {
|
||||
const topLevelLi = this.createTopLevelItem(topLevelItem);
|
||||
topLevelUl.appendChild(topLevelLi);
|
||||
});
|
||||
|
||||
nav.appendChild(topLevelUl);
|
||||
container.appendChild(nav);
|
||||
|
||||
this.attachEventListeners();
|
||||
this.createScreenReaderAnnouncer();
|
||||
}
|
||||
|
||||
private createTopLevelItem(item: IMenuItem): HTMLLIElement {
|
||||
const li = document.createElement('li');
|
||||
const hasChildren = item.hasChildren() && item.items && item.items.length > 0;
|
||||
const isActive = this.isItemActive(item);
|
||||
const isCurrent = this.isCurrentUrl(item.url);
|
||||
|
||||
li.className = this.joinClasses(
|
||||
'mega-menu-top-item',
|
||||
hasChildren ? 'has-children' : undefined,
|
||||
isActive ? 'is-active' : undefined,
|
||||
isCurrent ? 'is-current' : undefined
|
||||
);
|
||||
li.setAttribute('role', 'none');
|
||||
|
||||
const topElement = this.createTopLevelElement(item, hasChildren, isActive, isCurrent);
|
||||
li.appendChild(topElement);
|
||||
|
||||
if (hasChildren) {
|
||||
const megaMenu = this.createMegaMenu(item);
|
||||
li.appendChild(megaMenu);
|
||||
}
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
private createTopLevelElement(item: IMenuItem, hasChildren: boolean, isActive: boolean, isCurrent: boolean): HTMLElement {
|
||||
let element: HTMLElement;
|
||||
|
||||
if (item.url) {
|
||||
element = document.createElement('a');
|
||||
(element as HTMLAnchorElement).href = item.url;
|
||||
element.className = 'menu-item-link';
|
||||
} else {
|
||||
element = document.createElement('span');
|
||||
element.className = 'menu-item-text';
|
||||
element.setAttribute('tabindex', '0');
|
||||
}
|
||||
|
||||
element.setAttribute('role', 'menuitem');
|
||||
element.textContent = item.label;
|
||||
element.className = this.joinClasses(
|
||||
element.className,
|
||||
hasChildren ? 'menu-item-has-children' : undefined,
|
||||
isActive ? 'is-active' : undefined,
|
||||
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');
|
||||
}
|
||||
|
||||
if (item.hoverText) {
|
||||
element.title = item.hoverText;
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
private createMegaMenu(parentItem: IMenuItem): HTMLDivElement {
|
||||
const megaMenuDiv = document.createElement('div');
|
||||
megaMenuDiv.className = 'mega-menu';
|
||||
megaMenuDiv.setAttribute('role', 'menu');
|
||||
megaMenuDiv.setAttribute('aria-expanded', 'false');
|
||||
megaMenuDiv.setAttribute('aria-label', parentItem.label + ' Unterkategorien');
|
||||
|
||||
const gridDiv = document.createElement('div');
|
||||
gridDiv.className = 'mega-menu-grid';
|
||||
gridDiv.style.setProperty('--megaMenuColumnCount', this.getColumnCount(parentItem.items));
|
||||
|
||||
if (parentItem.items) {
|
||||
parentItem.items.forEach(secondLevelItem => {
|
||||
const categoryDiv = this.createCategorySection(secondLevelItem);
|
||||
gridDiv.appendChild(categoryDiv);
|
||||
});
|
||||
}
|
||||
|
||||
megaMenuDiv.appendChild(gridDiv);
|
||||
return megaMenuDiv;
|
||||
}
|
||||
|
||||
private createCategorySection(item: IMenuItem): HTMLDivElement {
|
||||
const categoryDiv = document.createElement('div');
|
||||
const isActive = this.isItemActive(item);
|
||||
const isCurrent = this.isCurrentUrl(item.url);
|
||||
categoryDiv.className = this.joinClasses(
|
||||
'mega-menu-category',
|
||||
isActive ? 'is-active' : undefined,
|
||||
isCurrent ? 'is-current' : undefined
|
||||
);
|
||||
|
||||
const h3 = document.createElement('h3');
|
||||
h3.className = 'mega-menu-category-title';
|
||||
|
||||
if (item.url) {
|
||||
const link = document.createElement('a');
|
||||
link.href = item.url;
|
||||
link.textContent = item.label;
|
||||
link.className = this.joinClasses(
|
||||
isActive ? 'is-active' : undefined,
|
||||
isCurrent ? 'is-current' : undefined
|
||||
);
|
||||
if (isCurrent) {
|
||||
link.setAttribute('aria-current', 'page');
|
||||
}
|
||||
if (item.hoverText) {
|
||||
link.title = item.hoverText;
|
||||
}
|
||||
h3.appendChild(link);
|
||||
} else {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = item.label;
|
||||
span.className = this.joinClasses(
|
||||
isActive ? 'is-active' : undefined
|
||||
);
|
||||
if (item.hoverText) {
|
||||
span.title = item.hoverText;
|
||||
}
|
||||
h3.appendChild(span);
|
||||
}
|
||||
|
||||
categoryDiv.appendChild(h3);
|
||||
|
||||
if (item.hasChildren() && item.items && item.items.length > 0) {
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'mega-menu-links';
|
||||
|
||||
item.items.forEach(thirdLevelItem => {
|
||||
const li = document.createElement('li');
|
||||
const link = document.createElement('a');
|
||||
const isLinkActive = this.isItemActive(thirdLevelItem);
|
||||
const isLinkCurrent = this.isCurrentUrl(thirdLevelItem.url);
|
||||
|
||||
li.className = this.joinClasses(
|
||||
isLinkActive ? 'is-active' : undefined,
|
||||
isLinkCurrent ? 'is-current' : undefined
|
||||
);
|
||||
|
||||
link.href = thirdLevelItem.url || '#';
|
||||
link.textContent = thirdLevelItem.label;
|
||||
link.className = this.joinClasses(
|
||||
isLinkActive ? 'is-active' : undefined,
|
||||
isLinkCurrent ? 'is-current' : undefined
|
||||
);
|
||||
|
||||
if (isLinkCurrent && thirdLevelItem.url) {
|
||||
link.setAttribute('aria-current', 'page');
|
||||
}
|
||||
|
||||
if (thirdLevelItem.hoverText) {
|
||||
link.title = thirdLevelItem.hoverText;
|
||||
}
|
||||
|
||||
li.appendChild(link);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
|
||||
categoryDiv.appendChild(ul);
|
||||
}
|
||||
|
||||
return categoryDiv;
|
||||
}
|
||||
|
||||
private attachEventListeners(): void {
|
||||
const headings = document.querySelectorAll('#Mega-Menu > ul > li > a, #Mega-Menu > ul > li > span[role="menuitem"]');
|
||||
|
||||
for (let i = 0; i < headings.length; i++) {
|
||||
const heading = headings[i] as HTMLElement;
|
||||
const megaMenu = heading.nextElementSibling as HTMLElement;
|
||||
|
||||
if (megaMenu && megaMenu.classList.contains('mega-menu')) {
|
||||
this.attachKeyboardNavigation(heading, megaMenu);
|
||||
this.attachMouseEvents(heading, megaMenu);
|
||||
this.attachFocusManagement(heading, megaMenu);
|
||||
}
|
||||
}
|
||||
|
||||
this.attachGlobalKeyboardNavigation();
|
||||
}
|
||||
|
||||
private attachKeyboardNavigation(heading: HTMLElement, megaMenu: HTMLElement): void {
|
||||
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') {
|
||||
e.preventDefault();
|
||||
this.openMegaMenu(heading, megaMenu);
|
||||
this.focusFirstLink(megaMenu);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
this.closeMegaMenu(heading, megaMenu);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
this.closeMegaMenu(heading, megaMenu);
|
||||
heading.focus();
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
private attachMouseEvents(heading: HTMLElement, megaMenu: HTMLElement): void {
|
||||
const parentLi = heading.parentElement as HTMLElement;
|
||||
let openTimeout: number | undefined;
|
||||
let closeTimeout: number | undefined;
|
||||
|
||||
const clearOpenTimeout = () => {
|
||||
if (typeof openTimeout === 'number') {
|
||||
window.clearTimeout(openTimeout);
|
||||
openTimeout = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const clearCloseTimeout = () => {
|
||||
if (typeof closeTimeout === 'number') {
|
||||
window.clearTimeout(closeTimeout);
|
||||
closeTimeout = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
parentLi.addEventListener('mouseenter', () => {
|
||||
clearCloseTimeout();
|
||||
|
||||
if (megaMenu.classList.contains('js-open')) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearOpenTimeout();
|
||||
openTimeout = window.setTimeout(() => {
|
||||
this.openMegaMenu(heading, megaMenu);
|
||||
openTimeout = undefined;
|
||||
}, MegaMenuRenderer.hoverOpenDelayMs);
|
||||
});
|
||||
|
||||
parentLi.addEventListener('mouseleave', () => {
|
||||
clearOpenTimeout();
|
||||
clearCloseTimeout();
|
||||
closeTimeout = window.setTimeout(() => {
|
||||
this.closeMegaMenu(heading, megaMenu);
|
||||
closeTimeout = undefined;
|
||||
}, MegaMenuRenderer.hoverCloseDelayMs);
|
||||
});
|
||||
|
||||
heading.addEventListener('focus', () => {
|
||||
clearCloseTimeout();
|
||||
});
|
||||
|
||||
megaMenu.addEventListener('mouseenter', () => {
|
||||
clearCloseTimeout();
|
||||
});
|
||||
}
|
||||
|
||||
private attachFocusManagement(heading: HTMLElement, megaMenu: HTMLElement): void {
|
||||
megaMenu.addEventListener('focusout', () => {
|
||||
setTimeout(() => {
|
||||
const focusedElement = document.activeElement as HTMLElement;
|
||||
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');
|
||||
|
||||
if (!isInsideThisMenu && !isOnThisTrigger && !isInAnyMegaMenu && !isOnAnyTopLevel) {
|
||||
debugLog(this.debug, LOG_SOURCE, 'Closing menu because focus left the navigation.');
|
||||
this.closeMegaMenu(heading, megaMenu);
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
}
|
||||
|
||||
private attachGlobalKeyboardNavigation(): void {
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
const openMenu = document.querySelector('.mega-menu[aria-expanded="true"]') as HTMLElement;
|
||||
if (openMenu) {
|
||||
const triggerLink = openMenu.previousElementSibling as HTMLElement;
|
||||
this.closeMegaMenu(triggerLink, openMenu);
|
||||
triggerLink.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
if (!e.shiftKey) {
|
||||
const currentTopLevel = activeElement.closest('#Mega-Menu > ul > li > a, #Mega-Menu > ul > li > span') as HTMLElement;
|
||||
if (currentTopLevel) {
|
||||
const parentLi = currentTopLevel.closest('li') as HTMLElement;
|
||||
const megaMenu = parentLi.querySelector('.mega-menu.js-open') as HTMLElement;
|
||||
if (megaMenu) {
|
||||
e.preventDefault();
|
||||
const firstLink = megaMenu.querySelector('a') as HTMLElement;
|
||||
if (firstLink) {
|
||||
firstLink.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
const megaMenu = activeElement.closest('.mega-menu') as HTMLElement;
|
||||
if (megaMenu && megaMenu.classList.contains('js-open')) {
|
||||
const allLinksInMenu = megaMenu.querySelectorAll('a');
|
||||
const firstLinkInMenu = allLinksInMenu[0] as HTMLElement;
|
||||
|
||||
if (activeElement === firstLinkInMenu) {
|
||||
e.preventDefault();
|
||||
const triggerElement = megaMenu.previousElementSibling as HTMLElement;
|
||||
triggerElement.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private openMegaMenu(trigger: HTMLElement, menu: HTMLElement): void {
|
||||
this.closeAllMegaMenus(menu);
|
||||
trigger.setAttribute('aria-expanded', 'true');
|
||||
menu.setAttribute('aria-expanded', 'true');
|
||||
menu.classList.add('js-open');
|
||||
debugLog(this.debug, LOG_SOURCE, 'Menu opened:', trigger.textContent);
|
||||
}
|
||||
|
||||
private closeMegaMenu(trigger: HTMLElement, menu: HTMLElement): void {
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
menu.setAttribute('aria-expanded', 'false');
|
||||
menu.classList.remove('js-open');
|
||||
debugLog(this.debug, LOG_SOURCE, 'Menu closed:', trigger.textContent);
|
||||
}
|
||||
|
||||
private toggleMegaMenu(trigger: HTMLElement, menu: HTMLElement): void {
|
||||
const isOpen = trigger.getAttribute('aria-expanded') === 'true';
|
||||
if (isOpen) {
|
||||
this.closeMegaMenu(trigger, menu);
|
||||
} else {
|
||||
this.openMegaMenu(trigger, menu);
|
||||
}
|
||||
}
|
||||
|
||||
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"]');
|
||||
for (let i = 0; i < allTriggers.length; i++) {
|
||||
const trigger = allTriggers[i] as HTMLElement;
|
||||
const menu = trigger.nextElementSibling as HTMLElement;
|
||||
if (menu && menu !== exceptMenu) {
|
||||
this.closeMegaMenu(trigger, menu);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private focusFirstLink(megaMenu: HTMLElement): void {
|
||||
const firstLink = megaMenu.querySelector('a') as HTMLElement;
|
||||
if (firstLink) {
|
||||
firstLink.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private isItemActive(item: IMenuItem): boolean {
|
||||
if (this.isCurrentUrl(item.url)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!item.items || item.items.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < item.items.length; i++) {
|
||||
if (this.isItemActive(item.items[i])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
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 '';
|
||||
}
|
||||
}
|
||||
|
||||
private getColumnCount(items?: IMenuItem[]): string {
|
||||
const itemCount = items ? items.length : 0;
|
||||
const columnCount = Math.min(Math.max(itemCount, 1), 4);
|
||||
return columnCount.toString();
|
||||
}
|
||||
|
||||
private joinClasses(...classNames: Array<string | undefined>): string {
|
||||
const filteredClassNames: string[] = [];
|
||||
|
||||
for (let i = 0; i < classNames.length; i++) {
|
||||
if (classNames[i]) {
|
||||
filteredClassNames.push(classNames[i] as string);
|
||||
}
|
||||
}
|
||||
|
||||
return filteredClassNames.join(' ');
|
||||
}
|
||||
|
||||
private createScreenReaderAnnouncer(): void {
|
||||
if (document.getElementById('mega-menu-sr-announcer')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const srAnnouncer = document.createElement('div');
|
||||
srAnnouncer.id = 'mega-menu-sr-announcer';
|
||||
srAnnouncer.setAttribute('aria-live', 'polite');
|
||||
srAnnouncer.setAttribute('aria-atomic', 'true');
|
||||
srAnnouncer.className = 'sr-only';
|
||||
document.body.appendChild(srAnnouncer);
|
||||
|
||||
debugLog(this.debug, LOG_SOURCE, 'Screen reader announcer is ready.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user