799 lines
31 KiB
TypeScript
799 lines
31 KiB
TypeScript
// 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 { 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 = 0;
|
|
private static readonly hoverCloseDelayMs: number = 0;
|
|
private _container: HTMLElement | undefined;
|
|
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 strings: IMegaMenuRendererStrings
|
|
) { }
|
|
|
|
public render(container: HTMLElement): void {
|
|
debugLog(this.debug, LOG_SOURCE, 'Render started.', {
|
|
menuItemCount: this.menuItems.length,
|
|
containerId: container.id || '',
|
|
containerClassName: container.className || ''
|
|
});
|
|
this.dispose();
|
|
this._container = container;
|
|
container.innerHTML = '';
|
|
container.id = 'CustomNavigation';
|
|
|
|
const nav = document.createElement('nav');
|
|
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', 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);
|
|
topLevelUl.appendChild(topLevelLi);
|
|
});
|
|
|
|
nav.appendChild(topLevelUl);
|
|
container.appendChild(nav);
|
|
|
|
this.attachEventListeners();
|
|
this.createScreenReaderAnnouncer();
|
|
debugLog(this.debug, LOG_SOURCE, 'Render completed.', {
|
|
navigationId: nav.id,
|
|
topLevelElements: topLevelUl.children.length
|
|
});
|
|
}
|
|
|
|
public dispose(): void {
|
|
debugLog(this.debug, LOG_SOURCE, 'Dispose started.', {
|
|
hasContainer: !!this._container,
|
|
hasAnnouncer: !!this._announcer
|
|
});
|
|
if (this._documentKeydownHandler) {
|
|
document.removeEventListener('keydown', this._documentKeydownHandler);
|
|
this._documentKeydownHandler = undefined;
|
|
}
|
|
if (this._documentClickHandler) {
|
|
document.removeEventListener('click', this._documentClickHandler);
|
|
this._documentClickHandler = undefined;
|
|
}
|
|
if (this._announcer && this._announcer.parentElement) {
|
|
this._announcer.parentElement.removeChild(this._announcer);
|
|
}
|
|
this._announcer = undefined;
|
|
this._mobileToggle = undefined;
|
|
if (this._container) {
|
|
this._container.innerHTML = '';
|
|
}
|
|
this._container = undefined;
|
|
debugLog(this.debug, LOG_SOURCE, 'Dispose completed.');
|
|
}
|
|
|
|
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
|
|
);
|
|
|
|
const topElement = this.createTopLevelElement(item, hasChildren, isActive, isCurrent);
|
|
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);
|
|
}
|
|
|
|
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';
|
|
if (!hasChildren) {
|
|
element.setAttribute('tabindex', '0');
|
|
}
|
|
}
|
|
|
|
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 (isCurrent && item.url) {
|
|
element.setAttribute('aria-current', 'page');
|
|
}
|
|
|
|
if (item.hoverText) {
|
|
element.title = item.hoverText;
|
|
}
|
|
|
|
if (item.url) {
|
|
this.applyLinkBehavior(element as HTMLAnchorElement, item);
|
|
}
|
|
|
|
return element;
|
|
}
|
|
|
|
private createMegaMenu(parentItem: IMenuItem): HTMLDivElement {
|
|
const megaMenuDiv = document.createElement('div');
|
|
megaMenuDiv.className = 'mega-menu';
|
|
megaMenuDiv.setAttribute('aria-expanded', 'false');
|
|
megaMenuDiv.setAttribute('aria-label', parentItem.label + ' ' + this.strings.submenuLabel);
|
|
|
|
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;
|
|
this.applyLinkBehavior(link, item);
|
|
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);
|
|
}
|
|
|
|
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) {
|
|
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;
|
|
this.applyLinkBehavior(link, thirdLevelItem);
|
|
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 createFlyoutMenu(parentItem: IMenuItem): HTMLDivElement {
|
|
const flyout = document.createElement('div');
|
|
flyout.className = 'mega-menu mega-menu-flyout';
|
|
flyout.setAttribute('aria-expanded', 'false');
|
|
flyout.setAttribute('aria-label', parentItem.label + ' ' + this.strings.submenuLabel);
|
|
|
|
const list = document.createElement('ul');
|
|
list.className = 'flyout-list flyout-level-2';
|
|
|
|
if (parentItem.items) {
|
|
parentItem.items.forEach(secondLevelItem => {
|
|
list.appendChild(this.createFlyoutItem(secondLevelItem));
|
|
});
|
|
}
|
|
|
|
flyout.appendChild(list);
|
|
return flyout;
|
|
}
|
|
|
|
private createFlyoutItem(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(
|
|
'flyout-item',
|
|
hasChildren ? 'has-children' : undefined,
|
|
isActive ? 'is-active' : undefined,
|
|
isCurrent ? 'is-current' : undefined
|
|
);
|
|
|
|
const element = this.createFlyoutElement(item, hasChildren, isActive, isCurrent);
|
|
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.id = childId;
|
|
childList.setAttribute('aria-label', item.label + ' ' + this.strings.submenuLabel);
|
|
|
|
item.items.forEach(thirdLevelItem => {
|
|
childList.appendChild(this.createFlyoutItem(thirdLevelItem));
|
|
});
|
|
|
|
li.appendChild(childList);
|
|
}
|
|
|
|
return li;
|
|
}
|
|
|
|
private createFlyoutElement(item: IMenuItem, hasChildren: boolean, isActive: boolean, isCurrent: boolean): HTMLElement {
|
|
const element: HTMLElement = item.url
|
|
? document.createElement('a')
|
|
: document.createElement('span');
|
|
|
|
if (item.url) {
|
|
(element as HTMLAnchorElement).href = item.url;
|
|
this.applyLinkBehavior(element as HTMLAnchorElement, item);
|
|
} else {
|
|
element.setAttribute('tabindex', '0');
|
|
}
|
|
|
|
element.textContent = item.label;
|
|
element.className = this.joinClasses(
|
|
'flyout-link',
|
|
hasChildren ? 'flyout-link-has-children' : undefined,
|
|
isActive ? 'is-active' : undefined,
|
|
isCurrent ? 'is-current' : undefined
|
|
);
|
|
|
|
if (isCurrent && item.url) {
|
|
element.setAttribute('aria-current', 'page');
|
|
}
|
|
if (item.hoverText) {
|
|
element.title = item.hoverText;
|
|
}
|
|
|
|
return element;
|
|
}
|
|
|
|
private attachEventListeners(): void {
|
|
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;
|
|
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();
|
|
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 === '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();
|
|
}
|
|
});
|
|
|
|
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-top-item');
|
|
|
|
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 {
|
|
this._documentKeydownHandler = (e: KeyboardEvent): void => {
|
|
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 === '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('.menu-item-toggle') 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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
document.addEventListener('keydown', this._documentKeydownHandler);
|
|
}
|
|
|
|
private attachGlobalClickOutsideHandler(): void {
|
|
this._documentClickHandler = (e: MouseEvent): void => {
|
|
const eventTarget = e.target as Element;
|
|
const isInsideNavigation = !!(eventTarget && typeof eventTarget.closest === 'function' &&
|
|
eventTarget.closest('#CustomNavigation'));
|
|
|
|
if (isInsideNavigation) {
|
|
return;
|
|
}
|
|
|
|
const openMenu = document.querySelector('.mega-menu.js-open') as HTMLElement;
|
|
|
|
if (openMenu) {
|
|
debugLog(this.debug, LOG_SOURCE, 'Closing menu because of outside click.');
|
|
this.closeAllMegaMenus();
|
|
}
|
|
};
|
|
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);
|
|
(trigger.parentElement as HTMLElement).classList.add('is-open');
|
|
menu.setAttribute('aria-expanded', 'true');
|
|
menu.classList.add('js-open');
|
|
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);
|
|
(trigger.parentElement as HTMLElement).classList.remove('is-open');
|
|
menu.setAttribute('aria-expanded', 'false');
|
|
menu.classList.remove('js-open');
|
|
this.announce(label + ' ' + this.strings.closed);
|
|
debugLog(this.debug, LOG_SOURCE, 'Menu closed:', label);
|
|
}
|
|
|
|
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 > .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;
|
|
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 {
|
|
return isNavigationUrlActive(window.location.href, url || '', window.location.origin);
|
|
}
|
|
|
|
private getColumnCount(items?: IMenuItem[]): string {
|
|
const itemCount = items ? items.length : 0;
|
|
const columnCount = Math.min(Math.max(itemCount, 1), 6);
|
|
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[] = [];
|
|
|
|
for (let i = 0; i < classNames.length; i++) {
|
|
if (classNames[i]) {
|
|
filteredClassNames.push(classNames[i] as string);
|
|
}
|
|
}
|
|
|
|
return filteredClassNames.join(' ');
|
|
}
|
|
|
|
private createScreenReaderAnnouncer(): void {
|
|
const existingAnnouncer: HTMLElement = document.getElementById('mega-menu-sr-announcer');
|
|
if (existingAnnouncer) {
|
|
this._announcer = existingAnnouncer;
|
|
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);
|
|
this._announcer = srAnnouncer;
|
|
|
|
debugLog(this.debug, LOG_SOURCE, 'Screen reader announcer is ready.');
|
|
}
|
|
|
|
private announce(message: string): void {
|
|
if (this._announcer) {
|
|
this._announcer.textContent = '';
|
|
window.setTimeout((): void => {
|
|
if (this._announcer) {
|
|
this._announcer.textContent = message;
|
|
}
|
|
}, 20);
|
|
}
|
|
}
|
|
}
|