UNPKG

smartmenus

Version:

Website/web app navbars with collapsible navs or dropdowns made easy yet highly configurable.

1,182 lines (991 loc) 39.4 kB
/* * SmartMenus * http://www.smartmenus.org/ * Copyright (c) since 2001 Vasil Dinkov, Vadikom Web Ltd. http://vadikom.com * Licensed MIT https://github.com/vadikom/smartmenus/blob/master/LICENSE-MIT */ import Data from './utility/dom-data.js' import Events from './utility/dom-events.js' import Helpers from './utility/dom-helpers.js' import Query from './utility/dom-query.js' import MouseInputDetect from './utility/mouse-input-detect.js' import DEFAULTS from './defaults.js' const DATA_KEY = 'sm' const DATA_SUFFIX = `-${DATA_KEY}` const DATA_ATTRIBUTE_PREFIX = `data-${DATA_KEY}-` const EVENTS_NS = `.${DATA_KEY}` const API_EVENTS_SUFFIX = `-${DATA_KEY}` const navbars = [] class SmartMenus { constructor(element, options) { this._navbar = element // Get any options defined as data- attributes const dataOptions = {} for (const key of Object.keys(DEFAULTS)) { let dataValue = this._navbar.dataset[`${DATA_KEY}${key.charAt(0).toUpperCase()}${key.slice(1)}`] if (dataValue !== undefined) { switch (typeof DEFAULTS[key]) { case 'number': { dataValue = Number.parseFloat(dataValue, 10) break } case 'boolean': { dataValue = dataValue === 'true' break } } dataOptions[key] = dataValue } } this._opts = { ...DEFAULTS, ...options, ...dataOptions } this._togglerState = null this._togglerAnchorShow = null this._togglerAnchorHide = null this._collapse = null this._offcanvas = null this._offcanvasOverlay = null this._nav = null this._navbarId = String(Date.now() + Math.random()).replace(/\D/g, '') // for internal use this._accessIdPrefix = `${DATA_KEY}-${this._navbarId}-` this._visibleSubs = [] // stores visible subs (might be in no particular order in collapsible non-accordion mode) this._dropdownsShowTimeout = 0 this._dropdownsHideTimeout = 0 this._clickActivated = false this._zIndexInc = 0 this._idInc = 0 this._disabled = false this._wasCollapsible = false // We'll access these for some tests at runtime so we'll cache them this._firstLink = null this._firstSub = null this._init() } // Getters static get DATA_KEY() { return DATA_KEY } static get Dom() { return { Data, Events, Helpers, Query } } static get MouseInputDetect() { return MouseInputDetect } static get navbars() { return navbars } // Public destroy(refresh) { this.subHideAll() if (this._nav) { this._destroySubs() this._destroyNav() this._nav = null } this._togglerState = null if (this._togglerAnchorShow) { Data.delete(this._togglerAnchorShow, `beforefirstshow-fired${DATA_SUFFIX}`) this._togglerAnchorShow = null } this._togglerAnchorHide = null this._collapse = null this._offcanvas = null this._offcanvasOverlay = null this._firstLink = null this._firstSub = null if (!refresh) { this._destroyNavbar() this._navbar = null } } disable() { if (!this._disabled) { this.subHideAll() this._disabled = true } } enable() { if (this._disabled) { this._disabled = false } } /** * Activate a menu link * * @param {HTMLElement} element Any element which matches the "selectorLink" selector * @param {('none'|'same'|'deeper'|'self')} hideSubs Hide any visible subs from the same or deeper levels or only the self sub (if it exists) * @param {boolean} showSub Should the link's sub be shown (if it exists)? * @returns {void} */ linkActivate(element, hideSubs = 'none', showSub = true) { const menu = element.closest(`${this._opts.selectorNav}, ${this._opts.selectorSub}`) const level = Data.get(menu, `level${DATA_SUFFIX}`) const sub = Data.get(element, `sub${DATA_SUFFIX}`) // If for some reason the containing menu is not visible (e.g. this is an API call to activate the link), show all parent menus first if (level > 1 && !this._subIsVisible(menu)) { const parents = Query.parentsUntil(menu, this._opts.selectorNav, this._opts.selectorSub).reverse() parents.push(menu) for (const menu of parents) { this.linkActivate(Data.get(menu, `parent-link${DATA_SUFFIX}`), this._isCollapsible() && !this._opts.collapsibleBehaviorAccordion ? 'none' : 'same') } } // Hide any visible subs switch (hideSubs) { case 'same': { this._subHideSubs(level - 1) break } case 'deeper': { this._subHideSubs(level) break } case 'self': { if (sub) { this._subHide(sub) } break } } if (Events.trigger(this._navbar, `activate${API_EVENTS_SUFFIX}`, element).defaultPrevented || !showSub) { return } // Show the sub menu if this link has one if (sub) { this._subShow(sub) } } refresh() { this.destroy(true) this._init(true) } subHideAll() { this._clearTimeout(this._dropdownsShowTimeout) this._clearTimeout(this._dropdownsHideTimeout) const visibleCount = this._visibleSubs.length if (visibleCount > 0) { for (const sub of [...this._visibleSubs].reverse()) { this._subHide(sub) } } this._visibleSubs = [] this._clickActivated = false this._zIndexInc = 0 if (visibleCount > 0) { Events.trigger(this._navbar, `hideall${API_EVENTS_SUFFIX}`) } } // Private _activateSelectedLink() { const selectedLink = Query.getAll(`.${this._opts.classLinkSelected}`, this._nav).pop() if (selectedLink) { this.linkActivate(selectedLink) } } _animate(element, classAnimate) { // Reflow to make sure the animation would be restarted void element.offsetWidth element.classList.add(classAnimate) const { animationDuration } = Helpers.getComputedStyle(element) if (!animationDuration || animationDuration.startsWith('0s')) { element.classList.remove(classAnimate) return } // Trigger animationend if animation needs to be stopped or just in case it isn't fired for some reason const endTimeout = Data.get(element, `animationend-timeout${DATA_SUFFIX}`) if (endTimeout) { window.clearTimeout(endTimeout) Data.get(element, `animationend-trigger${DATA_SUFFIX}`)() } const endTrigger = () => { Data.delete(element, `animationend-timeout${DATA_SUFFIX}`) Data.delete(element, `animationend-trigger${DATA_SUFFIX}`) if (element.classList.contains(classAnimate)) { Events.trigger(element, 'animationend') } } Data.set(element, `animationend-trigger${DATA_SUFFIX}`, endTrigger) Data.set(element, `animationend-timeout${DATA_SUFFIX}`, window.setTimeout(endTrigger, (Number.parseFloat(animationDuration) * 1000) + 1) ) Events.on(element, `animationend${EVENTS_NS}`, event => { if (event.target === element) { Events.off(element, `animationend${EVENTS_NS}`) element.classList.remove(classAnimate) } }) } _animateHide(element) { // This property is used for the expand/collapse animation element.style.setProperty(`--${DATA_KEY}-height`, `${Helpers.getHeight(element)}px`) element.classList.remove(this._opts.classShow) this._animate(element, this._opts.classHiding) } _animateShow(element) { // This property is used for the expand/collapse animation element.style.setProperty(`--${DATA_KEY}-height`, `${Helpers.getHeight(element)}px`) element.classList.add(this._opts.classShow) this._animate(element, this._opts.classShowing) } _clearTimeout(timeout) { if (timeout) { window.clearTimeout(timeout) timeout = 0 } } _destroyLink(element) { if (element.classList.contains(this._opts.classLinkHasSub)) { if ((element.getAttribute('id') || '').startsWith(this._accessIdPrefix)) { element.removeAttribute('id') } element.classList.remove(this._opts.classLinkHasSub) Data.delete(element, `sub${DATA_SUFFIX}`) Data.delete(element, `toggler${DATA_SUFFIX}`) Data.delete(element, `link${DATA_SUFFIX}`) element.removeAttribute('role') element.removeAttribute('aria-controls') element.removeAttribute('aria-expanded') } if (this._opts.markCurrentLinkAsSelectedOnInit && element.classList.contains(this._opts.classLinkSelected)) { element.classList.remove(this._opts.classLinkSelected) element.removeAttribute('aria-current') } } _destroyNav() { Data.delete(this._nav, `level${DATA_SUFFIX}`) Events.off(this._nav, EVENTS_NS) Events.off(window.document, `${EVENTS_NS}-${this._navbarId}`) Events.off(window, `${EVENTS_NS}-${this._navbarId}`) } _destroyNavbar() { Data.delete(this._navbar, DATA_KEY) this._navbar.removeAttribute(`${DATA_ATTRIBUTE_PREFIX}id`) Events.off(this._navbar, EVENTS_NS) navbars.splice(navbars.indexOf(this), 1) } _destroySub(element) { const shownBefore = Data.get(element, `shown-before${DATA_SUFFIX}`) if (shownBefore) { this._subResetPosition(element) } if ((element.getAttribute('id') || '').startsWith(this._accessIdPrefix)) { element.removeAttribute('id') } Data.delete(element, `shown-before${DATA_SUFFIX}`) Data.delete(element, `parent-link${DATA_SUFFIX}`) Data.delete(element, `level${DATA_SUFFIX}`) Data.delete(element, `beforefirstshow-fired${DATA_SUFFIX}`) element.removeAttribute('aria-hidden') element.removeAttribute('aria-labelledby') } _destroySubs() { for (const sub of Query.getAll(this._opts.selectorSub, this._nav)) { this._destroySub(sub) } // Let's process the links separately from the subs (unline on init) since some link might possibly be disconnected // from the sub it had on init (e.g. its sub might have been removed in some DOM operation) for (const link of Query.getAll(this._opts.selectorLink, this._nav)) { this._destroyLink(link) } } _docClick(event) { // Hide on any click outside the nav const isCollapsible = this._isCollapsible() if (((!isCollapsible && this._opts.dropdownsHideTrigger === 'click') || (isCollapsible && this._opts.collapsibleResetSubsOnClickOn === 'page')) && (!event.target || !this._nav.contains(event.target))) { this.subHideAll() } } _docTouchEnd() { if (!this._lastTouch) { return } if ((this._lastTouch.x2 === undefined || this._lastTouch.x1 === this._lastTouch.x2) && (this._lastTouch.y2 === undefined || this._lastTouch.y1 === this._lastTouch.y2)) { this._clearTimeout(this._dropdownsHideTimeout) // Call with a delay to prevent triggering accidental unwanted click on some page element const { target } = this._lastTouch this._dropdownsHideTimeout = window.setTimeout(() => { this._docClick({ target }) }, 350) } this._lastTouch = null } _docTouchMove(event) { if (!this._lastTouch) { return } const touchPoint = event.touches[0] this._lastTouch.x2 = touchPoint.pageX this._lastTouch.y2 = touchPoint.pageY } _docTouchStart(event) { const touchPoint = event.touches[0] this._lastTouch = { x1: touchPoint.pageX, y1: touchPoint.pageY, target: touchPoint.target } } _getRootZIndex() { const zIndex = Number.parseInt(Helpers.getComputedStyle(this._navbar).zIndex, 10) return Number.isNaN(zIndex) ? 1 : zIndex } _handleEvents() { return !this._disabled && this._isCSSOn() } _init(refresh) { if (this._navbar.classList.contains(this._opts.classNavbarDropReverseX)) { this._opts.dropdownsDropReverseX = true } if (this._navbar.classList.contains(this._opts.classNavbarDropReverseY)) { this._opts.dropdownsDropReverseY = true } this._togglerState = Query.get(this._opts.selectorTogglerState, this._navbar) this._togglerAnchorShow = Query.get(this._opts.selectorTogglerAnchorShow, this._navbar) this._togglerAnchorHide = Query.get(this._opts.selectorTogglerAnchorHide, this._navbar) this._collapse = Query.get(this._opts.selectorCollapse, this._navbar) this._offcanvas = Query.get(this._opts.selectorOffcanvas, this._navbar) this._offcanvasOverlay = Query.get(this._opts.selectorOffcanvasOverlay, this._navbar) this._nav = Query.get(this._opts.selectorNav, this._navbar) if (!refresh) { this._initNavbar() } if (this._nav) { this._initNav() this._initSubs() // Save initial state const isCollapsible = this._isCollapsible() this._wasCollapsible = isCollapsible if (this._opts.markCurrentLinkAsSelectedOnInit) { this._markCurrentLinkAsSelected() } if (this._opts.collapsibleActivateSelectedLinkOnInit && isCollapsible) { this._activateSelectedLink() } MouseInputDetect.enable( // On mouse input detected - check if we are not over some link by chance and call the mouseenter handler if yes event => { const { target } = event if (target?.closest) { navbars.some(object => { const link = target.closest(object._opts.selectorLink) if (link && object._navbar.contains(link)) { object._linkEnter(event, link) return true } return false }) } } ) } } _initNav() { Data.set(this._nav, `level${DATA_SUFFIX}`, 1) Events.on(this._nav, Events.getEventsNS({ 'mouseover focusin': this._navOver.bind(this), 'mouseout focusout': this._navOut.bind(this), keydown: this._navKeyDown.bind(this) }, EVENTS_NS)) Events.on(this._nav, Events.getEventsNS({ mouseenter: this._linkEnter.bind(this), mouseleave: this._linkLeave.bind(this), mousedown: this._linkDown.bind(this), focus: this._linkFocus.bind(this), blur: this._linkBlur.bind(this), click: this._linkClick.bind(this) }, EVENTS_NS), this._opts.selectorLink) } _initNavbar() { navbars.push(this) Data.set(this._navbar, DATA_KEY, this) this._navbar.setAttribute(`${DATA_ATTRIBUTE_PREFIX}id`, this._navbarId) Events.on(this._navbar, `click${EVENTS_NS}`, this._opts.selectorTogglerAnchorShow, this._togglerAnchorShowClick.bind(this)) Events.on(this._navbar, `click${EVENTS_NS}`, this._opts.selectorTogglerAnchorHide, this._togglerAnchorHideClick.bind(this)) Events.on(this._navbar, `click${EVENTS_NS}`, this._opts.selectorOffcanvasOverlay, this._offcanvasOverlayClick.bind(this)) } _initSub(element) { const parentLink = Query.get(this._opts.selectorLink, element.closest(this._opts.selectorItem)) parentLink.classList.add(this._opts.classLinkHasSub) const parentLinkNextElement = parentLink.nextElementSibling const parentLinkToggler = parentLinkNextElement?.matches(this._opts.selectorLinkSplit) && parentLink.matches(this._opts.selectorLinkSplit) ? parentLinkNextElement : null if (parentLinkToggler) { Data.set(parentLink, `toggler${DATA_SUFFIX}`, parentLinkToggler) Data.set(parentLinkToggler, `link${DATA_SUFFIX}`, parentLink) parentLinkToggler.classList.add(this._opts.classLinkHasSub) } Data.set(parentLink, `sub${DATA_SUFFIX}`, element) Data.set(element, `parent-link${DATA_SUFFIX}`, parentLink) Data.set(element, `level${DATA_SUFFIX}`, Query.parentsUntil(element, this._opts.selectorNav, this._opts.selectorSub).length + 2) this._setTogglerSubARIA(parentLinkToggler || parentLink, element) } _initSubs() { // Hide subs on tap or click outside the nav if (this._opts.dropdownsHideTrigger === 'click' || this._opts.collapsibleResetSubsOnClickOn === 'page') { Events.on(window.document, Events.getEventsNS({ touchstart: this._docTouchStart.bind(this), touchmove: this._docTouchMove.bind(this), touchend: this._docTouchEnd.bind(this), click: this._docClick.bind(this) }, `${EVENTS_NS}-${this._navbarId}`)) } // Hide subs on resize Events.on(window, Events.getEventsNS({ 'resize orientationchange': this._winResize.bind(this) }, `${EVENTS_NS}-${this._navbarId}`)) const subs = Query.getAll(this._opts.selectorSub, this._nav) for (const sub of subs) { this._initSub(sub) } // Cache these for faster access at runtime this._firstLink = Query.get(this._opts.selectorLink, this._nav) this._firstSub = subs[0] } _isCollapsible() { return this._isCSSOn() && this._firstSub && Helpers.getComputedStyle(this._firstSub).position === 'static' } _isCSSOn() { return this._firstLink && Helpers.getComputedStyle(this._firstLink).display !== 'inline' } _isTouchMode() { return !MouseInputDetect.supportMouseInput || this._isCollapsible() } _linkBlur(event, element) { if (!this._handleEvents()) { return } // If this is a split link toggler, trigger the handler for the actual link const link = Data.get(element, `link${DATA_SUFFIX}`) if (link) { this._linkBlur(event, link) return } Events.trigger(this._navbar, `blur${API_EVENTS_SUFFIX}`, element) } /* eslint-disable complexity */ // Maybe refactor this to lower complexity? _linkClick(event, element) { if (!this._handleEvents()) { return } if (element.classList.contains(this._opts.classLinkDisabled)) { event.preventDefault() return false } // If this is a split link toggler, get the actual link let link = Data.get(element, `link${DATA_SUFFIX}`) const isTogglerClicked = Boolean(link) if (!isTogglerClicked) { link = element } if (Events.trigger(this._navbar, `click${API_EVENTS_SUFFIX}`, link).defaultPrevented) { event.preventDefault() return false } const linkToggler = Data.get(link, `toggler${DATA_SUFFIX}`) const sub = Data.get(link, `sub${DATA_SUFFIX}`) const subIsVisible = sub && this._subIsVisible(sub) const level = Data.get(sub, `level${DATA_SUFFIX}`) - 1 const isCollapsible = this._isCollapsible() const selectLink = !sub || (linkToggler && !isTogglerClicked) if (selectLink && Events.trigger(this._navbar, `select${API_EVENTS_SUFFIX}`, link).defaultPrevented) { event.preventDefault() return false } if (sub && (!selectLink || this._opts.showSubOnSplitLinkSelect)) { this._clickActivated = this._clickActivated || Boolean(!isCollapsible && !subIsVisible && this._opts.dropdownsShowTrigger === 'click-then-mouseover' && level === 1) } // Activate link let hideSubs = 'none' if (subIsVisible && isCollapsible && !this._opts.collapsibleBehaviorAccordion && (!selectLink || !this._opts.showSubOnSplitLinkSelect)) { hideSubs = 'self' } else if ( (sub && ( (subIsVisible && (!isCollapsible || this._opts.collapsibleBehaviorAccordion) && (!selectLink || !this._opts.showSubOnSplitLinkSelect)) || (!subIsVisible && (!isCollapsible || (this._opts.collapsibleBehaviorAccordion && (!selectLink || this._opts.showSubOnSplitLinkSelect)))) ) ) || (!sub && !isCollapsible) ) { hideSubs = 'same' } const showSub = selectLink ? this._opts.showSubOnSplitLinkSelect : !subIsVisible this.linkActivate(link, hideSubs, showSub) // Select link if (selectLink) { if (this._opts.resetTogglerOnLinkSelect && this._togglerAnchorHide && Helpers.getComputedStyle(this._togglerAnchorHide).display !== 'none' && this._togglerAnchorHide.offsetWidth > 0) { this._togglerAnchorHide.click() } if (!isCollapsible && (!sub || subIsVisible || !this._opts.showSubOnSplitLinkSelect)) { this.subHideAll() } return true } // Toggle sub if (sub) { if (!isCollapsible && level === 1 && subIsVisible) { this.subHideAll() } event.preventDefault() return false } } /* eslint-enable complexity */ _linkDown(event, element) { if (!this._handleEvents()) { return } // If this is a split link toggler, trigger the handler for the actual link const link = Data.get(element, `link${DATA_SUFFIX}`) if (link) { this._linkDown(event, link) return } Data.set(element, `mousedown${DATA_SUFFIX}`, true) } _linkEnter(event, element) { if (!this._handleEvents()) { return } // If this is a split link toggler, trigger the handler for the actual link const link = Data.get(element, `link${DATA_SUFFIX}`) if (link) { this._linkEnter(event, link) return } if (!this._isTouchMode() && (this._opts.dropdownsShowTrigger === 'mouseover' || (this._opts.dropdownsShowTrigger === 'click-then-mouseover' && this._clickActivated) || this._opts.dropdownsHideTrigger !== 'click')) { this._clearTimeout(this._dropdownsShowTimeout) const level = Data.get(element.closest(`${this._opts.selectorNav}, ${this._opts.selectorSub}`), `level${DATA_SUFFIX}`) const sub = Data.get(element, `sub${DATA_SUFFIX}`) const hideSubs = !sub || !this._subIsVisible(sub) ? 'same' : 'deeper' const showSub = this._opts.dropdownsShowTrigger === 'mouseover' || (this._opts.dropdownsShowTrigger === 'click-then-mouseover' && this._clickActivated) const timeout = this._opts.dropdownsShowTrigger === 'click-then-mouseover' && level === 1 ? 1 : this._opts.dropdownsShowTimeout this._dropdownsShowTimeout = window.setTimeout(() => { this.linkActivate(element, hideSubs, showSub) }, timeout) } Events.trigger(this._navbar, `mouseenter${API_EVENTS_SUFFIX}`, element) } _linkFocus(event, element) { if (!this._handleEvents()) { return } // If this is a split link toggler, trigger the handler for the actual link const link = Data.get(element, `link${DATA_SUFFIX}`) if (link) { this._linkFocus(event, link) return } // Neglect focus events that were triggered by mouse input if (!Data.get(element, `mousedown${DATA_SUFFIX}`)) { const sub = Data.get(element, `sub${DATA_SUFFIX}`) const hideSubs = this._isCollapsible() && !this._opts.collapsibleBehaviorAccordion ? 'none' : (!sub || !this._subIsVisible(sub) ? 'same' : 'deeper') const showSub = false this.linkActivate(element, hideSubs, showSub) } Events.trigger(this._navbar, `focus${API_EVENTS_SUFFIX}`, element) } _linkLeave(event, element) { if (!this._handleEvents()) { return } // If this is a split link toggler, trigger the handler for the actual link const link = Data.get(element, `link${DATA_SUFFIX}`) if (link) { this._linkLeave(event, link) return } if (!this._isTouchMode() && this._opts.dropdownsHideTrigger !== 'click') { element.blur() const linkToggler = Data.get(element, `toggler${DATA_SUFFIX}`) if (linkToggler) { linkToggler.blur() } this._clearTimeout(this._dropdownsShowTimeout) } Data.delete(element, `mousedown${DATA_SUFFIX}`) Events.trigger(this._navbar, `mouseleave${API_EVENTS_SUFFIX}`, element) } _navKeyDown(event) { if (!this._handleEvents()) { return } switch (event.keyCode) { // Esc case 27: { const { target } = event // If has own sub and it's visible, hide it if (target?.matches(this._opts.selectorLink)) { const link = Data.get(target, `link${DATA_SUFFIX}`) || target const linkToggler = Data.get(link, `toggler${DATA_SUFFIX}`) const element = linkToggler || link const sub = Data.get(link, `sub${DATA_SUFFIX}`) if (sub && this._subIsVisible(sub)) { this._linkClick(event, element) event.preventDefault() return } } // Hide closest sub const closestSub = target?.closest(`${this._opts.selectorSub}`) if (closestSub) { const parentLink = Data.get(closestSub, `parent-link${DATA_SUFFIX}`) const parentLinkToggler = Data.get(parentLink, `toggler${DATA_SUFFIX}`) const element = parentLinkToggler || parentLink this._linkClick(event, element) element.focus() event.preventDefault() } return } // Space case 32: { const { target } = event // Toggle link's sub if (target?.matches(this._opts.selectorLink)) { this._linkClick(event, target) event.preventDefault() } } } } _navOut(event) { if (!this._handleEvents() || this._isTouchMode() || event.target === this._nav) { return } this._clearTimeout(this._dropdownsHideTimeout) // On focusout if there is no related target (i.e. another page element that was focused) it means the page lost focus so do not hide the sub menus if (this._opts.dropdownsHideTrigger !== 'click' || (event.type !== 'mouseout' && event.relatedTarget && (!event.target || !this._nav.contains(event.target)))) { this._dropdownsHideTimeout = window.setTimeout(() => { this.subHideAll() }, this._opts.dropdownsHideTimeout) } } _navOver(event) { if (!this._handleEvents() || this._isTouchMode() || event.target === this._nav) { return } this._clearTimeout(this._dropdownsHideTimeout) } _offcanvasOverlayClick(event) { if (!this._handleEvents()) { return } if (this._togglerAnchorHide) { this._togglerAnchorHide.click() } event.preventDefault() } _markCurrentLinkAsSelected() { const reDefaultDocument = /(index|default)\.[^#/?]*/i const reHash = /#.*/ const locHref = window.location.href.replace(reDefaultDocument, '') const locHrefNoHash = locHref.replace(reHash, '') for (const link of Query.getAll(this._opts.selectorLink, this._nav)) { // if this is a split link toggler, skip it if (Data.get(link, `link${DATA_SUFFIX}`)) { continue } const href = link.href.replace(reDefaultDocument, '') if (href === locHref || href === locHrefNoHash) { link.classList.add(this._opts.classLinkSelected) link.setAttribute('aria-current', 'page') const linkToggler = Data.get(link, `toggler${DATA_SUFFIX}`) if (linkToggler) { linkToggler.classList.add(this._opts.classLinkSelected) } if (this._opts.markCurrentLinkParentsAsSelected) { for (const sub of Query.parentsUntil(link, this._opts.selectorNav, this._opts.selectorSub)) { const parentLink = Data.get(sub, `parent-link${DATA_SUFFIX}`) parentLink.classList.add(this._opts.classLinkSelected) const parentLinkToggler = Data.get(parentLink, `toggler${DATA_SUFFIX}`) if (parentLinkToggler) { parentLinkToggler.classList.add(this._opts.classLinkSelected) } } } } } } _setTogglerSubARIA(togglerElement, subElement) { const togglerId = togglerElement.getAttribute('id') || this._accessIdPrefix + (++this._idInc) const subId = subElement.getAttribute('id') || this._accessIdPrefix + (++this._idInc) togglerElement.setAttribute('id', togglerId) togglerElement.setAttribute('role', 'button') togglerElement.setAttribute('aria-controls', subId) togglerElement.setAttribute('aria-expanded', 'false') subElement.setAttribute('id', subId) subElement.setAttribute('aria-hidden', 'true') subElement.setAttribute('aria-labelledby', togglerId) } _subHide(element) { if (!this._subIsVisible(element)) { return } if (Events.trigger(this._navbar, `beforehide${API_EVENTS_SUFFIX}`, element).defaultPrevented) { return } this._animateHide(element) const parentLink = Data.get(element, `parent-link${DATA_SUFFIX}`) parentLink.classList.remove(this._opts.classLinkExpanded) const parentLinkToggler = Data.get(parentLink, `toggler${DATA_SUFFIX}`) if (parentLinkToggler) { parentLinkToggler.classList.remove(this._opts.classLinkExpanded) parentLinkToggler.setAttribute('aria-expanded', 'false') } else { parentLink.setAttribute('aria-expanded', 'false') } element.setAttribute('aria-hidden', 'true') this._visibleSubs.splice(this._visibleSubs.indexOf(element), 1) Events.trigger(this._navbar, `hide${API_EVENTS_SUFFIX}`, element) } _subHideSubs(level) { for (let index = this._visibleSubs.length - 1; index >= level; index--) { this._subHide(this._visibleSubs[index]) } } _subIsVisible(element) { return element.classList.contains(this._opts.classShow) } /* eslint-disable complexity */ // Maybe refactor this to lower complexity? _subPosition(element) { const parentItem = element.closest(this._opts.selectorItem) const level = Data.get(element, `level${DATA_SUFFIX}`) const mega = element.classList.contains(this._opts.classSubMega) const vertical = this._navbar.classList.contains(this._opts.classNavbarVertical) const horizontalParent = level === 2 && !vertical const rtl = Helpers.getComputedStyle(this._navbar).direction === 'rtl' let rightToLeft = (rtl && !this._opts.dropdownsDropReverseX) || (!rtl && this._opts.dropdownsDropReverseX) if (parentItem.matches(`[${DATA_ATTRIBUTE_PREFIX}drop-reverse-x]`)) { rightToLeft = !rightToLeft } let downToUp = this._opts.dropdownsDropReverseY if (parentItem.matches(`[${DATA_ATTRIBUTE_PREFIX}drop-reverse-y]`)) { downToUp = !downToUp } const xProperty = rightToLeft ? 'right' : 'left' const xPropertyCapitalized = xProperty.charAt(0).toUpperCase() + xProperty.slice(1) const yProperty = downToUp ? 'bottom' : 'top' const yPropertyCapitalized = yProperty.charAt(0).toUpperCase() + yProperty.slice(1) const parentItemRect = parentItem.getBoundingClientRect() const parentItemRectX = parentItemRect[xProperty] const parentItemRectY = parentItemRect[yProperty] const parentItemWidth = Helpers.getWidth(parentItem) const parentItemHeight = Helpers.getHeight(parentItem) const subComputedStyle = Helpers.getComputedStyle(element) const subOffsetX = level === 2 ? this._opts.dropdownsNavSubOffsetX : this._opts.dropdownsSubSubOffsetX const subOffsetY = level === 2 && !vertical ? this._opts.dropdownsNavSubOffsetY : this._opts.dropdownsSubSubOffsetY - Number.parseFloat(subComputedStyle[`border${yPropertyCapitalized}Width`], 10) - Number.parseFloat(subComputedStyle[`padding${yPropertyCapitalized}`], 10) let x = 0 let y = 0 if (horizontalParent) { x = subOffsetX y = parentItemHeight + subOffsetY } else { x = parentItemWidth + subOffsetX y = subOffsetY } // Mega subs are not positioned against their parent item (as it has position: static) so compensate that if (mega) { // Find closest positioned parent (e.g. might be the navbar, offcanvas element) const positionedParent = Query.closest(element.parentElement, parent => Helpers.getComputedStyle(parent).position !== 'static') if (positionedParent) { const positionedParentRect = positionedParent.getBoundingClientRect() const positionedParentComputedStyle = Helpers.getComputedStyle(positionedParent) if (horizontalParent) { y += Math.abs(parentItemRectY - positionedParentRect[yProperty]) - Number.parseFloat(positionedParentComputedStyle[`border${yPropertyCapitalized}Width`], 10) } else { x += Math.abs(parentItemRectX - positionedParentRect[xProperty]) - Number.parseFloat(positionedParentComputedStyle[`border${xPropertyCapitalized}Width`], 10) } } } // Keep sub in viewport if (this._opts.dropdownsKeepInViewport && !mega) { const viewportWidth = Helpers.getViewportWidth() const viewportHeight = Helpers.getViewportHeight() const subWidth = Helpers.getWidth(element) const subHeight = Helpers.getHeight(element) const parentItemX = rightToLeft ? viewportWidth - parentItemRectX : parentItemRectX const parentItemY = downToUp ? viewportHeight - parentItemRectY : parentItemRectY if (subWidth < viewportWidth) { if (horizontalParent) { if (parentItemX + x + subWidth > viewportWidth) { // Align against the opposite edge of the viewport x = viewportWidth - subWidth - parentItemX } else if (parentItemX + x < 0) { // Align against the same edge of the viewport x = -parentItemX } } else if (parentItemX + x + subWidth > viewportWidth) { if (subOffsetX + subWidth <= parentItemX) { // Flip position if there is enough space on the other side of the parent item x = -subOffsetX - subWidth } else if (parentItemX > viewportWidth - parentItemX - parentItemWidth + 1) { // If there is no space for a flip, align it against the edge of the viewport where there is more space // 1px added for consistent results when the space difference is only due to rounding x = -parentItemX } else { x = viewportWidth - subWidth - parentItemX } } } else { // If the sub cannot fit inside the viewport, align it against the same edge of the viewport x = -parentItemX } if (!horizontalParent) { if (subHeight < viewportHeight) { if (parentItemY + y + subHeight > viewportHeight) { // Align against the opposite edge of the viewport y = viewportHeight - subHeight - parentItemY } else if (parentItemY + y < 0) { // Align against the same edge of the viewport y = -parentItemY } } else { // If the sub cannot fit inside the viewport, align it against the top edge of the viewport y = downToUp ? viewportHeight - subHeight - parentItemY : -parentItemY } } } const elementStyle = element.style elementStyle.zIndex = this._zIndexInc = (this._zIndexInc || this._getRootZIndex()) + 1 if (mega) { if (horizontalParent) { elementStyle[yProperty] = y + 'px' } else { elementStyle[xProperty] = x + 'px' } } else { elementStyle[xProperty] = x + 'px' elementStyle[yProperty] = y + 'px' } } /* eslint-enable complexity */ _subResetPosition(element) { const elementStyle = element.style elementStyle.zIndex = '' elementStyle.top = '' elementStyle.left = '' elementStyle.bottom = '' elementStyle.right = '' } _subShow(element) { if (this._subIsVisible(element)) { return } if (!Data.get(element, `beforefirstshow-fired${DATA_SUFFIX}`)) { Data.set(element, `beforefirstshow-fired${DATA_SUFFIX}`, true) if (Events.trigger(this._navbar, `beforefirstshow${API_EVENTS_SUFFIX}`, element).defaultPrevented) { return } } if (Events.trigger(this._navbar, `beforeshow${API_EVENTS_SUFFIX}`, element).defaultPrevented) { return } Data.set(element, `shown-before${DATA_SUFFIX}`, true) const parentLink = Data.get(element, `parent-link${DATA_SUFFIX}`) parentLink.classList.add(this._opts.classLinkExpanded) const parentLinkToggler = Data.get(parentLink, `toggler${DATA_SUFFIX}`) if (parentLinkToggler) { parentLinkToggler.classList.add(this._opts.classLinkExpanded) } const isCollapsible = this._isCollapsible() if (isCollapsible) { this._subResetPosition(element) } else { this._subPosition(element) } this._animateShow(element) // Accessibility if (parentLinkToggler) { parentLinkToggler.setAttribute('aria-expanded', 'true') } else { parentLink.setAttribute('aria-expanded', 'true') } element.setAttribute('aria-hidden', 'false') // Store sub menu in visible array this._visibleSubs.push(element) Events.trigger(this._navbar, `show${API_EVENTS_SUFFIX}`, element) } _togglerAnchorHideClick(event) { if (!this._handleEvents()) { return } if (Events.trigger(this._navbar, `beforehide${API_EVENTS_SUFFIX}`, this._collapse || this._offcanvas || null).defaultPrevented) { return } if (this._opts.collapsibleResetSubsOnClickOn === 'toggler') { this.subHideAll() } if (this._collapse) { this._animateHide(this._collapse) } if (this._offcanvas) { this._animateHide(this._offcanvas) } if (this._offcanvasOverlay) { this._animateHide(this._offcanvasOverlay) } if (this._togglerState) { this._togglerState.classList.remove(this._opts.classShow) // Just in case try to remove the hash too window.location.hash = window.location.hash.replace(new RegExp(`^#${this._togglerState.getAttribute('id')}$`), '') } if (this._togglerAnchorHide) { this._togglerAnchorShow.focus() } event.preventDefault() Events.trigger(this._navbar, `hide${API_EVENTS_SUFFIX}`, this._collapse || this._offcanvas || null) } _togglerAnchorShowClick(event, element) { if (!this._handleEvents()) { return } if (!Data.get(element, `beforefirstshow-fired${DATA_SUFFIX}`)) { Data.set(element, `beforefirstshow-fired${DATA_SUFFIX}`, true) if (Events.trigger(this._navbar, `beforefirstshow${API_EVENTS_SUFFIX}`, this._collapse || this._offcanvas || null).defaultPrevented) { return } } if (Events.trigger(this._navbar, `beforeshow${API_EVENTS_SUFFIX}`, this._collapse || this._offcanvas || null).defaultPrevented) { return } if (this._collapse) { this._animateShow(this._collapse) } if (this._offcanvas) { this._animateShow(this._offcanvas) } if (this._offcanvasOverlay) { this._animateShow(this._offcanvasOverlay) } if (this._togglerState) { this._togglerState.classList.add(this._opts.classShow) } if (this._togglerAnchorHide) { this._togglerAnchorHide.focus() } event.preventDefault() Events.trigger(this._navbar, `show${API_EVENTS_SUFFIX}`, this._collapse || this._offcanvas || null) } _winResize(event) { if (!this._handleEvents()) { return } // Hide subs on resize - on mobile do it only on orientation change if (!('onorientationchange' in window) || event.type === 'orientationchange') { const isCollapsible = this._isCollapsible() // If it was collapsible before resize and still is, don't do it if (!(this._wasCollapsible && isCollapsible)) { this.subHideAll() } this._wasCollapsible = isCollapsible } } // Static static destroy() { while (navbars.length > 0) { navbars[0].destroy() } MouseInputDetect.disable() } static subHideAll() { for (const navbar of navbars) { navbar.subHideAll() } } } export default SmartMenus