smdm-slide-menu
Version:
A library agnostic multilevel page menu with a smooth slide effect based on CSS transitions, focused on accessibility.
194 lines (193 loc) • 9 kB
JavaScript
import { Action, CLASSES } from './SlideMenuOptions.js';
import { focusFirstTabAbleElemIn, validateQuery } from './utils/dom.js';
let number = 0;
export class Slide {
get isActive() {
return this.active;
}
/**
* Construction phase: establishes identity and relationships only — no DOM writes except
* setting the element's id (needed for aria-controls wiring in mount()).
* Call mount() afterwards to apply all DOM decoration.
*/
constructor(menuElem, options, anchorElem, slidesByElem) {
var _a, _b, _c, _d;
this.menuElem = menuElem;
this.options = options;
this.anchorElem = anchorElem;
this.isFoldable = false;
this.active = false;
this.id = menuElem.id ? menuElem.id : 'smdm-' + number;
menuElem.id = this.id;
number++;
this.name = (_b = (_a = this.anchorElem) === null || _a === void 0 ? void 0 : _a.textContent) !== null && _b !== void 0 ? _b : '';
this.ref = anchorElem ? anchorElem.href.replace(window.location.origin, '') : '/';
this.parentMenuElem = (_d = (_c = anchorElem === null || anchorElem === void 0 ? void 0 : anchorElem.parentElement) === null || _c === void 0 ? void 0 : _c.closest('ul')) !== null && _d !== void 0 ? _d : undefined;
this.parent = this.parentMenuElem ? slidesByElem === null || slidesByElem === void 0 ? void 0 : slidesByElem.get(this.parentMenuElem) : undefined;
this.isFoldable = !!(anchorElem === null || anchorElem === void 0 ? void 0 : anchorElem.classList.contains(CLASSES.hasFoldableSubmenu));
}
/**
* DOM decoration phase: adds CSS classes, injects back-link and navigator button elements,
* and sets data-action attributes. Called by SlideMenu after registering the slide in its
* WeakMap so that sibling/child mounts can look up the correct parent instance.
*/
mount() {
const { anchorElem, menuElem, options } = this;
if (anchorElem) {
anchorElem.classList.add(CLASSES.hasSubMenu);
if (!options.navigationButtons) {
anchorElem.dataset.action = Action.NavigateTo;
anchorElem.dataset.arg = this.id;
anchorElem.role = 'button';
anchorElem.setAttribute('aria-controls', this.id);
anchorElem.setAttribute('aria-expanded', 'false');
}
}
menuElem.classList.add(CLASSES.submenu);
menuElem.dataset.smdmId = this.id;
menuElem.querySelectorAll('li').forEach((link) => {
link.classList.add(CLASSES.listItem);
});
menuElem.querySelectorAll('a').forEach((link) => {
link.classList.add(CLASSES.item);
});
if (this.isFoldable) {
menuElem.classList.add(CLASSES.foldableSubmenu);
}
if (options.showBackLink) {
this.addBackLink(options);
}
this.addNavigatorButton(options);
return this;
}
addBackLink(options = this.options) {
var _a, _b, _c, _d;
const anchorText = (_b = (_a = this.anchorElem) === null || _a === void 0 ? void 0 : _a.textContent) !== null && _b !== void 0 ? _b : '';
const backLink = document.createElement('a');
backLink.innerHTML =
((_c = options.backLinkBefore) !== null && _c !== void 0 ? _c : '') + anchorText + ((_d = options.backLinkAfter) !== null && _d !== void 0 ? _d : '');
backLink.classList.add(CLASSES.backlink, CLASSES.control, CLASSES.item);
backLink.dataset.action = Action.Back;
backLink.setAttribute('href', '#');
const backLinkLi = document.createElement('li');
backLinkLi.appendChild(backLink);
this.menuElem.insertBefore(backLinkLi, this.menuElem.firstChild);
return backLink;
}
addNavigatorButton(options) {
var _a, _b, _c, _d, _e, _f, _g, _h;
if (!options.navigationButtons) {
return;
}
const existingNavigator = Array.from((_c = (_b = (_a = this.anchorElem) === null || _a === void 0 ? void 0 : _a.parentElement) === null || _b === void 0 ? void 0 : _b.children) !== null && _c !== void 0 ? _c : []).find((elem) => elem.classList.contains(CLASSES.navigator));
const navigatorTag = 'button';
const navigator = (existingNavigator !== null && existingNavigator !== void 0 ? existingNavigator : document.createElement(navigatorTag));
navigator.classList.add(CLASSES.navigator);
navigator.dataset.action = (_e = (_d = navigator.dataset) === null || _d === void 0 ? void 0 : _d.action) !== null && _e !== void 0 ? _e : Action.NavigateTo;
navigator.dataset.arg = (_g = (_f = navigator.dataset) === null || _f === void 0 ? void 0 : _f.arg) !== null && _g !== void 0 ? _g : this.id;
navigator.setAttribute('aria-controls', this.id);
navigator.setAttribute('aria-expanded', 'false');
navigator.setAttribute('tabindex', '0');
navigator.title = navigator.title
? navigator.title
: options.navigationButtonsLabel + ': ' + this.name;
if (navigator.tagName !== 'BUTTON') {
navigator.role = 'button';
}
if (typeof options.navigationButtons === 'string' && !navigator.innerHTML.trim()) {
navigator.innerHTML = options.navigationButtons;
}
else if (!navigator.getAttribute('aria-label')) {
navigator.setAttribute('aria-label', options.navigationButtonsLabel + ': ' + this.name);
}
(_h = this.anchorElem) === null || _h === void 0 ? void 0 : _h.insertAdjacentElement('afterend', navigator);
this.navigatorElem = navigator;
}
deactivate() {
var _a, _b;
this.active = false;
this.menuElem.classList.remove(CLASSES.active);
this.menuElem.classList.remove(CLASSES.current);
if (this.options.navigationButtons) {
(_a = this.navigatorElem) === null || _a === void 0 ? void 0 : _a.setAttribute('aria-expanded', 'false');
}
else {
(_b = this.anchorElem) === null || _b === void 0 ? void 0 : _b.setAttribute('aria-expanded', 'false');
}
return this;
}
activate() {
var _a, _b;
this.active = true;
this.menuElem.classList.add(CLASSES.active);
this.menuElem.classList.add(CLASSES.current);
if (this.options.navigationButtons) {
(_a = this.navigatorElem) === null || _a === void 0 ? void 0 : _a.setAttribute('aria-expanded', 'true');
}
else {
(_b = this.anchorElem) === null || _b === void 0 ? void 0 : _b.setAttribute('aria-expanded', 'true');
}
return this;
}
setInvisible() {
if (this.isActive) {
this.menuElem.classList.add(CLASSES.active);
}
this.menuElem.classList.remove(CLASSES.current);
return this;
}
enableTabbing() {
this.menuElem.removeAttribute('inert');
}
disableTabbing() {
this.menuElem.setAttribute('inert', 'true');
}
appendTo(elem) {
elem.appendChild(this.menuElem);
return this;
}
getClosestUnfoldableSlide() {
return this.isFoldable ? this.getAllParents().find((p) => !p.isFoldable) : this;
}
getAllFoldableParents() {
return this.isFoldable ? this.getAllParents().filter((p) => p.isFoldable) : [];
}
getFirstUnfoldableParent() {
return this.getAllParents().find((p) => !p.canFold());
}
hasParent(possibleParentMenu) {
return this.getAllParents().some((p) => p.id === (possibleParentMenu === null || possibleParentMenu === void 0 ? void 0 : possibleParentMenu.id));
}
getAllParents() {
const parents = [];
let parent = this.parent;
while (parent) {
parents.push(parent);
parent = parent.parent;
}
return parents;
}
/**
* Focus the first tabbable element in the menu
* ⚠️ ATTENTION - setting the focus can mess with animations! Always set focus after animation is done
*/
focusFirstElem() {
focusFirstTabAbleElemIn(this.menuElem);
}
canFold() {
return this.isFoldable && window.innerWidth >= this.options.minWidthFold;
}
matches(idHrefOrSelector) {
var _a;
const validSelector = validateQuery(idHrefOrSelector.trim());
return !!(this.id === idHrefOrSelector ||
this.menuElem.id === idHrefOrSelector ||
((_a = this.anchorElem) === null || _a === void 0 ? void 0 : _a.id) === idHrefOrSelector.replace('#', '') ||
idHrefOrSelector.replace(window.location.origin, '').startsWith(this.ref) ||
(validSelector &&
this.menuElem.querySelector(idHrefOrSelector.trim() + `:not(.${CLASSES.hasSubMenu})`)));
}
contains(elem) {
return this.anchorElem === elem || this.menuElem.contains(elem);
}
}