5 Commits
Author SHA1 Message Date
Torsten Brendgen f95accd1ac feat: Update version to 2.2.3 and enhance styling in MegaMenu 2026-08-18 20:20:54 +02:00
Torsten Brendgen e977fd7365 feat: Update version to 2.2.2, enhance global navigation exclusion logic, and improve hover behavior in MegaMenu 2026-08-18 20:20:20 +02:00
Torsten Brendgen f58c6f6135 feat: Update MegaMenu styles to use theme variables and enhance static asset validation 2026-07-21 22:03:29 +02:00
Torsten Brendgen 50b85468f1 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.
2026-07-20 21:34:24 +02:00
Torsten Brendgen bc61166267 feat: Add support for flyout menu mode in MegaMenu
- Introduced `MegaMenuMode` type and `normalizeMegaMenuMode` function to handle menu modes.
- Updated `MegaMenuApplicationCustomizer` to accept `menuMode` property.
- Enhanced `MegaMenuRenderer` to render flyout menus based on the selected mode.
- Created new HTML previews for both flyout and mega menu modes.
- Removed unused mock services and user custom action service interfaces.
2026-07-20 20:37:32 +02:00
43 changed files with 3179 additions and 2058 deletions
+207 -210
View File
@@ -1,312 +1,309 @@
# SharePoint MegaMenu # SharePoint MegaMenu
Barrierearme, dreistufige Portalnavigation auf Basis von SharePoint Managed Metadata für SharePoint Server Subscription Edition und SharePoint Server 2019. Zentrale, barrierearme Portalnavigation aus SharePoint Managed Metadata für SharePoint Server Subscription Edition und SharePoint Server 2019.
| Eigenschaft | Wert | | Eigenschaft | Wert |
|---|---| |---|---|
| Version | 2.0.1 | | Version | 2.2.3 |
| SharePoint Framework | SPFx 1.4.1 | | SharePoint Framework | 1.4.1 |
| Node.js | 8.17.0 | | Node.js für den Build | 8.17.0 |
| Zieloberfläche | Moderne SharePoint-Seiten | | Moderne Seiten | SPFx Application Customizer |
| Klassische Seiten | separate ScriptLink-Variante |
| Paket | `sharepoint/solution/mega-menu.sppkg` | | Paket | `sharepoint/solution/mega-menu.sppkg` |
## Funktionen ## Funktionen
- Dreistufige Navigation aus einem Managed-Metadata-Termset - zentrale Konfiguration pro Site Collection
- Auswahl des Termsets per GUID oder, abwärtskompatibel, per Name - Termset-Auswahl bevorzugt per GUID, alternativ per Name
- Benutzerdefinierte Sortierung aus dem Term Store - drei Navigationsebenen
- Unterstützung der Navigations-Eigenschaften `_Sys_Nav_SimpleLinkUrl`, `_Sys_Nav_TargetUrl` und `_Sys_Nav_HoverText` - zwei Darstellungsmodi: SharePoint-ähnliches Mega Menu und strukturelles Flyout
- Auflösung von `~sitecollection` in Navigations-URLs - benutzerdefinierte Sortierung aus dem Term Store
- Markierung des aktuellen Menüpunktes und seiner übergeordneten Einträge - Unterstützung für `_Sys_Nav_SimpleLinkUrl`, `_Sys_Nav_TargetUrl` und `_Sys_Nav_HoverText`
- Bedienung mit Maus, Tastatur und Touch - Auflösung von `~sitecollection`
- Responsive Darstellung - Markierung des aktuellen Navigationspfads
- Optionales zusätzliches Stylesheet - Tastatur-, Fokus-, Maus- und Touch-Bedienung
- Taxonomy-Cache mit konfigurierbarer Ablaufzeit - responsive Darstellung und Unterstützung reduzierter Bewegung
- Bereinigter SPFx-Lifecycle ohne verbleibende globale Event Listener - Session-Cache mit konfigurierbarer Laufzeit
- Deutsche und englische Statusmeldungen - Debug-Protokollierung ohne Ausgabe im Normalbetrieb
- Unterstützung moderner und klassischer SharePoint-Seiten
## Architektur ## Architektur
```text ```text
UserCustomAction.ClientSideComponentProperties App Catalog
│ └── zentral bereitgestelltes MegaMenu-SPFx-Bundle
▼
MegaMenuApplicationCustomizer Site Collection
│ └── SPSite.UserCustomAction
├── TaxonomyNavigationService ├── ComponentId
│ └── SPTermStorePickerService └── ClientSideComponentProperties
│ └── Default Site Collection Term Store ├── termSetId / termSetName
│ ├── cacheMinutes
└── MegaMenuRenderer ├── menuMode
├── semantische Navigation └── debug
├── Fokus- und Tastatursteuerung
└── responsive Darstellung Moderne Seite Klassische Seite
└── Application Customizer └── ScriptLink + Classic-Runtime
``` ```
Das MegaMenu wird einmal pro Site Collection konfiguriert. Das Paket wird mit `skipFeatureDeployment: true` zentral über den App Catalog bereitgestellt, damit das SPFx-Bundle ohne eine App-Installation in jedem einzelnen Web verfügbar ist. PortalSettings speichert die zentrale Konfiguration im Property Bag des Root Webs. Eine Site-Collection-Action aktiviert das MegaMenu auf dem Root Web sowie auf vorhandenen und zukünftigen Unterwebs. Das MegaMenu selbst enthält keine eigene Administrationsoberfläche. Die Site-Collection-Action ist die einzige Konfigurationsquelle. Es gibt keine versteckte Konfigurationsliste, keine MegaMenu-Konfiguration im Property Bag und keine Web-scoped Kopien. PortalSettings kann diese Action zentral lesen und aktualisieren.
## Voraussetzungen Das SPFx-Paket verwendet `skipFeatureDeployment: true`. Es wird im App Catalog zentral bereitgestellt und nicht in Root Web oder Unterwebs über „App hinzufügen“ installiert.
- SharePoint Server Subscription Edition oder SharePoint Server 2019
- konfigurierter App Catalog
- konfigurierter App Management Service
- ein Termset im Standard-Term-Store der Site Collection
- Leseberechtigung der Benutzer auf den Managed-Metadata-Service
- für Entwicklung und Build: Node.js 8.17.0 und eine zu SPFx 1.4.1 passende npm-Version
## Konfiguration ## Konfiguration
Die UserCustomAction unterstützt folgende `ClientSideComponentProperties`:
```json ```json
{ {
"termSetId": "6f36a1a8-7bd8-4ed0-a22f-e219f4d2eacc", "termSetId": "7948e6f9-7af4-431e-b6fe-328122f2746c",
"termSetName": "Global Navigation", "termSetName": "Global Navigation",
"cacheMinutes": 15, "cacheMinutes": 15,
"cacheVersion": "1",
"menuMode": "megaMenu",
"debug": false "debug": false
} }
``` ```
| Eigenschaft | Typ | Standard | Beschreibung | | Property | Typ | Standard | Beschreibung |
|---|---:|---:|---| |---|---|---|---|
| `termSetId` | GUID | leer | Bevorzugte, eindeutige Identifikation des Termsets | | `termSetId` | String | leer | GUID des Termsets; hat Vorrang vor dem Namen |
| `termSetName` | String | leer | Rückwärtskompatibler Fallback, wenn keine gültige `termSetId` vorhanden ist | | `termSetName` | String | leer | Fallback für bestehende Konfigurationen |
| `cacheMinutes` | Zahl | `15` | Gültigkeit des Session-Caches, zulässig sind 1 bis 1440 Minuten | | `cacheMinutes` | Number | `15` | Cachezeit von 1 bis 1440 Minuten |
| `debug` | Boolean | `false` | Aktiviert zusätzliche Konsolenausgaben | | `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 |
`termSetId` hat Vorrang vor `termSetName`. Für neue Installationen wird die GUID empfohlen, weil Termset-Namen nicht zwingend eindeutig sind. ### Mega Menu
### Kompatibilität mit PortalSettings `"menuMode": "megaMenu"` zeigt die zweite Ebene als breites, spaltenbasiertes Panel. Die dritte Ebene wird als Linkliste unter der jeweiligen Kategorie dargestellt.
PortalSettings verwaltet `termSetId`, `termSetName`, `cacheMinutes` und `debug`. Individuelle Gestaltung wird nicht vom MegaMenu selbst geladen, sondern zentral über die Solution Custom Branding bereitgestellt. ### Flyout
## Termset-Aufbau `"menuMode": "flyout"` zeigt die zweite Ebene als kompaktes Dropdown. Die dritte Ebene öffnet sich seitlich als strukturelles Flyout.
Die Darstellung unterstützt drei Ebenen: ## Lokale Term-Eigenschaften
```text Die folgenden Eigenschaften werden im Term Store als lokale benutzerdefinierte Eigenschaften am jeweiligen Term gepflegt:
Ebene 1: Hauptnavigation
├── Ebene 2: Kategorie
│ ├── Ebene 3: Link
│ └── Ebene 3: Link
└── Ebene 2: Kategorie
```
Für Zieladressen werden die lokalen benutzerdefinierten Eigenschaften des Terms ausgewertet: | 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 |
1. `_Sys_Nav_SimpleLinkUrl` Zusätzlich werden die SharePoint-Navigationseigenschaften `_Sys_Nav_SimpleLinkUrl`, `_Sys_Nav_TargetUrl` und `_Sys_Nav_HoverText` ausgewertet. Unsichere URL-Protokolle werden verworfen.
2. `_Sys_Nav_TargetUrl`
Optional kann `_Sys_Nav_HoverText` als ergänzender Hilfetext verwendet werden. Veraltete Terms werden nicht angezeigt. Terms, die nicht für Tagging verfügbar sind, bleiben für Navigationsszenarien sichtbar.
## Build ## Build
SPFx 1.4.1 verwendet die Legacy-Gulp-Toolchain. Neuere Node-Versionen sind nicht kompatibel.
```powershell ```powershell
npm install npm install
npm run package npm run package
``` ```
Alternativ einzeln: `npm run package` erstellt automatisch beide Ausgaben:
- 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 ```powershell
gulp clean npm run build:classic
gulp bundle --ship
gulp package-solution --ship
``` ```
Das fertige Paket befindet sich anschließend unter: Erwartete Umgebung:
```text
Node.js 8.17.0
npm 6.x
lokales Gulp 3.9.1
```
Das Ergebnis liegt unter:
```text ```text
sharepoint/solution/mega-menu.sppkg sharepoint/solution/mega-menu.sppkg
``` ```
## Deployment ## Moderne Bereitstellung
1. `mega-menu.sppkg` in den App Catalog laden. 1. `mega-menu.sppkg` in den Farm App Catalog hochladen beziehungsweise ersetzen.
2. Die Solution freigeben. 2. Die Lösung zentral für alle Sites verfügbar machen.
3. Die App im Root Web der gewünschten Site Collection installieren. Der MegaMenu Application Customizer wird dort zunächst web-scoped registriert und von PortalSettings erkannt. 3. MegaMenu nicht über „App hinzufügen“ in Inhaltswebsites installieren.
4. Das Termset in PortalSettings konfigurieren. 4. Pro gewünschter Site Collection eine Site-Collection-Action anlegen.
Das folgende Skript ist nur erforderlich, wenn PortalSettings nicht verwendet wird oder eine vollständig skriptbasierte Vorkonfiguration gewünscht ist. Beispiel für das Mega Menu:
Empfohlen mit Termset-GUID:
```powershell ```powershell
.\deployment\add-megamenu.ps1 ` .\deployment\add-megamenu.ps1 `
-SiteUrl 'https://sharepoint/sites/portal' ` -SiteUrl 'http://clshp001/sites/target' `
-TermSetId '6f36a1a8-7bd8-4ed0-a22f-e219f4d2eacc' ` -TermSetId '7948e6f9-7af4-431e-b6fe-328122f2746c' `
-TermSetName 'Global Navigation' ` -TermSetName 'Global Navigation' `
-CacheMinutes 15 -CacheMinutes 15 `
-CacheVersion '1' `
-MenuMode megaMenu
``` ```
Abwärtskompatibel nur mit Namen: Beispiel für das Flyout:
```powershell ```powershell
.\deployment\add-megamenu.ps1 ` .\deployment\add-megamenu.ps1 `
-SiteUrl 'https://sharepoint/sites/portal' ` -SiteUrl 'http://clshp001/sites/target' `
-TermSetName 'Global Navigation' -TermSetId '7948e6f9-7af4-431e-b6fe-328122f2746c' `
-MenuMode flyout `
-EnableDebug
``` ```
Das Skript speichert die Konfiguration zentral unter `PortalSettings.MegaMenu.Configuration`, entfernt alte beziehungsweise doppelte Component IDs und synchronisiert die neue web-scoped Runtime-Bindung idempotent auf alle vorhandenen Webs. Fuer spaeter angelegte Unterwebs kann es ohne Nebenwirkungen erneut ausgefuehrt werden. Das Skript entfernt alte Web- und Site-Registrierungen, bereinigt die frühere Property-Bag-Konfiguration und erstellt genau eine neue Site-Collection-Action.
## Individuelles Branding ## Classic Pages
Zusätzliche Stylesheets werden zentral über die Solution Custom Branding eingebunden. Das MegaMenu besitzt bewusst keine eigene `cssUrl`-Property. Die Classic-Variante bleibt erhalten, weil ein SPFx Application Customizer nur in der modernen Oberfläche ausgeführt wird.
Die wichtigsten CSS-Klassen sind: Das Classic-Paket wird bei `npm run build` und `npm run package` automatisch erzeugt. Es kann bei Bedarf auch separat gebaut werden:
```css ```powershell
#CustomNavigation { } npm run build:classic
#Mega-Menu { }
.mega-menu-top-level { }
.mega-menu-top-item { }
.mega-menu { }
.mega-menu-grid { }
.mega-menu-category { }
.mega-menu-links { }
``` ```
Viele Farben und Abstände können über CSS Custom Properties überschrieben werden, beispielsweise: Ausgabe:
```text
classic/dist/
├── megamenu-classic.css
├── megamenu-services-standalone.js
├── megamenu-classic.js
└── classic-deployment.md
```
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:
```text
src/extensions/megaMenu/MegaMenu.module.scss
```
SPFx kompiliert diese Datei in das Bundle-CSS. Die eigenständige Classic-CSS liegt unter:
```text
classic/megamenu-classic.css
```
Beide Varianten orientieren sich an SharePoint mit Segoe UI, neutralen Flächen, Theme-Farbe für den aktiven Pfad, kompakten Abständen und Fabric-kompatiblen Fokusmarkierungen.
Die wichtigsten CSS-Variablen können durch Custom Branding überschrieben werden:
```css ```css
:root { :root {
--megaMenuNavBackground: #5f6f74; --megaMenuBarHeight: 44px;
--megaMenuNavTextColor: #ffffff; --megaMenuContentWidth: 1200px;
--megaMenuPanelWidth: 1280px; --megaMenuFlyoutWidth: 288px;
--megaMenuZIndex: 6000; --megaMenuZIndex: 6000;
} }
``` ```
## Cache Das MegaMenu lädt bewusst keine externe `cssUrl`-Property. Kundenspezifische Gestaltung gehört in die Solution Custom Branding.
Das geladene Termset wird im `sessionStorage` des Browsers gespeichert. Der Schlüssel ist mit `MegaMenu:TermSet:` namensräumlich getrennt. Nach Ablauf von `cacheMinutes` wird das Termset automatisch neu geladen. ## Diagnose
Für einen sofortigen administrativen Test kann der Session-Cache in den Browser-Entwicklertools gelöscht oder ein neues privates Browserfenster verwendet werden. Mit `"debug": true` schreibt die moderne Extension Meldungen mit diesen Quellen:
## Barrierefreiheit ```text
[MegaMenuApplicationCustomizer]
[TaxonomyNavigationService]
[SPTermStorePickerService]
[MegaMenuRenderer]
```
- semantisches `<nav aria-label="Hauptnavigation">` Geladenes Bundle prüfen:
- native Listen- und Linksemantik
- `aria-expanded` und `aria-haspopup` für aufklappbare Einträge
- `aria-current="page"` für den aktuellen Navigationspfad
- Öffnen über Leertaste oder Pfeil nach unten
- Schließen über Escape
- sichtbare Fokusindikatoren
- Unterstützung von `prefers-reduced-motion` und kontrastreichen Darstellungen
Bewusst wird kein `role="menubar"` verwendet: Eine Portalnavigation ist eine normale Navigation und kein Desktop-Menü-Widget. Dadurch bleiben native Browser- und Screenreader-Erwartungen erhalten. ```javascript
performance.getEntriesByType('resource')
.map(function (entry) { return entry.name; })
.filter(function (url) { return url.toLowerCase().indexOf('mega-menu') >= 0; });
```
## Fehlerdiagnose Aktiven Modus prüfen:
### „Die Navigation ist nicht konfiguriert“ ```javascript
document.getElementById('Mega-Menu').getAttribute('data-menu-mode');
Weder eine gültige `termSetId` noch ein `termSetName` ist gesetzt. UserCustomAction beziehungsweise PortalSettings-Konfiguration prüfen. ```
### „Die Navigation konnte nicht geladen werden“
- Existiert das Termset im Standard-Term-Store der Site Collection?
- Ist die GUID korrekt?
- Haben Benutzer Zugriff auf den Managed-Metadata-Service?
- Enthält das Termset mindestens einen sichtbaren Term?
- Ist der App-Catalog-Eintrag aktuell?
Für weitere technische Details kann vorübergehend `debug: true` gesetzt werden.
Bei aktiviertem Debug-Modus protokolliert MegaMenu den kompletten Startpfad mit den Quellen
`MegaMenuApplicationCustomizer`, `TaxonomyNavigationService`, `SPTermStorePickerService` und
`MegaMenuRenderer`. Dazu gehören die übergebenen Properties, Placeholder-Erkennung,
Taxonomy-Anfrage, Cache-Nutzung, Term-Anzahl, Containerwahl und vollständige Fehlerdetails.
Wenn keine dieser Meldungen erscheint, wurde das MegaMenu-Bundle nicht durch den SPFx-Loader
ausgeführt.
### Änderungen am Termset erscheinen verzögert
Die Navigation wird bis zu `cacheMinutes` im Session-Cache gehalten. Cache leeren oder Ablaufzeit abwarten.
### Zusätzliches CSS wird nicht geladen
- URL und Dateiberechtigungen prüfen.
- Für externe Quellen HTTPS verwenden.
- Browser-Konsole bei aktiviertem Debug-Modus prüfen.
## Upgrade von 1.0.x
- Bestehende `termSetName`-Konfigurationen funktionieren unverändert.
- Der alte unversionierte Session-Cache wird nicht weiterverwendet.
- `cacheMinutes` verwendet ohne Konfiguration den Wert 15.
- Die eigene, nicht verwendete MegaMenu-Einstellungsseite wurde entfernt; PortalSettings ist der zentrale Konfigurationsweg.
- Globale Event Listener werden beim Dispose der Extension entfernt.
- Paket-, npm- und Dokumentationsversion wurden auf 1.1.0 vereinheitlicht.
## Classic Pages
Unter `classic/` befindet sich eine separate Kompatibilitätsimplementierung für klassische SharePoint-Seiten. Sie wird nicht vom SPFx-Bundle ausgeführt und besitzt einen eigenen Deploymentweg. Neue Funktionen sollten zuerst in der modernen SPFx-Variante umgesetzt und anschließend bewusst in die Classic-Variante übernommen werden.
## Projektstruktur ## Projektstruktur
```text ```text
MegaMenu/ MegaMenu/
├── config/ SPFx-Build- und Paketkonfiguration ├── classic/ Classic-Runtime und CSS
├── deployment/ Farm-/Site-Registrierung ├── config/ SPFx-Buildkonfiguration
├── src/ ├── deployment/add-megamenu.ps1 zentrale Site-Registrierung
│ ├── extensions/megaMenu/ Application Customizer und Renderer ├── src/extensions/megaMenu/ Application Customizer, Renderer und SCSS
│ └── services/ Taxonomy- und Datenmodelle ├── src/services/ Taxonomy- und Navigationsdienste
├── classic/ optionale Classic-Kompatibilität ├── build-classic.ps1
├── build-classic.ps1 Classic-Build
├── gulpfile.js ├── gulpfile.js
└── package.json └── package.json
``` ```
## Versionshistorie ## Versionshistorie
### 2.2.1
- Terms mit `_Sys_Nav_ExcludedProviders = GlobalNavigationTaxonomyProvider` werden einschließlich ihres Unterbaums
aus der modernen und klassischen globalen Navigation ausgeblendet.
- Das Cache-Schema wurde erhöht, damit die neue Sichtbarkeitsregel sofort greift.
### 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.
- Neue Property `menuMode` mit `megaMenu` und `flyout`.
- Eigenständiges Flyout-Rendering für zweite und dritte Ebene.
- Classic-Runtime und Classic-CSS auf beide Modi aktualisiert.
- Konfiguration auf eine Site-Collection-Action konsolidiert.
- Veraltete `elements.xml`, Azure-CDN-Konfiguration, Mock- und UserCustomAction-Service-Dateien entfernt.
- Bereitstellungs- und Classic-Buildskripte vereinfacht.
### 2.0.1 ### 2.0.1
- Zentrale SPFx-Bereitstellung mit `skipFeatureDeployment: true`. - Zentrale SPFx-Bereitstellung mit `skipFeatureDeployment: true`.
- Das MegaMenu-Paket muss nicht mehr als App in jedem Unterweb installiert werden. - Keine App-Installation in einzelnen Inhaltswebsites erforderlich.
- Feature-Framework-Registrierung aus dem Paket entfernt; die Aktivierung erfolgt zentral über PortalSettings oder das Deploymentskript.
### 2.0.0 ### 2.0.0
- Neue Application-Customizer-ID `c0abbffb-355d-4d4e-bd38-9e15fb811506`, um alte On-Premises-Manifest- und Loader-Zuordnungen sicher zu umgehen. - Neue Application-Customizer-ID `c0abbffb-355d-4d4e-bd38-9e15fb811506`.
- Eine zentrale MegaMenu-Konfiguration pro Site Collection im Property Bag des Root Webs. - Alte On-Premises-Manifest- und Loader-Zuordnungen umgangen.
- Web-scoped Runtime-Bindungen werden auf Root Web und alle vorhandenen Unterwebs synchronisiert.
- Aktivierungsskript entfernt alte Component IDs und kann fuer neue Unterwebs idempotent erneut ausgefuehrt werden.
### 1.1.4
- Unnötige externe Abhängigkeit zu `@microsoft/sp-lodash-subset` entfernt.
- Terms werden beim Sortieren über eine lokale, SPFx-1.4.1-kompatible Pfadsuche zugeordnet.
- Verhindert, dass der Application Customizer bereits bei der Systemkomponenten-Auflösung übersprungen wird.
### 1.1.3
- Ausführliches, ausschließlich über `debug: true` aktiviertes Lifecycle-Logging ergänzt.
- Taxonomy-Anfragen, Session-Cache, Term-Filterung und Renderer werden nachvollziehbar protokolliert.
- Fehlerausgaben enthalten Name, Meldung und Stacktrace.
### 1.1.2
- `cssUrl` zugunsten der zentralen Custom-Branding-Solution entfernt
- deaktivierte, veraltete oder nicht zum Tagging verfügbare Terms werden ausgeblendet
- Cache-Namespace aktualisiert, damit alte deaktivierte Einträge nicht weiter angezeigt werden
### 1.1.1
- automatische Registrierung des MegaMenu Application Customizers bei der App-Installation
- direkte Erkennung durch PortalSettings ohne vorherigen Skriptaufruf
### 1.1.0
- konsistente Verwendung des Default Site Collection Term Store
- optionale Konfiguration per Termset-GUID
- Cache-TTL und namensräumlich getrennter Session-Cache
- korrekte Anwendung der Deprecated-/Availability-Filter
- Prüfung der Taxonomy-HTTP-Antworten
- Lifecycle-Cleanup für globale Event Listener
- korrigierte Navigationssemantik und aktive Screenreader-Statusmeldungen
- Validierung externer CSS-URLs
- sichtbare Konfigurations- und Ladefehler
- Entfernung des ungenutzten lokalen Settings-Panels
- vereinheitlichte Projektversion und neue Build-/Deployment-Dokumentation
+59
View File
@@ -0,0 +1,59 @@
# MegaMenu – Arbeitsplan Version 2.2.3
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.
+21 -259
View File
@@ -1,272 +1,34 @@
# Build script for classic SharePoint version
# Creates a standalone distribution with proper service extraction
Write-Host "Building MegaMenu Classic Distribution..." -ForegroundColor Green
# Ensure output directory exists
$outputPath = ".\classic"
if (-not (Test-Path $outputPath)) {
New-Item -ItemType Directory -Path $outputPath -Force
}
# Step 1: Build the modern SPFx version to ensure CSS is up to date
Write-Host "Building SPFx version for CSS..." -ForegroundColor Yellow
try {
gulp bundle --ship
Write-Host "✓ SPFx build completed" -ForegroundColor Green
} catch {
Write-Host "⚠ SPFx build failed, using existing CSS" -ForegroundColor Yellow
}
# Step 2: Copy CSS from the built version (same CSS for both classic and modern)
Write-Host "Copying CSS files..." -ForegroundColor Yellow
$cssSource = ".\lib\extensions\megaMenu\MegaMenu.css"
$cssTarget = ".\classic\MegaMenu.css"
if (Test-Path $cssSource) {
Copy-Item $cssSource $cssTarget -Force
Write-Host "✓ CSS copied from SPFx build" -ForegroundColor Green
} else {
Write-Host "⚠ Built CSS not found, keeping existing classic CSS" -ForegroundColor Yellow
}
# Step 3: Verify standalone services exist
$servicesFile = ".\classic\megamenu-services-standalone.js"
if (Test-Path $servicesFile) {
Write-Host "✓ Standalone services found" -ForegroundColor Green
} else {
Write-Host "✗ Missing megamenu-services-standalone.js" -ForegroundColor Red
Write-Host " Please ensure this file exists in the classic folder" -ForegroundColor Yellow
}
# Step 4: Verify classic wrapper exists
$wrapperFile = ".\classic\megamenu-classic.js"
if (Test-Path $wrapperFile) {
Write-Host "✓ Classic wrapper found" -ForegroundColor Green
} else {
Write-Host "✗ Missing megamenu-classic.js" -ForegroundColor Red
}
# Step 5: Create complete deployment package info
Write-Host "Creating deployment documentation..." -ForegroundColor Yellow
$deploymentContent = @"
# SharePoint MegaMenu Classic Deployment Guide
Version 1.0.2 - Classic SharePoint Support
## Architecture Overview
This classic version reuses the exact same service logic as the modern SPFx version:
- **Same CSS** (MegaMenu.css from SPFx build)
- **Same Services** (TaxonomyNavigationService, MegaMenuRenderer extracted to standalone)
- **Same Configuration** (Reads UserCustomAction properties like SPFx version)
- **Same Menu Structure** (Identical 3-level navigation rendering)
## Required Files
### Core Files (Required)
1. **MegaMenu.css** - Main stylesheet (shared with SPFx version)
2. **megamenu-services-standalone.js** - Extracted services without SPFx dependencies
3. **megamenu-classic.js** - Classic SharePoint integration wrapper
### Dependencies (SharePoint Built-in)
- SP.js (SharePoint JSOM)
- SP.Taxonomy.js (Taxonomy/Metadata services)
- jQuery (usually available in SharePoint)
## Deployment Methods
### Method 1: Site Assets (Recommended)
````html
<!-- Add to master page before closing </head> tag -->
<link rel="stylesheet" href="/SiteAssets/megamenu/MegaMenu.css" />
<script src="/SiteAssets/megamenu/megamenu-services-standalone.js"></script>
<script src="/SiteAssets/megamenu/megamenu-classic.js"></script>
````
### Method 2: Style Library
````html
<link rel="stylesheet" href="/Style Library/megamenu/MegaMenu.css" />
<script src="/Style Library/megamenu/megamenu-services-standalone.js"></script>
<script src="/Style Library/megamenu/megamenu-classic.js"></script>
````
### Method 3: CDN or Document Library
Upload to any accessible location and reference accordingly.
## Configuration
### Automatic Configuration Reading
The classic version automatically reads the same UserCustomAction configuration as the SPFx version:
- Term Set Name (default: "Navigation")
- External CSS URL (optional)
### Setting Configuration
Use one of these methods:
**Option A: Via Modern SPFx Version**
1. Install modern SPFx version on any modern SharePoint site
2. Use the settings panel to configure termset and CSS URL
3. Configuration is stored site-wide and will be read by classic version
**Option B: Via PowerShell (Advanced)**
````powershell
# Connect to SharePoint site
Add-PnPCustomAction -Name "MegaMenuConfig" -Location "ClientSideExtension.ApplicationCustomizer" `
-ClientSideComponentId "c0abbffb-355d-4d4e-bd38-9e15fb811506" `
-ClientSideComponentProperties '{"termSetName":"Your Navigation Termset","cssUrl":"/SiteAssets/custom-menu.css"}'
````
## Testing & Validation
### 1. Verify Dependencies
Open browser console and check:
````javascript
// These should all return objects/functions:
typeof SP
typeof SP.Taxonomy
typeof window.MegaMenuServices
typeof window.MegaMenuClassic
````
### 2. Enable Debug Mode
````javascript
window.MegaMenuConfig = { debug: true };
````
Then reload page to see console logging.
### 3. Manual Initialization
If auto-loading fails:
````javascript
window.MegaMenuClassic.reload();
````
## Troubleshooting
### Common Issues
1. **"SP is undefined"** - SP.js not loaded yet, classic wrapper will retry
2. **"MegaMenuServices not found"** - Include megamenu-services-standalone.js first
3. **"No menu items found"** - Check term set name and user permissions
4. **Menu renders but no styling** - Verify CSS path and inclusion
### Debug Steps
1. Check browser network tab for 404s on CSS/JS files
2. Verify SharePoint libraries load: SP.js, SP.Taxonomy.js
3. Check term store permissions and term set existence
4. Enable debug mode for detailed console logging
### Permissions Required
- Read access to term store
- Read access to site UserCustomActions (for configuration)
- Basic site user permissions
## Differences from SPFx Version
| Feature | SPFx Version | Classic Version |
|---------|-------------|----------------|
| Deployment | App Catalog | Master Page/Web Part |
| Configuration | Settings Panel | PowerShell or via SPFx |
| Auto-loading | Yes | Manual inclusion |
| Modern Sites | Yes | No |
| Classic Sites | No | Yes |
| SharePoint Online | Yes | Yes |
| SharePoint On-Premises | 2019+ | 2013+ |
## File Structure After Deployment
````
/SiteAssets/megamenu/
├── MegaMenu.css (from SPFx build)
├── megamenu-services-standalone.js (extracted services)
└── megamenu-classic.js (classic wrapper)
````
"@
Set-Content -Path ".\classic\deployment-guide.md" -Value $deploymentContent -Encoding UTF8
# Step 6: Create a simple deployment script template
$deployScript = @"
# PowerShell script to deploy MegaMenu Classic to SharePoint
# Requires PnP PowerShell module: Install-Module PnP.PowerShell
param( param(
[Parameter(Mandatory=`$true)] [string]$OutputPath = ''
[string]`$SiteUrl,
[string]`$LibraryName = "Site Assets",
[string]`$FolderName = "megamenu"
) )
Write-Host "Deploying MegaMenu Classic to `$SiteUrl" -ForegroundColor Green $sourcePath = Join-Path $PSScriptRoot 'classic'
$resolvedOutputPath = if ([string]::IsNullOrWhiteSpace($OutputPath)) {
# Connect to SharePoint Join-Path $sourcePath 'dist'
Connect-PnPOnline -Url `$SiteUrl -Interactive }
else {
# Create folder if it doesn't exist $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
try {
Get-PnPFolder -Url "`$LibraryName/`$FolderName" -ErrorAction Stop
Write-Host "✓ Folder exists: `$LibraryName/`$FolderName" -ForegroundColor Green
} catch {
Add-PnPFolder -Name `$FolderName -Folder `$LibraryName
Write-Host "✓ Created folder: `$LibraryName/`$FolderName" -ForegroundColor Green
} }
# Upload files if (-not (Test-Path -LiteralPath $resolvedOutputPath)) {
`$files = @( New-Item -ItemType Directory -Path $resolvedOutputPath -Force | Out-Null
@{Source=".\MegaMenu.css"; Target="`$LibraryName/`$FolderName/MegaMenu.css"},
@{Source=".\megamenu-services-standalone.js"; Target="`$LibraryName/`$FolderName/megamenu-services-standalone.js"},
@{Source=".\megamenu-classic.js"; Target="`$LibraryName/`$FolderName/megamenu-classic.js"}
)
foreach (`$file in `$files) {
if (Test-Path `$file.Source) {
Add-PnPFile -Path `$file.Source -Folder "`$LibraryName/`$FolderName"
Write-Host "✓ Uploaded: `$(`$file.Source)" -ForegroundColor Green
} else {
Write-Host "✗ File not found: `$(`$file.Source)" -ForegroundColor Red
}
} }
Write-Host "`nDeployment completed!" -ForegroundColor Green
Write-Host "Add these lines to your master page:" -ForegroundColor Yellow
Write-Host "<link rel=`"stylesheet`" href=`"/`$LibraryName/`$FolderName/MegaMenu.css`" />" -ForegroundColor Gray
Write-Host "<script src=`"/`$LibraryName/`$FolderName/megamenu-services-standalone.js`"></script>" -ForegroundColor Gray
Write-Host "<script src=`"/`$LibraryName/`$FolderName/megamenu-classic.js`"></script>" -ForegroundColor Gray
"@
Set-Content -Path ".\classic\deploy-to-sharepoint.ps1" -Value $deployScript -Encoding UTF8
# Step 7: Summary
Write-Host "`n" + "="*60 -ForegroundColor Cyan
Write-Host "MegaMenu Classic Build Completed!" -ForegroundColor Green
Write-Host "="*60 -ForegroundColor Cyan
Write-Host "`nDistribution files created:" -ForegroundColor Yellow
$files = @( $files = @(
@{Name="MegaMenu.css"; Desc="Shared stylesheet from SPFx"}, 'megamenu-classic.css',
@{Name="megamenu-services-standalone.js"; Desc="Extracted services (no SPFx deps)"}, 'megamenu-services-standalone.js',
@{Name="megamenu-classic.js"; Desc="Classic SharePoint wrapper"}, 'megamenu-classic.js',
@{Name="deployment-guide.md"; Desc="Complete deployment documentation"}, 'classic-deployment.md'
@{Name="deploy-to-sharepoint.ps1"; Desc="PowerShell deployment script"}
) )
foreach ($file in $files) { foreach ($file in $files) {
$exists = Test-Path ".\classic\$($file.Name)" $source = Join-Path $sourcePath $file
$status = if ($exists) { "✓" } else { "✗" } if (-not (Test-Path -LiteralPath $source)) {
$color = if ($exists) { "Green" } else { "Red" } throw ('Classic-Datei fehlt: ' + $source)
Write-Host " $status $($file.Name)" -ForegroundColor $color -NoNewline }
Write-Host " - $($file.Desc)" -ForegroundColor Gray
Copy-Item -LiteralPath $source -Destination (Join-Path $resolvedOutputPath $file) -Force
Write-Host ('Kopiert: ' + $file) -ForegroundColor Green
} }
Write-Host "`nNext steps:" -ForegroundColor Yellow Write-Host ('Classic-Paket erstellt: ' + $resolvedOutputPath) -ForegroundColor Green
Write-Host "1. Review deployment-guide.md for instructions" -ForegroundColor White
Write-Host "2. Use deploy-to-sharepoint.ps1 for automated deployment" -ForegroundColor White
Write-Host "3. Configure termset via modern SPFx version or PowerShell" -ForegroundColor White
Write-Host "4. Test on classic SharePoint pages" -ForegroundColor White
Write-Host "`nArchitectural Achievement:" -ForegroundColor Cyan
Write-Host "✓ Same CSS as SPFx version" -ForegroundColor Green
Write-Host "✓ Same service logic (extracted to standalone)" -ForegroundColor Green
Write-Host "✓ Same configuration system" -ForegroundColor Green
Write-Host "✓ Same menu rendering output" -ForegroundColor Green
Write-Host "✓ No SPFx dependencies in classic version" -ForegroundColor Green
+41 -192
View File
@@ -1,216 +1,65 @@
# MegaMenu für klassische SharePoint-Seiten # MegaMenu für klassische SharePoint-Seiten
Diese Dateien ermöglichen die Verwendung des MegaMenus auf klassischen SharePoint-Seiten (2019/SE) ohne SPFx Framework. Die Classic-Variante verwendet dieselbe Site-Collection-Konfiguration wie der moderne Application Customizer. Sie unterstützt `megaMenu` und `flyout`, wird aber unabhängig von SPFx über ScriptLink geladen.
## 📁 Dateien ## Dateien
- `megamenu-classic.js` - Hauptlogik für klassische Seiten - `megamenu-classic.css`
- `megamenu-classic.css` - Styling für klassische Seiten - `megamenu-services-standalone.js`
- `classic-deployment.md` - Diese Dokumentation - `megamenu-classic.js`
## 🚀 Installation Mit `build-classic.ps1` werden die Dateien nach `classic/dist` kopiert.
### Option 1: Master Page (empfohlen) ## Konfiguration
1. **Dateien hochladen:** Die Classic-Variante liest die `ClientSideComponentProperties` der zentralen MegaMenu-Site-Action:
```
/SiteAssets/megamenu/megamenu-classic.js
/SiteAssets/megamenu/megamenu-classic.css
```
2. **Master Page bearbeiten:** ```json
```html {
<!-- In den <head> Bereich --> "termSetId": "7948e6f9-7af4-431e-b6fe-328122f2746c",
<link rel="stylesheet" type="text/css" href="/SiteAssets/megamenu/megamenu-classic.css" /> "termSetName": "Global Navigation",
"cacheMinutes": 15,
<!-- Vor dem schließenden </body> Tag --> "cacheVersion": "1",
<script type="text/javascript" src="/SiteAssets/megamenu/megamenu-classic.js"></script> "menuMode": "megaMenu",
``` "debug": false
}
### Option 2: ScriptLink über PowerShell
```powershell
# CSS hinzufügen
$web = Get-SPWeb "http://your-site-url"
$web.AlternateCSSUrl = "/SiteAssets/megamenu/megamenu-classic.css"
$web.Update()
# JavaScript als Custom Action hinzufügen
$customAction = $web.UserCustomActions.Add()
$customAction.Location = "ScriptLink"
$customAction.ScriptSrc = "/SiteAssets/megamenu/megamenu-classic.js"
$customAction.Sequence = 1000
$customAction.Update()
$web.Update()
``` ```
### Option 3: Content Editor Web Part `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:
```html ```html
<link rel="stylesheet" type="text/css" href="/SiteAssets/megamenu/megamenu-classic.css" /> <link rel="stylesheet" href="/SiteAssets/megamenu/megamenu-classic.css" />
<script type="text/javascript" src="/SiteAssets/megamenu/megamenu-classic.js"></script> <script src="/SiteAssets/megamenu/megamenu-services-standalone.js"></script>
<script src="/SiteAssets/megamenu/megamenu-classic.js"></script>
``` ```
## ⚙️ Konfiguration Die Einbindung kann über die Masterpage oder eine Site-scoped ScriptLink-CustomAction erfolgen. Der moderne SPFx Application Customizer wird auf klassischen Seiten nicht ausgeführt.
### Basis-Konfiguration ## Optionaler Container
Vor dem Laden kann ein anderer Zielcontainer gesetzt werden:
```html ```html
<script type="text/javascript"> <script>
// Konfiguration vor dem Laden des Scripts setzen window.MegaMenuConfig = {
window.MegaMenuConfig = { containerId: 's4-titlerow',
termSetName: 'Navigation', // Name des Taxonomy Term Sets debug: true
cssUrl: '', // Optional: Externe CSS-Datei };
containerId: 's4-titlerow', // SharePoint Container ID
debug: true // Debug-Modus aktivieren
};
</script> </script>
<script type="text/javascript" src="/SiteAssets/megamenu/megamenu-classic.js"></script>
``` ```
### Container IDs für verschiedene SharePoint-Versionen ## Diagnose
| SharePoint Version | Container ID | Beschreibung |
|-------------------|--------------|-------------|
| 2019 | `s4-titlerow` | Standard Titel-Bereich |
| SE | `suiteBarTop` | Suite Bar Bereich |
| Custom | `custom-menu-container` | Eigener Container |
### Term Set Konfiguration
Das MegaMenu liest die Navigation aus einem Managed Metadata Term Set:
```
Navigation (Term Set)
├── Produkte (Level 1)
│ ├── Software (Level 2)
│ │ ├── Office 365 (Level 3) → URL
│ │ └── SharePoint (Level 3) → URL
│ └── Hardware (Level 2)
│ ├── Laptops (Level 3) → URL
│ └── Server (Level 3) → URL
└── Services (Level 1)
└── Consulting (Level 2)
└── SharePoint Beratung (Level 3) → URL
```
## 🎨 Anpassungen
### CSS Customization
```css
/* Eigene Farben */
#Mega-Menu-Classic {
background: #your-color;
}
#Mega-Menu > ul > li > span[role="menuitem"] {
color: #your-text-color;
}
/* Eigene Schriftarten */
#Mega-Menu {
font-family: 'Your Font', Arial, sans-serif;
}
```
### JavaScript Events
```javascript ```javascript
// Nach der Initialisierung eigene Logik hinzufügen typeof window.MegaMenuServices
document.addEventListener('DOMContentLoaded', function() { typeof window.MegaMenuClassic
// Warten bis MegaMenu geladen ist window.MegaMenuClassic.reload()
setTimeout(function() {
if (window.MegaMenuClassic) {
console.log('MegaMenu ist bereit!');
// Eigene Anpassungen hier...
}
}, 1000);
});
``` ```
## 🔧 Erweiterte Konfiguration Benutzer benötigen Leserechte auf den verwendeten Managed-Metadata-Termstore.
### Custom URL Mapping
Die URLs für die Navigation-Links können angepasst werden:
```javascript
// Überschreibe die getTermUrl Funktion
window.MegaMenuGetTermUrl = function(term) {
var termName = term.get_name();
// Eigene URL-Logik
return '/custom-pages/' + termName.toLowerCase() + '.aspx';
};
```
### Mehrsprachigkeit
```javascript
window.MegaMenuConfig = {
termSetName: _spPageContextInfo.currentUICultureName === 'de-DE' ? 'Navigation_DE' : 'Navigation_EN',
// ... andere Konfigurationen
};
```
## 🐛 Troubleshooting
### Häufige Probleme
**Problem:** Menü wird nicht angezeigt
**Lösung:**
- Browser-Konsole auf Fehler prüfen
- Taxonomy Term Set Name überprüfen
- Berechtigungen für Term Store prüfen
**Problem:** JavaScript-Fehler "SP is not defined"
**Lösung:**
- Script erst nach SharePoint-Bibliotheken laden
- `SP.SOD.executeFunc` verwenden
**Problem:** Styling funktioniert nicht
**Lösung:**
- CSS-Pfad überprüfen
- Cache leeren
- CSS-Spezifität erhöhen
### Debug-Modus
```javascript
window.MegaMenuConfig = {
debug: true, // Aktiviert Console-Logging
// ... andere Optionen
};
```
## 📝 Browser-Unterstützung
- Internet Explorer 11+
- Microsoft Edge (alle Versionen)
- Chrome 60+
- Firefox 55+
- Safari 12+
## ⚠️ Wichtige Hinweise
1. **Berechtigungen:** Benutzer benötigen Leserechte auf den Term Store
2. **Performance:** Bei großen Term Sets kann das Laden länger dauern
3. **Caching:** SharePoint cached Taxonomy-Daten - Änderungen können verzögert sichtbar werden
4. **Responsive:** Das Menü ist für mobile Geräte optimiert
## 🔄 Migration von SPFx Version
Falls Sie von der SPFx-Version migrieren:
1. SPFx ApplicationCustomizer deaktivieren
2. Klassische Dateien hochladen und einbinden
3. Konfiguration anpassen (gleiche Term Sets verwendbar)
4. Testen und CSS bei Bedarf anpassen
## 📞 Support
Bei Problemen:
1. Debug-Modus aktivieren
2. Browser-Konsole prüfen
3. Term Set Struktur validieren
4. Dateipfade und Berechtigungen überprüfen
+386 -43
View File
@@ -1,71 +1,414 @@
/** /* SharePoint MegaMenu 2.2.3 - Classic pages */
* MegaMenu CSS for Classic SharePoint Pages :root {
* Version: 1.0.2 --megaMenuBarHeight: 44px;
* --megaMenuContentWidth: 1200px;
* This file imports the exact same CSS as the modern SPFx version --megaMenuFlyoutWidth: 288px;
* to ensure 100% visual consistency. --megaMenuZIndex: 6000;
* }
* Usage:
* 1. Copy the compiled MegaMenu.css from src/extensions/megaMenu/ to your SharePoint assets
* 2. Reference it in your master page or via alternate CSS URL:
* <link rel="stylesheet" href="/SiteAssets/MegaMenu.css" />
*
* This file provides additional classic-specific adjustments if needed.
*/
/*
* The main MegaMenu.css should be loaded first!
* This file only contains classic SharePoint specific overrides.
*/
/* Classic SharePoint specific container adjustments */
.megamenu-classic-container { .megamenu-classic-container {
/* Insert after s4-titlerow or other SharePoint containers */ position: relative;
box-sizing: border-box;
width: 100%; width: 100%;
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow: visible;
border-top: 1px solid #edebe9;
border-bottom: 1px solid #edebe9;
z-index: var(--megaMenuZIndex);
font-family: "Segoe UI", Arial, sans-serif;
}
.mega-menu-main {
position: relative;
box-sizing: border-box;
width: 100%;
min-height: var(--megaMenuBarHeight);
overflow: visible;
border-top: 0;
border-bottom: 0;
background: #fff;
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;
box-sizing: border-box;
width: 100%;
max-width: var(--megaMenuContentWidth);
min-height: var(--megaMenuBarHeight);
margin: 0 auto;
padding: 0 24px;
list-style: none;
}
.mega-menu-top-item {
position: static;
display: flex;
align-items: stretch;
margin: 0;
padding: 0;
} }
/* Ensure proper z-index in classic SharePoint context */ .mega-menu-top-item:hover,
#Mega-Menu { .mega-menu-top-item:focus-within,
.mega-menu-top-item:active,
.mega-menu-top-item.is-open,
.mega-menu-top-item.is-active {
background: #f3f2f1;
color: #323130;
}
.mega-menu-mode-flyout .mega-menu-top-item {
position: relative; position: relative;
z-index: 999; /* Below SharePoint dialogs but above content */ }
.menu-item-link,
.menu-item-text {
position: relative;
display: flex;
align-items: center;
box-sizing: border-box;
min-height: var(--megaMenuBarHeight);
padding: 0 16px;
border: 0;
background: transparent;
color: #323130;
cursor: pointer;
font-size: 14px;
line-height: 20px;
text-decoration: none;
white-space: nowrap;
outline: none;
}
.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;
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-toggle:hover,
.menu-item-toggle[aria-expanded="true"] {
background: transparent;
color: inherit;
text-decoration: none;
}
.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;
outline-offset: -2px;
} }
.mega-menu { .mega-menu {
z-index: 1000; /* Above the main menu */ position: absolute;
top: 100%;
right: 0;
left: 0;
display: none;
box-sizing: border-box;
border: 1px solid #edebe9;
border-top: 0;
background: #fff;
box-shadow: 0 8px 24px rgba(0, 0, 0, .16);
color: #323130;
z-index: calc(var(--megaMenuZIndex) + 1);
} }
/* Classic SharePoint ribbon compatibility */ .mega-menu.js-open {
body.ms-backgroundImage #Mega-Menu { display: block;
/* Adjust if SharePoint has background images */ }
.mega-menu-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
box-sizing: border-box;
width: 100%;
max-width: var(--megaMenuContentWidth);
margin: 0 auto;
padding: 24px;
gap: 24px 32px;
}
.mega-menu-category {
min-width: 0;
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;
border-bottom: 1px solid #edebe9;
}
.mega-menu-category h3 a,
.mega-menu-category h3 span {
display: block;
color: #323130;
font-size: 14px;
font-weight: 600;
line-height: 20px;
text-decoration: none;
}
.mega-menu-category h3 a:hover {
color: #0078d4;
text-decoration: underline;
}
.mega-menu-category ul {
margin: 0;
padding: 0;
list-style: none;
}
.mega-menu-category li {
margin: 0;
padding: 0;
}
.mega-menu-category li a {
display: block;
margin: 0 -8px;
padding: 6px 8px;
color: #605e5c;
font-size: 14px;
line-height: 20px;
text-decoration: none;
}
.mega-menu-category li a:hover {
background: #f3f2f1;
color: #323130;
}
.mega-menu-flyout {
right: auto;
width: var(--megaMenuFlyoutWidth);
min-width: 240px;
border-top: 1px solid #edebe9;
}
.flyout-list {
box-sizing: border-box;
width: 100%;
margin: 0;
padding: 8px 0;
background: #fff;
list-style: none;
}
.flyout-item {
position: relative; position: relative;
margin: 0;
padding: 0;
} }
/* Ensure settings panel works in classic mode too */ .flyout-link {
.mm-settings-panel { position: relative;
z-index: 4001; /* Above everything else */ display: flex;
align-items: center;
box-sizing: border-box;
width: 100%;
min-height: 36px;
padding: 8px 36px 8px 16px;
border: 0;
background: #fff;
color: #323130;
cursor: pointer;
font-size: 14px;
line-height: 20px;
text-decoration: none;
outline: none;
} }
/* Classic SharePoint v4.master specific adjustments */ .flyout-item:hover > .flyout-link,
.v4master #Mega-Menu { .flyout-item:focus-within > .flyout-link,
/* Any v4.master specific styles if needed */ .flyout-item:active > .flyout-link {
background: #f3f2f1;
color: #323130;
} }
/* Classic SharePoint seattle.master specific adjustments */ .flyout-toggle {
.seattle #Mega-Menu { position: absolute;
/* Any seattle.master specific styles if needed */ 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;
} }
/* Responsive adjustments for classic SharePoint layouts */ .flyout-toggle > span::after {
@media (max-width: 1024px) { content: "";
.megamenu-classic-container { display: block;
/* Classic SharePoint is often used on older devices */ width: 6px;
overflow-x: auto; height: 6px;
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;
left: 100%;
display: none;
width: var(--megaMenuFlyoutWidth);
border: 1px solid #edebe9;
box-shadow: 0 8px 24px rgba(0, 0, 0, .16);
z-index: calc(var(--megaMenuZIndex) + 2);
}
.flyout-item:hover > .flyout-level-3,
.flyout-item:focus-within > .flyout-level-3,
.flyout-item.is-open > .flyout-level-3 {
display: block;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@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 {
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: 0; }
.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;
}
.flyout-level-3 {
position: static;
display: none;
width: 100%;
border: 0;
box-shadow: none;
padding-left: 16px;
}
.flyout-item.is-open > .flyout-level-3 { display: block; }
} }
/* Print styles for classic SharePoint */
@media print { @media print {
#Mega-Menu, #Mega-Menu,
.megamenu-classic-container { .megamenu-classic-container {
+70 -50
View File
@@ -1,6 +1,6 @@
/** /**
* SharePoint MegaMenu for Classic Pages * SharePoint MegaMenu for Classic Pages
* Version: 1.0.2 * Version: 2.2.0
* *
* This version uses standalone services that replicate the SPFx logic * This version uses standalone services that replicate the SPFx logic
* without requiring the SharePoint Framework. * without requiring the SharePoint Framework.
@@ -21,10 +21,44 @@
var MEGAMENU_UCA_ID = 'c0abbffb-355d-4d4e-bd38-9e15fb811506'; // Same as in MegaMenuApplicationCustomizer.ts var MEGAMENU_UCA_ID = 'c0abbffb-355d-4d4e-bd38-9e15fb811506'; // Same as in MegaMenuApplicationCustomizer.ts
var config = window.MegaMenuConfig || { var config = window.MegaMenuConfig || {};
containerId: 's4-titlerow', config.containerId = config.containerId || 's4-titlerow';
debug: false 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) { function log(message, data) {
if (config.debug && console && console.log) { if (config.debug && console && console.log) {
@@ -84,44 +118,30 @@
return response.json(); return response.json();
}) })
.then(function(data) { .then(function(data) {
if (data && data.value && data.value.length > 0) { var actions = data && data.value
var uca = data.value[0]; ? data.value
: (data && data.d && data.d.results ? data.d.results : []);
if (actions.length > 0) {
var uca = actions[0];
try { try {
var props = JSON.parse(uca.ClientSideComponentProperties); var props = JSON.parse(uca.ClientSideComponentProperties);
log('Configuration found', props); log('Configuration found', props);
callback(props); callback(props);
} catch (e) { } catch (e) {
log('Error parsing UserCustomAction properties: ' + e.message); log('Error parsing UserCustomAction properties: ' + e.message);
callback({ termSetName: 'Navigation', cssUrl: '' }); callback({});
} }
} else { } else {
log('No UserCustomAction found - using defaults'); log('No UserCustomAction found - using defaults');
callback({ termSetName: 'Navigation', cssUrl: '' }); callback({});
} }
}) })
.catch(function(error) { .catch(function(error) {
log('Error reading configuration: ' + error.message); log('Error reading configuration: ' + error.message);
callback({ termSetName: 'Navigation', cssUrl: '' }); callback({});
}); });
} }
// Load external CSS (same as SPFx version)
function loadExternalCSS(cssUrl) {
if (!cssUrl) return;
var link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = cssUrl;
link.onerror = function() {
log('Failed to load external CSS: ' + cssUrl);
};
link.onload = function() {
log('External CSS loaded successfully: ' + cssUrl);
};
document.head.appendChild(link);
}
// Main initialization // Main initialization
function initMegaMenu() { function initMegaMenu() {
log('Initializing MegaMenu for classic SharePoint...'); log('Initializing MegaMenu for classic SharePoint...');
@@ -133,12 +153,15 @@
} }
readMegaMenuConfiguration(function(props) { readMegaMenuConfiguration(function(props) {
config.debug = props.debug === true || config.debug === true;
log('Using configuration:', props); log('Using configuration:', props);
var strings = getStrings();
// Load external CSS if specified var termSetIdentifier = props.termSetId || props.termSetName;
if (props.cssUrl) { if (!termSetIdentifier) {
loadExternalCSS(props.cssUrl); showStatus(strings.missing);
return;
} }
var loadingContainer = showStatus(strings.loading);
// Create context // Create context
var context = createClassicContext(); var context = createClassicContext();
@@ -146,13 +169,25 @@
// Create taxonomy service using standalone implementation // Create taxonomy service using standalone implementation
var taxonomyService = new window.MegaMenuServices.TaxonomyNavigationService( var taxonomyService = new window.MegaMenuServices.TaxonomyNavigationService(
context, context,
props.termSetName termSetIdentifier,
{
cacheMinutes: props.cacheMinutes,
cacheVersion: props.cacheVersion,
languageLcid: getLanguageLcid()
}
); );
// Load menu items // Load menu items
taxonomyService.getMenuItems() taxonomyService.getMenuItems()
.then(function(menuItems) { .then(function(menuItems) {
log('Menu items loaded:', 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 // Find target container
var container = document.getElementById(config.containerId); var container = document.getElementById(config.containerId);
@@ -179,9 +214,8 @@
var renderer = new window.MegaMenuServices.MegaMenuRenderer( var renderer = new window.MegaMenuServices.MegaMenuRenderer(
context, context,
menuItems, menuItems,
function(updatedProps) { props.menuMode === 'flyout' ? 'flyout' : 'megaMenu',
log('Properties updated (not persisted in classic mode):', updatedProps); strings
}
); );
// Render the menu // Render the menu
@@ -191,21 +225,7 @@
}) })
.catch(function(error) { .catch(function(error) {
console.error('[MegaMenu] Error loading menu items:', error); console.error('[MegaMenu] Error loading menu items:', error);
showStatus(strings.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);
}
}
}); });
}); });
} }
+329 -174
View File
@@ -30,6 +30,10 @@
constructor(term, depth, siteCollectionUrl) { constructor(term, depth, siteCollectionUrl) {
this.title = term.Name; this.title = term.Name;
this.url = this._getNavigationUrl(term, siteCollectionUrl); 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.items = [];
this.pathDepth = this._calculateDepth(term.PathOfTerm); this.pathDepth = this._calculateDepth(term.PathOfTerm);
this.parentId = this._getParentId(term.PathOfTerm); this.parentId = this._getParentId(term.PathOfTerm);
@@ -51,14 +55,32 @@
} }
_getNavigationUrl(term, siteCollectionUrl) { _getNavigationUrl(term, siteCollectionUrl) {
// Extract URL from term properties const properties = term.LocalCustomProperties || {};
if (term.LocalCustomProperties && term.LocalCustomProperties._Sys_Nav_SimpleLinkUrl) { const rawUrl = properties._Sys_Nav_SimpleLinkUrl || properties._Sys_Nav_TargetUrl;
return term.LocalCustomProperties._Sys_Nav_SimpleLinkUrl; if (!rawUrl) {
return undefined;
} }
if (/[\u0000-\u001f\u007f]/.test(String(rawUrl))) {
return undefined;
}
const resolved = rawUrl.indexOf('~sitecollection') === 0
? siteCollectionUrl + rawUrl.substring('~sitecollection'.length)
: rawUrl;
const protocol = String(resolved).match(/^([a-z][a-z0-9+.-]*):/i);
if (protocol && ['http', 'https', 'mailto'].indexOf(protocol[1].toLowerCase()) < 0) {
return undefined;
}
return resolved;
}
// Fallback: generate URL based on term name _asBoolean(value) {
const termName = term.Name.toLowerCase().replace(/[^a-z0-9]/g, '-'); return value === true || ['true', '1', 'yes', 'ja'].indexOf(String(value || '').toLowerCase()) >= 0;
return `${siteCollectionUrl}/pages/${termName}.aspx`; }
_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,49 +89,90 @@
// =========================================== // ===========================================
class TaxonomyNavigationService { class TaxonomyNavigationService {
constructor(context, termSetName) { constructor(context, termSetIdentifier, options) {
this.context = context; this.context = context;
this.termSetName = termSetName; this.termSetIdentifier = termSetIdentifier;
this.options = options || {};
this._siteCollectionUrl = context.pageContext.site.absoluteUrl; 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() { async getMenuItems() {
console.log('[TaxonomyService] Loading terms for:', this.termSetName); 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 { try {
const termSet = await this._loadTermSet(this.termSetName); const termSet = await this._loadTermSet(this.termSetIdentifier);
this._setCache(termSet);
return this._processTerms(termSet); return this._processTerms(termSet);
} catch (error) { } catch (error) {
console.error('[TaxonomyService] Error loading terms:', 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;
} }
} }
_loadTermSet(termSetName) { _cacheKey() {
const normalize = value => encodeURIComponent(String(value || '').trim().toLowerCase());
return 'MegaMenu:TermSet:v4:' + normalize(this._siteCollectionUrl) + ':' + normalize(this.termSetIdentifier) + ':' + this._languageLcid + ':' + normalize(this._cacheVersion);
}
_getCache(allowExpired) {
try {
const serialized = window.sessionStorage.getItem(this._cacheKey());
if (!serialized) return null;
const entry = JSON.parse(serialized);
if (!entry || !entry.value || !entry.expiresAt) {
window.sessionStorage.removeItem(this._cacheKey());
return null;
}
return entry.expiresAt > Date.now() || allowExpired ? entry.value : null;
} catch (error) {
return null;
}
}
_setCache(value) {
try {
window.sessionStorage.setItem(this._cacheKey(), JSON.stringify({
expiresAt: Date.now() + this._cacheMinutes * 60 * 1000,
value: value
}));
} catch (error) {
console.warn('[TaxonomyService] Cache could not be written.', error);
}
}
_loadTermSet(termSetIdentifier) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const context = SP.ClientContext.get_current(); const context = SP.ClientContext.get_current();
const session = SP.Taxonomy.TaxonomySession.getTaxonomySession(context); const session = SP.Taxonomy.TaxonomySession.getTaxonomySession(context);
const termStore = session.getDefaultSiteCollectionTermStore(); const termStore = session.getDefaultSiteCollectionTermStore();
const termSets = termStore.getTermSetsByName(termSetName, 1033);
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(termSetIdentifier)) {
const termSet = termStore.getTermSet(new SP.Guid(termSetIdentifier));
this._loadAllTerms(context, termSet, resolve, reject);
return;
}
const termSets = termStore.getTermSetsByName(termSetIdentifier, this._languageLcid);
context.load(termSets); context.load(termSets);
context.executeQueryAsync( context.executeQueryAsync(
() => { () => {
if (termSets.get_count() > 0) { if (termSets.get_count() > 0) {
const termSet = termSets.get_item(0); const termSet = termSets.get_item(0);
const terms = termSet.get_terms(); this._loadAllTerms(context, termSet, resolve, reject);
context.load(terms);
context.executeQueryAsync(
() => {
// Load all terms with their properties and children
this._loadAllTermsRecursively(context, terms, resolve, reject);
},
(sender, args) => reject(new Error(args.get_message()))
);
} else { } else {
reject(new Error(`Term set '${termSetName}' not found`)); reject(new Error(`Term set '${termSetIdentifier}' not found`));
} }
}, },
(sender, args) => reject(new Error(args.get_message())) (sender, args) => reject(new Error(args.get_message()))
@@ -117,32 +180,29 @@
}); });
} }
_loadAllTermsRecursively(context, terms, resolve, reject) { _loadAllTerms(context, termSet, resolve, reject) {
const allTerms = []; const terms = termSet.getAllTerms();
const termsEnum = terms.getEnumerator(); context.load(terms, 'Include(Id,Name,PathOfTerm,LocalCustomProperties,IsAvailableForTagging,IsDeprecated)');
// First pass: collect all terms
while (termsEnum.moveNext()) {
const term = termsEnum.get_current();
context.load(term, 'Id', 'Name', 'PathOfTerm', 'LocalCustomProperties');
allTerms.push(term);
// Load child terms
const childTerms = term.get_terms();
context.load(childTerms);
this._loadChildTermsRecursively(context, childTerms, allTerms);
}
// Execute query to load all data
context.executeQueryAsync( context.executeQueryAsync(
() => { () => {
const processedTerms = allTerms.map(term => ({ const processedTerms = [];
Id: term.get_id().toString(), const enumerator = terms.getEnumerator();
Name: term.get_name(), while (enumerator.moveNext()) {
PathOfTerm: term.get_pathOfTerm(), const term = enumerator.get_current();
LocalCustomProperties: this._getCustomProperties(term), const properties = this._getCustomProperties(term);
IsRoot: term.get_pathOfTerm().split(';').length === 1 if (!term.get_isAvailableForTagging() || term.get_isDeprecated() ||
})); this._asBoolean(properties['MegaMenu.Hidden']) ||
this._isExcludedFromGlobalNavigation(properties)) {
continue;
}
processedTerms.push({
Id: term.get_id().toString(),
Name: term.get_name(),
PathOfTerm: term.get_pathOfTerm(),
LocalCustomProperties: properties,
IsRoot: term.get_pathOfTerm().split(';').length === 1
});
}
resolve(processedTerms); resolve(processedTerms);
}, },
@@ -150,40 +210,48 @@
); );
} }
_loadChildTermsRecursively(context, childTerms, allTerms) {
const childEnum = childTerms.getEnumerator();
while (childEnum.moveNext()) {
const childTerm = childEnum.get_current();
context.load(childTerm, 'Id', 'Name', 'PathOfTerm', 'LocalCustomProperties');
allTerms.push(childTerm);
// Recursively load grandchildren
const grandChildTerms = childTerm.get_terms();
context.load(grandChildTerms);
this._loadChildTermsRecursively(context, grandChildTerms, allTerms);
}
}
_getCustomProperties(term) { _getCustomProperties(term) {
try { try {
const props = term.get_localCustomProperties(); const props = term.get_localCustomProperties();
return { return {
_Sys_Nav_SimpleLinkUrl: props._Sys_Nav_SimpleLinkUrl || null, _Sys_Nav_SimpleLinkUrl: props._Sys_Nav_SimpleLinkUrl || null,
_Sys_Nav_HoverText: props._Sys_Nav_HoverText || null _Sys_Nav_TargetUrl: props._Sys_Nav_TargetUrl || null,
_Sys_Nav_HoverText: props._Sys_Nav_HoverText || null,
_Sys_Nav_ExcludedProviders: props._Sys_Nav_ExcludedProviders || null,
'MegaMenu.Hidden': props['MegaMenu.Hidden'] || null,
'MegaMenu.OpenInNewWindow': props['MegaMenu.OpenInNewWindow'] || null,
'MegaMenu.Description': props['MegaMenu.Description'] || null
}; };
} catch (e) { } catch (e) {
return {}; return {};
} }
} }
_asBoolean(value) {
return value === true || ['true', '1', 'yes', 'ja'].indexOf(String(value || '').toLowerCase()) >= 0;
}
_isExcludedFromGlobalNavigation(properties) {
const value = String((properties || {})._Sys_Nav_ExcludedProviders || '').replace(/["']/g, '');
return value.split(/[;,|]/).some(provider =>
provider.trim().toLowerCase() === 'globalnavigationtaxonomyprovider'
);
}
_processTerms(termsData) { _processTerms(termsData) {
const itemsDict = new ItemDictionary(); const itemsDict = new ItemDictionary();
const itemsByPath = {};
const menuItems = []; const menuItems = [];
const visibleTerms = termsData.filter(termData =>
!this._asBoolean((termData.LocalCustomProperties || {})['MegaMenu.Hidden']) &&
!this._isExcludedFromGlobalNavigation(termData.LocalCustomProperties)
);
// Create MenuItem objects // Create MenuItem objects
termsData.forEach(termData => { visibleTerms.forEach(termData => {
const menuItem = new MenuItem(termData, 0, this._siteCollectionUrl); const menuItem = new MenuItem(termData, 0, this._siteCollectionUrl);
itemsDict.Add(termData.Id, menuItem); itemsDict.Add(termData.Id, menuItem);
itemsByPath[termData.PathOfTerm] = menuItem;
if (menuItem.pathDepth === 1) { if (menuItem.pathDepth === 1) {
menuItems.push(menuItem); menuItems.push(menuItem);
@@ -191,11 +259,12 @@
}); });
// Build hierarchy // Build hierarchy
termsData.forEach(termData => { visibleTerms.forEach(termData => {
if (termData.PathOfTerm && termData.PathOfTerm.split(';').length > 1) { if (termData.PathOfTerm && termData.PathOfTerm.split(';').length > 1) {
const menuItem = itemsDict.Get(termData.Id); const menuItem = itemsDict.Get(termData.Id);
const parentId = menuItem.parentId; const pathParts = termData.PathOfTerm.split(';');
const parentItem = itemsDict.Get(parentId); pathParts.pop();
const parentItem = itemsByPath[pathParts.join(';')];
if (parentItem) { if (parentItem) {
parentItem.items.push(menuItem); parentItem.items.push(menuItem);
@@ -221,10 +290,11 @@
// =========================================== // ===========================================
class MegaMenuRenderer { class MegaMenuRenderer {
constructor(context, menuItems, updateCallback) { constructor(context, menuItems, menuMode, strings) {
this.context = context; this.context = context;
this.menuItems = menuItems; this.menuItems = menuItems;
this.updateCallback = updateCallback; 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) { render(container) {
@@ -232,23 +302,29 @@
const nav = document.createElement('nav'); const nav = document.createElement('nav');
nav.id = 'Mega-Menu'; nav.id = 'Mega-Menu';
nav.className = 'mega-menu-main'; nav.className = 'mega-menu-main mega-menu-mode-' + this.menuMode;
nav.setAttribute('role', 'navigation'); nav.setAttribute('data-menu-mode', this.menuMode);
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'); const topLevelUl = document.createElement('ul');
topLevelUl.setAttribute('role', 'menubar'); topLevelUl.className = 'mega-menu-top-level';
topLevelUl.id = 'Mega-Menu-Items';
this.menuItems.forEach(topLevelItem => { this.menuItems.forEach(topLevelItem => {
const topLevelLi = this.createTopLevelItem(topLevelItem); const topLevelLi = this.createTopLevelItem(topLevelItem);
topLevelUl.appendChild(topLevelLi); topLevelUl.appendChild(topLevelLi);
}); });
// Add settings if user has permissions (simplified check)
if (this._hasManagePermissions()) {
topLevelUl.appendChild(this.createSettingsItem());
}
nav.appendChild(topLevelUl); nav.appendChild(topLevelUl);
container.appendChild(nav); container.appendChild(nav);
@@ -258,32 +334,42 @@
createTopLevelItem(item) { createTopLevelItem(item) {
const li = document.createElement('li'); const li = document.createElement('li');
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) { if (item.url) {
// Simple link
const a = document.createElement('a'); const a = document.createElement('a');
a.href = item.url; a.href = item.url;
a.textContent = item.title; a.textContent = item.title;
a.className = 'menu-item-link'; a.className = 'menu-item-link';
a.setAttribute('role', 'menuitem'); this.applyLinkBehavior(a, item);
a.setAttribute('tabindex', '0');
li.appendChild(a); li.appendChild(a);
} else { } else {
// Menu with submenu
const span = document.createElement('span'); const span = document.createElement('span');
span.textContent = item.title; span.textContent = item.title;
span.className = 'menu-item-text'; span.className = 'menu-item-text';
span.setAttribute('role', 'menuitem'); if (!hasChildren) span.setAttribute('tabindex', '0');
span.setAttribute('tabindex', '0');
span.setAttribute('aria-haspopup', 'true');
span.setAttribute('aria-expanded', 'false');
li.appendChild(span); li.appendChild(span);
}
if (item.items.length > 0) { if (hasChildren) {
const megaMenu = this.createMegaMenu(item.items); const toggle = document.createElement('button');
li.appendChild(megaMenu); 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; return li;
@@ -292,7 +378,6 @@
createMegaMenu(categories) { createMegaMenu(categories) {
const megaMenuDiv = document.createElement('div'); const megaMenuDiv = document.createElement('div');
megaMenuDiv.className = 'mega-menu'; megaMenuDiv.className = 'mega-menu';
megaMenuDiv.setAttribute('role', 'menu');
const gridDiv = document.createElement('div'); const gridDiv = document.createElement('div');
gridDiv.className = 'mega-menu-grid'; gridDiv.className = 'mega-menu-grid';
@@ -316,7 +401,7 @@
const a = document.createElement('a'); const a = document.createElement('a');
a.href = category.url; a.href = category.url;
a.textContent = category.title; a.textContent = category.title;
a.setAttribute('tabindex', '0'); this.applyLinkBehavior(a, category);
h3.appendChild(a); h3.appendChild(a);
} else { } else {
const span = document.createElement('span'); const span = document.createElement('span');
@@ -326,19 +411,23 @@
categoryDiv.appendChild(h3); 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) { if (category.items.length > 0) {
const ul = document.createElement('ul'); const ul = document.createElement('ul');
ul.setAttribute('role', 'group');
category.items.forEach(link => { category.items.forEach(link => {
const li = document.createElement('li'); const li = document.createElement('li');
li.setAttribute('role', 'none');
const a = document.createElement('a'); const a = document.createElement('a');
a.href = link.url; a.href = link.url || '#';
a.textContent = link.title; a.textContent = link.title;
a.setAttribute('role', 'menuitem'); this.applyLinkBehavior(a, link);
a.setAttribute('tabindex', '0');
li.appendChild(a); li.appendChild(a);
ul.appendChild(li); ul.appendChild(li);
@@ -350,37 +439,129 @@
return categoryDiv; return categoryDiv;
} }
createSettingsItem() { createFlyoutMenu(items) {
const flyout = document.createElement('div');
flyout.className = 'mega-menu mega-menu-flyout';
const list = document.createElement('ul');
list.className = 'flyout-list flyout-level-2';
items.forEach(item => list.appendChild(this.createFlyoutItem(item)));
flyout.appendChild(list);
return flyout;
}
createFlyoutItem(item) {
const li = document.createElement('li'); const li = document.createElement('li');
li.setAttribute('role', 'none'); const hasChildren = item.items && item.items.length > 0;
li.className = hasChildren ? 'flyout-item has-children' : 'flyout-item';
const button = document.createElement('button'); const element = document.createElement(item.url ? 'a' : 'span');
button.className = 'menu-item-settings'; if (item.url) {
button.setAttribute('type', 'button'); element.href = item.url;
button.setAttribute('role', 'menuitem'); this.applyLinkBehavior(element, item);
button.setAttribute('tabindex', '0'); } else {
button.setAttribute('aria-label', 'Einstellungen'); element.setAttribute('tabindex', '0');
button.onclick = () => this._openSettings(); }
element.textContent = item.title;
element.className = hasChildren ? 'flyout-link flyout-link-has-children' : 'flyout-link';
li.appendChild(element);
const icon = document.createElement('i'); if (hasChildren) {
icon.className = 'ms-Icon ms-Icon--Settings menu-item-settings__icon'; const toggle = document.createElement('button');
icon.setAttribute('aria-hidden', 'true'); const childId = 'Mega-Menu-Flyout-' + String(item.id).replace(/[^a-z0-9_-]/gi, '');
button.appendChild(icon); 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.id = childId;
childList.setAttribute('aria-label', item.title + ' ' + this.strings.submenuLabel);
item.items.forEach(child => childList.appendChild(this.createFlyoutItem(child)));
li.appendChild(childList);
}
li.appendChild(button);
return li; return li;
} }
attachEventListeners() { attachEventListeners() {
// Keyboard and mouse event handling (simplified) const renderer = this;
const menuItems = document.querySelectorAll('#Mega-Menu [role="menuitem"]'); const toggles = document.querySelectorAll('#Mega-Menu > ul > li > .menu-item-toggle');
menuItems.forEach(item => { Array.prototype.forEach.call(toggles, toggle => {
item.addEventListener('keydown', this.handleKeyDown.bind(this)); 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));
parent.classList.toggle('is-open', open);
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), 0); });
});
const parent = item.parentElement; const flyoutToggles = document.querySelectorAll('#Mega-Menu .flyout-toggle');
if (parent && parent.querySelector('.mega-menu')) { Array.prototype.forEach.call(flyoutToggles, toggle => {
parent.addEventListener('mouseenter', this.showMegaMenu); const parent = toggle.parentElement;
parent.addEventListener('mouseleave', this.hideMegaMenu); 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(); }
} }
}); });
} }
@@ -395,61 +576,35 @@
document.body.appendChild(announcer); document.body.appendChild(announcer);
} }
showMegaMenu() { closePanel(toggle, panel) {
const megaMenu = this.querySelector('.mega-menu'); toggle.setAttribute('aria-expanded', 'false');
if (megaMenu) { toggle.setAttribute('aria-label', this.strings.openSubmenu + ' ' + this.getToggleLabel(toggle));
megaMenu.classList.add('js-open'); panel.setAttribute('aria-expanded', 'false');
const menuItem = this.querySelector('[role="menuitem"]'); panel.classList.remove('js-open');
if (menuItem) { }
menuItem.setAttribute('aria-expanded', 'true');
} closeAll(exceptPanel) {
const panels = document.querySelectorAll('#Mega-Menu .mega-menu.js-open');
Array.prototype.forEach.call(panels, panel => {
if (panel !== exceptPanel) this.closePanel(panel.previousElementSibling, panel);
});
}
getToggleLabel(toggle) {
const label = toggle.parentElement.querySelector('.menu-item-link, .menu-item-text');
return label ? label.textContent : '';
}
applyLinkBehavior(link, item) {
if (item.openInNewWindow) {
link.target = '_blank';
link.rel = 'noopener noreferrer';
}
if (item.external) {
link.setAttribute('aria-label', item.title + ' (' + this.strings.externalLink + ')');
} }
} }
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');
}
}
}
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();
}
}
}
}
_hasManagePermissions() {
// Simplified permission check for classic SharePoint
return _spPageContextInfo.isSiteAdmin || false;
}
_openSettings() {
console.log('[MegaMenu] Settings not implemented in classic mode');
alert('Einstellungen sind nur in der modernen SPFx-Version verfügbar.');
}
} }
// =========================================== // ===========================================
-7
View File
@@ -1,7 +0,0 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/deploy-azure-storage.schema.json",
"workingDir": "./temp/deploy/",
"account": "<!-- STORAGE ACCOUNT NAME -->",
"container": "mega-menu",
"accessKey": "<!-- ACCESS KEY -->"
}
+1 -1
View File
@@ -3,7 +3,7 @@
"solution": { "solution": {
"name": "mega-menu-client-side-solution", "name": "mega-menu-client-side-solution",
"id": "f4660e06-ce08-43ee-bfb7-5c4464e01133", "id": "f4660e06-ce08-43ee-bfb7-5c4464e01133",
"version": "2.0.1.0", "version": "2.2.3.0",
"includeClientSideAssets": true, "includeClientSideAssets": true,
"skipFeatureDeployment": true "skipFeatureDeployment": true
}, },
+9 -2
View File
@@ -10,7 +10,10 @@
"location": "ClientSideExtension.ApplicationCustomizer", "location": "ClientSideExtension.ApplicationCustomizer",
"properties": { "properties": {
"termSetName": "Horizontale Navigation", "termSetName": "Horizontale Navigation",
"additionalCssUrl": "/siteAssets/Navigation/Navigation.css" "cacheMinutes": 15,
"cacheVersion": "1",
"menuMode": "megaMenu",
"debug": true
} }
} }
} }
@@ -21,7 +24,11 @@
"c0abbffb-355d-4d4e-bd38-9e15fb811506": { "c0abbffb-355d-4d4e-bd38-9e15fb811506": {
"location": "ClientSideExtension.ApplicationCustomizer", "location": "ClientSideExtension.ApplicationCustomizer",
"properties": { "properties": {
"testMessage": "Test message" "termSetName": "Global Navigation",
"cacheMinutes": 15,
"cacheVersion": "1",
"menuMode": "flyout",
"debug": true
} }
} }
} }
+63 -80
View File
@@ -1,6 +1,5 @@
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[Alias('WebUrl')]
[string]$SiteUrl, [string]$SiteUrl,
[string]$TermSetName = '', [string]$TermSetName = '',
@@ -10,6 +9,11 @@ param(
[ValidateRange(1, 1440)] [ValidateRange(1, 1440)]
[int]$CacheMinutes = 15, [int]$CacheMinutes = 15,
[string]$CacheVersion = '1',
[ValidateSet('megaMenu', 'flyout')]
[string]$MenuMode = 'megaMenu',
[switch]$EnableDebug, [switch]$EnableDebug,
[string]$Description = 'MSFT-Custom-Solution:MegaMenu' [string]$Description = 'MSFT-Custom-Solution:MegaMenu'
@@ -29,107 +33,86 @@ if (-not (Get-Command -Name Get-SPSite -ErrorAction SilentlyContinue)) {
$componentId = [Guid]'c0abbffb-355d-4d4e-bd38-9e15fb811506' $componentId = [Guid]'c0abbffb-355d-4d4e-bd38-9e15fb811506'
$legacyComponentId = [Guid]'abc3361f-bb2d-491f-aba3-cd51c19a299b' $legacyComponentId = [Guid]'abc3361f-bb2d-491f-aba3-cd51c19a299b'
$location = 'ClientSideExtension.ApplicationCustomizer' $legacyPropertyName = 'PortalSettings.MegaMenu.Configuration'
$configurationProperty = 'PortalSettings.MegaMenu.Configuration'
if ([string]::IsNullOrWhiteSpace($TermSetId) -and [string]::IsNullOrWhiteSpace($TermSetName)) {
throw 'Bitte TermSetId oder TermSetName angeben.'
}
$properties = [string](@{ $properties = [string](@{
termSetName = $TermSetName termSetName = $TermSetName.Trim()
termSetId = $TermSetId termSetId = $TermSetId.Trim()
cacheMinutes = $CacheMinutes cacheMinutes = $CacheMinutes
cacheVersion = $CacheVersion
menuMode = $MenuMode
debug = [bool]$EnableDebug.IsPresent debug = [bool]$EnableDebug.IsPresent
} | ConvertTo-Json -Depth 10 -Compress) } | ConvertTo-Json -Depth 10 -Compress)
$site = Get-SPSite -Identity $SiteUrl $site = Get-SPSite -Identity $SiteUrl
try { try {
$rootWeb = $site.RootWeb @($site.UserCustomActions | Where-Object {
$rootWeb.AllProperties[$configurationProperty] = [string]$properties $_.ClientSideComponentId -eq $componentId -or
$rootWeb.Update() $_.ClientSideComponentId -eq $legacyComponentId
}) | ForEach-Object {
$siteActions = @( Write-Host ('Entferne alte Site-Action: ' + $_.Id)
$site.UserCustomActions | $_.Delete()
Where-Object {
$_.ClientSideComponentId -eq $componentId -or
$_.ClientSideComponentId -eq $legacyComponentId
}
)
foreach ($siteAction in $siteActions) {
$siteAction.Delete()
} }
$created = 0 try {
$updated = 0 if ($site.RootWeb.AllProperties.ContainsKey($legacyPropertyName)) {
$removedLegacy = 0 $site.RootWeb.AllProperties.Remove($legacyPropertyName)
$site.RootWeb.Update()
Write-Host ('Alte Property-Bag-Konfiguration entfernt: ' + $legacyPropertyName)
}
}
catch {
Write-Warning ('Die alte Property-Bag-Konfiguration konnte nicht entfernt werden: ' + $_.Exception.Message)
}
foreach ($web in $site.AllWebs) { foreach ($web in $site.AllWebs) {
try { try {
$webUrl = $web.Url $webUrl = $web.Url
$userCustomActions = $web.UserCustomActions $actions = @($web.UserCustomActions | Where-Object {
$_.ClientSideComponentId -eq $componentId -or
$_.ClientSideComponentId -eq $legacyComponentId
})
if ($null -eq $userCustomActions) { foreach ($action in $actions) {
throw ('UserCustomActions konnten fuer das Web nicht geladen werden: ' + $webUrl) Write-Host ('Entferne alte Web-Action: ' + $webUrl + ' / ' + $action.Id)
$action.Delete()
} }
$actions = @(
$userCustomActions |
Where-Object {
$_.ClientSideComponentId -eq $componentId -or
$_.ClientSideComponentId -eq $legacyComponentId
}
)
$currentAction = $actions |
Where-Object {
$_.ClientSideComponentId -eq $componentId
} |
Select-Object -First 1
foreach ($existingAction in $actions) {
if ($currentAction -and $existingAction.Id -eq $currentAction.Id) {
continue
}
$existingAction.Delete()
$removedLegacy++
}
if (-not $currentAction) {
$currentAction = $userCustomActions.Add()
$currentAction.Name = [Guid]::NewGuid().ToString('B')
$created++
}
else {
$updated++
}
$currentAction.Title = 'MegaMenu Application Customizer'
$currentAction.Description = $Description
$currentAction.Location = $location
$currentAction.ClientSideComponentId = $componentId
$currentAction.ClientSideComponentProperties = $properties
$currentAction.Sequence = 100
$currentAction.Update()
$web.Update()
Write-Host ('MegaMenu synchronisiert: ' + $webUrl) -ForegroundColor Green
} }
finally { finally {
$web.Dispose() $web.Dispose()
} }
} }
Write-Host ''
Write-Host 'MegaMenu wurde fuer die gesamte Site Collection konfiguriert.' -ForegroundColor Green
Write-Host ('Zentrale Konfiguration: ' + $configurationProperty)
Write-Host ('Neu registrierte Webs: ' + $created)
Write-Host ('Aktualisierte Webs: ' + $updated)
Write-Host ('Entfernte alte oder doppelte Registrierungen: ' + $removedLegacy)
Write-Host ('Properties: ' + $properties)
Write-Host 'Neue Unterwebs koennen durch erneutes Ausfuehren idempotent synchronisiert werden.' -ForegroundColor Yellow
} }
finally { finally {
if ($site) { $site.Dispose()
$site.Dispose() }
}
$site = Get-SPSite -Identity $SiteUrl
try {
$action = $site.UserCustomActions.Add()
$action.Name = [Guid]::NewGuid().ToString('B')
$action.Title = 'MegaMenu Application Customizer'
$action.Description = $Description
$action.Location = 'ClientSideExtension.ApplicationCustomizer'
$action.ClientSideComponentId = $componentId
$action.ClientSideComponentProperties = $properties
$action.Sequence = 100
$action.Update()
Write-Host ''
Write-Host 'MegaMenu wurde zentral fuer die Site Collection registriert.' -ForegroundColor Green
Write-Host ('Site Collection: ' + $site.Url)
Write-Host ('Action-ID: ' + $action.Id)
Write-Host ('Menu mode: ' + $MenuMode)
Write-Host ('Properties: ' + $properties)
Write-Host 'Das MegaMenu-SPFx-Paket muss im App Catalog zentral bereitgestellt sein.' -ForegroundColor Yellow
}
finally {
$site.Dispose()
} }
-136
View File
@@ -1,136 +0,0 @@
param(
[Parameter(Mandatory = $true)]
[string]$SiteUrl,
[string]$Description = 'MSFT-Custom-Solution:MegaMenu'
)
if (-not (Get-Command -Name Get-SPSite -ErrorAction SilentlyContinue)) {
if (Get-Module -ListAvailable -Name Microsoft.SharePoint.PowerShell) {
Import-Module Microsoft.SharePoint.PowerShell -DisableNameChecking
}
elseif (Get-PSSnapin -Registered -Name Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue) {
Add-PSSnapin Microsoft.SharePoint.PowerShell
}
else {
throw 'Die SharePoint PowerShell-Komponenten wurden nicht gefunden. Bitte in der SharePoint Management Shell ausfuehren.'
}
}
$componentId = [Guid]'c0abbffb-355d-4d4e-bd38-9e15fb811506'
$legacyComponentId = [Guid]'abc3361f-bb2d-491f-aba3-cd51c19a299b'
$location = 'ClientSideExtension.ApplicationCustomizer'
$configurationProperty = 'PortalSettings.MegaMenu.Configuration'
$site = Get-SPSite -Identity $SiteUrl
try {
$rootWeb = $site.RootWeb
$properties = [string]$rootWeb.AllProperties[$configurationProperty]
if ([string]::IsNullOrWhiteSpace($properties)) {
$existingAction = $null
foreach ($web in $site.AllWebs) {
try {
$existingAction = @(
$web.UserCustomActions |
Where-Object {
$_.ClientSideComponentId -eq $componentId -or
$_.ClientSideComponentId -eq $legacyComponentId
}
) | Select-Object -First 1
if ($existingAction) {
$properties = [string]$existingAction.ClientSideComponentProperties
break
}
}
finally {
$web.Dispose()
}
}
}
if ([string]::IsNullOrWhiteSpace($properties)) {
throw ('Keine MegaMenu-Konfiguration im Property Bag oder in einer bestehenden ' +
'Web-Registrierung gefunden. Bitte zuerst add-megamenu.ps1 ausfuehren.')
}
try {
$null = $properties | ConvertFrom-Json
}
catch {
throw ('Die gefundene MegaMenu-Konfiguration ist kein gueltiges JSON: ' + $_.Exception.Message)
}
$removedSiteActions = 0
@(
$site.UserCustomActions |
Where-Object {
$_.ClientSideComponentId -eq $componentId -or
$_.ClientSideComponentId -eq $legacyComponentId
}
) | ForEach-Object {
$_.Delete()
$removedSiteActions++
}
$siteAction = $site.UserCustomActions.Add()
$siteAction.Name = [Guid]::NewGuid().ToString('B')
$siteAction.Title = 'MegaMenu Application Customizer'
$siteAction.Description = $Description
$siteAction.Location = $location
$siteAction.ClientSideComponentId = $componentId
$siteAction.ClientSideComponentProperties = $properties
$siteAction.Sequence = 100
$siteAction.Update()
$removedWebActions = 0
$testedWebUrls = @()
foreach ($web in $site.AllWebs) {
try {
$testedWebUrls += $web.Url
@(
$web.UserCustomActions |
Where-Object {
$_.ClientSideComponentId -eq $componentId -or
$_.ClientSideComponentId -eq $legacyComponentId
}
) | ForEach-Object {
$_.Delete()
$removedWebActions++
}
}
finally {
$web.Dispose()
}
}
$verification = @(
$site.UserCustomActions |
Where-Object { $_.ClientSideComponentId -eq $componentId }
)
Write-Host ''
Write-Host '=== MegaMenu Site-Scope-Test vorbereitet ===' -ForegroundColor Green
Write-Host ('Site-Action-ID: ' + $siteAction.Id)
Write-Host ('Component-ID: ' + $componentId)
Write-Host ('Entfernte vorherige Site-Actions: ' + $removedSiteActions)
Write-Host ('Entfernte Web-Actions: ' + $removedWebActions)
Write-Host ('Verifizierte Site-Actions: ' + $verification.Count)
Write-Host ('Properties: ' + $properties)
Write-Host ''
Write-Host 'Bitte jeweils mit Strg+F5 testen:' -ForegroundColor Cyan
$testedWebUrls | ForEach-Object { Write-Host (' ' + $_) }
Write-Host ''
Write-Host 'Rueckkehr zum bisherigen Web-Scope:' -ForegroundColor Yellow
Write-Host ('.\deployment\add-megamenu.ps1 -SiteUrl ''' + $site.Url + '''')
}
finally {
if ($site) {
$site.Dispose()
}
}
+1
View File
@@ -3,5 +3,6 @@
const build = require('@microsoft/sp-build-web'); const build = require('@microsoft/sp-build-web');
build.addSuppression(`Warning - [sass] The local CSS class 'ms-Grid' is not camelCase and will not be type-safe.`); build.addSuppression(`Warning - [sass] The local CSS class 'ms-Grid' is not camelCase and will not be type-safe.`);
build.addSuppression(`Warning - MegaMenuApplicationCustomizer: Admins can make this solution available to all sites in the organization, but extensions won’t automatically appear. SharePoint Framework extensions must be specifically associated to sites, lists, and fields programmatically to be visible to site users.`);
build.initialize(require('gulp')); build.initialize(require('gulp'));
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mega-menu", "name": "mega-menu",
"version": "2.0.1", "version": "2.2.3",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
+5 -4
View File
@@ -1,16 +1,17 @@
{ {
"name": "mega-menu", "name": "mega-menu",
"version": "2.0.1", "version": "2.2.3",
"private": true, "private": true,
"main": "lib/index.js", "main": "lib/index.js",
"engines": { "engines": {
"node": ">=6.9.0 <9.0.0" "node": ">=6.9.0 <9.0.0"
}, },
"scripts": { "scripts": {
"build": "gulp bundle", "build:classic": "powershell -NoProfile -ExecutionPolicy Bypass -File .\\build-classic.ps1",
"build": "npm run build:classic && gulp bundle",
"clean": "gulp clean", "clean": "gulp clean",
"test": "gulp test", "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": "gulp clean && gulp bundle --ship && gulp package-solution --ship" "package": "npm test && npm run build:classic && gulp clean && gulp bundle --ship && gulp package-solution --ship"
}, },
"dependencies": { "dependencies": {
"@microsoft/decorators": "~1.4.1", "@microsoft/decorators": "~1.4.1",
+97
View File
@@ -0,0 +1,97 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MegaMenu – Flyout-Vorschau</title>
<link rel="stylesheet" href="../classic/megamenu-classic.css" />
<style>
* { box-sizing: border-box; }
body { margin: 0; background: #faf9f8; color: #323130; font-family: "Segoe UI", Arial, sans-serif; }
.suite-bar { height: 48px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; background: #0078d4; color: #fff; }
.suite-brand { display: flex; align-items: center; gap: 14px; font-size: 16px; font-weight: 600; }
.waffle { display: grid; grid-template-columns: repeat(3, 4px); gap: 3px; }
.waffle span { width: 4px; height: 4px; background: #fff; }
.suite-user { display: flex; align-items: center; gap: 10px; font-size: 13px; }
.avatar { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 50%; background: #fff; color: #0078d4; font-weight: 700; }
.site-header { display: flex; align-items: center; justify-content: space-between; min-height: 72px; padding: 0 32px; background: #fff; }
.site-title { font-size: 24px; font-weight: 300; }
.site-search { width: 280px; padding: 9px 12px; border: 1px solid #8a8886; background: #fff; }
.preview-label { padding: 7px 24px; background: #deecf9; color: #605e5c; font-size: 12px; text-align: center; }
.page { max-width: 1200px; margin: 28px auto; padding: 0 24px; }
.page h1 { margin: 0 0 8px; font-size: 32px; font-weight: 300; }
.page p { color: #605e5c; }
.news { max-width: 760px; margin-top: 28px; border-top: 1px solid #edebe9; }
.news article { padding: 18px 0; border-bottom: 1px solid #edebe9; }
.news time { color: #605e5c; font-size: 12px; }
.news h2 { margin: 5px 0; font-size: 18px; font-weight: 600; }
@media (max-width: 760px) { .site-search { display: none; } }
</style>
</head>
<body>
<header class="suite-bar">
<div class="suite-brand"><span class="waffle" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span>SharePoint</div>
<div class="suite-user"><span>Max Mustermann</span><span class="avatar">MM</span></div>
</header>
<div class="site-header"><div class="site-title">Intranet Portal</div><input class="site-search" type="search" placeholder="Auf dieser Website suchen" /></div>
<div class="megamenu-classic-container">
<nav id="Mega-Menu" class="mega-menu-main mega-menu-mode-flyout" data-menu-mode="flyout" aria-label="Hauptnavigation">
<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">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">Ü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">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>
<li class="flyout-item"><a class="flyout-link" href="#telefonbuch">Telefonbuch</a></li>
</ul>
</div>
</li>
<li class="mega-menu-top-item"><a class="menu-item-link menu-item-has-children" href="#personal" aria-haspopup="true" aria-expanded="false">Personal</a></li>
<li class="mega-menu-top-item"><a class="menu-item-link menu-item-has-children" href="#services" aria-haspopup="true" aria-expanded="false">Services</a></li>
<li class="mega-menu-top-item"><a class="menu-item-link" href="#projekte">Projekte</a></li>
</ul>
</nav>
</div>
<div class="preview-label">Vorschau: menuMode = "flyout" · Für Ebene 3 mit der Maus über „Über uns“ oder „Organisation“ fahren</div>
<main class="page">
<h1>Aktuelle Meldungen</h1>
<p>Die Flyout-Navigation bleibt kompakt und folgt der hierarchischen Struktur des Termsets.</p>
<section class="news"><article><time>20. Juli 2026</time><h2>Neue Services im Intranet verfügbar</h2><p>Alle wichtigen Anwendungen sind jetzt zentral über das Serviceportal erreichbar.</p></article><article><time>18. Juli 2026</time><h2>Informationen aus der Geschäftsleitung</h2><p>Die aktuelle Mitarbeitendeninformation steht zum Abruf bereit.</p></article></section>
</main>
<script>
(function () {
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;
if (!panel || !panel.classList.contains('mega-menu')) { return; }
event.preventDefault();
var open = panel.classList.contains('js-open');
Array.prototype.forEach.call(document.querySelectorAll('.mega-menu.js-open'), function (item) { item.classList.remove('js-open'); if (item.previousElementSibling) { item.previousElementSibling.setAttribute('aria-expanded', 'false'); } });
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>
</html>
+102
View File
@@ -0,0 +1,102 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MegaMenu – Mega-Menu-Vorschau</title>
<link rel="stylesheet" href="../classic/megamenu-classic.css" />
<style>
* { box-sizing: border-box; }
body { margin: 0; background: #faf9f8; color: #323130; font-family: "Segoe UI", Arial, sans-serif; }
.suite-bar { height: 48px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; background: #0078d4; color: #fff; }
.suite-brand { display: flex; align-items: center; gap: 14px; font-size: 16px; font-weight: 600; }
.waffle { display: grid; grid-template-columns: repeat(3, 4px); gap: 3px; }
.waffle span { width: 4px; height: 4px; background: #fff; }
.suite-user { display: flex; align-items: center; gap: 10px; font-size: 13px; }
.avatar { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 50%; background: #fff; color: #0078d4; font-weight: 700; }
.site-header { display: flex; align-items: center; justify-content: space-between; min-height: 72px; padding: 0 32px; background: #fff; }
.site-title { font-size: 24px; font-weight: 300; }
.site-search { width: 280px; padding: 9px 12px; border: 1px solid #8a8886; background: #fff; }
.preview-label { padding: 7px 24px; background: #fff4ce; color: #605e5c; font-size: 12px; text-align: center; }
.page { max-width: 1200px; margin: 28px auto; padding: 0 24px; }
.page h1 { margin: 0 0 8px; font-size: 32px; font-weight: 300; }
.page p { color: #605e5c; }
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 28px; }
.card { min-height: 150px; padding: 20px; border: 1px solid #edebe9; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.06); }
.card strong { color: #0078d4; }
@media (max-width: 760px) { .site-search { display: none; } .cards { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<header class="suite-bar">
<div class="suite-brand"><span class="waffle" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span><span></span></span>SharePoint</div>
<div class="suite-user"><span>Max Mustermann</span><span class="avatar">MM</span></div>
</header>
<div class="site-header"><div class="site-title">Intranet Portal</div><input class="site-search" type="search" placeholder="Auf dieser Website suchen" /></div>
<div class="megamenu-classic-container">
<nav id="Mega-Menu" class="mega-menu-main mega-menu-mode-megaMenu" data-menu-mode="megaMenu" aria-label="Hauptnavigation">
<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">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>
<ul class="mega-menu-links"><li><a href="#profil">Unternehmensprofil</a></li><li><a href="#leitung">Geschäftsleitung</a></li><li><a href="#standorte">Standorte</a></li></ul>
</section>
<section class="mega-menu-category">
<h3 class="mega-menu-category-title"><a href="#organisation">Organisation</a></h3>
<ul class="mega-menu-links"><li><a href="#bereiche">Fachbereiche</a></li><li><a href="#organigramm">Organigramm</a></li><li><a href="#ansprechpartner">Ansprechpartner</a></li></ul>
</section>
<section class="mega-menu-category">
<h3 class="mega-menu-category-title"><a href="#kommunikation">Kommunikation</a></h3>
<ul class="mega-menu-links"><li class="is-current"><a class="is-current" href="#news">Aktuelle Meldungen</a></li><li><a href="#termine">Veranstaltungen</a></li><li><a href="#medien">Mediencenter</a></li></ul>
</section>
<section class="mega-menu-category">
<h3 class="mega-menu-category-title"><span>Direktzugriff</span></h3>
<ul class="mega-menu-links"><li><a href="#telefonbuch">Telefonbuch</a></li><li><a href="#formulare">Formulare</a></li><li><a href="#vorlagen">Vorlagen</a></li></ul>
</section>
</div>
</div>
</li>
<li class="mega-menu-top-item"><a class="menu-item-link menu-item-has-children" href="#personal" aria-haspopup="true" aria-expanded="false">Personal</a></li>
<li class="mega-menu-top-item"><a class="menu-item-link menu-item-has-children" href="#services" aria-haspopup="true" aria-expanded="false">Services</a></li>
<li class="mega-menu-top-item"><a class="menu-item-link" href="#projekte">Projekte</a></li>
</ul>
</nav>
</div>
<div class="preview-label">Vorschau: menuMode = "megaMenu" · „Unternehmen“ ist zur Ansicht geöffnet</div>
<main class="page">
<h1>Willkommen im Intranet</h1>
<p>Diese statische Vorschau verwendet dieselben CSS-Klassen wie die MegaMenu-Solution.</p>
<div class="cards"><div class="card"><strong>Aktuelle Meldungen</strong><p>Neuigkeiten und wichtige Informationen aus dem Unternehmen.</p></div><div class="card"><strong>Schnellzugriff</strong><p>Formulare, Vorlagen und häufig verwendete Anwendungen.</p></div><div class="card"><strong>Meine Aufgaben</strong><p>Persönliche Aufgaben und zuletzt verwendete Dokumente.</p></div></div>
</main>
<script>
(function () {
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'); } }
});
}
Array.prototype.forEach.call(triggers, function (trigger) {
trigger.addEventListener('click', function (event) {
var panel = trigger.nextElementSibling;
if (!panel || !panel.classList.contains('mega-menu')) { return; }
event.preventDefault();
var open = panel.classList.contains('js-open'); closeAll(panel);
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>
</html>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
<CustomAction
Title="MegaMenu Application Customizer"
Location="ClientSideExtension.ApplicationCustomizer"
ClientSideComponentId="c0abbffb-355d-4d4e-bd38-9e15fb811506"
ClientSideComponentProperties="{}" />
</Elements>
+499 -449
View File
@@ -1,482 +1,532 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss'; @import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
/* $white: "[theme: white, default: #ffffff]";
Global override example: $neutralLighter: "[theme: neutralLighter, default: #f4f4f4]";
:root { $neutralLight: "[theme: neutralLight, default: #eaeaea]";
--themePrimary: #00407f; $neutralTertiary: "[theme: neutralTertiary, default: #a6a6a6]";
--themeLighterAlt: #f0f5fa; $neutralSecondary: "[theme: neutralSecondary, default: #666666]";
--themeLighter: #c5d8eb; $neutralPrimary: "[theme: neutralPrimary, default: #333333]";
--themeLight: #98b8d9; $themePrimary: "[theme: themePrimary, default: #0078d7]";
--themeTertiary: #477db3; $themeDark: "[theme: themeDark, default: #005a9e]";
--themeSecondary: #11508f; $themeLighterAlt: "[theme: themeLighterAlt, default: #eff6fc]";
--themeDarkAlt: #003973; $menuShadow: 0 8px 24px rgba(0, 0, 0, 0.16);
--themeDark: #003061; $menuLayer: var(--megaMenuZIndex, 6000);
--themeDarker: #002447;
--neutralLighterAlt: #faf9f8;
--neutralLighter: #f3f2f1;
--neutralLight: #edebe9;
--neutralQuaternaryAlt: #e1dfdd;
--neutralQuaternary: #d0d0d0;
--neutralTertiaryAlt: #c8c6c4;
--neutralTertiary: #a19f9d;
--neutralSecondary: #605e5c;
--neutralSecondaryAlt: #8a8886;
--neutralPrimaryAlt: #3b3a39;
--neutralPrimary: #323130;
--neutralDark: #201f1e;
--black: #000000;
--white: #ffffff;
--megaMenuNavBackground: #5f6f74;
--megaMenuNavHoverBackground: #6f8085;
--megaMenuNavOpenBackground: #728388;
--megaMenuNavTextColor: #ffffff;
--megaMenuNavDividerColor: rgba(255, 255, 255, 0.22);
--megaMenuPanelWidth: 1280px;
--megaMenuZIndex: 6000;
}
*/
$mm-white: var(--white, #{$ms-color-white});
$mm-panel-surface: var(--white, #{$ms-color-white});
$mm-panel-border: var(--neutralLight, #{$ms-color-neutralLight});
$mm-panel-divider: var(--neutralQuaternaryAlt, #{$ms-color-neutralQuaternaryAlt});
$mm-text: var(--neutralPrimary, #{$ms-color-neutralPrimary});
$mm-text-muted: var(--neutralSecondary, #{$ms-color-neutralSecondary});
$mm-nav-background: var(--megaMenuNavBackground, #5f6f74);
$mm-nav-hover-background: var(--megaMenuNavHoverBackground, #6f8085);
$mm-nav-open-background: var(--megaMenuNavOpenBackground, #728388);
$mm-nav-divider: var(--megaMenuNavDividerColor, rgba(255, 255, 255, 0.22));
$mm-nav-text: var(--megaMenuNavTextColor, #ffffff);
$mm-focus-accent: var(--themePrimary, #{$ms-color-themePrimary});
$mm-focus-soft: var(--themeLighterAlt, #{$ms-color-themeLighterAlt});
$mm-panel-shadow: var(--megaMenuPanelShadow, 0 14px 28px rgba(0, 0, 0, 0.18));
$mm-panel-width: var(--megaMenuPanelWidth, 1280px);
$mm-nav-height: var(--megaMenuTopNavHeight, 34px);
$mm-nav-inline-padding: var(--megaMenuNavInlinePadding, 8px);
$mm-item-inline-padding: var(--megaMenuItemInlinePadding, 12px);
$mm-column-padding-x: var(--megaMenuColumnPaddingX, 18px);
$mm-column-padding-y: var(--megaMenuColumnPaddingY, 16px);
$mm-link-radius: var(--megaMenuLinkRadius, 2px);
$mm-layer-index: var(--megaMenuZIndex, 6000);
:global { :global {
:root { :root {
--megaMenuNavBackground: #5f6f74; --megaMenuBarHeight: 44px;
--megaMenuNavHoverBackground: #6f8085; --megaMenuContentWidth: 1200px;
--megaMenuNavOpenBackground: #728388; --megaMenuFlyoutWidth: 288px;
--megaMenuNavTextColor: #ffffff; --megaMenuZIndex: 6000;
--megaMenuNavDividerColor: rgba(255, 255, 255, 0.22);
--megaMenuTopNavHeight: 34px;
--megaMenuPanelWidth: 1280px;
--megaMenuPanelShadow: 0 14px 28px rgba(0, 0, 0, 0.18);
--megaMenuZIndex: 6000;
--megaMenuNavInlinePadding: 8px;
--megaMenuItemInlinePadding: 12px;
--megaMenuColumnPaddingX: 18px;
--megaMenuColumnPaddingY: 16px;
--megaMenuLinkRadius: 2px;
--megaMenuColumnCount: 4;
}
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: $mm-focus-accent;
color: $mm-white;
padding: 8px 12px;
text-decoration: none;
font-weight: 600;
border-radius: 4px;
z-index: 10000;
transition: top 0.2s ease;
}
.skip-link:focus {
top: 6px;
}
#CustomNavigation {
position: relative;
overflow: visible;
z-index: $mm-layer-index;
}
.mega-menu-status {
padding: 8px 14px;
border-bottom: 1px solid $mm-panel-border;
background: $mm-focus-soft;
color: $mm-text;
font-size: 13px;
}
.mega-menu-main {
position: relative;
width: 100%;
background-color: #5f6f74;
background-color: $mm-nav-background;
border-bottom: 1px solid rgba(0, 0, 0, 0.18);
box-shadow: none;
overflow: visible;
z-index: $mm-layer-index;
}
.mega-menu-main > ul,
.mega-menu-top-level {
margin: 0 auto;
max-width: $mm-panel-width;
background-color: inherit;
width: 100%;
box-sizing: border-box;
list-style: none;
padding: 0 $mm-nav-inline-padding;
padding-inline-start: $mm-nav-inline-padding;
display: flex;
align-items: stretch;
justify-content: flex-start;
gap: 0;
}
.mega-menu-main > ul > li,
.mega-menu-top-item {
position: static;
display: flex;
align-items: stretch;
flex: 0 0 auto;
border-right: 1px solid $mm-nav-divider;
}
.mega-menu-main > ul > li:last-child,
.mega-menu-top-item:last-child {
border-right: 0;
}
.mega-menu-main > ul > li > a,
.mega-menu-main > ul > li > .menu-item-text,
.menu-item-link,
.menu-item-text {
position: relative;
display: flex;
align-items: center;
min-height: $mm-nav-height;
padding: 0 $mm-item-inline-padding;
color: $mm-nav-text;
font-size: 12px;
font-weight: 400;
line-height: 1;
text-decoration: none;
background: transparent;
border: 0;
transition: background-color 0.14s ease, color 0.14s ease;
outline: none;
white-space: nowrap;
}
.menu-item-text {
cursor: pointer;
}
.menu-item-has-children::after {
content: '';
width: 5px;
height: 5px;
margin-left: 8px;
border-right: 1px solid currentColor;
border-bottom: 1px solid currentColor;
transform: translateY(-1px) rotate(45deg);
transition: transform 0.14s ease;
}
.menu-item-has-children[aria-expanded="true"]::after {
transform: translateY(1px) rotate(-135deg);
}
.mega-menu-top-item.is-active > a,
.mega-menu-top-item.is-active > span,
.menu-item-link.is-active,
.menu-item-text.is-active {
background: $mm-nav-open-background;
}
.mega-menu-top-item.is-current > a,
.mega-menu-top-item.is-current > span,
.menu-item-link.is-current,
.menu-item-text.is-current {
font-weight: 600;
}
.mega-menu-main > ul > li:hover > a,
.mega-menu-main > ul > li:hover > .menu-item-text,
.mega-menu-main > ul > li > a[aria-expanded="true"],
.mega-menu-main > ul > li > span[aria-expanded="true"],
.menu-item-link:hover,
.menu-item-text:hover {
color: $mm-nav-text;
background: $mm-nav-hover-background;
}
.mega-menu-main > ul > li > a[aria-expanded="true"],
.mega-menu-main > ul > li > span[aria-expanded="true"] {
background: $mm-nav-open-background;
}
.mega-menu-main > ul > li > a:focus,
.mega-menu-main > ul > li > .menu-item-text:focus,
.menu-item-link:focus,
.menu-item-text:focus {
color: $mm-nav-text;
background: $mm-nav-open-background;
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.65);
}
.mega-menu {
position: absolute;
left: 0;
right: 0;
top: 100%;
display: none;
visibility: hidden;
opacity: 0;
background-color: #ffffff;
background-color: $mm-panel-surface;
background-image: none;
mix-blend-mode: normal;
border: 1px solid $mm-panel-border;
border-top: 0;
box-shadow: $mm-panel-shadow;
max-height: calc(100vh - #{$mm-nav-height});
isolation: isolate;
overflow-y: auto;
overflow-x: hidden;
z-index: calc(#{$mm-layer-index} + 1);
transition: opacity 0.16s ease, visibility 0.16s ease;
}
.mega-menu-main > ul > li:hover .mega-menu,
.mega-menu.js-open {
visibility: visible;
opacity: 1;
display: block;
background-color: #ffffff;
background-color: $mm-panel-surface;
background-image: none;
}
.mega-menu-grid {
max-width: $mm-panel-width;
background-color: inherit;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(var(--megaMenuColumnCount, 4), minmax(0, 1fr));
gap: 0;
align-items: start;
}
.mega-menu-category {
min-width: 0;
padding: $mm-column-padding-y $mm-column-padding-x 14px;
border-right: 1px solid $mm-panel-divider;
}
.mega-menu-grid > .mega-menu-category:last-child {
border-right: 0;
}
.mega-menu-category > h3,
.mega-menu-category-title {
margin: 0 0 10px;
padding: 0;
border: 0;
}
.mega-menu-category > h3 > span,
.mega-menu-category > h3 > a {
display: block;
color: $mm-text;
font-size: 13px;
font-weight: 600;
line-height: 18px;
text-decoration: none;
}
.mega-menu-category.is-active > h3 > span,
.mega-menu-category.is-active > h3 > a,
.mega-menu-category > h3 > span.is-active,
.mega-menu-category > h3 > a.is-active {
color: $mm-text;
}
.mega-menu-category.is-current > h3 > a,
.mega-menu-category > h3 > a.is-current {
text-decoration: underline;
}
.mega-menu-category > h3 > a:hover {
color: $mm-text;
text-decoration: underline;
}
.mega-menu-category > h3 > a:focus {
background: $mm-focus-soft;
color: $mm-text;
border-radius: 2px;
box-shadow: 0 0 0 2px $mm-focus-accent;
padding: 1px 4px;
margin: -1px -4px;
}
.mega-menu-category ul,
.mega-menu-links {
list-style: none;
margin: 0;
padding-inline-start: 0;
}
.mega-menu-category ul li,
.mega-menu-links li {
margin: 0 0 6px;
}
.mega-menu-category ul li:last-child,
.mega-menu-links li:last-child {
margin-bottom: 0;
}
.mega-menu-category ul li a,
.mega-menu-links li a {
display: block;
margin: 0;
padding: 1px 2px;
border-radius: $mm-link-radius;
color: $mm-text-muted;
font-size: 12px;
font-weight: 400;
line-height: 18px;
text-decoration: none;
transition: background-color 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
}
.mega-menu-category ul li.is-active > a,
.mega-menu-links li.is-active > a,
.mega-menu-category ul li a.is-active,
.mega-menu-links li a.is-active {
color: $mm-text;
}
.mega-menu-category ul li.is-current > a,
.mega-menu-links li.is-current > a,
.mega-menu-category ul li a.is-current,
.mega-menu-links li a.is-current {
color: $mm-text;
font-weight: 600;
text-decoration: underline;
}
.mega-menu-category ul li a:hover,
.mega-menu-links li a:hover {
background: transparent;
color: $mm-text;
text-decoration: underline;
}
.mega-menu-category ul li a:focus,
.mega-menu-links li a:focus {
background: $mm-focus-soft;
color: $mm-text;
box-shadow: 0 0 0 2px $mm-focus-accent;
outline: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 1024px) {
.mega-menu-grid {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
} }
.mega-menu-category { #CustomNavigation {
padding: 14px 16px 12px; position: relative;
box-sizing: border-box;
width: 100%;
overflow: visible;
border-top: 1px solid #edebe9;
border-bottom: 1px solid #edebe9;
z-index: $menuLayer;
font-family: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif;
}
.mega-menu-status {
box-sizing: border-box;
padding: 10px 24px;
border-bottom: 1px solid $neutralLight;
background: $themeLighterAlt;
color: $neutralPrimary;
font-size: 13px;
}
.mega-menu-main {
position: relative;
box-sizing: border-box;
width: 100%;
min-height: var(--megaMenuBarHeight, 44px);
overflow: visible;
border-top: 0;
border-bottom: 0;
background: $white;
color: $neutralPrimary;
z-index: $menuLayer;
} }
}
@media (max-width: 767px) {
.mega-menu-main > ul,
.mega-menu-top-level { .mega-menu-top-level {
padding: 0 6px; display: flex;
padding-inline-start: 6px; align-items: stretch;
flex-wrap: wrap; box-sizing: border-box;
width: 100%;
max-width: var(--megaMenuContentWidth, 1200px);
min-height: var(--megaMenuBarHeight, 44px);
margin: 0 auto;
padding: 0 24px;
list-style: none;
} }
.mega-menu-main > ul > li,
.mega-menu-top-item { .mega-menu-top-item {
border-right: 0; position: static;
display: flex;
align-items: stretch;
margin: 0;
padding: 0;
}
.mega-menu-top-item:hover,
.mega-menu-top-item:focus-within,
.mega-menu-top-item:active,
.mega-menu-top-item.is-open,
.mega-menu-top-item.is-active {
background: $neutralLighter;
color: $neutralPrimary;
}
.mega-menu-mobile-toggle {
display: none;
}
.mega-menu-mode-flyout .mega-menu-top-item {
position: relative;
} }
.mega-menu-main > ul > li > a,
.mega-menu-main > ul > li > .menu-item-text,
.menu-item-link, .menu-item-link,
.menu-item-text { .menu-item-text {
min-height: 36px; position: relative;
padding: 0 10px; display: flex;
font-size: 12px; align-items: center;
box-sizing: border-box;
min-height: var(--megaMenuBarHeight, 44px);
padding: 0 16px;
border: 0;
background: transparent;
color: $neutralPrimary;
cursor: pointer;
font-size: 14px;
font-weight: 400;
line-height: 20px;
text-decoration: none;
white-space: nowrap;
outline: none;
}
.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: $neutralPrimary;
cursor: pointer;
}
.menu-item-toggle > span::after {
content: '';
display: block;
width: 6px;
height: 6px;
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-toggle:hover,
.menu-item-toggle[aria-expanded='true'] {
background: transparent;
color: inherit;
text-decoration: none;
}
.mega-menu-top-item.is-active > .menu-item-link::before,
.mega-menu-top-item.is-active > .menu-item-text::before,
.mega-menu-top-item.is-current > .menu-item-link::before,
.mega-menu-top-item.is-current > .menu-item-text::before {
content: '';
position: absolute;
right: 12px;
bottom: 0;
left: 12px;
height: 2px;
background: $themePrimary;
}
.mega-menu-top-item.is-current > .menu-item-link,
.mega-menu-top-item.is-current > .menu-item-text {
color: $themeDark;
font-weight: 600;
}
.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 $themePrimary;
outline-offset: -2px;
} }
.mega-menu { .mega-menu {
border-left: 0; position: absolute;
border-right: 0; top: 100%;
left: 0;
right: 0;
display: none;
box-sizing: border-box;
visibility: hidden;
opacity: 0;
border: 1px solid $neutralLight;
border-top: 0;
background: $white;
box-shadow: $menuShadow;
color: $neutralPrimary;
z-index: calc(#{$menuLayer} + 1);
}
.mega-menu.js-open {
display: block;
visibility: visible;
opacity: 1;
} }
.mega-menu-grid { .mega-menu-grid {
grid-template-columns: 1fr; display: grid;
grid-template-columns: repeat(var(--megaMenuColumnCount, 4), minmax(0, 1fr));
box-sizing: border-box;
width: 100%;
max-width: var(--megaMenuContentWidth, 1200px);
margin: 0 auto;
padding: 24px;
gap: 24px 32px;
background: $white;
} }
.mega-menu-category { .mega-menu-category {
padding: 14px 12px; min-width: 0;
border-right: 0; padding: 0 0 8px;
border-bottom: 1px solid $mm-panel-divider;
} }
.mega-menu-grid > .mega-menu-category:last-child { .mega-menu-description {
border-bottom: 0; margin: -4px 0 12px;
color: $neutralSecondary;
font-size: 12px;
line-height: 18px;
} }
}
@media (prefers-reduced-motion: reduce) { .mega-menu-category-title {
.skip-link, margin: 0 0 10px;
.menu-item-has-children::after, padding: 0 0 8px;
.mega-menu-main > ul > li > a, border-bottom: 1px solid $neutralLight;
.mega-menu-main > ul > li > .menu-item-text,
.menu-item-link,
.menu-item-text,
.mega-menu,
.mega-menu-category ul li a,
.mega-menu-links li a {
transition: none;
} }
}
@media (prefers-contrast: high) { .mega-menu-category-title > a,
.mega-menu-main > ul > li > a:focus, .mega-menu-category-title > span {
.mega-menu-main > ul > li > .menu-item-text:focus, display: block;
.menu-item-link:focus, color: $neutralPrimary;
.menu-item-text:focus, font-size: 14px;
.mega-menu-category > h3 > a:focus, font-weight: 600;
.mega-menu-category ul li a:focus, line-height: 20px;
.mega-menu-links li a:focus { text-decoration: none;
outline: 2px solid; }
outline-offset: 2px;
box-shadow: none; .mega-menu-category-title > a:hover {
color: $themePrimary;
text-decoration: underline;
}
.mega-menu-links {
margin: 0;
padding: 0;
list-style: none;
}
.mega-menu-links li {
margin: 0;
padding: 0;
}
.mega-menu-links a {
display: block;
margin: 0 -8px;
padding: 6px 8px;
border-radius: 2px;
color: $neutralSecondary;
font-size: 14px;
line-height: 20px;
text-decoration: none;
}
.mega-menu-links a:hover {
background: $neutralLighter;
color: $neutralPrimary;
text-decoration: none;
}
.mega-menu-links a.is-current,
.mega-menu-links li.is-current > a {
color: $themeDark;
font-weight: 600;
}
.mega-menu-flyout {
right: auto;
width: var(--megaMenuFlyoutWidth, 288px);
min-width: 240px;
border-top: 1px solid $neutralLight;
}
.flyout-list {
box-sizing: border-box;
width: 100%;
margin: 0;
padding: 8px 0;
background: $white;
list-style: none;
}
.flyout-item {
position: relative;
margin: 0;
padding: 0;
}
.flyout-link {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
width: 100%;
min-height: 36px;
padding: 8px 36px 8px 16px;
border: 0;
background: $white;
color: $neutralPrimary;
cursor: pointer;
font-size: 14px;
line-height: 20px;
text-decoration: none;
outline: none;
}
.flyout-item:hover > .flyout-link,
.flyout-item:focus-within > .flyout-link,
.flyout-item:active > .flyout-link,
.flyout-item.is-active > .flyout-link {
background: $neutralLighter;
color: $neutralPrimary;
text-decoration: none;
}
.flyout-item.is-current > .flyout-link {
color: $themeDark;
font-weight: 600;
}
.flyout-toggle {
position: absolute;
top: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: 0;
background: transparent;
color: $neutralPrimary;
cursor: pointer;
}
.flyout-toggle > span::after {
content: '';
display: block;
width: 6px;
height: 6px;
border-top: 1px solid currentColor;
border-right: 1px solid currentColor;
transform: rotate(45deg);
}
.flyout-toggle:focus {
outline: 2px solid $themePrimary;
outline-offset: -2px;
}
.flyout-level-3 {
position: absolute;
top: -9px;
left: 100%;
display: none;
width: var(--megaMenuFlyoutWidth, 288px);
border: 1px solid $neutralLight;
box-shadow: $menuShadow;
z-index: calc(#{$menuLayer} + 2);
}
.flyout-item:hover > .flyout-level-3,
.flyout-item:focus-within > .flyout-level-3,
.flyout-item.is-open > .flyout-level-3 {
display: block;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 1024px) {
.mega-menu-top-level {
padding: 0 12px;
}
.mega-menu-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 20px;
}
}
@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: $neutralPrimary;
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 {
display: none;
flex-direction: column;
padding: 0;
border-top: 1px solid $neutralLight;
}
.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 $neutralLight;
}
.menu-item-link,
.menu-item-text {
min-height: 40px;
padding: 0 12px;
font-size: 13px;
}
.menu-item-toggle {
width: 44px;
margin: 0;
border-left: 0;
}
.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;
}
.mega-menu-mode-flyout .mega-menu-flyout {
position: fixed;
right: 8px;
left: 8px;
width: auto;
}
.flyout-level-3 {
position: static;
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) {
.mega-menu,
.menu-item-link,
.menu-item-text,
.flyout-link {
transition: none;
}
}
@media (forced-colors: active) {
.menu-item-link:focus,
.menu-item-text:focus,
.mega-menu-category a:focus,
.flyout-link:focus {
outline: 2px solid CanvasText;
}
} }
} }
} /* End :global */
@@ -15,6 +15,7 @@ import { TaxonomyNavigationService } from '../../services/TaxonomyNavigationServ
import { MegaMenuRenderer } from './MegaMenuRenderer'; import { MegaMenuRenderer } from './MegaMenuRenderer';
import { IMenuItem } from '../../services/IMenuItem'; import { IMenuItem } from '../../services/IMenuItem';
import { debugError, debugLog, debugWarn } from '../../services/MegaMenuDebug'; import { debugError, debugLog, debugWarn } from '../../services/MegaMenuDebug';
import { MegaMenuMode, normalizeMegaMenuMode } from './MegaMenuMode';
import './MegaMenu.module.scss'; import './MegaMenu.module.scss';
@@ -26,6 +27,8 @@ export interface IMegaMenuApplicationCustomizerProperties {
termSetId?: string; termSetId?: string;
cacheMinutes?: number; cacheMinutes?: number;
debug?: boolean; debug?: boolean;
menuMode?: MegaMenuMode;
cacheVersion?: string;
} }
export default class MegaMenuApplicationCustomizer export default class MegaMenuApplicationCustomizer
@@ -40,7 +43,7 @@ export default class MegaMenuApplicationCustomizer
const debug: boolean = this._isDebugEnabled(); const debug: boolean = this._isDebugEnabled();
debugLog(debug, LOG_SOURCE, 'onInit started.', { debugLog(debug, LOG_SOURCE, 'onInit started.', {
componentId: UserCustomActionMegaMenuId, componentId: UserCustomActionMegaMenuId,
version: '2.0.1', version: '2.2.0',
properties: this.properties || {}, properties: this.properties || {},
pageUrl: window.location.href, pageUrl: window.location.href,
webUrl: this.context.pageContext.web.absoluteUrl, webUrl: this.context.pageContext.web.absoluteUrl,
@@ -92,7 +95,8 @@ export default class MegaMenuApplicationCustomizer
debugLog(debug, LOG_SOURCE, 'Starting MegaMenu rendering.', { debugLog(debug, LOG_SOURCE, 'Starting MegaMenu rendering.', {
termSetIdentifier: termSetIdentifier, termSetIdentifier: termSetIdentifier,
cacheMinutes: this._getCacheMinutes() cacheMinutes: this._getCacheMinutes(),
menuMode: this._getMenuMode()
}); });
this._renderMegaMenu(termSetIdentifier, debug); this._renderMegaMenu(termSetIdentifier, debug);
} else { } else {
@@ -105,13 +109,16 @@ export default class MegaMenuApplicationCustomizer
return; return;
} }
this._renderStatus(strings.Loading);
try { try {
debugLog(debug, LOG_SOURCE, 'Creating TaxonomyNavigationService.'); debugLog(debug, LOG_SOURCE, 'Creating TaxonomyNavigationService.');
const taxonomyService: TaxonomyNavigationService = new TaxonomyNavigationService( const taxonomyService: TaxonomyNavigationService = new TaxonomyNavigationService(
this.context, this.context,
termSetIdentifier, termSetIdentifier,
debug, debug,
this._getCacheMinutes() this._getCacheMinutes(),
this._getCacheVersion(),
this._getLanguageLcid()
); );
debugLog(debug, LOG_SOURCE, 'Requesting navigation items from taxonomy.'); debugLog(debug, LOG_SOURCE, 'Requesting navigation items from taxonomy.');
@@ -120,6 +127,11 @@ export default class MegaMenuApplicationCustomizer
topLevelItemCount: menuItems.length topLevelItemCount: menuItems.length
}); });
if (!menuItems || menuItems.length === 0) {
this._renderStatus(strings.EmptyNavigation);
return;
}
if (this._renderer) { if (this._renderer) {
debugLog(debug, LOG_SOURCE, 'Disposing the previous renderer instance.'); debugLog(debug, LOG_SOURCE, 'Disposing the previous renderer instance.');
this._renderer.dispose(); this._renderer.dispose();
@@ -127,7 +139,19 @@ export default class MegaMenuApplicationCustomizer
this._renderer = new MegaMenuRenderer( this._renderer = new MegaMenuRenderer(
menuItems, menuItems,
debug debug,
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); this._menuContainer = this._getOrCreateContainer('CustomHeader', this._topPlaceholder);
@@ -205,6 +229,23 @@ export default class MegaMenuApplicationCustomizer
return isFinite(value) ? Math.min(Math.max(Math.floor(value), 1), 1440) : 15; return isFinite(value) ? Math.min(Math.max(Math.floor(value), 1), 1440) : 15;
} }
private _getMenuMode(): MegaMenuMode {
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 { private _renderStatus(message: string): void {
if (!this._topPlaceholder) { if (!this._topPlaceholder) {
return; return;
+5
View File
@@ -0,0 +1,5 @@
export type MegaMenuMode = 'megaMenu' | 'flyout';
export function normalizeMegaMenuMode(value?: string): MegaMenuMode {
return value && value.toLowerCase() === 'flyout' ? 'flyout' : 'megaMenu';
}
+287 -71
View File
@@ -5,22 +5,37 @@
import { IMenuItem } from '../../services/IMenuItem'; import { IMenuItem } from '../../services/IMenuItem';
import { debugLog } from '../../services/MegaMenuDebug'; import { debugLog } from '../../services/MegaMenuDebug';
import { MegaMenuMode } from './MegaMenuMode';
import styles from './MegaMenu.module.scss'; // tslint:disable-line:no-unused-variable import { isNavigationUrlActive } from '../../services/MegaMenuCore';
const LOG_SOURCE: string = 'MegaMenuRenderer'; 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 { export class MegaMenuRenderer {
private static readonly hoverOpenDelayMs: number = 140; private static readonly hoverOpenDelayMs: number = 0;
private static readonly hoverCloseDelayMs: number = 180; private static readonly hoverCloseDelayMs: number = 0;
private _container: HTMLElement | undefined; private _container: HTMLElement | undefined;
private _documentKeydownHandler: ((event: KeyboardEvent) => void) | undefined; private _documentKeydownHandler: ((event: KeyboardEvent) => void) | undefined;
private _documentClickHandler: ((event: MouseEvent) => void) | undefined; private _documentClickHandler: ((event: MouseEvent) => void) | undefined;
private _announcer: HTMLElement | undefined; private _announcer: HTMLElement | undefined;
private _mobileToggle: HTMLButtonElement | undefined;
constructor( constructor(
private menuItems: IMenuItem[], private menuItems: IMenuItem[],
private debug: boolean = false private debug: boolean = false,
private menuMode: MegaMenuMode = 'megaMenu',
private strings: IMegaMenuRendererStrings
) { } ) { }
public render(container: HTMLElement): void { public render(container: HTMLElement): void {
@@ -36,11 +51,24 @@ export class MegaMenuRenderer {
const nav = document.createElement('nav'); const nav = document.createElement('nav');
nav.id = 'Mega-Menu'; nav.id = 'Mega-Menu';
nav.className = 'mega-menu-main'; nav.className = this.joinClasses('mega-menu-main', 'mega-menu-mode-' + this.menuMode);
nav.setAttribute('aria-label', 'Hauptnavigation'); nav.setAttribute('data-menu-mode', this.menuMode);
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'); const topLevelUl = document.createElement('ul');
topLevelUl.className = 'mega-menu-top-level'; topLevelUl.className = 'mega-menu-top-level';
topLevelUl.id = 'Mega-Menu-Items';
this.menuItems.forEach(topLevelItem => { this.menuItems.forEach(topLevelItem => {
const topLevelLi = this.createTopLevelItem(topLevelItem); const topLevelLi = this.createTopLevelItem(topLevelItem);
@@ -75,6 +103,7 @@ export class MegaMenuRenderer {
this._announcer.parentElement.removeChild(this._announcer); this._announcer.parentElement.removeChild(this._announcer);
} }
this._announcer = undefined; this._announcer = undefined;
this._mobileToggle = undefined;
if (this._container) { if (this._container) {
this._container.innerHTML = ''; this._container.innerHTML = '';
} }
@@ -99,8 +128,21 @@ export class MegaMenuRenderer {
li.appendChild(topElement); li.appendChild(topElement);
if (hasChildren) { if (hasChildren) {
const megaMenu = this.createMegaMenu(item); const toggle: HTMLButtonElement = document.createElement('button');
li.appendChild(megaMenu); 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);
} }
return li; return li;
@@ -116,7 +158,9 @@ export class MegaMenuRenderer {
} else { } else {
element = document.createElement('span'); element = document.createElement('span');
element.className = 'menu-item-text'; element.className = 'menu-item-text';
element.setAttribute('tabindex', '0'); if (!hasChildren) {
element.setAttribute('tabindex', '0');
}
} }
element.textContent = item.label; element.textContent = item.label;
@@ -127,11 +171,6 @@ export class MegaMenuRenderer {
isCurrent ? 'is-current' : undefined isCurrent ? 'is-current' : undefined
); );
if (hasChildren) {
element.setAttribute('aria-haspopup', 'true');
element.setAttribute('aria-expanded', 'false');
}
if (isCurrent && item.url) { if (isCurrent && item.url) {
element.setAttribute('aria-current', 'page'); element.setAttribute('aria-current', 'page');
} }
@@ -140,6 +179,10 @@ export class MegaMenuRenderer {
element.title = item.hoverText; element.title = item.hoverText;
} }
if (item.url) {
this.applyLinkBehavior(element as HTMLAnchorElement, item);
}
return element; return element;
} }
@@ -147,7 +190,7 @@ export class MegaMenuRenderer {
const megaMenuDiv = document.createElement('div'); const megaMenuDiv = document.createElement('div');
megaMenuDiv.className = 'mega-menu'; megaMenuDiv.className = 'mega-menu';
megaMenuDiv.setAttribute('aria-expanded', 'false'); 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'); const gridDiv = document.createElement('div');
gridDiv.className = 'mega-menu-grid'; gridDiv.className = 'mega-menu-grid';
@@ -181,6 +224,7 @@ export class MegaMenuRenderer {
const link = document.createElement('a'); const link = document.createElement('a');
link.href = item.url; link.href = item.url;
link.textContent = item.label; link.textContent = item.label;
this.applyLinkBehavior(link, item);
link.className = this.joinClasses( link.className = this.joinClasses(
isActive ? 'is-active' : undefined, isActive ? 'is-active' : undefined,
isCurrent ? 'is-current' : undefined isCurrent ? 'is-current' : undefined
@@ -204,6 +248,13 @@ export class MegaMenuRenderer {
h3.appendChild(span); 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); categoryDiv.appendChild(h3);
if (item.hasChildren() && item.items && item.items.length > 0) { if (item.hasChildren() && item.items && item.items.length > 0) {
@@ -223,6 +274,7 @@ export class MegaMenuRenderer {
link.href = thirdLevelItem.url || '#'; link.href = thirdLevelItem.url || '#';
link.textContent = thirdLevelItem.label; link.textContent = thirdLevelItem.label;
this.applyLinkBehavior(link, thirdLevelItem);
link.className = this.joinClasses( link.className = this.joinClasses(
isLinkActive ? 'is-active' : undefined, isLinkActive ? 'is-active' : undefined,
isLinkCurrent ? 'is-current' : undefined isLinkCurrent ? 'is-current' : undefined
@@ -246,8 +298,98 @@ export class MegaMenuRenderer {
return categoryDiv; return categoryDiv;
} }
private createFlyoutMenu(parentItem: IMenuItem): HTMLDivElement {
const flyout = document.createElement('div');
flyout.className = 'mega-menu mega-menu-flyout';
flyout.setAttribute('aria-expanded', 'false');
flyout.setAttribute('aria-label', parentItem.label + ' ' + this.strings.submenuLabel);
const list = document.createElement('ul');
list.className = 'flyout-list flyout-level-2';
if (parentItem.items) {
parentItem.items.forEach(secondLevelItem => {
list.appendChild(this.createFlyoutItem(secondLevelItem));
});
}
flyout.appendChild(list);
return flyout;
}
private createFlyoutItem(item: IMenuItem): HTMLLIElement {
const li = document.createElement('li');
const hasChildren = item.hasChildren() && item.items && item.items.length > 0;
const isActive = this.isItemActive(item);
const isCurrent = this.isCurrentUrl(item.url);
li.className = this.joinClasses(
'flyout-item',
hasChildren ? 'has-children' : undefined,
isActive ? 'is-active' : undefined,
isCurrent ? 'is-current' : undefined
);
const element = this.createFlyoutElement(item, hasChildren, isActive, isCurrent);
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.id = childId;
childList.setAttribute('aria-label', item.label + ' ' + this.strings.submenuLabel);
item.items.forEach(thirdLevelItem => {
childList.appendChild(this.createFlyoutItem(thirdLevelItem));
});
li.appendChild(childList);
}
return li;
}
private createFlyoutElement(item: IMenuItem, hasChildren: boolean, isActive: boolean, isCurrent: boolean): HTMLElement {
const element: HTMLElement = item.url
? document.createElement('a')
: document.createElement('span');
if (item.url) {
(element as HTMLAnchorElement).href = item.url;
this.applyLinkBehavior(element as HTMLAnchorElement, item);
} else {
element.setAttribute('tabindex', '0');
}
element.textContent = item.label;
element.className = this.joinClasses(
'flyout-link',
hasChildren ? 'flyout-link-has-children' : undefined,
isActive ? 'is-active' : undefined,
isCurrent ? 'is-current' : undefined
);
if (isCurrent && item.url) {
element.setAttribute('aria-current', 'page');
}
if (item.hoverText) {
element.title = item.hoverText;
}
return element;
}
private attachEventListeners(): void { 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++) { for (let i = 0; i < headings.length; i++) {
const heading = headings[i] as HTMLElement; const heading = headings[i] as HTMLElement;
@@ -262,21 +404,17 @@ export class MegaMenuRenderer {
this.attachGlobalKeyboardNavigation(); this.attachGlobalKeyboardNavigation();
this.attachGlobalClickOutsideHandler(); this.attachGlobalClickOutsideHandler();
this.attachMobileToggle();
this.attachFlyoutToggles();
} }
private attachKeyboardNavigation(heading: HTMLElement, megaMenu: HTMLElement): void { private attachKeyboardNavigation(heading: HTMLElement, megaMenu: HTMLElement): void {
heading.addEventListener('click', (e: MouseEvent) => {
e.preventDefault();
this.toggleMegaMenu(heading, megaMenu);
});
heading.addEventListener('keydown', (e: KeyboardEvent) => { heading.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter') { if (e.key === 'ArrowDown') {
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') {
e.preventDefault(); e.preventDefault();
this.openMegaMenu(heading, megaMenu); this.openMegaMenu(heading, megaMenu);
this.focusFirstLink(megaMenu); this.focusFirstLink(megaMenu);
@@ -290,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', () => { heading.addEventListener('focus', () => {
debugLog(this.debug, LOG_SOURCE, 'Focus moved to:', heading.textContent); debugLog(this.debug, LOG_SOURCE, 'Focus moved to:', heading.textContent);
}); });
@@ -359,7 +491,7 @@ export class MegaMenuRenderer {
const isInsideThisMenu = megaMenu.contains(focusedElement); const isInsideThisMenu = megaMenu.contains(focusedElement);
const isOnThisTrigger = focusedElement === heading; const isOnThisTrigger = focusedElement === heading;
const isInAnyMegaMenu = focusedElement.closest('.mega-menu'); 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) { if (!isInsideThisMenu && !isOnThisTrigger && !isInAnyMegaMenu && !isOnAnyTopLevel) {
debugLog(this.debug, LOG_SOURCE, 'Closing menu because focus left the navigation.'); debugLog(this.debug, LOG_SOURCE, 'Closing menu because focus left the navigation.');
@@ -382,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.key === 'Tab') {
if (!e.shiftKey) { 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) { if (currentTopLevel) {
const parentLi = currentTopLevel.closest('li') as HTMLElement; const parentLi = currentTopLevel.closest('li') as HTMLElement;
const megaMenu = parentLi.querySelector('.mega-menu.js-open') as HTMLElement; const megaMenu = parentLi.querySelector('.mega-menu.js-open') as HTMLElement;
@@ -438,21 +591,88 @@ export class MegaMenuRenderer {
document.addEventListener('click', this._documentClickHandler); 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 { private openMegaMenu(trigger: HTMLElement, menu: HTMLElement): void {
this.closeAllMegaMenus(menu); this.closeAllMegaMenus(menu);
const label: string = this.getTriggerLabel(trigger);
trigger.setAttribute('aria-expanded', 'true'); trigger.setAttribute('aria-expanded', 'true');
trigger.setAttribute('aria-label', this.strings.closeSubmenu + ' ' + label);
(trigger.parentElement as HTMLElement).classList.add('is-open');
menu.setAttribute('aria-expanded', 'true'); menu.setAttribute('aria-expanded', 'true');
menu.classList.add('js-open'); menu.classList.add('js-open');
this.announce((trigger.textContent || '') + ' geöffnet'); this.announce(label + ' ' + this.strings.opened);
debugLog(this.debug, LOG_SOURCE, 'Menu opened:', trigger.textContent); debugLog(this.debug, LOG_SOURCE, 'Menu opened:', label);
} }
private closeMegaMenu(trigger: HTMLElement, menu: HTMLElement): void { private closeMegaMenu(trigger: HTMLElement, menu: HTMLElement): void {
const label: string = this.getTriggerLabel(trigger);
trigger.setAttribute('aria-expanded', 'false'); trigger.setAttribute('aria-expanded', 'false');
trigger.setAttribute('aria-label', this.strings.openSubmenu + ' ' + label);
(trigger.parentElement as HTMLElement).classList.remove('is-open');
menu.setAttribute('aria-expanded', 'false'); menu.setAttribute('aria-expanded', 'false');
menu.classList.remove('js-open'); menu.classList.remove('js-open');
this.announce((trigger.textContent || '') + ' geschlossen'); this.announce(label + ' ' + this.strings.closed);
debugLog(this.debug, LOG_SOURCE, 'Menu closed:', trigger.textContent); debugLog(this.debug, LOG_SOURCE, 'Menu closed:', label);
} }
private toggleMegaMenu(trigger: HTMLElement, menu: HTMLElement): void { private toggleMegaMenu(trigger: HTMLElement, menu: HTMLElement): void {
@@ -465,7 +685,7 @@ export class MegaMenuRenderer {
} }
private closeAllMegaMenus(exceptMenu?: HTMLElement): void { 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++) { for (let i = 0; i < allTriggers.length; i++) {
const trigger = allTriggers[i] as HTMLElement; const trigger = allTriggers[i] as HTMLElement;
const menu = trigger.nextElementSibling as HTMLElement; const menu = trigger.nextElementSibling as HTMLElement;
@@ -501,36 +721,7 @@ export class MegaMenuRenderer {
} }
private isCurrentUrl(url?: string): boolean { private isCurrentUrl(url?: string): boolean {
const currentPath = this.normalizePath(window.location.href); return isNavigationUrlActive(window.location.href, url || '', window.location.origin);
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 '';
}
} }
private getColumnCount(items?: IMenuItem[]): string { private getColumnCount(items?: IMenuItem[]): string {
@@ -539,6 +730,31 @@ export class MegaMenuRenderer {
return columnCount.toString(); 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 { private joinClasses(...classNames: Array<string | undefined>): string {
const filteredClassNames: string[] = []; const filteredClassNames: string[] = [];
+12 -1
View File
@@ -2,6 +2,17 @@ define([], function() {
return { return {
"Title": "MegaMenuApplicationCustomizer", "Title": "MegaMenuApplicationCustomizer",
"ConfigurationMissing": "Die Navigation ist nicht konfiguriert.", "ConfigurationMissing": "Die Navigation ist nicht konfiguriert.",
"LoadError": "Die Navigation konnte nicht geladen werden." "LoadError": "Die Navigation konnte nicht geladen werden.",
"Loading": "Navigation wird geladen…",
"EmptyNavigation": "Die Navigation enthält keine sichtbaren Einträge.",
"NavigationLabel": "Hauptnavigation",
"OpenNavigation": "Hauptnavigation öffnen",
"CloseNavigation": "Hauptnavigation schließen",
"OpenSubmenu": "Untermenü öffnen für",
"CloseSubmenu": "Untermenü schließen für",
"SubmenuLabel": "Untermenü",
"ExternalLink": "externer Link",
"Opened": "geöffnet",
"Closed": "geschlossen"
}; };
}); });
+12 -1
View File
@@ -2,6 +2,17 @@ define([], function() {
return { return {
"Title": "MegaMenuApplicationCustomizer", "Title": "MegaMenuApplicationCustomizer",
"ConfigurationMissing": "Navigation is not configured.", "ConfigurationMissing": "Navigation is not configured.",
"LoadError": "Navigation could not be loaded." "LoadError": "Navigation could not be loaded.",
"Loading": "Loading navigation…",
"EmptyNavigation": "The navigation does not contain any visible entries.",
"NavigationLabel": "Main navigation",
"OpenNavigation": "Open main navigation",
"CloseNavigation": "Close main navigation",
"OpenSubmenu": "Open submenu for",
"CloseSubmenu": "Close submenu for",
"SubmenuLabel": "submenu",
"ExternalLink": "external link",
"Opened": "opened",
"Closed": "closed"
} }
}); });
+11
View File
@@ -2,6 +2,17 @@ declare interface IMegaMenuApplicationCustomizerStrings {
Title: string; Title: string;
ConfigurationMissing: string; ConfigurationMissing: string;
LoadError: 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' { declare module 'MegaMenuApplicationCustomizerStrings' {
+1 -15
View File
@@ -1,20 +1,6 @@
// tslint:disable:max-line-length no-any // tslint:disable:max-line-length no-any
// A file is required to be in the root of the /src directory by the TypeScript compiler // A file is required to be in the root of the /src directory by the TypeScript compiler
// Export services for classic SharePoint usage
export { TaxonomyNavigationService } from './services/TaxonomyNavigationService'; export { TaxonomyNavigationService } from './services/TaxonomyNavigationService';
export { MegaMenuRenderer } from './extensions/megaMenu/MegaMenuRenderer'; export { MegaMenuRenderer } from './extensions/megaMenu/MegaMenuRenderer';
export { UserCustomActionService } from './services/UserCustomActionService/UserCustomActionService'; export { MegaMenuMode, normalizeMegaMenuMode } from './extensions/megaMenu/MegaMenuMode';
export { UserCustomActionScope } from './services/UserCustomActionService/UserCustomActionScope';
// Expose services globally for classic JavaScript consumption
declare var window: any;
if (typeof window !== 'undefined') {
window.__megaMenuServices = {
TaxonomyNavigationService: require('./services/TaxonomyNavigationService').TaxonomyNavigationService,
MegaMenuRenderer: require('./extensions/megaMenu/MegaMenuRenderer').MegaMenuRenderer,
UserCustomActionService: require('./services/UserCustomActionService/UserCustomActionService').UserCustomActionService,
UserCustomActionScope: require('./services/UserCustomActionService/UserCustomActionScope').UserCustomActionScope
};
}
+3
View File
@@ -3,6 +3,9 @@ export interface IMenuItem {
label: string; label: string;
icon?: string; icon?: string;
hoverText: string; hoverText: string;
description?: string;
openInNewWindow?: boolean;
external?: boolean;
url?: string; url?: string;
pathDepth: number; pathDepth: number;
items?: IMenuItem[]; items?: IMenuItem[];
+11
View File
@@ -78,11 +78,22 @@ export interface ITerm {
PathDepth?: number; PathDepth?: number;
ParentId?: string; ParentId?: string;
TermsCount?: number; TermsCount?: number;
Labels?: ITermLabelCollection;
LocalCustomProperties?: { LocalCustomProperties?: {
[property: string]: any [property: string]: any
}; };
} }
export interface ITermLabelCollection {
_Child_Items_: ITermLabel[];
}
export interface ITermLabel {
Language: number;
Value: string;
IsDefaultForLanguage?: boolean;
}
export interface ISuggestTerm { export interface ISuggestTerm {
Id: string; Id: string;
DefaultLabel: string; DefaultLabel: string;
@@ -5,4 +5,7 @@ export interface ISPTermStorePickerServiceProps {
hideTagsNotAvailableForTagging: boolean; hideTagsNotAvailableForTagging: boolean;
anchorId: string; anchorId: string;
cacheMinutes: number; cacheMinutes: number;
siteCollectionUrl: string;
languageLcid: number;
cacheVersion: string;
} }
-9
View File
@@ -1,9 +0,0 @@
export class ItemDictionary<T> {
public Get(key: string): T {
return this[key];
}
public Add(key: string, value: T): void {
this[key] = value;
}
}
+194
View File
@@ -0,0 +1,194 @@
import { ITerm, ITermLabelCollection } from './ISPTermStorePickerService';
export const MegaMenuCacheSchemaVersion: string = '4';
export const MegaMenuTermProperties = {
hidden: 'MegaMenu.Hidden',
openInNewWindow: 'MegaMenu.OpenInNewWindow',
description: 'MegaMenu.Description',
excludedProviders: '_Sys_Nav_ExcludedProviders'
};
export const GlobalNavigationTaxonomyProvider: string = 'GlobalNavigationTaxonomyProvider';
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 isTermExcludedFromGlobalNavigation(term: ITerm): boolean {
const value: string = getTermProperty(term, MegaMenuTermProperties.excludedProviders);
if (!value) {
return false;
}
const providers: string[] = value.replace(/["']/g, '').split(/[;,|]/);
const expected: string = GlobalNavigationTaxonomyProvider.toLowerCase();
return providers.some((provider: string): boolean => provider.trim().toLowerCase() === expected);
}
export function getLocalizedTermLabel(term: ITerm, languageLcid: number): string {
const labels: ITermLabelCollection | undefined = term && term.Labels;
const items = labels && labels._Child_Items_ ? labels._Child_Items_ : [];
let fallback: string = term && term.Name ? term.Name : '';
for (let i: number = 0; i < items.length; i++) {
const labelLanguage: number = Number(items[i].Language);
if (labelLanguage === languageLcid && items[i].Value) {
return items[i].Value;
}
if (items[i].IsDefaultForLanguage && items[i].Value) {
fallback = items[i].Value;
}
}
return fallback;
}
export function resolveNavigationUrl(rawUrl: string, siteCollectionUrl: string): string | undefined {
const value: string = String(rawUrl || '').trim();
if (!value) {
return undefined;
}
if (/[\u0000-\u001f\u007f]/.test(value)) {
return undefined;
}
const resolved: string = value.toLowerCase().indexOf('~sitecollection') === 0
? String(siteCollectionUrl || '').replace(/\/+$/, '') + value.substring('~sitecollection'.length)
: value;
const protocolMatch: RegExpMatchArray | null = resolved.match(/^([a-z][a-z0-9+.-]*):/i);
if (protocolMatch) {
const protocol: string = protocolMatch[1].toLowerCase();
if (protocol !== 'http' && protocol !== 'https' && protocol !== 'mailto') {
return undefined;
}
}
return resolved;
}
export function isExternalNavigationUrl(url: string, currentOrigin: string): boolean {
const absoluteMatch: RegExpMatchArray | null = String(url || '').match(/^(?:(https?):)?\/\/([^/]+)/i);
if (!absoluteMatch) {
return false;
}
const originMatch: RegExpMatchArray | null = String(currentOrigin || '').match(/^(https?):\/\/([^/]+)/i);
if (!originMatch) {
return true;
}
const targetProtocol: string = absoluteMatch[1] || originMatch[1];
return targetProtocol.toLowerCase() !== originMatch[1].toLowerCase()
|| absoluteMatch[2].toLowerCase() !== originMatch[2].toLowerCase();
}
export function normalizeNavigationPath(url: string, currentOrigin: string): string {
let value: string = String(url || '').trim();
if (!value) {
return '';
}
const origin: string = String(currentOrigin || '').replace(/\/+$/, '');
if (origin && value.toLowerCase().indexOf(origin.toLowerCase()) === 0) {
value = value.substring(origin.length) || '/';
} else {
value = value.replace(/^https?:\/\/[^/]+/i, '');
}
value = value.split('#')[0].split('?')[0].replace(/\/+$/, '');
return (value || '/').toLowerCase();
}
export function isNavigationUrlActive(currentUrl: string, itemUrl: string, currentOrigin: string): boolean {
const currentPath: string = normalizeNavigationPath(currentUrl, currentOrigin);
const itemPath: string = normalizeNavigationPath(itemUrl, currentOrigin);
if (!currentPath || !itemPath) {
return false;
}
return itemPath === '/' ? currentPath === '/' : currentPath === itemPath || currentPath.indexOf(itemPath + '/') === 0;
}
export function buildTermCacheKey(
siteCollectionUrl: string,
termSetId: string,
languageLcid: number,
cacheVersion?: string
): string {
const normalize = (value: string): string => encodeURIComponent(String(value || '').trim().toLowerCase());
return 'MegaMenu:TermSet:v' + MegaMenuCacheSchemaVersion + ':'
+ normalize(siteCollectionUrl) + ':'
+ normalize(termSetId) + ':'
+ String(languageLcid || 0) + ':'
+ normalize(cacheVersion || '1');
}
export interface IMegaMenuHierarchyEntry<T> {
id: string;
parentId?: string;
depth: number;
item: T;
}
export function buildMenuHierarchy<T extends { items?: T[] }>(entries: Array<IMegaMenuHierarchyEntry<T>>): T[] {
const byId: { [id: string]: T } = {};
const roots: T[] = [];
for (let i: number = 0; i < entries.length; i++) {
byId[String(entries[i].id || '').toLowerCase()] = entries[i].item;
entries[i].item.items = [];
}
for (let i: number = 0; i < entries.length; i++) {
const entry: IMegaMenuHierarchyEntry<T> = entries[i];
const parent: T = entry.parentId ? byId[String(entry.parentId).toLowerCase()] : undefined;
if (entry.depth <= 1) {
roots.push(entry.item);
} else if (parent) {
parent.items.push(entry.item);
}
}
return roots;
}
export function compareTermsByConfiguredOrder(a: ITerm, b: ITerm): number {
const aIndex: number = typeof a.CustomSortOrderIndex === 'number' ? a.CustomSortOrderIndex : -1;
const bIndex: number = typeof b.CustomSortOrderIndex === 'number' ? b.CustomSortOrderIndex : -1;
if (aIndex >= 0 || bIndex >= 0) {
if (aIndex < 0) { return 1; }
if (bIndex < 0) { return -1; }
return aIndex - bIndex;
}
const aPath: string = String(a.PathOfTerm || '').toLowerCase();
const bPath: string = String(b.PathOfTerm || '').toLowerCase();
return aPath < bPath ? -1 : (aPath > bPath ? 1 : 0);
}
+25 -9
View File
@@ -2,26 +2,37 @@
import { IMenuItem } from './IMenuItem'; import { IMenuItem } from './IMenuItem';
import { ITerm } from './ISPTermStorePickerService'; import { ITerm } from './ISPTermStorePickerService';
import {
getLocalizedTermLabel,
getTermProperty,
isExternalNavigationUrl,
MegaMenuTermProperties,
parseBooleanProperty,
resolveNavigationUrl
} from './MegaMenuCore';
export class MenuItem implements IMenuItem { export class MenuItem implements IMenuItem {
public id: string; public id: string;
public label: string; public label: string;
public hoverText: string; public hoverText: string;
public description: string;
public openInNewWindow: boolean;
public external: boolean;
public pathDepth: number; public pathDepth: number;
public url?: string; public url?: string;
public items?: IMenuItem[]; 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.id = term.Id;
this.label = term.Name; this.label = getLocalizedTermLabel(term, languageLcid);
this.hoverText = term.LocalCustomProperties['_Sys_Nav_HoverText']; 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; this.pathDepth = term.PathDepth;
const rawUrl: string = term.LocalCustomProperties['_Sys_Nav_SimpleLinkUrl'] || term.LocalCustomProperties['_Sys_Nav_TargetUrl']; const rawUrl: string = properties['_Sys_Nav_SimpleLinkUrl'] || properties['_Sys_Nav_TargetUrl'];
if (rawUrl) { this.url = resolveNavigationUrl(rawUrl, siteCollectionUrl || '');
this.url = siteCollectionUrl && rawUrl.indexOf('~sitecollection') === 0 this.external = !!this.url && isExternalNavigationUrl(this.url, this.getOrigin(siteCollectionUrl || ''));
? siteCollectionUrl + rawUrl.substring('~sitecollection'.length)
: rawUrl;
}
this.items = []; this.items = [];
} }
@@ -34,4 +45,9 @@ export class MenuItem implements IMenuItem {
(window as any).location.href = this.url; (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] : '';
}
} }
@@ -1,106 +0,0 @@
import { IMenuItem } from './IMenuItem';
import { ITaxonomyNavigationService } from './ITaxonomyNavigationService';
import * as uuid from 'uuid';
export default class MockTaxonomyNavigationService implements ITaxonomyNavigationService {
public getMenuItems(): Promise<IMenuItem[]> {
return new Promise<IMenuItem[]>((resolve) => {
resolve([
{
id: uuid.v4(),
label: 'Menu Item 1',
url: 'https://www.bing.com',
hoverText: 'Hover me!',
pathDepth: 1,
hasChildren: () => true,
items: [
{
id: uuid.v4(),
label: 'Submenu Item 1',
hoverText: 'Huch!',
url: 'https://www.bing.com',
pathDepth: 2,
hasChildren: () => false
},
{
id: uuid.v4(),
label: 'Submenu Item 2',
hoverText: 'Huch!',
url: 'https://www.bing.com',
pathDepth: 2,
hasChildren: () => false
}
]
},
{
id: uuid.v4(),
label: 'Menu Item 2',
hoverText: 'Huch!',
url: 'https://www.bing.com',
pathDepth: 1,
hasChildren: () => false
},
{
id: uuid.v4(),
label: 'Menu Item 3',
hoverText: 'Huch!',
url: 'https://www.bing.com',
pathDepth: 1,
hasChildren: () => true,
items: [
{
id: uuid.v4(),
label: 'Submenu Item 1',
hoverText: 'Huch!',
url: 'https://www.bing.com',
pathDepth: 2,
hasChildren: () => false
},
{
id: uuid.v4(),
label: 'Submenu Item 2',
hoverText: 'Huch!',
url: 'https://www.bing.com',
pathDepth: 2,
hasChildren: () => true,
items: [
{
id: uuid.v4(),
label: 'Submenu Item 1',
hoverText: 'Huch!',
url: 'https://www.bing.com',
pathDepth: 3,
hasChildren: () => false
},
{
id: uuid.v4(),
label: 'Submenu Item 2',
hoverText: 'Huch!',
url: 'https://www.bing.com',
pathDepth: 3,
hasChildren: () => false
}
]
},
{
id: uuid.v4(),
label: 'Submenu Item 3',
hoverText: 'Huch!',
url: 'https://www.bing.com',
pathDepth: 2,
hasChildren: () => false
},
{
id: uuid.v4(),
label: 'Submenu Item 4',
hoverText: 'Huch!',
url: 'https://www.bing.com',
pathDepth: 2,
hasChildren: () => false
}
]
}
]);
});
}
}
+30 -28
View File
@@ -9,9 +9,9 @@ import { ISPTermStorePickerServiceProps } from './ISPTermStorePickerServiceProps
import { IPickerTerm } from './IPickerTerm'; import { IPickerTerm } from './IPickerTerm';
import { ApplicationCustomizerContext } from '@microsoft/sp-application-base'; import { ApplicationCustomizerContext } from '@microsoft/sp-application-base';
import { debugError, debugLog, debugWarn } from './MegaMenuDebug'; import { debugError, debugLog, debugWarn } from './MegaMenuDebug';
import { buildTermCacheKey, compareTermsByConfiguredOrder, readCachedValue } from './MegaMenuCore';
const EmptyGuid: string = '00000000-0000-0000-0000-000000000000'; const EmptyGuid: string = '00000000-0000-0000-0000-000000000000';
const TermCachePrefix: string = 'MegaMenu:TermSet:v2:';
const PickerCachePrefix: string = 'MegaMenu:PickerTerms:v2:'; const PickerCachePrefix: string = 'MegaMenu:PickerTerms:v2:';
interface ITermCacheEntry { interface ITermCacheEntry {
@@ -41,7 +41,9 @@ export default class SPTermStorePickerService {
clientServiceUrl: this.clientServiceUrl, clientServiceUrl: this.clientServiceUrl,
termSetNameOrId: this.props.termsetNameOrID, termSetNameOrId: this.props.termsetNameOrID,
useSessionStorage: this.props.useSessionStorage, 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) { if (childTerms) {
debugLog(this.debug, 'SPTermStorePickerService', 'Returning terms from session cache.', { debugLog(this.debug, 'SPTermStorePickerService', 'Returning terms from session cache.', {
@@ -272,9 +275,10 @@ export default class SPTermStorePickerService {
expiresAt: Date.now() + ttlMinutes * 60 * 1000, expiresAt: Date.now() + ttlMinutes * 60 * 1000,
value: termStoreResultTermSet 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.', { debugLog(this.debug, 'SPTermStorePickerService', 'Term set stored in session cache.', {
key: TermCachePrefix + termsetId, key: cacheKey,
expiresAt: cacheEntry.expiresAt expiresAt: cacheEntry.expiresAt
}); });
} }
@@ -290,6 +294,12 @@ export default class SPTermStorePickerService {
debugWarn(this.debug, 'SPTermStorePickerService', 'ProcessQuery response contained no term set.'); debugWarn(this.debug, 'SPTermStorePickerService', 'ProcessQuery response contained no term set.');
return null; 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; return null;
} }
private getCachedTermSet(termId: string, useSessionStorage: boolean = true): ITermSet { private getCachedTermSet(termId: string, useSessionStorage: boolean = true, allowExpired: boolean = false): ITermSet {
try { try {
if (useSessionStorage && window.sessionStorage) { if (useSessionStorage && window.sessionStorage) {
const key: string = TermCachePrefix + termId; const key: string = this.getTermCacheKey(termId);
const terms: string = window.sessionStorage.getItem(key); const terms: string = window.sessionStorage.getItem(key);
if (!terms) { if (!terms) {
return null; return null;
} }
const cacheEntry: ITermCacheEntry = JSON.parse(terms) as ITermCacheEntry; const cachedValue: ITermSet = readCachedValue<ITermSet>(terms, Date.now(), allowExpired);
if (!cacheEntry || !cacheEntry.value || !cacheEntry.expiresAt || cacheEntry.expiresAt <= Date.now()) { if (!cachedValue && allowExpired) {
window.sessionStorage.removeItem(key); window.sessionStorage.removeItem(key);
return null;
} }
return cacheEntry.value; return cachedValue || null;
} else { } else {
return null; return null;
} }
@@ -473,6 +482,15 @@ export default class SPTermStorePickerService {
return Math.min(Math.max(Math.floor(value), 1), 1440); 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 { private ensureSuccessfulResponse(response: SPHttpClientResponse, operation: string): void {
if (!response.ok) { if (!response.ok) {
throw new Error(operation + ' request failed (' + response.status + ' ' + response.statusText + ').'); throw new Error(operation + ' request failed (' + response.status + ' ' + response.statusText + ').');
@@ -623,23 +641,7 @@ export default class SPTermStorePickerService {
* @param b term 2 * @param b term 2
*/ */
private sortTermByPath(a: ITerm, b: ITerm) { private sortTermByPath(a: ITerm, b: ITerm) {
if (a.CustomSortOrderIndex === -1) { return compareTermsByConfiguredOrder(a, b);
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;
}
} }
private convertTermToPickerTerm(term: ITerm): IPickerTerm { private convertTermToPickerTerm(term: ITerm): IPickerTerm {
+26 -18
View File
@@ -5,8 +5,13 @@ import { MenuItem } from './MenuItem';
import { ApplicationCustomizerContext } from '@microsoft/sp-application-base'; import { ApplicationCustomizerContext } from '@microsoft/sp-application-base';
import SPTermStorePickerService from './SPTermStorePickerService'; import SPTermStorePickerService from './SPTermStorePickerService';
import { ITerm, ITermSet } from './ISPTermStorePickerService'; import { ITerm, ITermSet } from './ISPTermStorePickerService';
import { ItemDictionary } from './ItemDictionary';
import { debugLog, debugWarn } from './MegaMenuDebug'; import { debugLog, debugWarn } from './MegaMenuDebug';
import {
buildMenuHierarchy,
IMegaMenuHierarchyEntry,
isTermExcludedFromGlobalNavigation,
isTermHidden
} from './MegaMenuCore';
const LOG_SOURCE: string = 'TaxonomyNavigationService'; const LOG_SOURCE: string = 'TaxonomyNavigationService';
@@ -16,7 +21,9 @@ export class TaxonomyNavigationService implements ITaxonomyNavigationService {
private context: ApplicationCustomizerContext, private context: ApplicationCustomizerContext,
private termSetNameOrId: string, private termSetNameOrId: string,
private debug: boolean = false, 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.', { debugLog(this.debug, LOG_SOURCE, 'Initializing taxonomy navigation service.', {
termSetNameOrId: this.termSetNameOrId, termSetNameOrId: this.termSetNameOrId,
@@ -34,7 +41,10 @@ export class TaxonomyNavigationService implements ITaxonomyNavigationService {
useSessionStorage: true, useSessionStorage: true,
hideDeprecatedTags: true, hideDeprecatedTags: true,
hideTagsNotAvailableForTagging: true, hideTagsNotAvailableForTagging: true,
cacheMinutes: cacheMinutes cacheMinutes: cacheMinutes,
siteCollectionUrl: this.context.pageContext.site.absoluteUrl,
languageLcid: this.languageLcid,
cacheVersion: this.cacheVersion
}, },
this.context, this.context,
this.debug this.debug
@@ -58,29 +68,27 @@ export class TaxonomyNavigationService implements ITaxonomyNavigationService {
termSetFound: !!termset, termSetFound: !!termset,
termCount: termset && termset.Terms ? termset.Terms.length : 0 termCount: termset && termset.Terms ? termset.Terms.length : 0
}); });
const itemsDict: ItemDictionary<IMenuItem> = new ItemDictionary<IMenuItem>(); const entries: Array<IMegaMenuHierarchyEntry<IMenuItem>> = [];
const menuItems: IMenuItem[] = [];
if (!termset || !termset.Terms || termset.Terms.length === 0) { if (!termset || !termset.Terms || termset.Terms.length === 0) {
debugWarn(this.debug, LOG_SOURCE, 'No terms found in the term set.'); 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.'); throw new Error('The configured navigation term set was not found or contains no terms.');
} }
termset.Terms.forEach((term: ITerm) => { termset.Terms.filter((term: ITerm) =>
const menuItem: IMenuItem = new MenuItem(term, 0, siteCollectionUrl); !isTermHidden(term) && !isTermExcludedFromGlobalNavigation(term)
itemsDict.Add(term.Id, menuItem); ).forEach((term: ITerm) => {
if (menuItem.pathDepth === 1) { const menuItem: IMenuItem = new MenuItem(term, 0, siteCollectionUrl, this.languageLcid);
menuItems.push(menuItem); entries.push({
} else { id: term.Id,
const parentItem: IMenuItem = itemsDict.Get(term.ParentId); parentId: term.ParentId,
if (parentItem) { depth: menuItem.pathDepth,
parentItem.items.push(menuItem); item: menuItem
} else { });
debugWarn(this.debug, LOG_SOURCE, 'Item without parent:', term.PathOfTerm);
}
}
}); });
const menuItems: IMenuItem[] = buildMenuHierarchy(entries);
debugLog(this.debug, LOG_SOURCE, 'Navigation hierarchy built.', { debugLog(this.debug, LOG_SOURCE, 'Navigation hierarchy built.', {
totalTerms: termset.Terms.length, totalTerms: termset.Terms.length,
topLevelItems: menuItems.length topLevelItems: menuItems.length
@@ -1,21 +0,0 @@
export interface IUserCustomActionProps {
Id?: string;
Title: string;
Name?: string;
Description?: string;
Location: string;
ScriptSrc?: string;
ScriptBlock?: string;
Url?: string;
Sequence?: number;
Group?: string;
ImageUrl?: string;
CommandUIExtension?: string;
RegistrationType?: number;
RegistrationId?: string;
Rights?: {};
Scope?: number;
ClientSideComponentId?: string;
ClientSideComponentProperties?: string;
}
@@ -1,12 +0,0 @@
/* tslint:disable:max-line-length */
import { UserCustomActionScope } from './UserCustomActionScope';
import { IUserCustomActionProps } from './IUserCustomActionProps';
import { UserCustomActionAddResult, UserCustomActionUpdateResult } from '@pnp/sp';
export interface IUserCustomActionService {
getUserCustomActions(scope: UserCustomActionScope, listId?: string): Promise<IUserCustomActionProps[]>;
getUserCustomActionById(scope: UserCustomActionScope, id: string, listId?: string): Promise<IUserCustomActionProps>;
addUserCustomAction(scope: UserCustomActionScope, customAction: IUserCustomActionProps, listId?: string): Promise<UserCustomActionAddResult>;
updateUserCustomAction(scope: UserCustomActionScope, id: string, props: {}, listId?: string): Promise<UserCustomActionUpdateResult>;
deleteUserCustomAction(scope: UserCustomActionScope, customAction: IUserCustomActionProps, listId?: string): Promise<void>;
}
@@ -1,5 +0,0 @@
export enum UserCustomActionScope {
Web = 'web',
Site = 'site',
List = 'list'
}
@@ -1,134 +0,0 @@
// tslint:disable:max-line-length
// tslint:disable:export-name
import { UserCustomActionAddResult, UserCustomActions, UserCustomActionUpdateResult } from '@pnp/sp/src/usercustomactions';
import { IUserCustomActionService } from './IUserCustomActionService';
import { sp } from '@pnp/sp';
import { UserCustomActionScope } from './UserCustomActionScope';
import { IUserCustomActionProps } from './IUserCustomActionProps';
import { ApplicationCustomizerContext } from '@microsoft/sp-application-base';
import { debugError } from '../MegaMenuDebug';
const LOG_SOURCE: string = 'UserCustomActionService';
export class UserCustomActionService implements IUserCustomActionService {
constructor(context: ApplicationCustomizerContext, private debug: boolean = false) {
sp.setup({
spfxContext: context
});
}
public async getUserCustomActions(scope: UserCustomActionScope, listId?: string): Promise<IUserCustomActionProps[]> {
try {
let actions: UserCustomActions | IUserCustomActionProps[];
switch (scope) {
case UserCustomActionScope.Web:
actions = await sp.web.userCustomActions.get();
break;
case UserCustomActionScope.Site:
actions = await sp.site.userCustomActions.get();
break;
case UserCustomActionScope.List:
if (!listId) {
throw new Error('List ID is required for List scope');
}
actions = await sp.web.lists.getById(listId).userCustomActions.get();
break;
default:
throw new Error('Invalid scope');
}
return actions as IUserCustomActionProps[];
} catch (error) {
debugError(this.debug, LOG_SOURCE, 'Error getting user custom actions.', error);
throw error;
}
}
public async getUserCustomActionById(scope: UserCustomActionScope, id: string, listId?: string): Promise<IUserCustomActionProps> {
try {
switch (scope) {
case UserCustomActionScope.Web:
return sp.web.userCustomActions.getById(id) as {} as IUserCustomActionProps;
case UserCustomActionScope.Site:
return sp.site.userCustomActions.getById(id) as {} as IUserCustomActionProps;
case UserCustomActionScope.List:
if (!listId) {
throw new Error('List ID is required for List scope');
}
return sp.web.lists.getById(listId).userCustomActions.getById(id) as {} as IUserCustomActionProps;
default:
throw new Error('Invalid scope');
}
} catch (error) {
debugError(this.debug, LOG_SOURCE, 'Error getting user custom action by ID.', error);
throw error;
}
}
public async addUserCustomAction(scope: UserCustomActionScope, customAction: IUserCustomActionProps, listId?: string): Promise<UserCustomActionAddResult> {
try {
switch (scope) {
case UserCustomActionScope.Web:
return sp.web.userCustomActions.add(customAction);
case UserCustomActionScope.Site:
return sp.site.userCustomActions.add(customAction);
case UserCustomActionScope.List:
if (!listId) {
throw new Error('List ID is required for List scope');
}
return sp.web.lists.getById(listId).userCustomActions.add(customAction);
default:
throw new Error('Invalid scope');
}
} catch (error) {
debugError(this.debug, LOG_SOURCE, 'Error adding user custom action.', error);
throw error;
}
}
public async updateUserCustomAction(scope: UserCustomActionScope, id: string, props: {}, listId?: string): Promise<UserCustomActionUpdateResult> {
try {
let result: UserCustomActionUpdateResult;
switch (scope) {
case UserCustomActionScope.Web:
result = await sp.web.userCustomActions.getById(id).update(props);
break;
case UserCustomActionScope.Site:
result = await sp.site.userCustomActions.getById(id).update(props);
break;
case UserCustomActionScope.List:
if (!listId) {
throw new Error('List ID is required for List scope');
}
result = await sp.web.lists.getById(listId).userCustomActions.getById(id).update(props);
break;
default:
throw new Error('Invalid scope');
}
return result;
} catch (error) {
debugError(this.debug, LOG_SOURCE, 'Error updating user custom action.', error);
throw error;
}
}
public async deleteUserCustomAction(scope: UserCustomActionScope, customAction: IUserCustomActionProps, listId?: string): Promise<void> {
try {
switch (scope) {
case UserCustomActionScope.Web:
return sp.web.userCustomActions.getById(customAction.Id).delete();
case UserCustomActionScope.Site:
return sp.site.userCustomActions.getById(customAction.Id).delete();
case UserCustomActionScope.List:
if (!listId) {
throw new Error('List ID is required for List scope');
}
return sp.web.lists.getById(listId).userCustomActions.getById(customAction.Id).delete();
default:
throw new Error('Invalid scope');
}
} catch (error) {
debugError(this.debug, LOG_SOURCE, 'Error deleting user custom action.', error);
throw error;
}
}
}
+105
View File
@@ -0,0 +1,105 @@
import {
buildMenuHierarchy,
buildTermCacheKey,
compareTermsByConfiguredOrder,
getLocalizedTermLabel,
getTermProperty,
isExternalNavigationUrl,
isNavigationUrlActive,
isTermExcludedFromGlobalNavigation,
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('global navigation provider excludes term', isTermExcludedFromGlobalNavigation(term({
LocalCustomProperties: { '_Sys_Nav_ExcludedProviders': 'GlobalNavigationTaxonomyProvider' }
})));
assert('global navigation provider is matched case-insensitively in provider list',
isTermExcludedFromGlobalNavigation(term({
LocalCustomProperties: {
'_Sys_Nav_ExcludedProviders': 'CurrentNavigationTaxonomyProvider; globalnavigationtaxonomyprovider'
}
})));
assert('different excluded provider keeps term visible', !isTermExcludedFromGlobalNavigation(term({
LocalCustomProperties: { '_Sys_Nav_ExcludedProviders': 'CurrentNavigationTaxonomyProvider' }
})));
assert('missing excluded provider keeps term visible', !isTermExcludedFromGlobalNavigation(term({
LocalCustomProperties: {}
})));
assert('description local term property', getTermProperty(term({ LocalCustomProperties: { 'MegaMenu.Description': 'Forms' } }), MegaMenuTermProperties.description) === 'Forms');
assert('open-in-new-window local term property', parseBooleanProperty(getTermProperty(term({ LocalCustomProperties: { 'MegaMenu.OpenInNewWindow': '1' } }), MegaMenuTermProperties.openInNewWindow)));
assert('site collection token is resolved',
resolveNavigationUrl('~sitecollection/SitePages/Home.aspx', 'https://portal/sites/hr') ===
'https://portal/sites/hr/SitePages/Home.aspx');
assert('relative URL is accepted', resolveNavigationUrl('/sites/hr', 'https://portal') === '/sites/hr');
assert('javascript URL is blocked', resolveNavigationUrl('javascript:alert(1)', 'https://portal') === undefined);
assert('data URL is blocked', resolveNavigationUrl('data:text/html,test', 'https://portal') === undefined);
assert('control characters in URL are blocked', resolveNavigationUrl('java\nscript:alert(1)', 'https://portal') === undefined);
assert('mailto URL is accepted', resolveNavigationUrl('mailto:test@example.com', 'https://portal') === 'mailto:test@example.com');
assert('external host is detected', isExternalNavigationUrl('https://example.com/a', 'https://portal'));
assert('protocol-relative external host is detected', isExternalNavigationUrl('//example.com/a', 'https://portal'));
assert('same origin is not external', !isExternalNavigationUrl('https://portal/a', 'https://portal'));
assert('current child page activates navigation parent',
isNavigationUrlActive('https://portal/sites/hr/docs/view.aspx?x=1', '/sites/hr/docs', 'https://portal'));
assert('similar path does not create false active state',
!isNavigationUrlActive('https://portal/sites/hr/documents', '/sites/hr/docs', 'https://portal'));
const keyA: string = buildTermCacheKey('https://portal/sites/a', 'ABC', 1031, '1');
const keyB: string = buildTermCacheKey('https://portal/sites/a', 'ABC', 1033, '1');
const keyC: string = buildTermCacheKey('https://portal/sites/a', 'ABC', 1031, '2');
assert('cache key includes language', keyA !== keyB);
assert('cache key includes configuration version', keyA !== keyC);
assert('cache key is deterministic', keyA === buildTermCacheKey('https://portal/sites/a', 'abc', 1031, '1'));
assert('valid cache value is returned', readCachedValue<string>(JSON.stringify({ expiresAt: 200, value: 'menu' }), 100, false) === 'menu');
assert('expired cache is rejected normally', readCachedValue<string>(JSON.stringify({ expiresAt: 50, value: 'menu' }), 100, false) === undefined);
assert('expired cache is available as fallback', readCachedValue<string>(JSON.stringify({ expiresAt: 50, value: 'menu' }), 100, true) === 'menu');
assert('malformed cache is rejected', readCachedValue<string>('{invalid', 100, true) === undefined);
const localized = term({
Name: 'Default',
Labels: { _Child_Items_: [
{ Language: 1033, Value: 'Company', IsDefaultForLanguage: true },
{ Language: 1031, Value: 'Unternehmen' }
] }
});
assert('German term label is selected', getLocalizedTermLabel(localized, 1031) === 'Unternehmen');
assert('default label is fallback', getLocalizedTermLabel(localized, 1040) === 'Company');
interface ITestItem { name: string; items?: ITestItem[]; }
const child: ITestItem = { name: 'child' };
const root: ITestItem = { name: 'root' };
const hierarchy: ITestItem[] = buildMenuHierarchy([
{ id: 'child', parentId: 'root', depth: 2, item: child },
{ id: 'root', depth: 1, item: root }
]);
assert('hierarchy works when child precedes parent', hierarchy.length === 1 && hierarchy[0].items[0] === child);
const sorted: ITerm[] = [
term({ PathOfTerm: 'B', CustomSortOrderIndex: 1 }),
term({ PathOfTerm: 'A', CustomSortOrderIndex: 0 })
].sort(compareTermsByConfiguredOrder);
assert('custom term order wins', sorted[0].PathOfTerm === 'A');
console.log('MegaMenu core tests passed:', passed);
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "../temp/tests",
"rootDir": "..",
"skipLibCheck": true,
"types": [],
"lib": ["es5", "dom", "es2015.collection"]
},
"files": [
"../src/services/ISPTermStorePickerService.ts",
"../src/services/MegaMenuCore.ts",
"MegaMenuCore.test.ts"
]
}
+59
View File
@@ -0,0 +1,59 @@
'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');
var modernStyles = read('src/extensions/megaMenu/MegaMenu.module.scss');
[
'white',
'neutralLighter',
'neutralLight',
'neutralTertiary',
'neutralSecondary',
'neutralPrimary',
'themePrimary',
'themeDark',
'themeLighterAlt'
].forEach(function (themeSlot) {
assert('modern styles use SharePoint theme slot ' + themeSlot,
modernStyles.indexOf('[theme: ' + themeSlot + ', default:') >= 0);
});
console.log('Static MegaMenu asset tests passed.');