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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 + ')');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mega-menu",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
||||
+3
-3
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,6 +3,9 @@ export interface IMenuItem {
|
||||
label: string;
|
||||
icon?: string;
|
||||
hoverText: string;
|
||||
description?: string;
|
||||
openInNewWindow?: boolean;
|
||||
external?: boolean;
|
||||
url?: string;
|
||||
pathDepth: number;
|
||||
items?: IMenuItem[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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] : '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.');
|
||||
|
||||
Reference in New Issue
Block a user