smdm-slide-menu
Version:
A library agnostic multilevel page menu with a smooth slide effect based on CSS transitions, focused on accessibility.
831 lines (695 loc) • 25.9 kB
text/typescript
import { Slide, SlideHTMLElement } from './Slide.js';
import { Action, SlideMenuOptions, MenuPosition, CLASSES, NAMESPACE } from './SlideMenuOptions.js';
import { parentsOne, trapFocus } from './utils/dom.js';
export interface MenuHTMLElement extends HTMLElement {
_slideMenu: SlideMenu;
}
const DEFAULT_OPTIONS: SlideMenuOptions = {
backLinkAfter: '',
backLinkBefore: '',
showBackLink: true,
keyClose: 'Escape',
keyOpen: '',
position: MenuPosition.Right,
submenuLinkBefore: '',
closeOnClickOutside: false,
navigationButtonsLabel: 'Open submenu ',
navigationButtons: false,
menuWidth: 320, // px
minWidthFold: 640, // px
transitionDuration: 300, // ms
dynamicOpenDefault: false,
debug: false,
id: '',
};
let counter = 0;
export class SlideMenu {
private activeSubmenu: Slide | undefined;
private lastFocusedElement: Element | null = null;
private isOpen: boolean = false;
private isAnimating: boolean = false;
private lastAction: Action | null = null;
private readonly slides: Slide[] = [];
private readonly options: SlideMenuOptions;
private readonly menuTitleDefaultText: string = 'Menu';
private readonly menuElem: MenuHTMLElement;
private readonly sliderElem: HTMLElement;
private readonly menuTitle: HTMLElement | null;
private readonly sliderWrapperElem: HTMLElement;
private readonly foldableWrapperElem: HTMLElement;
public constructor(elem?: HTMLElement | null, options?: Partial<SlideMenuOptions>) {
if (elem === null) {
throw new Error('Argument `elem` must be a valid HTML node');
}
// (Create a new object for every instance)
this.options = Object.assign({}, DEFAULT_OPTIONS, options);
this.menuElem = elem as MenuHTMLElement;
this.options.id = this.menuElem.id ? this.menuElem.id : 'smdm-slide-menu-' + counter;
counter++;
this.menuElem.id = this.options.id;
this.menuElem.classList.add(NAMESPACE);
this.menuElem.classList.add(this.options.position);
this.menuElem.role = 'navigation';
// Save this instance in menu to DOM node
this.menuElem._slideMenu = this;
// Set CSS Base Variables based on configuration options
document.documentElement.style.setProperty(
'--smdm-sm-menu-width',
`${this.options.menuWidth}px`,
);
document.documentElement.style.setProperty(
'--smdm-sm-min-width-fold',
`${this.options.minWidthFold}px`,
);
document.documentElement.style.setProperty(
'--smdm-sm-transition-duration',
`${this.options.transitionDuration}ms`,
);
document.documentElement.style.setProperty('--smdm-sm-menu-level', '0');
// Add slider container
this.sliderElem = document.createElement('div');
this.sliderElem.classList.add(CLASSES.slider);
while (this.menuElem.firstChild) {
this.sliderElem.appendChild(this.menuElem.firstChild);
}
this.menuElem.appendChild(this.sliderElem);
// Add slider wrapper (for the slide effect)
this.sliderWrapperElem = document.createElement('div');
this.sliderWrapperElem.classList.add(CLASSES.sliderWrapper);
this.sliderElem.appendChild(this.sliderWrapperElem);
// Add foldable wrapper
this.foldableWrapperElem = document.createElement('div');
this.foldableWrapperElem.classList.add(CLASSES.foldableWrapper);
this.sliderElem.after(this.foldableWrapperElem);
// Extract menu title
this.menuTitle = this.menuElem.querySelector(`.${CLASSES.title}`) as HTMLElement;
this.menuTitleDefaultText = this.menuTitle?.textContent?.trim() ?? this.menuTitleDefaultText;
this.initMenu();
this.initSlides();
this.initEventHandlers();
// Enable Menu
this.menuElem.style.display = 'flex';
// Set the default open target and activate it
this.activeSubmenu = this.slides[0].activate();
this.navigateTo(this.defaultOpenTarget ?? this.slides[0], false);
this.menuElem.setAttribute('inert', 'true');
this.slides.forEach((menu) => {
menu.disableTabbing();
});
// Send event that menu is initialized
this.triggerEvent(Action.Initialize);
}
private get defaultOpenTarget(): Slide | undefined {
const defaultTargetSelector =
this.menuElem.dataset.openDefault ??
this.menuElem.dataset.defaultTarget ??
this.menuElem.dataset.openTarget ??
this.menuElem.dataset.defaultOpenTarget ??
'smdm-sm-no-default-provided';
return this.getTargetSlideByIdentifier(defaultTargetSelector);
}
public get isFoldOpen(): boolean {
return this.menuElem.classList.contains(CLASSES.foldOpen);
}
public debugLog(...args: any[]): void {
if (this.options.debug) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
console.log(...args);
}
}
/**
* Toggle the menu
*/
public toggleVisibility(show?: boolean, animate: boolean = true): void {
let offset;
if (show === undefined) {
this.isOpen ? this.close(animate) : this.show(animate);
return;
}
if (show) {
offset = 0;
this.lastFocusedElement = document.activeElement;
// Can mess with animation - set focus after animation is done
setTimeout(() => {
this.activeSubmenu?.focusFirstElem();
}, this.options.transitionDuration);
} else {
offset = this.options.position === MenuPosition.Left ? '-100%' : '100%';
// Deactivate all menus and hide fold
// Timeout to not mess with animation because of setting the focus
setTimeout(() => {
this.slides.forEach((menu) => !menu.isActive && menu.deactivate());
// Refocus last focused element before opening menu
// @ts-expect-error // possibly has no focus() function
this.lastFocusedElement?.focus();
this.menuElem.classList.remove(CLASSES.foldOpen);
}, this.options.transitionDuration);
}
this.isOpen = !!show;
this.moveElem(this.menuElem, offset);
}
/**
* Get menu that has current path or hash as anchor element or within the menu
* @returns
*/
private getTargetSlideDynamically(): Slide | undefined {
const currentPath = location.pathname;
const currentHash = location.hash;
const currentHashItem = this.slides.find((menu) => menu.matches(currentHash));
const currentPathItem = this.slides.find((menu) => menu.matches(currentPath));
return currentPathItem ?? currentHashItem;
}
public open(animate: boolean = true): void {
const target =
(this.options.dynamicOpenDefault
? this.getTargetSlideDynamically()
: this.defaultOpenTarget) ?? this.activeSubmenu;
this.menuElem.removeAttribute('inert');
if (target) {
this.navigateTo(target);
}
this.show(animate);
}
public toggle(animate: boolean = true): void {
if (this.isOpen) {
this.close(animate);
return;
}
this.open(animate);
}
/**
* Shows the menu, adds `slide-menu--open` class to body
*/
public show(animate: boolean = true): void {
this.triggerEvent(Action.Open);
this.toggleVisibility(true, animate);
document.querySelector('body')?.classList.add(CLASSES.open);
}
/**
* Hide / Close the menu, removes `slide-menu--open` class from body
*/
public close(animate: boolean = true): void {
this.triggerEvent(Action.Close);
this.toggleVisibility(false, animate);
this.menuElem.setAttribute('inert', 'true');
this.slides.forEach((menu) => {
menu.disableTabbing();
});
document.querySelector('body')?.classList.remove(CLASSES.open);
}
/**
* Navigate one menu hierarchy back if possible
*/
public back(closeFold: boolean = false): void {
const rootSlide = this.slides[0];
let nextMenu = this.activeSubmenu?.parent ?? rootSlide;
if (closeFold) {
this.activeSubmenu = this.activeSubmenu?.getClosestUnfoldableSlide() ?? rootSlide;
nextMenu = this.activeSubmenu?.parent ?? rootSlide;
this.closeFold();
}
// Event is triggered in navigate()
this.navigateTo(nextMenu);
}
private closeFold(): void {
this.slides.forEach((menu) => {
menu.appendTo(this.sliderWrapperElem);
});
this.menuElem.classList.remove(CLASSES.foldOpen);
}
private openFold(): void {
this.slides.forEach((menu) => {
if (menu.isFoldable) {
menu.appendTo(this.foldableWrapperElem);
}
});
this.menuElem.classList.add(CLASSES.foldOpen);
}
/**
* Navigate to a specific submenu of link on any level (useful to open the correct hierarchy directly), if no submenu is found opens the submenu of link directly
*/
public navigateTo(target: HTMLElement | Slide | string, runInForeground: boolean = true): void {
// Open Menu if still closed
if (runInForeground && !this.isOpen) {
this.show();
}
const nextMenu: Slide = this.findNextMenu(target);
const previousMenu = this.activeSubmenu;
const parents = nextMenu.getAllParents();
const firstUnfoldableParent = nextMenu.getFirstUnfoldableParent();
const visibleSlides = new Set([nextMenu, ...nextMenu.getAllFoldableParents()]);
if (firstUnfoldableParent) {
visibleSlides.add(firstUnfoldableParent);
}
const isNavigatingBack = previousMenu?.hasParent(nextMenu);
const isNavigatingForward = nextMenu?.hasParent(previousMenu);
if (runInForeground) {
this.triggerEvent(Action.Navigate);
if (isNavigatingBack) {
this.triggerEvent(Action.Back);
} else if (isNavigatingForward) {
this.triggerEvent(Action.Forward);
} else {
this.triggerEvent(Action.NavigateTo);
}
}
this.updateMenuTitle(nextMenu, firstUnfoldableParent);
this.setTabbing(nextMenu, firstUnfoldableParent, previousMenu, parents);
// all parents need to be active to calculate slider width and level
const nextActiveMenus = [nextMenu, ...parents];
this.activateMenus(nextActiveMenus, isNavigatingBack, previousMenu, nextMenu);
const level = this.setSlideLevel(nextMenu, isNavigatingBack);
this.hideControlsIfOnRootLevel(level);
this.setBodyTagSlideLevel(level);
this.setActiveSubmenu(nextMenu);
setTimeout(() => {
if (runInForeground) {
// Wait for anmiation to finish to focus next link in nav otherwise focus messes with slide animation
nextMenu.focusFirstElem();
}
if (isNavigatingBack) {
// Wait for anmiation to finish to deactivate previous otherwise width of container messes with slide animation
previousMenu?.deactivate();
}
// hide all non visible menu elements to prevent screen reader confusion
this.slides.forEach((slide: Slide) => {
if (slide.isActive && !visibleSlides.has(slide)) {
slide.setInvisible();
}
});
}, this.options.transitionDuration);
}
private setActiveSubmenu(nextMenu: Slide): void {
this.activeSubmenu = nextMenu;
}
private setBodyTagSlideLevel(level: number): void {
document.querySelector('body')?.setAttribute('data-slide-menu-level', level.toString());
}
private setTabbing(
nextMenu: Slide,
firstUnfoldableParent: Slide | undefined,
previousMenu: Slide | undefined,
parents: Slide[],
): void {
if (this.isOpen) {
this.menuElem.removeAttribute('inert');
}
if (nextMenu.canFold()) {
this.openFold();
// Enable Tabbing for foldable Parents
nextMenu.getAllParents().forEach((menu) => {
if (menu.canFold()) {
menu.enableTabbing();
}
});
firstUnfoldableParent?.enableTabbing();
// disable Tabbing for invisible unfoldable parents
firstUnfoldableParent?.getAllParents().forEach((menu) => {
menu.disableTabbing();
});
return;
}
if (previousMenu?.canFold() && !nextMenu.canFold()) {
// close fold
this.closeFold();
}
parents.forEach((menu) => {
menu.disableTabbing();
});
nextMenu.enableTabbing();
}
private activateMenus(
currentlyActiveMenus: Slide[],
isNavigatingBack: boolean | undefined,
previousMenu: Slide | undefined,
nextMenu: Slide,
): void {
const currentlyActiveIds = currentlyActiveMenus.map((menu) => menu?.id);
// Disable all previous active menus not active now
this.slides.forEach((slide) => {
if (!currentlyActiveIds.includes(slide.id)) {
// When navigating backwards deactivate (hide) previous after transition to not mess with animation
if (isNavigatingBack && slide.id === previousMenu?.id) {
return;
}
slide.deactivate();
slide.disableTabbing();
}
});
// Activate menus
currentlyActiveMenus.forEach((menu) => {
menu?.activate();
});
nextMenu.enableTabbing();
}
private findNextMenu(target: string | HTMLElement | Slide): Slide {
if (typeof target === 'string') {
const menu = this.getTargetSlideByIdentifier(target);
if (menu instanceof Slide) {
return menu;
} else {
throw new Error('Invalid parameter `target`. A valid query selector is required.');
}
}
if (target instanceof HTMLElement) {
const menu = this.slides.find((menu) => menu.contains(target as HTMLElement));
if (menu instanceof Slide) {
return menu;
} else {
throw new Error('Invalid parameter `target`. Not found in slide menu');
}
}
if (target instanceof Slide) {
return target;
} else {
throw new Error('No valid next slide fund');
}
}
private hideControlsIfOnRootLevel(level: number): void {
const controlsToHideIfOnRootLevel = document.querySelectorAll(
`.${CLASSES.control}.${CLASSES.hiddenOnRoot}, .${CLASSES.control}.${CLASSES.invisibleOnRoot}`,
);
if (level === 0) {
controlsToHideIfOnRootLevel.forEach((elem) => {
elem.setAttribute('tabindex', '-1');
});
} else {
controlsToHideIfOnRootLevel.forEach((elem) => {
elem.removeAttribute('tabindex');
});
}
}
private setSlideLevel(nextMenu?: Slide, isNavigatingBack: boolean = false): number {
const activeNum = Array.from(
this.sliderWrapperElem.querySelectorAll(`.${CLASSES.active}, .${CLASSES.current}`),
).length;
const navDecrement = !nextMenu?.canFold() ? Number(isNavigatingBack) : 0;
const level = Math.max(1, activeNum) - 1 - navDecrement;
this.setBodyTagSlideLevel(level);
document.documentElement.style.setProperty('--smdm-sm-menu-level', `${level}`);
return level;
}
private updateMenuTitle(nextMenu: Slide, firstUnfoldableParent?: Slide): void {
if (this.menuTitle) {
let anchorText = nextMenu?.anchorElem?.textContent ?? this.menuTitleDefaultText;
const navigatorTextAfter = this.options?.navigationButtons ?? '';
const navigatorTextBefore = this.options?.submenuLinkBefore ?? '';
if (nextMenu.canFold() && firstUnfoldableParent) {
anchorText = firstUnfoldableParent.anchorElem?.textContent ?? anchorText;
}
if (navigatorTextAfter && typeof navigatorTextAfter === 'string') {
anchorText = anchorText.replace(navigatorTextAfter, '');
}
if (navigatorTextBefore && typeof navigatorTextBefore === 'string') {
anchorText = anchorText.replace(navigatorTextBefore, '');
}
if (this.menuTitle.tagName === 'A') {
// @ts-expect-error // menuTitle is HTMLElement | null
this.menuTitle.href = nextMenu.ref;
}
this.menuTitle.innerText = anchorText.trim();
}
}
/**
*
* @param targetMenuIdHrefOrSelector a selector or Slide ID or Slug of Href
* @returns
*/
private getTargetSlideByIdentifier(targetMenuIdAnchorHrefOrSelector: string): Slide | undefined {
// search from bottom to top
const sortedByTreeDepth = this.slides.slice().sort((a, b) => {
const depthA = a.ref.split('/').length;
const depthB = b.ref.split('/').length;
if (depthB !== depthA) {
return depthB - depthA;
}
return b.ref.length - a.ref.length;
});
return sortedByTreeDepth.find((menu) => menu.matches(targetMenuIdAnchorHrefOrSelector));
}
/**
* Set up all event handlers
*/
private initEventHandlers(): void {
// Handler for end of CSS transition
this.menuElem.addEventListener('transitionend', this.onTransitionEnd.bind(this));
this.sliderElem.addEventListener('transitionend', this.onTransitionEnd.bind(this));
// Hide menu on click outside menu
if (this.options.closeOnClickOutside) {
document.addEventListener('click', (event) => {
if (
this.isOpen &&
!this.isAnimating &&
// @ts-expect-error // Event Target will always be Element
!this.menuElem.contains(event.target) &&
// @ts-expect-error // Event Target will always be Element
!event.target?.closest('.' + CLASSES.control)
) {
this.close();
}
});
}
this.initKeybindings();
}
private onTransitionEnd(event: Event): void {
// Ensure the transitionEnd event was fired by the correct element
// (elements inside the menu might use CSS transitions as well)
if (
event.target !== this.menuElem &&
event.target !== this.sliderElem &&
event.target !== this.foldableWrapperElem &&
event.target !== this.sliderWrapperElem
) {
return;
}
this.isAnimating = false;
if (this.lastAction) {
this.triggerEvent(this.lastAction, true);
this.lastAction = null;
}
}
private initKeybindings(): void {
document.addEventListener('keydown', (event) => {
const elem = document.activeElement;
switch (event.key) {
case this.options.keyClose:
event.preventDefault();
this.close();
break;
case this.options.keyOpen:
event.preventDefault();
this.show();
break;
case 'Enter':
// @ts-expect-error // simulate click event
if (elem?.classList.contains(CLASSES.navigator)) elem.click();
break;
}
});
}
/**
* Trigger a custom event to support callbacks
*/
private triggerEvent(action: Action, afterAnimation: boolean = false): void {
this.lastAction = action;
const name = `sm.${action}${afterAnimation ? '-after' : ''}`;
const event = new CustomEvent(name);
this.menuElem.dispatchEvent(event);
}
public markSelectedItem(anchor: Element): void {
this.menuElem.querySelectorAll('.' + CLASSES.activeItem).forEach((elem) => {
elem.classList.remove(CLASSES.activeItem);
});
anchor.classList.add(CLASSES.activeItem);
}
/**
* Start the slide animation (the CSS transition)
*/
private moveElem(elem: HTMLElement, offset: string | number, unit: string = '%'): void {
setTimeout(() => {
// Add percentage sign
if (!offset.toString().includes(unit)) {
offset += unit;
}
const newTranslateX = `translateX(${offset})`;
if (elem.style.transform !== newTranslateX) {
this.isAnimating = true;
elem.style.transform = newTranslateX;
}
}, 0);
}
/**
* Initialize the menu
*/
private initMenu(): void {
this.runWithoutAnimation(() => {
switch (this.options.position) {
case MenuPosition.Left:
Object.assign(this.menuElem.style, {
left: 0,
right: 'auto',
transform: 'translateX(-100%)',
});
break;
default:
Object.assign(this.menuElem.style, {
left: 'auto',
right: 0,
});
break;
}
});
this.menuElem.classList.add(this.options.position);
const rootMenu = this.menuElem.querySelector('ul') as unknown as SlideHTMLElement | null;
if (rootMenu) {
this.slides.push(new Slide(rootMenu, this.options));
}
this.menuElem.addEventListener('keydown', (event) => {
// WCAG - if anchors are used for navigation make them usable with space
if (
event.key === ' ' &&
event.target instanceof HTMLAnchorElement &&
event.target.role === 'button'
) {
event.preventDefault();
event.target.click();
}
// WCAG - trap focus in menu
const firstControl = this.menuElem.querySelector(
`.${CLASSES.controls} .${CLASSES.control}:not([disabled]):not([tabindex="-1"])`,
) as HTMLElement | undefined;
trapFocus(event, this.activeSubmenu?.menuElem ?? this.menuElem, firstControl);
});
const resizeObserver = new ResizeObserver((entries) => {
if (entries.length === 0) {
return;
}
const bodyContentWidth = entries[0].contentRect.width;
if (bodyContentWidth < this.options.minWidthFold && this.isFoldOpen) {
this.closeFold();
const parents = this.activeSubmenu?.getAllParents();
const firstUnfoldableParent = parents?.find((p) => !p.canFold());
this.setTabbing(
this.activeSubmenu ?? this.slides[0],
firstUnfoldableParent,
this.activeSubmenu,
parents ?? [],
);
this.setSlideLevel(this.activeSubmenu ?? this.slides[0]);
this.setTabbing(this.activeSubmenu ?? this.slides[0], undefined, undefined, []);
}
if (bodyContentWidth > this.options.minWidthFold && !this.isFoldOpen) {
this.openFold();
const parents = this.activeSubmenu?.getAllParents();
const firstUnfoldableParent = parents?.find((p) => !p.canFold());
this.setTabbing(
this.activeSubmenu ?? this.slides[0],
firstUnfoldableParent,
this.activeSubmenu,
parents ?? [],
);
this.setSlideLevel(this.activeSubmenu ?? this.slides[0]);
}
});
resizeObserver.observe(document.body);
}
/**
* Pause the CSS transitions, to apply CSS changes directly without an animation
*/
private runWithoutAnimation(action: () => void): void {
const transitionElems = [this.menuElem, this.sliderElem];
transitionElems.forEach((elem) => (elem.style.transition = 'none'));
action();
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
this.menuElem.offsetHeight; // Trigger a reflow, flushing the CSS changes
transitionElems.forEach((elem) => elem.style.removeProperty('transition'));
this.isAnimating = false;
}
/**
* Enhance the markup of menu items which contain a submenu and move them into the slider
*/
private initSlides(): void {
this.menuElem.querySelectorAll('a').forEach((anchor: HTMLAnchorElement, index: number) => {
if (anchor.parentElement === null) {
return;
}
const submenu = anchor.parentElement.querySelector(
'ul',
) as unknown as SlideHTMLElement | null;
if (!submenu) {
return;
}
const menuSlide = new Slide(submenu, this.options, anchor);
this.slides.push(menuSlide);
});
this.slides.forEach((menuSlide) => {
menuSlide.appendTo(this.sliderWrapperElem);
});
}
get onlyNavigateDecorator(): boolean {
return !!this.options.navigationButtons;
}
}
// Link control buttons with the API
document.addEventListener('click', (event) => {
const canControlMenu = (elem: Element | undefined | null): boolean => {
if (!elem) {
return false;
}
return (
elem.classList.contains(CLASSES.control) ||
elem.classList.contains(CLASSES.hasSubMenu) ||
elem.classList.contains(CLASSES.navigator)
);
};
const btn = canControlMenu(event.target as Element)
? event.target
: // @ts-expect-error target is Element | null | undefined
event.target?.closest(
`.${CLASSES.navigator}[data-action], .${CLASSES.control}[data-action], .${CLASSES.hasSubMenu}[data-action]`,
);
if (!btn || !canControlMenu(btn as Element)) {
return;
}
// Find Slide-Menu that should be controlled
const target = btn.getAttribute('data-target');
const menu =
!target || target === 'this'
? parentsOne(btn as Node, `.${NAMESPACE}`)
: document.getElementById(target as string) ?? document.querySelector(target); // assumes #id
if (!menu) {
throw new Error(`Unable to find menu ${target}`);
}
const slideMenuInstance = (menu as MenuHTMLElement)._slideMenu;
// Always prevent opening of links if not onlyNavigateDecorator
if (slideMenuInstance && !slideMenuInstance.onlyNavigateDecorator) {
event.preventDefault();
}
// Only prevent opening of links when clicking the decorator when onlyNavigateDecorator
// if (slideMenuInstance && slideMenuInstance.onlyNavigateDecorator) {
// event.preventDefault();
// }
const methodName = btn.getAttribute('data-action');
const dataArg = btn.getAttribute('data-arg') ?? btn.href;
const dataArgMapping = {
false: false,
true: true,
null: null,
undefined,
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const arg = Object.keys(dataArgMapping).includes(dataArg?.toString() ?? '')
? // @ts-expect-error // user input can be undefined
dataArgMapping[dataArg]
: dataArg;
// console.log(slideMenuInstance, methodName, arg);
// @ts-expect-error // make functions dynamically accessible from outside context
if (slideMenuInstance && methodName && typeof slideMenuInstance[methodName] === 'function') {
// @ts-expect-error // make functions dynamically accessible from outside context
arg ? slideMenuInstance[methodName](arg) : slideMenuInstance[methodName]();
}
});
// @ts-expect-error // Expose SlideMenu to the global namespace
window.SlideMenu = SlideMenu;
// send global event when SlideMenu is ready and available in global namespace
window.dispatchEvent(new Event('sm.ready'));