feat: Enhance MegaMenu with localization, caching, and new properties

- Added localization support for loading states, empty navigation, and submenu actions in both German and English.
- Introduced new properties in IMenuItem for description, openInNewWindow, and external links.
- Updated SPTermStorePickerService to handle cache versioning and language-specific caching.
- Implemented a new MegaMenuCore service for handling term properties, cache management, and navigation URL resolution.
- Refactored TaxonomyNavigationService to build menu hierarchy and filter hidden terms.
- Added automated tests for MegaMenu core functionalities and static asset validation.
- Removed unused ItemDictionary service.
- Created a comprehensive ToDo document outlining the implementation status and future tasks.
This commit is contained in:
Torsten Brendgen
2026-07-20 21:34:24 +02:00
parent bc61166267
commit 50b85468f1
30 changed files with 1394 additions and 294 deletions
+51 -1
View File
@@ -4,7 +4,7 @@ Zentrale, barrierearme Portalnavigation aus SharePoint Managed Metadata für Sha
| Eigenschaft | Wert |
|---|---|
| Version | 2.1.0 |
| Version | 2.2.0 |
| SharePoint Framework | 1.4.1 |
| Node.js für den Build | 8.17.0 |
| Moderne Seiten | SPFx Application Customizer |
@@ -57,6 +57,7 @@ Das SPFx-Paket verwendet `skipFeatureDeployment: true`. Es wird im App Catalog z
"termSetId": "7948e6f9-7af4-431e-b6fe-328122f2746c",
"termSetName": "Global Navigation",
"cacheMinutes": 15,
"cacheVersion": "1",
"menuMode": "megaMenu",
"debug": false
}
@@ -67,6 +68,7 @@ Das SPFx-Paket verwendet `skipFeatureDeployment: true`. Es wird im App Catalog z
| `termSetId` | String | leer | GUID des Termsets; hat Vorrang vor dem Namen |
| `termSetName` | String | leer | Fallback für bestehende Konfigurationen |
| `cacheMinutes` | Number | `15` | Cachezeit von 1 bis 1440 Minuten |
| `cacheVersion` | String | `1` | Frei wählbare Version zur sofortigen Invalidierung vorhandener Browser-Caches |
| `menuMode` | String | `megaMenu` | `megaMenu` oder `flyout` |
| `debug` | Boolean | `false` | ausführliche Browser-Konsolenausgaben |
@@ -78,6 +80,18 @@ Das SPFx-Paket verwendet `skipFeatureDeployment: true`. Es wird im App Catalog z
`"menuMode": "flyout"` zeigt die zweite Ebene als kompaktes Dropdown. Die dritte Ebene öffnet sich seitlich als strukturelles Flyout.
## Lokale Term-Eigenschaften
Die folgenden Eigenschaften werden im Term Store als lokale benutzerdefinierte Eigenschaften am jeweiligen Term gepflegt:
| Eigenschaft | Beispiel | Wirkung |
|---|---|---|
| `MegaMenu.Hidden` | `true` | Term und darunterliegender Navigationszweig werden nicht angezeigt |
| `MegaMenu.OpenInNewWindow` | `true` | Ziel wird sicher in einem neuen Browserfenster geöffnet |
| `MegaMenu.Description` | `Anträge und Vorlagen` | optionale Beschreibung einer Kategorie im Mega-Menu-Modus |
Zusätzlich werden die SharePoint-Navigationseigenschaften `_Sys_Nav_SimpleLinkUrl`, `_Sys_Nav_TargetUrl` und `_Sys_Nav_HoverText` ausgewertet. Unsichere URL-Protokolle werden verworfen.
## Build
```powershell
@@ -90,6 +104,8 @@ npm run package
- das SPFx-Paket für moderne Seiten
- das Classic-Paket unter `classic/dist/`
Vor dem Packaging werden automatisch die Kernlogik-, Classic-Syntax- und statischen Asset-Tests ausgeführt. Bei einem Fehler wird kein Paket erzeugt.
Nur die Classic-Dateien neu erzeugen:
```powershell
@@ -125,6 +141,7 @@ Beispiel für das Mega Menu:
-TermSetId '7948e6f9-7af4-431e-b6fe-328122f2746c' `
-TermSetName 'Global Navigation' `
-CacheMinutes 15 `
-CacheVersion '1' `
-MenuMode megaMenu
```
@@ -162,6 +179,29 @@ classic/dist/
Die Classic-Runtime liest dieselben `ClientSideComponentProperties` der zentralen Site-Collection-Action und unterstützt ebenfalls beide Menümodi. Die Installationsdetails stehen in [classic/classic-deployment.md](classic/classic-deployment.md).
## Mobile Bedienung und Barrierefreiheit
- Unter 768 Pixeln wird die Navigation als Hamburger-Menü mit Accordion-Unterebenen dargestellt.
- Hover bleibt auf Desktop-Geräten als Komfortfunktion erhalten.
- Links und Schaltflächen zum Öffnen eines Untermenüs sind getrennt.
- Touch, Klick und Tastatur werden gleichwertig unterstützt.
- Escape schließt das aktive Menü; Links/Rechts wechselt zwischen Hauptpunkten.
- Fokusmarkierungen, Hochkontrast und reduzierte Bewegung werden unterstützt.
## Cache und Mehrsprachigkeit
Der Session-Cache ist nach Site Collection, Termset, SharePoint-Oberflächensprache, Cache-Schema und `cacheVersion` getrennt. Nach einem temporären Taxonomy-Fehler kann der letzte abgelaufene, aber strukturell gültige Eintrag als Fallback verwendet werden. Eine geänderte `cacheVersion` erzwingt beim nächsten Laden einen neuen Cache.
Term-Labels sowie sichtbare Status- und ARIA-Texte werden anhand der aktuellen SharePoint-Sprache auf Deutsch oder Englisch ausgegeben.
## Automatisierte Tests
```powershell
npm test
```
Die Tests prüfen Term-Eigenschaften, URL-Sicherheit, externe Links, sprachabhängige Labels, Cache-Schlüssel, Hierarchieaufbau und Sortierung.
## Styling
Das moderne Styling liegt in:
@@ -232,6 +272,16 @@ MegaMenu/
## Versionshistorie
### 2.2.0
- mobiles Hamburger-/Accordion-Menü für Modern und Classic
- getrennte Link- und Untermenü-Schaltflächen für Touch und Tastatur
- sichere URL-Prüfung und Unterstützung externer Links
- lokale Term-Eigenschaften für Sichtbarkeit, neues Fenster und Beschreibung
- sprachabhängige Term-Labels, Status- und ARIA-Texte
- versionierter Cache pro Site Collection und Sprache mit Stale-Fallback
- automatisierte Kernlogiktests
### 2.1.0
- SharePoint-ähnliches, helles Navigationsdesign.
+59
View File
@@ -0,0 +1,59 @@
# MegaMenu – Arbeitsplan Version 2.2
Status: Implementierung abgeschlossen; Ship-Build auf der vorgesehenen Node-8-Buildumgebung ausstehend
## Funktionsumfang
- [x] Mobile Navigation als Hamburger-Menü mit aufklappbaren Unterebenen umsetzen.
- [x] Hover als Komfortfunktion beibehalten; Bedienung per Klick, Touch und Tastatur gleichwertig unterstützen.
- [x] Für Menüpunkte mit Ziel und Unterpunkten eine getrennte Öffnen-Schaltfläche anbieten.
- [x] Navigations-URLs validieren und unsichere Protokolle blockieren.
- [x] Externe Links und `MegaMenu.OpenInNewWindow` sicher unterstützen.
- [x] Lade-, Leer- und Fehlerzustände verständlich und barrierearm darstellen.
## Lokale Term-Eigenschaften
- [x] `MegaMenu.Hidden` zum gezielten Ausblenden eines Terms unterstützen.
- [x] `MegaMenu.OpenInNewWindow` für ein neues Browserfenster unterstützen.
- [x] `MegaMenu.Description` als optionale Linkbeschreibung unterstützen.
- [x] Eigenschaften in Modern und Classic identisch auswerten.
Die Eigenschaften werden im Term Store als lokale benutzerdefinierte Eigenschaften am jeweiligen Term gepflegt. Styling bleibt Aufgabe der Solution Custom Branding.
## Cache
- [x] Cache-Schlüssel um Site Collection, Sprache und Schemaversion erweitern.
- [x] Konfigurierbare `cacheVersion` zur administrativen Invalidierung ergänzen.
- [x] Abgelaufene und ungültige Cache-Einträge sicher entfernen.
- [x] Bei einem temporären Taxonomy-Fehler auf den letzten vorhandenen Cache zurückfallen.
## Mehrsprachigkeit
- [x] Sämtliche sichtbaren Status- und ARIA-Texte lokalisieren.
- [x] Bevorzugtes Term-Label anhand der aktuellen SharePoint-Sprache verwenden.
- [x] Cache nach Sprache trennen.
- [x] Deutsche und englische Ressourcen pflegen.
## Barrierefreiheit
- [x] Semantische Schaltflächen für das Öffnen von Untermenüs verwenden.
- [x] Vollständige Tastaturbedienung inklusive Pfeiltasten und Escape sicherstellen.
- [x] Fokus beim Öffnen und Schließen nachvollziehbar führen.
- [x] Mobile Accordion-Zustände über `aria-expanded` kommunizieren.
- [x] Fokusdarstellung, Hochkontrast und reduzierte Bewegung berücksichtigen.
## Qualitätssicherung
- [x] Automatisierte Tests für URL-Validierung und externe Links ergänzen.
- [x] Automatisierte Tests für lokale Term-Eigenschaften ergänzen.
- [x] Automatisierte Tests für Cache-Schlüssel und Cache-Verhalten ergänzen.
- [x] Automatisierte Tests für Hierarchie, Sortierung und aktive URLs ergänzen.
- [x] TypeScript-, Classic-Syntax-, Classic-Build- und statische Asset-Prüfungen ausführen.
- [ ] `npm run package` mit Node.js 8.17.0 auf der vorgesehenen Buildumgebung ausführen.
Der lokale SPFx-Gulp-Build wurde mit Node.js 24 gestartet und erwartungsgemäß durch die alte Gulp-3-Abhängigkeit mit `primordials is not defined` beendet. Das ist kein TypeScript- oder Projektfehler; SPFx 1.4.1 benötigt für diesen Schritt Node.js 8.17.0.
## Nicht Bestandteil dieses Durchlaufs
- [ ] PortalSettings in Version 3 als SPFx-Webpart neu aufbauen.
- [ ] MegaMenu-Diagnose und Live-Vorschau in PortalSettings integrieren.
+3
View File
@@ -19,6 +19,7 @@ Die Classic-Variante liest die `ClientSideComponentProperties` der zentralen Meg
"termSetId": "7948e6f9-7af4-431e-b6fe-328122f2746c",
"termSetName": "Global Navigation",
"cacheMinutes": 15,
"cacheVersion": "1",
"menuMode": "megaMenu",
"debug": false
}
@@ -26,6 +27,8 @@ Die Classic-Variante liest die `ClientSideComponentProperties` der zentralen Meg
`menuMode` erlaubt `megaMenu` oder `flyout`. `termSetId` hat Vorrang vor `termSetName`.
Die lokalen benutzerdefinierten Term-Eigenschaften `MegaMenu.Hidden`, `MegaMenu.OpenInNewWindow` und `MegaMenu.Description` werden wie in der modernen Variante ausgewertet. Der Cache ist nach Site Collection, Sprache und `cacheVersion` getrennt.
## Einbindung
Die drei Dateien beispielsweise nach `/SiteAssets/megamenu/` hochladen und in dieser Reihenfolge laden:
+122 -13
View File
@@ -1,4 +1,4 @@
/* SharePoint MegaMenu 2.1.0 - Classic pages */
/* SharePoint MegaMenu 2.2.0 - Classic pages */
:root {
--megaMenuBarHeight: 44px;
--megaMenuContentWidth: 1200px;
@@ -28,6 +28,17 @@
color: #323130;
}
.mega-menu-status {
box-sizing: border-box;
padding: 10px 24px;
border-bottom: 1px solid #edebe9;
background: #f3f9fd;
color: #323130;
font-size: 13px;
}
.mega-menu-mobile-toggle { display: none; }
.mega-menu-top-level {
display: flex;
align-items: stretch;
@@ -71,19 +82,38 @@
outline: none;
}
.menu-item-text[aria-haspopup="true"]::after {
.menu-item-toggle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 32px;
min-height: var(--megaMenuBarHeight);
margin: 0 8px 0 -10px;
padding: 0;
border: 0;
background: transparent;
color: #323130;
cursor: pointer;
}
.menu-item-toggle > span::after {
content: "";
display: block;
width: 6px;
height: 6px;
margin: -3px 0 0 10px;
border-right: 1px solid currentColor;
border-bottom: 1px solid currentColor;
transform: rotate(45deg);
}
.menu-item-toggle[aria-expanded="true"] > span::after { transform: rotate(225deg); }
.menu-item-link:hover,
.menu-item-text:hover,
.menu-item-text[aria-expanded="true"] {
.menu-item-toggle:hover,
.menu-item-toggle[aria-expanded="true"] {
background: #f3f2f1;
color: #323130;
text-decoration: none;
@@ -91,6 +121,8 @@
.menu-item-link:focus,
.menu-item-text:focus,
.menu-item-toggle:focus,
.mega-menu-mobile-toggle:focus,
.mega-menu-category a:focus,
.flyout-link:focus {
outline: 2px solid #0078d4;
@@ -132,6 +164,13 @@
padding-bottom: 8px;
}
.mega-menu-description {
margin: -4px 0 12px;
color: #605e5c;
font-size: 12px;
line-height: 18px;
}
.mega-menu-category h3 {
margin: 0 0 10px;
padding: 0 0 8px;
@@ -225,19 +264,34 @@
color: #323130;
}
.flyout-link-has-children::after {
content: "";
.flyout-toggle {
position: absolute;
top: 50%;
right: 18px;
top: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: 0;
background: transparent;
color: #323130;
cursor: pointer;
}
.flyout-toggle > span::after {
content: "";
display: block;
width: 6px;
height: 6px;
margin-top: -4px;
border-top: 1px solid currentColor;
border-right: 1px solid currentColor;
transform: rotate(45deg);
}
.flyout-toggle:focus { outline: 2px solid #0078d4; outline-offset: -2px; }
.flyout-level-3 {
position: absolute;
top: -9px;
@@ -250,7 +304,8 @@
}
.flyout-item:hover > .flyout-level-3,
.flyout-item:focus-within > .flyout-level-3 {
.flyout-item:focus-within > .flyout-level-3,
.flyout-item.is-open > .flyout-level-3 {
display: block;
}
@@ -267,9 +322,61 @@
}
@media (max-width: 767px) {
.mega-menu-mobile-toggle {
display: flex;
align-items: center;
width: 100%;
min-height: var(--megaMenuBarHeight);
padding: 0 16px;
border: 0;
background: #fff;
color: #323130;
cursor: pointer;
font: inherit;
font-weight: 600;
}
.mega-menu-mobile-icon,
.mega-menu-mobile-icon::before,
.mega-menu-mobile-icon::after {
display: block;
width: 18px;
height: 2px;
background: currentColor;
}
.mega-menu-mobile-icon { position: relative; margin-right: 12px; }
.mega-menu-mobile-icon::before,
.mega-menu-mobile-icon::after { content: ""; position: absolute; left: 0; }
.mega-menu-mobile-icon::before { top: -6px; }
.mega-menu-mobile-icon::after { top: 6px; }
.mega-menu-top-level {
flex-wrap: wrap;
padding: 0 8px;
display: none;
flex-direction: column;
padding: 0;
border-top: 1px solid #edebe9;
}
.mega-menu-main.is-mobile-open .mega-menu-top-level { display: flex; }
.mega-menu-top-item,
.mega-menu-mode-flyout .mega-menu-top-item {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 44px;
border-bottom: 1px solid #edebe9;
}
.menu-item-toggle { width: 44px; margin: 0; border-left: 1px solid #edebe9; }
.mega-menu {
position: static;
grid-column: 1 / -1;
width: 100%;
border-right: 0;
border-left: 0;
box-shadow: none;
}
.mega-menu-grid {
@@ -279,12 +386,14 @@
.flyout-level-3 {
position: static;
display: block;
display: none;
width: 100%;
border: 0;
box-shadow: none;
padding-left: 16px;
}
.flyout-item.is-open > .flyout-level-3 { display: block; }
}
@media print {
+63 -22
View File
@@ -1,6 +1,6 @@
/**
* SharePoint MegaMenu for Classic Pages
* Version: 2.1.0
* Version: 2.2.0
*
* This version uses standalone services that replicate the SPFx logic
* without requiring the SharePoint Framework.
@@ -25,6 +25,41 @@
config.containerId = config.containerId || 's4-titlerow';
config.debug = config.debug === true;
function getLanguageLcid() {
return Number(window._spPageContextInfo && _spPageContextInfo.currentLanguage) || 1033;
}
function getStrings() {
return getLanguageLcid() === 1031 ? {
navigationLabel: 'Hauptnavigation', openNavigation: 'Hauptnavigation öffnen', closeNavigation: 'Hauptnavigation schließen',
openSubmenu: 'Untermenü öffnen für', closeSubmenu: 'Untermenü schließen für', submenuLabel: 'Untermenü',
externalLink: 'externer Link', loading: 'Navigation wird geladen…', missing: 'Die Navigation ist nicht konfiguriert.',
empty: 'Die Navigation enthält keine sichtbaren Einträge.', error: 'Die Navigation konnte nicht geladen werden.'
} : {
navigationLabel: 'Main navigation', openNavigation: 'Open main navigation', closeNavigation: 'Close main navigation',
openSubmenu: 'Open submenu for', closeSubmenu: 'Close submenu for', submenuLabel: 'submenu',
externalLink: 'external link', loading: 'Loading navigation…', missing: 'Navigation is not configured.',
empty: 'The navigation does not contain any visible entries.', error: 'Navigation could not be loaded.'
};
}
function showStatus(message) {
var existing = document.querySelector('.megamenu-classic-container');
var container = existing || document.createElement('div');
container.className = 'megamenu-classic-container';
container.innerHTML = '';
var status = document.createElement('div');
status.className = 'mega-menu-status';
status.setAttribute('role', 'status');
status.textContent = message;
container.appendChild(status);
if (!existing) {
var anchor = document.getElementById(config.containerId) || document.body.firstChild;
anchor.parentNode.insertBefore(container, anchor.nextSibling);
}
return container;
}
function log(message, data) {
if (config.debug && console && console.log) {
console.log('[MegaMenu Classic] ' + message, data || '');
@@ -94,16 +129,16 @@
callback(props);
} catch (e) {
log('Error parsing UserCustomAction properties: ' + e.message);
callback({ termSetName: 'Navigation', menuMode: 'megaMenu', debug: false });
callback({});
}
} else {
log('No UserCustomAction found - using defaults');
callback({ termSetName: 'Navigation', menuMode: 'megaMenu', debug: false });
callback({});
}
})
.catch(function(error) {
log('Error reading configuration: ' + error.message);
callback({ termSetName: 'Navigation', menuMode: 'megaMenu', debug: false });
callback({});
});
}
@@ -120,20 +155,39 @@
readMegaMenuConfiguration(function(props) {
config.debug = props.debug === true || config.debug === true;
log('Using configuration:', props);
var strings = getStrings();
var termSetIdentifier = props.termSetId || props.termSetName;
if (!termSetIdentifier) {
showStatus(strings.missing);
return;
}
var loadingContainer = showStatus(strings.loading);
// Create context
var context = createClassicContext();
// Create taxonomy service using standalone implementation
var taxonomyService = new window.MegaMenuServices.TaxonomyNavigationService(
context,
props.termSetId || props.termSetName
context,
termSetIdentifier,
{
cacheMinutes: props.cacheMinutes,
cacheVersion: props.cacheVersion,
languageLcid: getLanguageLcid()
}
);
// Load menu items
taxonomyService.getMenuItems()
.then(function(menuItems) {
log('Menu items loaded:', menuItems);
if (!menuItems || menuItems.length === 0) {
showStatus(strings.empty);
return;
}
if (loadingContainer && loadingContainer.parentNode) {
loadingContainer.parentNode.removeChild(loadingContainer);
}
// Find target container
var container = document.getElementById(config.containerId);
@@ -160,7 +214,8 @@
var renderer = new window.MegaMenuServices.MegaMenuRenderer(
context,
menuItems,
props.menuMode === 'flyout' ? 'flyout' : 'megaMenu'
props.menuMode === 'flyout' ? 'flyout' : 'megaMenu',
strings
);
// Render the menu
@@ -170,21 +225,7 @@
})
.catch(function(error) {
console.error('[MegaMenu] Error loading menu items:', error);
// Show error message to user
var container = document.getElementById(config.containerId);
if (container) {
var errorDiv = document.createElement('div');
errorDiv.style.cssText = 'background:#ffebee;color:#c62828;padding:10px;border:1px solid #ef5350;margin:5px 0;';
errorDiv.innerHTML = '<strong>MegaMenu Error:</strong> ' + error.message +
'<br><small>Check browser console for details.</small>';
if (container.nextSibling) {
container.parentNode.insertBefore(errorDiv, container.nextSibling);
} else {
container.parentNode.appendChild(errorDiv);
}
}
showStatus(strings.error);
});
});
}
+236 -90
View File
@@ -30,6 +30,10 @@
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);
@@ -56,9 +60,27 @@
if (!rawUrl) {
return undefined;
}
return rawUrl.indexOf('~sitecollection') === 0
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());
}
}
@@ -67,21 +89,65 @@
// ===========================================
class TaxonomyNavigationService {
constructor(context, termSetIdentifier) {
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);
return [this._createNoTermsItem()];
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);
}
}
@@ -97,7 +163,7 @@
return;
}
const termSets = termStore.getTermSetsByName(termSetIdentifier, 1033);
const termSets = termStore.getTermSetsByName(termSetIdentifier, this._languageLcid);
context.load(termSets);
context.executeQueryAsync(
@@ -123,14 +189,15 @@
const enumerator = terms.getEnumerator();
while (enumerator.moveNext()) {
const term = enumerator.get_current();
if (!term.get_isAvailableForTagging() || term.get_isDeprecated()) {
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: this._getCustomProperties(term),
LocalCustomProperties: properties,
IsRoot: term.get_pathOfTerm().split(';').length === 1
});
}
@@ -147,13 +214,20 @@
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
_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 = {};
@@ -202,10 +276,11 @@
// ===========================================
class MegaMenuRenderer {
constructor(context, menuItems, menuMode) {
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) {
@@ -215,12 +290,21 @@
nav.id = 'Mega-Menu';
nav.className = 'mega-menu-main mega-menu-mode-' + this.menuMode;
nav.setAttribute('data-menu-mode', this.menuMode);
nav.setAttribute('role', 'navigation');
nav.setAttribute('aria-label', 'Hauptnavigation');
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 = '<span class="mega-menu-mobile-icon" aria-hidden="true"></span><span class="mega-menu-mobile-label"></span>';
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.setAttribute('role', 'menubar');
topLevelUl.id = 'Mega-Menu-Items';
this.menuItems.forEach(topLevelItem => {
const topLevelLi = this.createTopLevelItem(topLevelItem);
@@ -236,35 +320,42 @@
createTopLevelItem(item) {
const li = document.createElement('li');
li.className = 'mega-menu-top-item';
li.setAttribute('role', 'none');
const hasChildren = item.items && item.items.length > 0;
li.className = hasChildren ? 'mega-menu-top-item has-children' : 'mega-menu-top-item';
if (item.url && item.items.length === 0) {
// Simple link
if (item.url) {
const a = document.createElement('a');
a.href = item.url;
a.textContent = item.title;
a.className = 'menu-item-link';
a.setAttribute('role', 'menuitem');
a.setAttribute('tabindex', '0');
this.applyLinkBehavior(a, item);
li.appendChild(a);
} else {
// Menu with submenu
const span = document.createElement('span');
span.textContent = item.title;
span.className = 'menu-item-text';
span.setAttribute('role', 'menuitem');
span.setAttribute('tabindex', '0');
span.setAttribute('aria-haspopup', 'true');
span.setAttribute('aria-expanded', 'false');
if (!hasChildren) span.setAttribute('tabindex', '0');
li.appendChild(span);
}
if (item.items.length > 0) {
const megaMenu = this.menuMode === 'flyout'
? this.createFlyoutMenu(item.items)
: this.createMegaMenu(item.items);
li.appendChild(megaMenu);
}
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 = '<span aria-hidden="true"></span>';
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;
@@ -273,7 +364,6 @@
createMegaMenu(categories) {
const megaMenuDiv = document.createElement('div');
megaMenuDiv.className = 'mega-menu';
megaMenuDiv.setAttribute('role', 'menu');
const gridDiv = document.createElement('div');
gridDiv.className = 'mega-menu-grid';
@@ -297,7 +387,7 @@
const a = document.createElement('a');
a.href = category.url;
a.textContent = category.title;
a.setAttribute('tabindex', '0');
this.applyLinkBehavior(a, category);
h3.appendChild(a);
} else {
const span = document.createElement('span');
@@ -307,19 +397,23 @@
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');
ul.setAttribute('role', 'group');
category.items.forEach(link => {
const li = document.createElement('li');
li.setAttribute('role', 'none');
const a = document.createElement('a');
a.href = link.url;
a.href = link.url || '#';
a.textContent = link.title;
a.setAttribute('role', 'menuitem');
a.setAttribute('tabindex', '0');
this.applyLinkBehavior(a, link);
li.appendChild(a);
ul.appendChild(li);
@@ -334,7 +428,6 @@
createFlyoutMenu(items) {
const flyout = document.createElement('div');
flyout.className = 'mega-menu mega-menu-flyout';
flyout.setAttribute('role', 'menu');
const list = document.createElement('ul');
list.className = 'flyout-list flyout-level-2';
@@ -352,21 +445,28 @@
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';
element.setAttribute('role', 'menuitem');
if (hasChildren) {
element.setAttribute('aria-haspopup', 'true');
}
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 = '<span aria-hidden="true"></span>';
li.appendChild(toggle);
const childList = document.createElement('ul');
childList.className = 'flyout-list flyout-level-3';
childList.setAttribute('role', 'menu');
childList.id = childId;
childList.setAttribute('aria-label', item.title + ' ' + this.strings.submenuLabel);
item.items.forEach(child => childList.appendChild(this.createFlyoutItem(child)));
li.appendChild(childList);
}
@@ -375,15 +475,78 @@
}
attachEventListeners() {
// Keyboard and mouse event handling (simplified)
const menuItems = document.querySelectorAll('#Mega-Menu [role="menuitem"]');
menuItems.forEach(item => {
item.addEventListener('keydown', this.handleKeyDown.bind(this));
const parent = item.parentElement;
if (parent && parent.querySelector('.mega-menu')) {
parent.addEventListener('mouseenter', this.showMegaMenu);
parent.addEventListener('mouseleave', this.hideMegaMenu);
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(); }
}
});
}
@@ -398,49 +561,32 @@
document.body.appendChild(announcer);
}
showMegaMenu() {
const megaMenu = this.querySelector('.mega-menu');
if (megaMenu) {
megaMenu.classList.add('js-open');
const menuItem = this.querySelector('[role="menuitem"]');
if (menuItem) {
menuItem.setAttribute('aria-expanded', 'true');
}
}
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');
}
hideMegaMenu() {
const megaMenu = this.querySelector('.mega-menu');
if (megaMenu) {
megaMenu.classList.remove('js-open');
const menuItem = this.querySelector('[role="menuitem"]');
if (menuItem) {
menuItem.setAttribute('aria-expanded', 'false');
}
}
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);
});
}
handleKeyDown(e) {
// Keyboard navigation logic (simplified)
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const parent = e.target.parentElement;
const megaMenu = parent.querySelector('.mega-menu');
if (megaMenu) {
megaMenu.classList.toggle('js-open');
e.target.setAttribute('aria-expanded',
megaMenu.classList.contains('js-open') ? 'true' : 'false');
}
} else if (e.key === 'Escape') {
const megaMenu = document.querySelector('.mega-menu.js-open');
if (megaMenu) {
megaMenu.classList.remove('js-open');
const menuItem = megaMenu.parentElement.querySelector('[role="menuitem"]');
if (menuItem) {
menuItem.setAttribute('aria-expanded', 'false');
menuItem.focus();
}
}
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 + ')');
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
"solution": {
"name": "mega-menu-client-side-solution",
"id": "f4660e06-ce08-43ee-bfb7-5c4464e01133",
"version": "2.1.0.0",
"version": "2.2.0.0",
"includeClientSideAssets": true,
"skipFeatureDeployment": true
},
+2
View File
@@ -11,6 +11,7 @@
"properties": {
"termSetName": "Horizontale Navigation",
"cacheMinutes": 15,
"cacheVersion": "1",
"menuMode": "megaMenu",
"debug": true
}
@@ -25,6 +26,7 @@
"properties": {
"termSetName": "Global Navigation",
"cacheMinutes": 15,
"cacheVersion": "1",
"menuMode": "flyout",
"debug": true
}
+3
View File
@@ -9,6 +9,8 @@ param(
[ValidateRange(1, 1440)]
[int]$CacheMinutes = 15,
[string]$CacheVersion = '1',
[ValidateSet('megaMenu', 'flyout')]
[string]$MenuMode = 'megaMenu',
@@ -41,6 +43,7 @@ $properties = [string](@{
termSetName = $TermSetName.Trim()
termSetId = $TermSetId.Trim()
cacheMinutes = $CacheMinutes
cacheVersion = $CacheVersion
menuMode = $MenuMode
debug = [bool]$EnableDebug.IsPresent
} | ConvertTo-Json -Depth 10 -Compress)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mega-menu",
"version": "2.1.0",
"version": "2.2.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "mega-menu",
"version": "2.1.0",
"version": "2.2.0",
"private": true,
"main": "lib/index.js",
"engines": {
@@ -10,8 +10,8 @@
"build:classic": "powershell -NoProfile -ExecutionPolicy Bypass -File .\\build-classic.ps1",
"build": "npm run build:classic && gulp bundle",
"clean": "gulp clean",
"test": "gulp test",
"package": "npm run build:classic && gulp clean && gulp bundle --ship && gulp package-solution --ship"
"test": "tsc -p tests/tsconfig.json && node temp/tests/tests/MegaMenuCore.test.js && node --check classic/megamenu-services-standalone.js && node --check classic/megamenu-classic.js && node tests/validate-static-assets.js",
"package": "npm test && npm run build:classic && gulp clean && gulp bundle --ship && gulp package-solution --ship"
},
"dependencies": {
"@microsoft/decorators": "~1.4.1",
+15 -8
View File
@@ -37,19 +37,23 @@
<div class="megamenu-classic-container">
<nav id="Mega-Menu" class="mega-menu-main mega-menu-mode-flyout" data-menu-mode="flyout" aria-label="Hauptnavigation">
<ul class="mega-menu-top-level">
<button type="button" class="mega-menu-mobile-toggle" aria-expanded="false" aria-controls="Mega-Menu-Items" aria-label="Hauptnavigation öffnen"><span class="mega-menu-mobile-icon" aria-hidden="true"></span><span class="mega-menu-mobile-label">Hauptnavigation</span></button>
<ul id="Mega-Menu-Items" class="mega-menu-top-level">
<li class="mega-menu-top-item"><a class="menu-item-link" href="#">Startseite</a></li>
<li class="mega-menu-top-item is-active">
<a class="menu-item-link menu-item-has-children" href="#unternehmen" aria-haspopup="true" aria-expanded="true">Unternehmen</a>
<div class="mega-menu mega-menu-flyout js-open" aria-expanded="true">
<a class="menu-item-link menu-item-has-children" href="#unternehmen">Unternehmen</a>
<button type="button" class="menu-item-toggle" aria-haspopup="true" aria-expanded="true" aria-controls="Mega-Menu-Panel-Unternehmen" aria-label="Untermenü schließen für Unternehmen"><span aria-hidden="true"></span></button>
<div id="Mega-Menu-Panel-Unternehmen" class="mega-menu mega-menu-flyout js-open" aria-expanded="true">
<ul class="flyout-list flyout-level-2">
<li class="flyout-item has-children">
<a class="flyout-link flyout-link-has-children" href="#ueber-uns" aria-haspopup="true">Über uns</a>
<ul class="flyout-list flyout-level-3"><li class="flyout-item"><a class="flyout-link" href="#profil">Unternehmensprofil</a></li><li class="flyout-item"><a class="flyout-link" href="#leitung">Geschäftsleitung</a></li><li class="flyout-item"><a class="flyout-link" href="#standorte">Standorte</a></li></ul>
<a class="flyout-link flyout-link-has-children" href="#ueber-uns">Über uns</a>
<button type="button" class="flyout-toggle" aria-expanded="false" aria-controls="Flyout-UeberUns" aria-label="Untermenü öffnen für Über uns"><span aria-hidden="true"></span></button>
<ul id="Flyout-UeberUns" class="flyout-list flyout-level-3"><li class="flyout-item"><a class="flyout-link" href="#profil">Unternehmensprofil</a></li><li class="flyout-item"><a class="flyout-link" href="#leitung">Geschäftsleitung</a></li><li class="flyout-item"><a class="flyout-link" href="#standorte">Standorte</a></li></ul>
</li>
<li class="flyout-item has-children">
<a class="flyout-link flyout-link-has-children" href="#organisation" aria-haspopup="true">Organisation</a>
<ul class="flyout-list flyout-level-3"><li class="flyout-item"><a class="flyout-link" href="#bereiche">Fachbereiche</a></li><li class="flyout-item"><a class="flyout-link" href="#organigramm">Organigramm</a></li><li class="flyout-item"><a class="flyout-link" href="#ansprechpartner">Ansprechpartner</a></li></ul>
<a class="flyout-link flyout-link-has-children" href="#organisation">Organisation</a>
<button type="button" class="flyout-toggle" aria-expanded="false" aria-controls="Flyout-Organisation" aria-label="Untermenü öffnen für Organisation"><span aria-hidden="true"></span></button>
<ul id="Flyout-Organisation" class="flyout-list flyout-level-3"><li class="flyout-item"><a class="flyout-link" href="#bereiche">Fachbereiche</a></li><li class="flyout-item"><a class="flyout-link" href="#organigramm">Organigramm</a></li><li class="flyout-item"><a class="flyout-link" href="#ansprechpartner">Ansprechpartner</a></li></ul>
</li>
<li class="flyout-item is-current"><a class="flyout-link" href="#news">Aktuelle Meldungen</a></li>
<li class="flyout-item"><a class="flyout-link" href="#termine">Veranstaltungen</a></li>
@@ -73,7 +77,7 @@
<script>
(function () {
var triggers = document.querySelectorAll('.mega-menu-top-level > .mega-menu-top-item > [aria-haspopup="true"]');
var triggers = document.querySelectorAll('.mega-menu-top-level > .mega-menu-top-item > .menu-item-toggle');
Array.prototype.forEach.call(triggers, function (trigger) {
trigger.addEventListener('click', function (event) {
var panel = trigger.nextElementSibling;
@@ -84,6 +88,9 @@
panel.classList.toggle('js-open', !open); trigger.setAttribute('aria-expanded', String(!open));
});
});
var mobile = document.querySelector('.mega-menu-mobile-toggle');
mobile.addEventListener('click', function () { var open = mobile.getAttribute('aria-expanded') !== 'true'; mobile.setAttribute('aria-expanded', String(open)); document.getElementById('Mega-Menu').classList.toggle('is-mobile-open', open); });
Array.prototype.forEach.call(document.querySelectorAll('.flyout-toggle'), function (toggle) { toggle.addEventListener('click', function () { var open = toggle.getAttribute('aria-expanded') !== 'true'; toggle.setAttribute('aria-expanded', String(open)); toggle.parentElement.classList.toggle('is-open', open); }); });
}());
</script>
</body>
+8 -4
View File
@@ -36,11 +36,13 @@
<div class="megamenu-classic-container">
<nav id="Mega-Menu" class="mega-menu-main mega-menu-mode-megaMenu" data-menu-mode="megaMenu" aria-label="Hauptnavigation">
<ul class="mega-menu-top-level">
<button type="button" class="mega-menu-mobile-toggle" aria-expanded="false" aria-controls="Mega-Menu-Items" aria-label="Hauptnavigation öffnen"><span class="mega-menu-mobile-icon" aria-hidden="true"></span><span class="mega-menu-mobile-label">Hauptnavigation</span></button>
<ul id="Mega-Menu-Items" class="mega-menu-top-level">
<li class="mega-menu-top-item"><a class="menu-item-link" href="#">Startseite</a></li>
<li class="mega-menu-top-item is-active">
<a class="menu-item-link menu-item-has-children" href="#unternehmen" aria-haspopup="true" aria-expanded="true">Unternehmen</a>
<div class="mega-menu js-open" aria-expanded="true">
<a class="menu-item-link menu-item-has-children" href="#unternehmen">Unternehmen</a>
<button type="button" class="menu-item-toggle" aria-haspopup="true" aria-expanded="true" aria-controls="Mega-Menu-Panel-Unternehmen" aria-label="Untermenü schließen für Unternehmen"><span aria-hidden="true"></span></button>
<div id="Mega-Menu-Panel-Unternehmen" class="mega-menu js-open" aria-expanded="true">
<div class="mega-menu-grid" style="--megaMenuColumnCount:4">
<section class="mega-menu-category">
<h3 class="mega-menu-category-title"><a href="#ueber-uns">Über uns</a></h3>
@@ -77,7 +79,7 @@
<script>
(function () {
var triggers = document.querySelectorAll('.mega-menu-top-level > .mega-menu-top-item > [aria-haspopup="true"]');
var triggers = document.querySelectorAll('.mega-menu-top-level > .mega-menu-top-item > .menu-item-toggle');
function closeAll(except) {
Array.prototype.forEach.call(document.querySelectorAll('.mega-menu.js-open'), function (panel) {
if (panel !== except) { panel.classList.remove('js-open'); panel.setAttribute('aria-expanded', 'false'); if (panel.previousElementSibling) { panel.previousElementSibling.setAttribute('aria-expanded', 'false'); } }
@@ -92,6 +94,8 @@
panel.classList.toggle('js-open', !open); panel.setAttribute('aria-expanded', String(!open)); trigger.setAttribute('aria-expanded', String(!open));
});
});
var mobile = document.querySelector('.mega-menu-mobile-toggle');
mobile.addEventListener('click', function () { var open = mobile.getAttribute('aria-expanded') !== 'true'; mobile.setAttribute('aria-expanded', String(open)); document.getElementById('Mega-Menu').classList.toggle('is-mobile-open', open); });
}());
</script>
</body>
+136 -13
View File
@@ -70,6 +70,10 @@ $menu-layer: var(--megaMenuZIndex, 6000);
padding: 0;
}
.mega-menu-mobile-toggle {
display: none;
}
.mega-menu-mode-flyout .mega-menu-top-item {
position: relative;
}
@@ -94,20 +98,40 @@ $menu-layer: var(--megaMenuZIndex, 6000);
outline: none;
}
.menu-item-has-children::after {
.menu-item-toggle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 32px;
min-height: var(--megaMenuBarHeight, 44px);
margin: 0 8px 0 -10px;
padding: 0;
border: 0;
background: transparent;
color: $neutral-primary;
cursor: pointer;
}
.menu-item-toggle > span::after {
content: '';
display: block;
width: 6px;
height: 6px;
margin: -3px 0 0 10px;
border-right: 1px solid currentColor;
border-bottom: 1px solid currentColor;
transform: rotate(45deg);
}
.menu-item-toggle[aria-expanded='true'] > span::after {
transform: rotate(225deg);
}
.menu-item-link:hover,
.menu-item-text:hover,
.menu-item-link[aria-expanded='true'],
.menu-item-text[aria-expanded='true'] {
.menu-item-toggle:hover,
.menu-item-toggle[aria-expanded='true'] {
background: $neutral-lighter;
color: $neutral-primary;
text-decoration: none;
@@ -134,6 +158,8 @@ $menu-layer: var(--megaMenuZIndex, 6000);
.menu-item-link:focus,
.menu-item-text:focus,
.menu-item-toggle:focus,
.mega-menu-mobile-toggle:focus,
.mega-menu-category a:focus,
.flyout-link:focus {
outline: 2px solid $theme-primary;
@@ -180,6 +206,13 @@ $menu-layer: var(--megaMenuZIndex, 6000);
padding: 0 0 8px;
}
.mega-menu-description {
margin: -4px 0 12px;
color: $neutral-secondary;
font-size: 12px;
line-height: 18px;
}
.mega-menu-category-title {
margin: 0 0 10px;
padding: 0 0 8px;
@@ -289,19 +322,37 @@ $menu-layer: var(--megaMenuZIndex, 6000);
font-weight: 600;
}
.flyout-link-has-children::after {
content: '';
.flyout-toggle {
position: absolute;
top: 50%;
right: 18px;
top: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: 0;
background: transparent;
color: $neutral-primary;
cursor: pointer;
}
.flyout-toggle > span::after {
content: '';
display: block;
width: 6px;
height: 6px;
margin-top: -4px;
border-top: 1px solid currentColor;
border-right: 1px solid currentColor;
transform: rotate(45deg);
}
.flyout-toggle:focus {
outline: 2px solid $theme-primary;
outline-offset: -2px;
}
.flyout-level-3 {
position: absolute;
top: -9px;
@@ -314,7 +365,8 @@ $menu-layer: var(--megaMenuZIndex, 6000);
}
.flyout-item:hover > .flyout-level-3,
.flyout-item:focus-within > .flyout-level-3 {
.flyout-item:focus-within > .flyout-level-3,
.flyout-item.is-open > .flyout-level-3 {
display: block;
}
@@ -342,9 +394,61 @@ $menu-layer: var(--megaMenuZIndex, 6000);
}
@media (max-width: 767px) {
.mega-menu-mobile-toggle {
display: flex;
align-items: center;
width: 100%;
min-height: var(--megaMenuBarHeight, 44px);
padding: 0 16px;
border: 0;
background: $white;
color: $neutral-primary;
cursor: pointer;
font: inherit;
font-weight: 600;
}
.mega-menu-mobile-icon,
.mega-menu-mobile-icon::before,
.mega-menu-mobile-icon::after {
display: block;
width: 18px;
height: 2px;
background: currentColor;
}
.mega-menu-mobile-icon {
position: relative;
margin-right: 12px;
}
.mega-menu-mobile-icon::before,
.mega-menu-mobile-icon::after {
content: '';
position: absolute;
left: 0;
}
.mega-menu-mobile-icon::before { top: -6px; }
.mega-menu-mobile-icon::after { top: 6px; }
.mega-menu-top-level {
flex-wrap: wrap;
padding: 0 8px;
display: none;
flex-direction: column;
padding: 0;
border-top: 1px solid $neutral-light;
}
.mega-menu-main.is-mobile-open .mega-menu-top-level {
display: flex;
}
.mega-menu-top-item,
.mega-menu-mode-flyout .mega-menu-top-item {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 44px;
border-bottom: 1px solid $neutral-light;
}
.menu-item-link,
@@ -354,6 +458,21 @@ $menu-layer: var(--megaMenuZIndex, 6000);
font-size: 13px;
}
.menu-item-toggle {
width: 44px;
margin: 0;
border-left: 1px solid $neutral-light;
}
.mega-menu {
position: static;
grid-column: 1 / -1;
width: 100%;
border-right: 0;
border-left: 0;
box-shadow: none;
}
.mega-menu-grid {
grid-template-columns: 1fr;
padding: 16px;
@@ -368,12 +487,16 @@ $menu-layer: var(--megaMenuZIndex, 6000);
.flyout-level-3 {
position: static;
display: block;
display: none;
width: 100%;
border: 0;
box-shadow: none;
padding: 0 0 4px 16px;
}
.flyout-item.is-open > .flyout-level-3 {
display: block;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -28,6 +28,7 @@ export interface IMegaMenuApplicationCustomizerProperties {
cacheMinutes?: number;
debug?: boolean;
menuMode?: MegaMenuMode;
cacheVersion?: string;
}
export default class MegaMenuApplicationCustomizer
@@ -42,7 +43,7 @@ export default class MegaMenuApplicationCustomizer
const debug: boolean = this._isDebugEnabled();
debugLog(debug, LOG_SOURCE, 'onInit started.', {
componentId: UserCustomActionMegaMenuId,
version: '2.1.0',
version: '2.2.0',
properties: this.properties || {},
pageUrl: window.location.href,
webUrl: this.context.pageContext.web.absoluteUrl,
@@ -108,13 +109,16 @@ export default class MegaMenuApplicationCustomizer
return;
}
this._renderStatus(strings.Loading);
try {
debugLog(debug, LOG_SOURCE, 'Creating TaxonomyNavigationService.');
const taxonomyService: TaxonomyNavigationService = new TaxonomyNavigationService(
this.context,
termSetIdentifier,
debug,
this._getCacheMinutes()
this._getCacheMinutes(),
this._getCacheVersion(),
this._getLanguageLcid()
);
debugLog(debug, LOG_SOURCE, 'Requesting navigation items from taxonomy.');
@@ -123,6 +127,11 @@ export default class MegaMenuApplicationCustomizer
topLevelItemCount: menuItems.length
});
if (!menuItems || menuItems.length === 0) {
this._renderStatus(strings.EmptyNavigation);
return;
}
if (this._renderer) {
debugLog(debug, LOG_SOURCE, 'Disposing the previous renderer instance.');
this._renderer.dispose();
@@ -131,7 +140,18 @@ export default class MegaMenuApplicationCustomizer
this._renderer = new MegaMenuRenderer(
menuItems,
debug,
this._getMenuMode()
this._getMenuMode(),
{
navigationLabel: strings.NavigationLabel,
openNavigation: strings.OpenNavigation,
closeNavigation: strings.CloseNavigation,
openSubmenu: strings.OpenSubmenu,
closeSubmenu: strings.CloseSubmenu,
submenuLabel: strings.SubmenuLabel,
externalLink: strings.ExternalLink,
opened: strings.Opened,
closed: strings.Closed
}
);
this._menuContainer = this._getOrCreateContainer('CustomHeader', this._topPlaceholder);
@@ -213,6 +233,19 @@ export default class MegaMenuApplicationCustomizer
return normalizeMegaMenuMode(this.properties && this.properties.menuMode);
}
private _getCacheVersion(): string {
const value: string = this.properties && this.properties.cacheVersion
? String(this.properties.cacheVersion).trim()
: '1';
return value || '1';
}
private _getLanguageLcid(): number {
const legacyContext: any = (this.context.pageContext as any).legacyPageContext; // tslint:disable-line:no-any
const value: number = legacyContext ? Number(legacyContext.currentLanguage) : 0;
return isFinite(value) && value > 0 ? value : 1033;
}
private _renderStatus(message: string): void {
if (!this._topPlaceholder) {
return;
+198 -69
View File
@@ -6,9 +6,22 @@
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 = 140;
private static readonly hoverCloseDelayMs: number = 180;
@@ -16,11 +29,13 @@ export class MegaMenuRenderer {
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 menuMode: MegaMenuMode = 'megaMenu',
private strings: IMegaMenuRendererStrings
) { }
public render(container: HTMLElement): void {
@@ -38,10 +53,22 @@ export class MegaMenuRenderer {
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', 'Hauptnavigation');
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);
@@ -76,6 +103,7 @@ export class MegaMenuRenderer {
this._announcer.parentElement.removeChild(this._announcer);
}
this._announcer = undefined;
this._mobileToggle = undefined;
if (this._container) {
this._container.innerHTML = '';
}
@@ -100,9 +128,20 @@ export class MegaMenuRenderer {
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);
}
@@ -119,7 +158,9 @@ export class MegaMenuRenderer {
} else {
element = document.createElement('span');
element.className = 'menu-item-text';
element.setAttribute('tabindex', '0');
if (!hasChildren) {
element.setAttribute('tabindex', '0');
}
}
element.textContent = item.label;
@@ -130,11 +171,6 @@ export class MegaMenuRenderer {
isCurrent ? 'is-current' : undefined
);
if (hasChildren) {
element.setAttribute('aria-haspopup', 'true');
element.setAttribute('aria-expanded', 'false');
}
if (isCurrent && item.url) {
element.setAttribute('aria-current', 'page');
}
@@ -143,6 +179,10 @@ export class MegaMenuRenderer {
element.title = item.hoverText;
}
if (item.url) {
this.applyLinkBehavior(element as HTMLAnchorElement, item);
}
return element;
}
@@ -150,7 +190,7 @@ export class MegaMenuRenderer {
const megaMenuDiv = document.createElement('div');
megaMenuDiv.className = 'mega-menu';
megaMenuDiv.setAttribute('aria-expanded', 'false');
megaMenuDiv.setAttribute('aria-label', parentItem.label + ' Unterkategorien');
megaMenuDiv.setAttribute('aria-label', parentItem.label + ' ' + this.strings.submenuLabel);
const gridDiv = document.createElement('div');
gridDiv.className = 'mega-menu-grid';
@@ -184,6 +224,7 @@ export class MegaMenuRenderer {
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
@@ -207,6 +248,13 @@ export class MegaMenuRenderer {
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) {
@@ -226,6 +274,7 @@ export class MegaMenuRenderer {
link.href = thirdLevelItem.url || '#';
link.textContent = thirdLevelItem.label;
this.applyLinkBehavior(link, thirdLevelItem);
link.className = this.joinClasses(
isLinkActive ? 'is-active' : undefined,
isLinkCurrent ? 'is-current' : undefined
@@ -253,7 +302,7 @@ export class MegaMenuRenderer {
const flyout = document.createElement('div');
flyout.className = 'mega-menu mega-menu-flyout';
flyout.setAttribute('aria-expanded', 'false');
flyout.setAttribute('aria-label', parentItem.label + ' Untermenü');
flyout.setAttribute('aria-label', parentItem.label + ' ' + this.strings.submenuLabel);
const list = document.createElement('ul');
list.className = 'flyout-list flyout-level-2';
@@ -285,9 +334,19 @@ export class MegaMenuRenderer {
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.setAttribute('aria-label', item.label + ' Untermenü');
childList.id = childId;
childList.setAttribute('aria-label', item.label + ' ' + this.strings.submenuLabel);
item.items.forEach(thirdLevelItem => {
childList.appendChild(this.createFlyoutItem(thirdLevelItem));
@@ -306,6 +365,7 @@ export class MegaMenuRenderer {
if (item.url) {
(element as HTMLAnchorElement).href = item.url;
this.applyLinkBehavior(element as HTMLAnchorElement, item);
} else {
element.setAttribute('tabindex', '0');
}
@@ -318,9 +378,6 @@ export class MegaMenuRenderer {
isCurrent ? 'is-current' : undefined
);
if (hasChildren) {
element.setAttribute('aria-haspopup', 'true');
}
if (isCurrent && item.url) {
element.setAttribute('aria-current', 'page');
}
@@ -332,7 +389,7 @@ export class MegaMenuRenderer {
}
private attachEventListeners(): void {
const headings = document.querySelectorAll('#Mega-Menu > ul > li > a, #Mega-Menu > ul > li > .menu-item-text');
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;
@@ -347,21 +404,17 @@ export class MegaMenuRenderer {
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 === 'Enter') {
if (heading.tagName === 'A') {
return;
}
e.preventDefault();
this.toggleMegaMenu(heading, megaMenu);
} else if (e.key === ' ') {
e.preventDefault();
this.toggleMegaMenu(heading, megaMenu);
} else if (e.key === 'ArrowDown') {
if (e.key === 'ArrowDown') {
e.preventDefault();
this.openMegaMenu(heading, megaMenu);
this.focusFirstLink(megaMenu);
@@ -375,12 +428,6 @@ export class MegaMenuRenderer {
}
});
if (heading.tagName === 'A') {
heading.addEventListener('click', () => {
debugLog(this.debug, LOG_SOURCE, 'Link clicked:', (heading as HTMLAnchorElement).href);
});
}
heading.addEventListener('focus', () => {
debugLog(this.debug, LOG_SOURCE, 'Focus moved to:', heading.textContent);
});
@@ -444,7 +491,7 @@ export class MegaMenuRenderer {
const isInsideThisMenu = megaMenu.contains(focusedElement);
const isOnThisTrigger = focusedElement === heading;
const isInAnyMegaMenu = focusedElement.closest('.mega-menu');
const isOnAnyTopLevel = focusedElement.closest('#Mega-Menu > ul > li > a, #Mega-Menu > ul > li > span');
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.');
@@ -467,9 +514,30 @@ export class MegaMenuRenderer {
}
}
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('#Mega-Menu > ul > li > a, #Mega-Menu > ul > li > span') as HTMLElement;
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;
@@ -523,21 +591,86 @@ export class MegaMenuRenderer {
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);
menu.setAttribute('aria-expanded', 'true');
menu.classList.add('js-open');
this.announce((trigger.textContent || '') + ' geöffnet');
debugLog(this.debug, LOG_SOURCE, 'Menu opened:', trigger.textContent);
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);
menu.setAttribute('aria-expanded', 'false');
menu.classList.remove('js-open');
this.announce((trigger.textContent || '') + ' geschlossen');
debugLog(this.debug, LOG_SOURCE, 'Menu closed:', trigger.textContent);
this.announce(label + ' ' + this.strings.closed);
debugLog(this.debug, LOG_SOURCE, 'Menu closed:', label);
}
private toggleMegaMenu(trigger: HTMLElement, menu: HTMLElement): void {
@@ -550,7 +683,7 @@ export class MegaMenuRenderer {
}
private closeAllMegaMenus(exceptMenu?: HTMLElement): void {
const allTriggers = document.querySelectorAll('#Mega-Menu > ul > li > a[aria-expanded="true"], #Mega-Menu > ul > li > span[aria-expanded="true"]');
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;
@@ -586,36 +719,7 @@ export class MegaMenuRenderer {
}
private isCurrentUrl(url?: string): boolean {
const currentPath = this.normalizePath(window.location.href);
const itemPath = this.normalizePath(url);
if (!currentPath || !itemPath) {
return false;
}
if (itemPath === '/') {
return currentPath === '/';
}
if (currentPath === itemPath) {
return true;
}
return currentPath.indexOf(itemPath + '/') === 0;
}
private normalizePath(url?: string): string {
if (!url) {
return '';
}
try {
const parsedUrl = new URL(url, window.location.origin);
const pathname = (parsedUrl.pathname || '/').replace(/\/+$/, '');
return (pathname || '/').toLowerCase();
} catch (error) {
return '';
}
return isNavigationUrlActive(window.location.href, url || '', window.location.origin);
}
private getColumnCount(items?: IMenuItem[]): string {
@@ -624,6 +728,31 @@ export class MegaMenuRenderer {
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[] = [];
+12 -1
View File
@@ -2,6 +2,17 @@ define([], function() {
return {
"Title": "MegaMenuApplicationCustomizer",
"ConfigurationMissing": "Die Navigation ist nicht konfiguriert.",
"LoadError": "Die Navigation konnte nicht geladen werden."
"LoadError": "Die Navigation konnte nicht geladen werden.",
"Loading": "Navigation wird geladen…",
"EmptyNavigation": "Die Navigation enthält keine sichtbaren Einträge.",
"NavigationLabel": "Hauptnavigation",
"OpenNavigation": "Hauptnavigation öffnen",
"CloseNavigation": "Hauptnavigation schließen",
"OpenSubmenu": "Untermenü öffnen für",
"CloseSubmenu": "Untermenü schließen für",
"SubmenuLabel": "Untermenü",
"ExternalLink": "externer Link",
"Opened": "geöffnet",
"Closed": "geschlossen"
};
});
+12 -1
View File
@@ -2,6 +2,17 @@ define([], function() {
return {
"Title": "MegaMenuApplicationCustomizer",
"ConfigurationMissing": "Navigation is not configured.",
"LoadError": "Navigation could not be loaded."
"LoadError": "Navigation could not be loaded.",
"Loading": "Loading navigation…",
"EmptyNavigation": "The navigation does not contain any visible entries.",
"NavigationLabel": "Main navigation",
"OpenNavigation": "Open main navigation",
"CloseNavigation": "Close main navigation",
"OpenSubmenu": "Open submenu for",
"CloseSubmenu": "Close submenu for",
"SubmenuLabel": "submenu",
"ExternalLink": "external link",
"Opened": "opened",
"Closed": "closed"
}
});
+11
View File
@@ -2,6 +2,17 @@ declare interface IMegaMenuApplicationCustomizerStrings {
Title: string;
ConfigurationMissing: string;
LoadError: string;
Loading: string;
EmptyNavigation: string;
NavigationLabel: string;
OpenNavigation: string;
CloseNavigation: string;
OpenSubmenu: string;
CloseSubmenu: string;
SubmenuLabel: string;
ExternalLink: string;
Opened: string;
Closed: string;
}
declare module 'MegaMenuApplicationCustomizerStrings' {
+3
View File
@@ -3,6 +3,9 @@ export interface IMenuItem {
label: string;
icon?: string;
hoverText: string;
description?: string;
openInNewWindow?: boolean;
external?: boolean;
url?: string;
pathDepth: number;
items?: IMenuItem[];
+11
View File
@@ -78,11 +78,22 @@ export interface ITerm {
PathDepth?: number;
ParentId?: string;
TermsCount?: number;
Labels?: ITermLabelCollection;
LocalCustomProperties?: {
[property: string]: any
};
}
export interface ITermLabelCollection {
_Child_Items_: ITermLabel[];
}
export interface ITermLabel {
Language: number;
Value: string;
IsDefaultForLanguage?: boolean;
}
export interface ISuggestTerm {
Id: string;
DefaultLabel: string;
@@ -5,4 +5,7 @@ export interface ISPTermStorePickerServiceProps {
hideTagsNotAvailableForTagging: boolean;
anchorId: string;
cacheMinutes: number;
siteCollectionUrl: string;
languageLcid: number;
cacheVersion: string;
}
-9
View File
@@ -1,9 +0,0 @@
export class ItemDictionary<T> {
public Get(key: string): T {
return this[key];
}
public Add(key: string, value: T): void {
this[key] = value;
}
}
+181
View File
@@ -0,0 +1,181 @@
import { ITerm, ITermLabelCollection } from './ISPTermStorePickerService';
export const MegaMenuCacheSchemaVersion: string = '3';
export const MegaMenuTermProperties = {
hidden: 'MegaMenu.Hidden',
openInNewWindow: 'MegaMenu.OpenInNewWindow',
description: 'MegaMenu.Description'
};
export interface IMegaMenuCacheEnvelope<T> {
expiresAt: number;
value: T;
}
export function readCachedValue<T>(serialized: string, now: number, allowExpired: boolean): T | undefined {
if (!serialized) {
return undefined;
}
try {
const entry: IMegaMenuCacheEnvelope<T> = JSON.parse(serialized) as IMegaMenuCacheEnvelope<T>;
if (!entry || !entry.value || !entry.expiresAt || (!allowExpired && entry.expiresAt <= now)) {
return undefined;
}
return entry.value;
} catch (error) {
return undefined;
}
}
export function parseBooleanProperty(value: any): boolean { // tslint:disable-line:no-any
if (value === true || value === 1) {
return true;
}
const normalized: string = String(value || '').trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'ja';
}
export function getTermProperty(term: ITerm, propertyName: string): string {
const properties: { [property: string]: any } = term && term.LocalCustomProperties // tslint:disable-line:no-any
? term.LocalCustomProperties
: {};
const value: any = properties[propertyName]; // tslint:disable-line:no-any
return value === undefined || value === null ? '' : String(value).trim();
}
export function isTermHidden(term: ITerm): boolean {
return parseBooleanProperty(getTermProperty(term, MegaMenuTermProperties.hidden));
}
export function getLocalizedTermLabel(term: ITerm, languageLcid: number): string {
const labels: ITermLabelCollection | undefined = term && term.Labels;
const items = labels && labels._Child_Items_ ? labels._Child_Items_ : [];
let fallback: string = term && term.Name ? term.Name : '';
for (let i: number = 0; i < items.length; i++) {
const labelLanguage: number = Number(items[i].Language);
if (labelLanguage === languageLcid && items[i].Value) {
return items[i].Value;
}
if (items[i].IsDefaultForLanguage && items[i].Value) {
fallback = items[i].Value;
}
}
return fallback;
}
export function resolveNavigationUrl(rawUrl: string, siteCollectionUrl: string): string | undefined {
const value: string = String(rawUrl || '').trim();
if (!value) {
return undefined;
}
if (/[\u0000-\u001f\u007f]/.test(value)) {
return undefined;
}
const resolved: string = value.toLowerCase().indexOf('~sitecollection') === 0
? String(siteCollectionUrl || '').replace(/\/+$/, '') + value.substring('~sitecollection'.length)
: value;
const protocolMatch: RegExpMatchArray | null = resolved.match(/^([a-z][a-z0-9+.-]*):/i);
if (protocolMatch) {
const protocol: string = protocolMatch[1].toLowerCase();
if (protocol !== 'http' && protocol !== 'https' && protocol !== 'mailto') {
return undefined;
}
}
return resolved;
}
export function isExternalNavigationUrl(url: string, currentOrigin: string): boolean {
const absoluteMatch: RegExpMatchArray | null = String(url || '').match(/^(?:(https?):)?\/\/([^/]+)/i);
if (!absoluteMatch) {
return false;
}
const originMatch: RegExpMatchArray | null = String(currentOrigin || '').match(/^(https?):\/\/([^/]+)/i);
if (!originMatch) {
return true;
}
const targetProtocol: string = absoluteMatch[1] || originMatch[1];
return targetProtocol.toLowerCase() !== originMatch[1].toLowerCase()
|| absoluteMatch[2].toLowerCase() !== originMatch[2].toLowerCase();
}
export function normalizeNavigationPath(url: string, currentOrigin: string): string {
let value: string = String(url || '').trim();
if (!value) {
return '';
}
const origin: string = String(currentOrigin || '').replace(/\/+$/, '');
if (origin && value.toLowerCase().indexOf(origin.toLowerCase()) === 0) {
value = value.substring(origin.length) || '/';
} else {
value = value.replace(/^https?:\/\/[^/]+/i, '');
}
value = value.split('#')[0].split('?')[0].replace(/\/+$/, '');
return (value || '/').toLowerCase();
}
export function isNavigationUrlActive(currentUrl: string, itemUrl: string, currentOrigin: string): boolean {
const currentPath: string = normalizeNavigationPath(currentUrl, currentOrigin);
const itemPath: string = normalizeNavigationPath(itemUrl, currentOrigin);
if (!currentPath || !itemPath) {
return false;
}
return itemPath === '/' ? currentPath === '/' : currentPath === itemPath || currentPath.indexOf(itemPath + '/') === 0;
}
export function buildTermCacheKey(
siteCollectionUrl: string,
termSetId: string,
languageLcid: number,
cacheVersion?: string
): string {
const normalize = (value: string): string => encodeURIComponent(String(value || '').trim().toLowerCase());
return 'MegaMenu:TermSet:v' + MegaMenuCacheSchemaVersion + ':'
+ normalize(siteCollectionUrl) + ':'
+ normalize(termSetId) + ':'
+ String(languageLcid || 0) + ':'
+ normalize(cacheVersion || '1');
}
export interface IMegaMenuHierarchyEntry<T> {
id: string;
parentId?: string;
depth: number;
item: T;
}
export function buildMenuHierarchy<T extends { items?: T[] }>(entries: Array<IMegaMenuHierarchyEntry<T>>): T[] {
const byId: { [id: string]: T } = {};
const roots: T[] = [];
for (let i: number = 0; i < entries.length; i++) {
byId[String(entries[i].id || '').toLowerCase()] = entries[i].item;
entries[i].item.items = [];
}
for (let i: number = 0; i < entries.length; i++) {
const entry: IMegaMenuHierarchyEntry<T> = entries[i];
const parent: T = entry.parentId ? byId[String(entry.parentId).toLowerCase()] : undefined;
if (entry.depth <= 1) {
roots.push(entry.item);
} else if (parent) {
parent.items.push(entry.item);
}
}
return roots;
}
export function compareTermsByConfiguredOrder(a: ITerm, b: ITerm): number {
const aIndex: number = typeof a.CustomSortOrderIndex === 'number' ? a.CustomSortOrderIndex : -1;
const bIndex: number = typeof b.CustomSortOrderIndex === 'number' ? b.CustomSortOrderIndex : -1;
if (aIndex >= 0 || bIndex >= 0) {
if (aIndex < 0) { return 1; }
if (bIndex < 0) { return -1; }
return aIndex - bIndex;
}
const aPath: string = String(a.PathOfTerm || '').toLowerCase();
const bPath: string = String(b.PathOfTerm || '').toLowerCase();
return aPath < bPath ? -1 : (aPath > bPath ? 1 : 0);
}
+25 -9
View File
@@ -2,26 +2,37 @@
import { IMenuItem } from './IMenuItem';
import { ITerm } from './ISPTermStorePickerService';
import {
getLocalizedTermLabel,
getTermProperty,
isExternalNavigationUrl,
MegaMenuTermProperties,
parseBooleanProperty,
resolveNavigationUrl
} from './MegaMenuCore';
export class MenuItem implements IMenuItem {
public id: string;
public label: string;
public hoverText: string;
public description: string;
public openInNewWindow: boolean;
public external: boolean;
public pathDepth: number;
public url?: string;
public items?: IMenuItem[];
constructor(term: ITerm, public level: number, siteCollectionUrl?: string) {
constructor(term: ITerm, public level: number, siteCollectionUrl?: string, languageLcid: number = 1033) {
this.id = term.Id;
this.label = term.Name;
this.hoverText = term.LocalCustomProperties['_Sys_Nav_HoverText'];
this.label = getLocalizedTermLabel(term, languageLcid);
const properties = term.LocalCustomProperties || {};
this.hoverText = properties['_Sys_Nav_HoverText'];
this.description = getTermProperty(term, MegaMenuTermProperties.description);
this.openInNewWindow = parseBooleanProperty(getTermProperty(term, MegaMenuTermProperties.openInNewWindow));
this.pathDepth = term.PathDepth;
const rawUrl: string = term.LocalCustomProperties['_Sys_Nav_SimpleLinkUrl'] || term.LocalCustomProperties['_Sys_Nav_TargetUrl'];
if (rawUrl) {
this.url = siteCollectionUrl && rawUrl.indexOf('~sitecollection') === 0
? siteCollectionUrl + rawUrl.substring('~sitecollection'.length)
: rawUrl;
}
const rawUrl: string = properties['_Sys_Nav_SimpleLinkUrl'] || properties['_Sys_Nav_TargetUrl'];
this.url = resolveNavigationUrl(rawUrl, siteCollectionUrl || '');
this.external = !!this.url && isExternalNavigationUrl(this.url, this.getOrigin(siteCollectionUrl || ''));
this.items = [];
}
@@ -34,4 +45,9 @@ export class MenuItem implements IMenuItem {
(window as any).location.href = this.url;
}
}
private getOrigin(url: string): string {
const match: RegExpMatchArray | null = String(url || '').match(/^(https?:\/\/[^/]+)/i);
return match ? match[1] : '';
}
}
+30 -28
View File
@@ -9,9 +9,9 @@ import { ISPTermStorePickerServiceProps } from './ISPTermStorePickerServiceProps
import { IPickerTerm } from './IPickerTerm';
import { ApplicationCustomizerContext } from '@microsoft/sp-application-base';
import { debugError, debugLog, debugWarn } from './MegaMenuDebug';
import { buildTermCacheKey, compareTermsByConfiguredOrder, readCachedValue } from './MegaMenuCore';
const EmptyGuid: string = '00000000-0000-0000-0000-000000000000';
const TermCachePrefix: string = 'MegaMenu:TermSet:v2:';
const PickerCachePrefix: string = 'MegaMenu:PickerTerms:v2:';
interface ITermCacheEntry {
@@ -41,7 +41,9 @@ export default class SPTermStorePickerService {
clientServiceUrl: this.clientServiceUrl,
termSetNameOrId: this.props.termsetNameOrID,
useSessionStorage: this.props.useSessionStorage,
cacheMinutes: this.props.cacheMinutes
cacheMinutes: this.props.cacheMinutes,
cacheVersion: this.props.cacheVersion,
languageLcid: this.props.languageLcid
});
}
@@ -175,7 +177,8 @@ export default class SPTermStorePickerService {
}
}
const childTerms = this.getCachedTermSet(termsetId, useSessionStorage);
const childTerms = this.getCachedTermSet(termsetId, useSessionStorage, false);
const staleTerms = this.getCachedTermSet(termsetId, useSessionStorage, true);
if (childTerms) {
debugLog(this.debug, 'SPTermStorePickerService', 'Returning terms from session cache.', {
@@ -272,9 +275,10 @@ export default class SPTermStorePickerService {
expiresAt: Date.now() + ttlMinutes * 60 * 1000,
value: termStoreResultTermSet
};
window.sessionStorage.setItem(TermCachePrefix + termsetId, JSON.stringify(cacheEntry));
const cacheKey: string = this.getTermCacheKey(termsetId);
window.sessionStorage.setItem(cacheKey, JSON.stringify(cacheEntry));
debugLog(this.debug, 'SPTermStorePickerService', 'Term set stored in session cache.', {
key: TermCachePrefix + termsetId,
key: cacheKey,
expiresAt: cacheEntry.expiresAt
});
}
@@ -290,6 +294,12 @@ export default class SPTermStorePickerService {
debugWarn(this.debug, 'SPTermStorePickerService', 'ProcessQuery response contained no term set.');
return null;
});
}).catch((error: any) => {
if (staleTerms) {
debugWarn(this.debug, 'SPTermStorePickerService', 'Taxonomy request failed; returning stale cache.', error);
return staleTerms;
}
throw error;
});
}
@@ -420,21 +430,20 @@ export default class SPTermStorePickerService {
return null;
}
private getCachedTermSet(termId: string, useSessionStorage: boolean = true): ITermSet {
private getCachedTermSet(termId: string, useSessionStorage: boolean = true, allowExpired: boolean = false): ITermSet {
try {
if (useSessionStorage && window.sessionStorage) {
const key: string = TermCachePrefix + termId;
const key: string = this.getTermCacheKey(termId);
const terms: string = window.sessionStorage.getItem(key);
if (!terms) {
return null;
}
const cacheEntry: ITermCacheEntry = JSON.parse(terms) as ITermCacheEntry;
if (!cacheEntry || !cacheEntry.value || !cacheEntry.expiresAt || cacheEntry.expiresAt <= Date.now()) {
const cachedValue: ITermSet = readCachedValue<ITermSet>(terms, Date.now(), allowExpired);
if (!cachedValue && allowExpired) {
window.sessionStorage.removeItem(key);
return null;
}
return cacheEntry.value;
return cachedValue || null;
} else {
return null;
}
@@ -473,6 +482,15 @@ export default class SPTermStorePickerService {
return Math.min(Math.max(Math.floor(value), 1), 1440);
}
private getTermCacheKey(termId: string): string {
return buildTermCacheKey(
this.props.siteCollectionUrl,
termId,
this.props.languageLcid,
this.props.cacheVersion
);
}
private ensureSuccessfulResponse(response: SPHttpClientResponse, operation: string): void {
if (!response.ok) {
throw new Error(operation + ' request failed (' + response.status + ' ' + response.statusText + ').');
@@ -623,23 +641,7 @@ export default class SPTermStorePickerService {
* @param b term 2
*/
private sortTermByPath(a: ITerm, b: ITerm) {
if (a.CustomSortOrderIndex === -1) {
if (a.PathOfTerm.toLowerCase() < b.PathOfTerm.toLowerCase()) {
return -1;
}
if (a.PathOfTerm.toLowerCase() > b.PathOfTerm.toLowerCase()) {
return 1;
}
return 0;
} else {
if (a.CustomSortOrderIndex < b.CustomSortOrderIndex) {
return -1;
}
if (a.CustomSortOrderIndex > b.CustomSortOrderIndex) {
return 1;
}
return 0;
}
return compareTermsByConfiguredOrder(a, b);
}
private convertTermToPickerTerm(term: ITerm): IPickerTerm {
+19 -18
View File
@@ -5,8 +5,8 @@ import { MenuItem } from './MenuItem';
import { ApplicationCustomizerContext } from '@microsoft/sp-application-base';
import SPTermStorePickerService from './SPTermStorePickerService';
import { ITerm, ITermSet } from './ISPTermStorePickerService';
import { ItemDictionary } from './ItemDictionary';
import { debugLog, debugWarn } from './MegaMenuDebug';
import { buildMenuHierarchy, IMegaMenuHierarchyEntry, isTermHidden } from './MegaMenuCore';
const LOG_SOURCE: string = 'TaxonomyNavigationService';
@@ -16,7 +16,9 @@ export class TaxonomyNavigationService implements ITaxonomyNavigationService {
private context: ApplicationCustomizerContext,
private termSetNameOrId: string,
private debug: boolean = false,
private cacheMinutes: number = 15
private cacheMinutes: number = 15,
private cacheVersion: string = '1',
private languageLcid: number = 1033
) {
debugLog(this.debug, LOG_SOURCE, 'Initializing taxonomy navigation service.', {
termSetNameOrId: this.termSetNameOrId,
@@ -34,7 +36,10 @@ export class TaxonomyNavigationService implements ITaxonomyNavigationService {
useSessionStorage: true,
hideDeprecatedTags: true,
hideTagsNotAvailableForTagging: true,
cacheMinutes: cacheMinutes
cacheMinutes: cacheMinutes,
siteCollectionUrl: this.context.pageContext.site.absoluteUrl,
languageLcid: this.languageLcid,
cacheVersion: this.cacheVersion
},
this.context,
this.debug
@@ -58,29 +63,25 @@ export class TaxonomyNavigationService implements ITaxonomyNavigationService {
termSetFound: !!termset,
termCount: termset && termset.Terms ? termset.Terms.length : 0
});
const itemsDict: ItemDictionary<IMenuItem> = new ItemDictionary<IMenuItem>();
const menuItems: IMenuItem[] = [];
const entries: Array<IMegaMenuHierarchyEntry<IMenuItem>> = [];
if (!termset || !termset.Terms || termset.Terms.length === 0) {
debugWarn(this.debug, LOG_SOURCE, 'No terms found in the term set.');
throw new Error('The configured navigation term set was not found or contains no terms.');
}
termset.Terms.forEach((term: ITerm) => {
const menuItem: IMenuItem = new MenuItem(term, 0, siteCollectionUrl);
itemsDict.Add(term.Id, menuItem);
if (menuItem.pathDepth === 1) {
menuItems.push(menuItem);
} else {
const parentItem: IMenuItem = itemsDict.Get(term.ParentId);
if (parentItem) {
parentItem.items.push(menuItem);
} else {
debugWarn(this.debug, LOG_SOURCE, 'Item without parent:', term.PathOfTerm);
}
}
termset.Terms.filter((term: ITerm) => !isTermHidden(term)).forEach((term: ITerm) => {
const menuItem: IMenuItem = new MenuItem(term, 0, siteCollectionUrl, this.languageLcid);
entries.push({
id: term.Id,
parentId: term.ParentId,
depth: menuItem.pathDepth,
item: menuItem
});
});
const menuItems: IMenuItem[] = buildMenuHierarchy(entries);
debugLog(this.debug, LOG_SOURCE, 'Navigation hierarchy built.', {
totalTerms: termset.Terms.length,
topLevelItems: menuItems.length
+89
View File
@@ -0,0 +1,89 @@
import {
buildMenuHierarchy,
buildTermCacheKey,
compareTermsByConfiguredOrder,
getLocalizedTermLabel,
getTermProperty,
isExternalNavigationUrl,
isNavigationUrlActive,
isTermHidden,
MegaMenuTermProperties,
parseBooleanProperty,
readCachedValue,
resolveNavigationUrl
} from '../src/services/MegaMenuCore';
import { ITerm } from '../src/services/ISPTermStorePickerService';
let passed: number = 0;
function assert(name: string, condition: boolean): void {
if (!condition) {
throw new Error('FAILED: ' + name);
}
passed++;
console.log('OK:', name);
}
function term(values: any): ITerm { // tslint:disable-line:no-any
return values as ITerm;
}
assert('boolean property accepts German yes', parseBooleanProperty('ja'));
assert('boolean property rejects false', !parseBooleanProperty('false'));
assert('hidden local term property', isTermHidden(term({ LocalCustomProperties: { 'MegaMenu.Hidden': 'true' } })));
assert('description local term property', getTermProperty(term({ LocalCustomProperties: { 'MegaMenu.Description': 'Forms' } }), MegaMenuTermProperties.description) === 'Forms');
assert('open-in-new-window local term property', parseBooleanProperty(getTermProperty(term({ LocalCustomProperties: { 'MegaMenu.OpenInNewWindow': '1' } }), MegaMenuTermProperties.openInNewWindow)));
assert('site collection token is resolved',
resolveNavigationUrl('~sitecollection/SitePages/Home.aspx', 'https://portal/sites/hr') ===
'https://portal/sites/hr/SitePages/Home.aspx');
assert('relative URL is accepted', resolveNavigationUrl('/sites/hr', 'https://portal') === '/sites/hr');
assert('javascript URL is blocked', resolveNavigationUrl('javascript:alert(1)', 'https://portal') === undefined);
assert('data URL is blocked', resolveNavigationUrl('data:text/html,test', 'https://portal') === undefined);
assert('control characters in URL are blocked', resolveNavigationUrl('java\nscript:alert(1)', 'https://portal') === undefined);
assert('mailto URL is accepted', resolveNavigationUrl('mailto:test@example.com', 'https://portal') === 'mailto:test@example.com');
assert('external host is detected', isExternalNavigationUrl('https://example.com/a', 'https://portal'));
assert('protocol-relative external host is detected', isExternalNavigationUrl('//example.com/a', 'https://portal'));
assert('same origin is not external', !isExternalNavigationUrl('https://portal/a', 'https://portal'));
assert('current child page activates navigation parent',
isNavigationUrlActive('https://portal/sites/hr/docs/view.aspx?x=1', '/sites/hr/docs', 'https://portal'));
assert('similar path does not create false active state',
!isNavigationUrlActive('https://portal/sites/hr/documents', '/sites/hr/docs', 'https://portal'));
const keyA: string = buildTermCacheKey('https://portal/sites/a', 'ABC', 1031, '1');
const keyB: string = buildTermCacheKey('https://portal/sites/a', 'ABC', 1033, '1');
const keyC: string = buildTermCacheKey('https://portal/sites/a', 'ABC', 1031, '2');
assert('cache key includes language', keyA !== keyB);
assert('cache key includes configuration version', keyA !== keyC);
assert('cache key is deterministic', keyA === buildTermCacheKey('https://portal/sites/a', 'abc', 1031, '1'));
assert('valid cache value is returned', readCachedValue<string>(JSON.stringify({ expiresAt: 200, value: 'menu' }), 100, false) === 'menu');
assert('expired cache is rejected normally', readCachedValue<string>(JSON.stringify({ expiresAt: 50, value: 'menu' }), 100, false) === undefined);
assert('expired cache is available as fallback', readCachedValue<string>(JSON.stringify({ expiresAt: 50, value: 'menu' }), 100, true) === 'menu');
assert('malformed cache is rejected', readCachedValue<string>('{invalid', 100, true) === undefined);
const localized = term({
Name: 'Default',
Labels: { _Child_Items_: [
{ Language: 1033, Value: 'Company', IsDefaultForLanguage: true },
{ Language: 1031, Value: 'Unternehmen' }
] }
});
assert('German term label is selected', getLocalizedTermLabel(localized, 1031) === 'Unternehmen');
assert('default label is fallback', getLocalizedTermLabel(localized, 1040) === 'Company');
interface ITestItem { name: string; items?: ITestItem[]; }
const child: ITestItem = { name: 'child' };
const root: ITestItem = { name: 'root' };
const hierarchy: ITestItem[] = buildMenuHierarchy([
{ id: 'child', parentId: 'root', depth: 2, item: child },
{ id: 'root', depth: 1, item: root }
]);
assert('hierarchy works when child precedes parent', hierarchy.length === 1 && hierarchy[0].items[0] === child);
const sorted: ITerm[] = [
term({ PathOfTerm: 'B', CustomSortOrderIndex: 1 }),
term({ PathOfTerm: 'A', CustomSortOrderIndex: 0 })
].sort(compareTermsByConfiguredOrder);
assert('custom term order wins', sorted[0].PathOfTerm === 'A');
console.log('MegaMenu core tests passed:', passed);
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "../temp/tests",
"rootDir": "..",
"skipLibCheck": true,
"types": [],
"lib": ["es5", "dom", "es2015.collection"]
},
"files": [
"../src/services/ISPTermStorePickerService.ts",
"../src/services/MegaMenuCore.ts",
"MegaMenuCore.test.ts"
]
}
+44
View File
@@ -0,0 +1,44 @@
'use strict';
var fs = require('fs');
function assert(name, condition) {
if (!condition) {
throw new Error('FAILED: ' + name);
}
console.log('OK:', name);
}
function read(path) {
return fs.readFileSync(path, 'utf8');
}
function validatePreview(path) {
var html = read(path);
var start = html.indexOf('<script>');
var end = html.indexOf('</script>', start);
assert(path + ' contains script', start >= 0 && end > start);
new Function(html.substring(start + 8, end));
assert(path + ' script syntax', true);
assert(path + ' uses production CSS', html.indexOf('../classic/megamenu-classic.css') >= 0);
assert(path + ' has mobile toggle', html.indexOf('mega-menu-mobile-toggle') >= 0);
assert(path + ' has accessible submenu toggle', html.indexOf('menu-item-toggle') >= 0 && html.indexOf('aria-controls') >= 0);
}
function validateStyle(path) {
var css = read(path);
var opening = (css.match(/\{/g) || []).length;
var closing = (css.match(/\}/g) || []).length;
assert(path + ' balanced blocks', opening === closing);
['mega-menu-mobile-toggle', 'menu-item-toggle', 'flyout-toggle', 'mega-menu-description'].forEach(function (className) {
assert(path + ' contains ' + className, css.indexOf(className) >= 0);
});
}
validatePreview('preview/mega-menu-preview.html');
validatePreview('preview/flyout-menu-preview.html');
validateStyle('classic/megamenu-classic.css');
validateStyle('src/extensions/megaMenu/MegaMenu.module.scss');
console.log('Static MegaMenu asset tests passed.');