/** * Standalone MegaMenu Services for Classic SharePoint * * This file contains extracted and adapted versions of the SPFx services * that work in classic SharePoint without SPFx dependencies. */ (function(global) { 'use strict'; // =========================================== // 1. UTILITY CLASSES (from SPFx services) // =========================================== class ItemDictionary { constructor() { this._items = {}; } Add(key, value) { this._items[key] = value; } Get(key) { return this._items[key]; } } class MenuItem { constructor(term, depth, siteCollectionUrl) { this.title = term.Name; this.url = this._getNavigationUrl(term, siteCollectionUrl); const properties = term.LocalCustomProperties || {}; this.description = String(properties['MegaMenu.Description'] || '').trim(); this.openInNewWindow = this._asBoolean(properties['MegaMenu.OpenInNewWindow']); this.external = this._isExternal(this.url, siteCollectionUrl); this.items = []; this.pathDepth = this._calculateDepth(term.PathOfTerm); this.parentId = this._getParentId(term.PathOfTerm); this.id = term.Id; this._term = term; } _calculateDepth(pathOfTerm) { if (!pathOfTerm) return 1; return pathOfTerm.split(';').length; } _getParentId(pathOfTerm) { if (!pathOfTerm) return null; const parts = pathOfTerm.split(';'); if (parts.length <= 1) return null; // Return parent term ID (simplified - may need adjustment) return parts[parts.length - 2]; } _getNavigationUrl(term, siteCollectionUrl) { const properties = term.LocalCustomProperties || {}; const rawUrl = properties._Sys_Nav_SimpleLinkUrl || properties._Sys_Nav_TargetUrl; if (!rawUrl) { return undefined; } if (/[\u0000-\u001f\u007f]/.test(String(rawUrl))) { return undefined; } const resolved = rawUrl.indexOf('~sitecollection') === 0 ? siteCollectionUrl + rawUrl.substring('~sitecollection'.length) : rawUrl; const protocol = String(resolved).match(/^([a-z][a-z0-9+.-]*):/i); if (protocol && ['http', 'https', 'mailto'].indexOf(protocol[1].toLowerCase()) < 0) { return undefined; } return resolved; } _asBoolean(value) { return value === true || ['true', '1', 'yes', 'ja'].indexOf(String(value || '').toLowerCase()) >= 0; } _isExternal(url, siteCollectionUrl) { const target = String(url || '').match(/^(?:(https?):)?\/\/([^/]+)/i); const current = String(siteCollectionUrl || '').match(/^(https?):\/\/([^/]+)/i); return !!target && (!current || (target[1] || current[1]).toLowerCase() !== current[1].toLowerCase() || target[2].toLowerCase() !== current[2].toLowerCase()); } } // =========================================== // 2. TAXONOMY NAVIGATION SERVICE (adapted) // =========================================== class TaxonomyNavigationService { constructor(context, termSetIdentifier, options) { this.context = context; this.termSetIdentifier = termSetIdentifier; this.options = options || {}; this._siteCollectionUrl = context.pageContext.site.absoluteUrl; this._languageLcid = Number(this.options.languageLcid) || 1033; this._cacheMinutes = Math.min(Math.max(Number(this.options.cacheMinutes) || 15, 1), 1440); this._cacheVersion = String(this.options.cacheVersion || '1'); } async getMenuItems() { console.log('[TaxonomyService] Loading terms for:', this.termSetIdentifier); const cached = this._getCache(false); if (cached) { return this._processTerms(cached); } const stale = this._getCache(true); try { const termSet = await this._loadTermSet(this.termSetIdentifier); this._setCache(termSet); return this._processTerms(termSet); } catch (error) { console.error('[TaxonomyService] Error loading terms:', error); if (stale) { console.warn('[TaxonomyService] Using stale cache after taxonomy error.'); return this._processTerms(stale); } throw error; } } _cacheKey() { const normalize = value => encodeURIComponent(String(value || '').trim().toLowerCase()); return 'MegaMenu:TermSet:v3:' + normalize(this._siteCollectionUrl) + ':' + normalize(this.termSetIdentifier) + ':' + this._languageLcid + ':' + normalize(this._cacheVersion); } _getCache(allowExpired) { try { const serialized = window.sessionStorage.getItem(this._cacheKey()); if (!serialized) return null; const entry = JSON.parse(serialized); if (!entry || !entry.value || !entry.expiresAt) { window.sessionStorage.removeItem(this._cacheKey()); return null; } return entry.expiresAt > Date.now() || allowExpired ? entry.value : null; } catch (error) { return null; } } _setCache(value) { try { window.sessionStorage.setItem(this._cacheKey(), JSON.stringify({ expiresAt: Date.now() + this._cacheMinutes * 60 * 1000, value: value })); } catch (error) { console.warn('[TaxonomyService] Cache could not be written.', error); } } _loadTermSet(termSetIdentifier) { return new Promise((resolve, reject) => { const context = SP.ClientContext.get_current(); const session = SP.Taxonomy.TaxonomySession.getTaxonomySession(context); const termStore = session.getDefaultSiteCollectionTermStore(); if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(termSetIdentifier)) { const termSet = termStore.getTermSet(new SP.Guid(termSetIdentifier)); this._loadAllTerms(context, termSet, resolve, reject); return; } const termSets = termStore.getTermSetsByName(termSetIdentifier, this._languageLcid); context.load(termSets); context.executeQueryAsync( () => { if (termSets.get_count() > 0) { const termSet = termSets.get_item(0); this._loadAllTerms(context, termSet, resolve, reject); } else { reject(new Error(`Term set '${termSetIdentifier}' not found`)); } }, (sender, args) => reject(new Error(args.get_message())) ); }); } _loadAllTerms(context, termSet, resolve, reject) { const terms = termSet.getAllTerms(); context.load(terms, 'Include(Id,Name,PathOfTerm,LocalCustomProperties,IsAvailableForTagging,IsDeprecated)'); context.executeQueryAsync( () => { const processedTerms = []; const enumerator = terms.getEnumerator(); while (enumerator.moveNext()) { const term = enumerator.get_current(); const properties = this._getCustomProperties(term); if (!term.get_isAvailableForTagging() || term.get_isDeprecated() || this._asBoolean(properties['MegaMenu.Hidden'])) { continue; } processedTerms.push({ Id: term.get_id().toString(), Name: term.get_name(), PathOfTerm: term.get_pathOfTerm(), LocalCustomProperties: properties, IsRoot: term.get_pathOfTerm().split(';').length === 1 }); } resolve(processedTerms); }, (sender, args) => reject(new Error(args.get_message())) ); } _getCustomProperties(term) { try { const props = term.get_localCustomProperties(); return { _Sys_Nav_SimpleLinkUrl: props._Sys_Nav_SimpleLinkUrl || null, _Sys_Nav_TargetUrl: props._Sys_Nav_TargetUrl || null, _Sys_Nav_HoverText: props._Sys_Nav_HoverText || null, 'MegaMenu.Hidden': props['MegaMenu.Hidden'] || null, 'MegaMenu.OpenInNewWindow': props['MegaMenu.OpenInNewWindow'] || null, 'MegaMenu.Description': props['MegaMenu.Description'] || null }; } catch (e) { return {}; } } _asBoolean(value) { return value === true || ['true', '1', 'yes', 'ja'].indexOf(String(value || '').toLowerCase()) >= 0; } _processTerms(termsData) { const itemsDict = new ItemDictionary(); const itemsByPath = {}; const menuItems = []; // Create MenuItem objects termsData.forEach(termData => { const menuItem = new MenuItem(termData, 0, this._siteCollectionUrl); itemsDict.Add(termData.Id, menuItem); itemsByPath[termData.PathOfTerm] = menuItem; if (menuItem.pathDepth === 1) { menuItems.push(menuItem); } }); // Build hierarchy termsData.forEach(termData => { if (termData.PathOfTerm && termData.PathOfTerm.split(';').length > 1) { const menuItem = itemsDict.Get(termData.Id); const pathParts = termData.PathOfTerm.split(';'); pathParts.pop(); const parentItem = itemsByPath[pathParts.join(';')]; if (parentItem) { parentItem.items.push(menuItem); } } }); return menuItems.length > 0 ? menuItems : [this._createNoTermsItem()]; } _createNoTermsItem() { return new MenuItem({ Id: 'no-terms', Name: 'Es wurden keine Terms gefunden. Bitte überprüfen Sie Ihre Einstellungen.', PathOfTerm: '', LocalCustomProperties: {} }, 0, this._siteCollectionUrl); } } // =========================================== // 3. MEGA MENU RENDERER (adapted from SPFx) // =========================================== class MegaMenuRenderer { constructor(context, menuItems, menuMode, strings) { this.context = context; this.menuItems = menuItems; this.menuMode = menuMode === 'flyout' ? 'flyout' : 'megaMenu'; this.strings = strings || { navigationLabel: 'Main navigation', openNavigation: 'Open navigation', closeNavigation: 'Close navigation', openSubmenu: 'Open submenu for', closeSubmenu: 'Close submenu for', submenuLabel: 'submenu', externalLink: 'external link' }; } render(container) { container.innerHTML = ''; const nav = document.createElement('nav'); nav.id = 'Mega-Menu'; nav.className = 'mega-menu-main mega-menu-mode-' + this.menuMode; nav.setAttribute('data-menu-mode', this.menuMode); nav.setAttribute('aria-label', this.strings.navigationLabel); const mobileToggle = document.createElement('button'); mobileToggle.type = 'button'; mobileToggle.className = 'mega-menu-mobile-toggle'; mobileToggle.setAttribute('aria-expanded', 'false'); mobileToggle.setAttribute('aria-controls', 'Mega-Menu-Items'); mobileToggle.setAttribute('aria-label', this.strings.openNavigation); mobileToggle.innerHTML = ''; mobileToggle.querySelector('.mega-menu-mobile-label').textContent = this.strings.navigationLabel; nav.appendChild(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(); } createTopLevelItem(item) { const li = document.createElement('li'); const hasChildren = item.items && item.items.length > 0; li.className = hasChildren ? 'mega-menu-top-item has-children' : 'mega-menu-top-item'; if (item.url) { const a = document.createElement('a'); a.href = item.url; a.textContent = item.title; a.className = 'menu-item-link'; this.applyLinkBehavior(a, item); li.appendChild(a); } else { const span = document.createElement('span'); span.textContent = item.title; span.className = 'menu-item-text'; if (!hasChildren) span.setAttribute('tabindex', '0'); li.appendChild(span); } if (hasChildren) { const toggle = document.createElement('button'); const menuId = 'Mega-Menu-Panel-' + String(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.title); toggle.innerHTML = ''; li.appendChild(toggle); const megaMenu = this.menuMode === 'flyout' ? this.createFlyoutMenu(item.items) : this.createMegaMenu(item.items); megaMenu.id = menuId; megaMenu.setAttribute('aria-expanded', 'false'); megaMenu.setAttribute('aria-label', item.title + ' ' + this.strings.submenuLabel); li.appendChild(megaMenu); } return li; } createMegaMenu(categories) { const megaMenuDiv = document.createElement('div'); megaMenuDiv.className = 'mega-menu'; const gridDiv = document.createElement('div'); gridDiv.className = 'mega-menu-grid'; categories.forEach(category => { const categoryDiv = this.createCategory(category); gridDiv.appendChild(categoryDiv); }); megaMenuDiv.appendChild(gridDiv); return megaMenuDiv; } createCategory(category) { const categoryDiv = document.createElement('div'); categoryDiv.className = 'mega-menu-category'; const h3 = document.createElement('h3'); if (category.url) { const a = document.createElement('a'); a.href = category.url; a.textContent = category.title; this.applyLinkBehavior(a, category); h3.appendChild(a); } else { const span = document.createElement('span'); span.textContent = category.title; h3.appendChild(span); } categoryDiv.appendChild(h3); if (category.description) { const description = document.createElement('p'); description.className = 'mega-menu-description'; description.textContent = category.description; categoryDiv.appendChild(description); } if (category.items.length > 0) { const ul = document.createElement('ul'); category.items.forEach(link => { const li = document.createElement('li'); const a = document.createElement('a'); a.href = link.url || '#'; a.textContent = link.title; this.applyLinkBehavior(a, link); li.appendChild(a); ul.appendChild(li); }); categoryDiv.appendChild(ul); } return categoryDiv; } createFlyoutMenu(items) { const flyout = document.createElement('div'); flyout.className = 'mega-menu mega-menu-flyout'; const list = document.createElement('ul'); list.className = 'flyout-list flyout-level-2'; items.forEach(item => list.appendChild(this.createFlyoutItem(item))); flyout.appendChild(list); return flyout; } createFlyoutItem(item) { const li = document.createElement('li'); const hasChildren = item.items && item.items.length > 0; li.className = hasChildren ? 'flyout-item has-children' : 'flyout-item'; const element = document.createElement(item.url ? 'a' : 'span'); if (item.url) { element.href = item.url; this.applyLinkBehavior(element, item); } else { element.setAttribute('tabindex', '0'); } element.textContent = item.title; element.className = hasChildren ? 'flyout-link flyout-link-has-children' : 'flyout-link'; li.appendChild(element); if (hasChildren) { const toggle = document.createElement('button'); const childId = 'Mega-Menu-Flyout-' + String(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.title); toggle.innerHTML = ''; li.appendChild(toggle); const childList = document.createElement('ul'); childList.className = 'flyout-list flyout-level-3'; childList.id = childId; childList.setAttribute('aria-label', item.title + ' ' + this.strings.submenuLabel); item.items.forEach(child => childList.appendChild(this.createFlyoutItem(child))); li.appendChild(childList); } return li; } attachEventListeners() { const renderer = this; const toggles = document.querySelectorAll('#Mega-Menu > ul > li > .menu-item-toggle'); Array.prototype.forEach.call(toggles, toggle => { const parent = toggle.parentElement; const panel = parent.querySelector('.mega-menu'); const setOpen = open => { renderer.closeAll(panel); toggle.setAttribute('aria-expanded', String(open)); toggle.setAttribute('aria-label', (open ? renderer.strings.closeSubmenu : renderer.strings.openSubmenu) + ' ' + renderer.getToggleLabel(toggle)); panel.setAttribute('aria-expanded', String(open)); panel.classList.toggle('js-open', open); }; toggle.addEventListener('click', event => { event.preventDefault(); setOpen(toggle.getAttribute('aria-expanded') !== 'true'); }); toggle.addEventListener('keydown', event => { if (event.key === 'ArrowDown') { event.preventDefault(); setOpen(true); const firstLink = panel.querySelector('a'); if (firstLink) firstLink.focus(); } else if (event.key === 'Escape' || event.key === 'ArrowUp') { event.preventDefault(); setOpen(false); toggle.focus(); } }); let closeTimer; parent.addEventListener('mouseenter', () => { window.clearTimeout(closeTimer); setOpen(true); }); parent.addEventListener('mouseleave', () => { closeTimer = window.setTimeout(() => setOpen(false), 180); }); }); const flyoutToggles = document.querySelectorAll('#Mega-Menu .flyout-toggle'); Array.prototype.forEach.call(flyoutToggles, toggle => { const parent = toggle.parentElement; const setOpen = open => { toggle.setAttribute('aria-expanded', String(open)); const label = parent.querySelector('.flyout-link'); toggle.setAttribute('aria-label', (open ? renderer.strings.closeSubmenu : renderer.strings.openSubmenu) + ' ' + (label ? label.textContent : '')); parent.classList.toggle('is-open', open); }; toggle.addEventListener('click', event => { event.preventDefault(); setOpen(toggle.getAttribute('aria-expanded') !== 'true'); }); toggle.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); setOpen(false); toggle.focus(); } }); parent.addEventListener('mouseenter', () => setOpen(true)); parent.addEventListener('mouseleave', () => setOpen(false)); parent.addEventListener('focusin', () => setOpen(true)); parent.addEventListener('focusout', () => window.setTimeout(() => { if (!parent.contains(document.activeElement)) setOpen(false); }, 0)); }); const mobileToggle = document.querySelector('#Mega-Menu > .mega-menu-mobile-toggle'); if (mobileToggle) { mobileToggle.addEventListener('click', () => { const nav = mobileToggle.parentElement; const open = mobileToggle.getAttribute('aria-expanded') !== 'true'; mobileToggle.setAttribute('aria-expanded', String(open)); mobileToggle.setAttribute('aria-label', open ? renderer.strings.closeNavigation : renderer.strings.openNavigation); nav.classList.toggle('is-mobile-open', open); if (!open) renderer.closeAll(); }); } document.addEventListener('keydown', event => { if (event.key === 'Escape') { const openPanel = document.querySelector('#Mega-Menu .mega-menu.js-open'); if (openPanel) { const toggle = openPanel.previousElementSibling; renderer.closePanel(toggle, openPanel); toggle.focus(); } } if ((event.key === 'ArrowLeft' || event.key === 'ArrowRight') && document.activeElement.closest('.mega-menu-top-item')) { const items = Array.prototype.slice.call(document.querySelectorAll('#Mega-Menu > ul > .mega-menu-top-item')); const current = document.activeElement.closest('.mega-menu-top-item'); const index = items.indexOf(current); const target = items[(index + (event.key === 'ArrowRight' ? 1 : -1) + items.length) % items.length]; const focusTarget = target.querySelector('a, .menu-item-toggle'); if (focusTarget) { event.preventDefault(); focusTarget.focus(); } } }); } createScreenReaderAnnouncer() { // Accessibility support const announcer = document.createElement('div'); announcer.id = 'mega-menu-announcer'; announcer.className = 'sr-only'; announcer.setAttribute('aria-live', 'polite'); announcer.setAttribute('aria-atomic', 'true'); document.body.appendChild(announcer); } closePanel(toggle, panel) { toggle.setAttribute('aria-expanded', 'false'); toggle.setAttribute('aria-label', this.strings.openSubmenu + ' ' + this.getToggleLabel(toggle)); panel.setAttribute('aria-expanded', 'false'); panel.classList.remove('js-open'); } closeAll(exceptPanel) { const panels = document.querySelectorAll('#Mega-Menu .mega-menu.js-open'); Array.prototype.forEach.call(panels, panel => { if (panel !== exceptPanel) this.closePanel(panel.previousElementSibling, panel); }); } getToggleLabel(toggle) { const label = toggle.parentElement.querySelector('.menu-item-link, .menu-item-text'); return label ? label.textContent : ''; } applyLinkBehavior(link, item) { if (item.openInNewWindow) { link.target = '_blank'; link.rel = 'noopener noreferrer'; } if (item.external) { link.setAttribute('aria-label', item.title + ' (' + this.strings.externalLink + ')'); } } } // =========================================== // 4. EXPOSE SERVICES GLOBALLY // =========================================== global.MegaMenuServices = { TaxonomyNavigationService: TaxonomyNavigationService, MegaMenuRenderer: MegaMenuRenderer, MenuItem: MenuItem, ItemDictionary: ItemDictionary }; console.log('[MegaMenu] Standalone services loaded successfully'); })(window);