6 Commits
17 changed files with 548 additions and 137 deletions
+23 -4
View File
@@ -1,7 +1,8 @@
# CurrentNavigation # CurrentNavigation
CurrentNavigation 1.0.0 ist ein SPFx-1.4.1-Application-Customizer für moderne Seiten in SharePoint Server CurrentNavigation 1.1.6 ist ein SPFx-1.4.1-Application-Customizer für moderne Seiten in SharePoint Server
Subscription Edition. Die Solution rendert im oberen Placeholder eine Navigation für das aktuelle Web. Subscription Edition. Die Solution ergänzt unterhalb der vorhandenen Einträge im modernen SiteNavigation-Bereich
einen Treeview für das aktuelle Web. Native Einträge wie Documents bleiben unverändert sichtbar.
## Term-Konfiguration ## Term-Konfiguration
@@ -11,8 +12,26 @@ Die Steuerung erfolgt über Local Custom Properties:
- CurrentNavigation.Hidden = true: Dieser Term und sein kompletter Unterbaum werden ausgeblendet. - CurrentNavigation.Hidden = true: Dieser Term und sein kompletter Unterbaum werden ausgeblendet.
Die URL des Root-Terms wird mit pageContext.web.absoluteUrl verglichen. Sowohl die Web-URL selbst als auch eine Die URL des Root-Terms wird mit pageContext.web.absoluteUrl verglichen. Sowohl die Web-URL selbst als auch eine
Homepage unterhalb der Web-URL werden erkannt. Der Root-Term wird standardmäßig nicht dargestellt; seine direkten Homepage unterhalb der Web-URL werden erkannt. Der nächstgelegene Root-Term gilt außerdem für alle darunterliegenden
Kinder bilden die erste Navigationsebene. Webs. Der Root-Term wird standardmäßig nicht dargestellt; seine direkten Kinder bilden die erste Navigationsebene.
`TopNavigation.Hidden` beeinflusst die Current Navigation nicht. Ausschließlich `CurrentNavigation.Hidden` blendet
einen Term und seinen Unterbaum aus.
Der Treeview unterstützt auf- und zuklappbare Zweige, aktive Seitenpfade sowie die Navigation mit den Pfeiltasten,
Pos1 und Ende.
Der initiale Aufklappzustand ist über `initialExpansion` steuerbar: `activePath` öffnet wie bisher nur den aktiven
Pfad, `expanded` öffnet alle Zweige und `collapsed` startet mit vollständig geschlossenen Zweigen.
Für `activePath` wird die Term-URL sowohl mit der aktuellen Seite als auch mit der aktuellen SharePoint-Web-URL
verglichen. Dadurch wird der Pfad auch auf modernen `SitePages` und in Listen oder Bibliotheken korrekt geöffnet.
Wenn kein passender Root-Term gefunden wird oder das Termset nicht geladen werden kann, bleibt ausschließlich die
native SharePoint-Sitenavigation sichtbar.
Für moderne Teamwebsites wird unter anderem das stabile Fabric-Muster
nav[role="navigation"].ms-Nav mit enthaltener ms-Nav-navItems-Liste erkannt. Dynamisch erzeugte Klassen wie root-70
werden nicht verwendet.
## Build und Bereitstellung ## Build und Bereitstellung
+5 -1
View File
@@ -1,14 +1,18 @@
# CurrentNavigation – Umsetzungsstand # CurrentNavigation – Umsetzungsstand
Zielversion: 1.0.0 Zielversion: 1.1.6
Status: Implementierung und lokale Tests abgeschlossen; Ship-Build auf Node 8 ausstehend Status: Implementierung und lokale Tests abgeschlossen; Ship-Build auf Node 8 ausstehend
- [x] SPFx Application Customizer für moderne Seiten - [x] SPFx Application Customizer für moderne Seiten
- [x] Ergänzendes Rendering unterhalb der nativen Einträge im modernen SiteNavigation-Container
- [x] Barrierearmer Treeview mit Tastatursteuerung
- [x] `activePath` über aktuelle Seiten- und Web-URL zuverlässig erkennen
- [x] Site-Collection-weite Registrierung - [x] Site-Collection-weite Registrierung
- [x] Termset-Picker über PortalSettings - [x] Termset-Picker über PortalSettings
- [x] Cache-Dauer, Cache-Version und Debug-Modus - [x] Cache-Dauer, Cache-Version und Debug-Modus
- [x] CurrentNavigation.Root und CurrentNavigation.Hidden - [x] CurrentNavigation.Root und CurrentNavigation.Hidden
- [x] URL-basierte Zuordnung zum aktuellen Web - [x] URL-basierte Zuordnung zum aktuellen Web
- [x] Vererbung des nächstgelegenen CurrentNavigation.Root auf darunterliegende Webs
- [x] Begrenzbare Navigationstiefe - [x] Begrenzbare Navigationstiefe
- [x] Sicherer DOM-Renderer ohne innerHTML - [x] Sicherer DOM-Renderer ohne innerHTML
- [x] Session-Cache mit Stale-Fallback - [x] Session-Cache mit Stale-Fallback
+1 -1
View File
@@ -3,7 +3,7 @@
"solution": { "solution": {
"name": "current-navigation-client-side-solution", "name": "current-navigation-client-side-solution",
"id": "bb8f9af5-b318-4111-8401-ce189cfe1b1a", "id": "bb8f9af5-b318-4111-8401-ce189cfe1b1a",
"version": "1.0.0.0", "version": "1.1.6.0",
"includeClientSideAssets": true, "includeClientSideAssets": true,
"skipFeatureDeployment": true "skipFeatureDeployment": true
}, },
+1 -1
View File
@@ -11,7 +11,7 @@
"properties": { "properties": {
"termSetId": "00000000-0000-0000-0000-000000000000", "termSetId": "00000000-0000-0000-0000-000000000000",
"cacheMinutes": 15, "cacheVersion": "1", "maxDepth": 3, "cacheMinutes": 15, "cacheVersion": "1", "maxDepth": 3,
"showRoot": false, "debug": true "showRoot": false, "initialExpansion": "activePath", "debug": true
} }
} }
} }
+2
View File
@@ -5,6 +5,7 @@ param(
[string]$CacheVersion = '1', [string]$CacheVersion = '1',
[ValidateRange(1, 8)][int]$MaxDepth = 3, [ValidateRange(1, 8)][int]$MaxDepth = 3,
[switch]$ShowRoot, [switch]$ShowRoot,
[ValidateSet('activePath', 'expanded', 'collapsed')][string]$InitialExpansion = 'activePath',
[switch]$EnableDebug [switch]$EnableDebug
) )
@@ -28,6 +29,7 @@ $properties = @{
cacheVersion = $(if ([string]::IsNullOrWhiteSpace($CacheVersion)) { '1' } else { $CacheVersion.Trim() }) cacheVersion = $(if ([string]::IsNullOrWhiteSpace($CacheVersion)) { '1' } else { $CacheVersion.Trim() })
maxDepth = $MaxDepth maxDepth = $MaxDepth
showRoot = [bool]$ShowRoot.IsPresent showRoot = [bool]$ShowRoot.IsPresent
initialExpansion = $InitialExpansion
debug = [bool]$EnableDebug.IsPresent debug = [bool]$EnableDebug.IsPresent
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "current-navigation", "name": "current-navigation",
"version": "1.0.0", "version": "1.1.6",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "current-navigation", "name": "current-navigation",
"version": "1.0.0", "version": "1.1.6",
"private": true, "private": true,
"main": "lib/index.js", "main": "lib/index.js",
"engines": { "engines": {
@@ -1,15 +1,15 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss'; @import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
:global { :global {
.current-navigation { #CurrentNavigationHost {
position: relative; width: 100%;
z-index: 299; }
.current-navigation {
width: 100%; width: 100%;
border-bottom: 1px solid $ms-color-neutralLight;
background: $ms-color-white;
color: $ms-color-neutralPrimary; color: $ms-color-neutralPrimary;
font-family: $ms-font-family-fallbacks; font-family: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif;
font-size: $ms-font-size-s; font-size: 14px;
} }
.current-navigation ul { .current-navigation ul {
@@ -18,74 +18,99 @@
list-style: none; list-style: none;
} }
.current-navigation-root { .current-navigation-group {
display: flex; padding-left: 20px !important;
min-height: 40px;
padding: 0 16px !important;
} }
.current-navigation li { .current-navigation-row {
position: relative;
}
.current-navigation a,
.current-navigation span {
display: flex; display: flex;
align-items: center; align-items: center;
min-height: 40px; min-height: 36px;
padding: 0 12px; }
.current-navigation-row:hover,
.current-navigation-row:focus-within {
background: $ms-color-neutralLighter;
color: $ms-color-themePrimary;
}
.current-navigation-row:active {
background: $ms-color-neutralLight;
}
.current-navigation-toggle,
.current-navigation-toggle-spacer {
flex: 0 0 32px;
width: 32px;
height: 36px;
}
.current-navigation-toggle {
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
}
.current-navigation-toggle:hover {
background: transparent;
color: inherit;
}
.current-navigation-chevron {
width: 7px;
height: 7px;
border-right: 1px solid currentColor;
border-bottom: 1px solid currentColor;
transform: rotate(-45deg);
}
.current-navigation-item[aria-expanded='true'] > .current-navigation-row .current-navigation-chevron {
transform: rotate(45deg);
}
.current-navigation-label {
display: flex;
flex: 1 1 auto;
align-items: center;
min-width: 0;
min-height: 36px;
padding: 0 12px 0 4px;
overflow: hidden;
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.current-navigation a:hover, .current-navigation-label:hover,
.current-navigation a:focus, .current-navigation-label:focus {
.current-navigation a.is-current { background: transparent;
color: $ms-color-themePrimary; color: inherit;
background: $ms-color-neutralLighter;
} }
.current-navigation a:focus { .current-navigation-label:focus {
outline: 2px solid $ms-color-themePrimary; outline: 2px solid $ms-color-themePrimary;
outline-offset: -2px; outline-offset: -2px;
} }
.current-navigation-children { .current-navigation-label.is-current {
position: absolute; background: $ms-color-neutralLighter;
top: 100%; color: $ms-color-themePrimary;
left: 0; font-weight: $ms-font-weight-semibold;
}
.current-navigation-group[hidden] {
display: none; display: none;
min-width: 220px;
border: 1px solid $ms-color-neutralLight;
background: $ms-color-white;
box-shadow: 0 4px 12px rgba(0, 0, 0, .16);
} }
.current-navigation-children .current-navigation-children { @media (forced-colors: active) {
top: 0; .current-navigation-label:focus { outline: 2px solid CanvasText; }
left: 100%;
}
.current-navigation li:hover > .current-navigation-children,
.current-navigation li:focus-within > .current-navigation-children {
display: block;
}
@media (max-width: 767px) {
.current-navigation-root {
flex-wrap: wrap;
padding: 0 8px !important;
}
.current-navigation-children,
.current-navigation-children .current-navigation-children {
position: static;
display: block;
min-width: 0;
border: 0;
box-shadow: none;
padding-left: 16px;
}
} }
} }
@@ -1,14 +1,11 @@
// tslint:disable:max-line-length variable-name // tslint:disable:max-line-length variable-name
import { override } from '@microsoft/decorators'; import { override } from '@microsoft/decorators';
import { BaseApplicationCustomizer, PlaceholderContent, PlaceholderName } from '@microsoft/sp-application-base'; import { BaseApplicationCustomizer } from '@microsoft/sp-application-base';
import * as strings from 'CurrentNavigationApplicationCustomizerStrings'; import * as strings from 'CurrentNavigationApplicationCustomizerStrings';
import { import { buildCurrentNavigationItems, selectCurrentNavigationRoot } from '../../services/CurrentNavigationCore';
buildCurrentNavigationItems,
selectCurrentNavigationRoot
} from '../../services/CurrentNavigationCore';
import { CurrentNavigationTaxonomyService } from '../../services/CurrentNavigationTaxonomyService'; import { CurrentNavigationTaxonomyService } from '../../services/CurrentNavigationTaxonomyService';
import { ICurrentNavigationItem, ICurrentNavigationTerm, ITermSetResult } from '../../services/CurrentNavigationTypes'; import { ICurrentNavigationItem, ICurrentNavigationTerm, ITermSetResult } from '../../services/CurrentNavigationTypes';
import { CurrentNavigationRenderer } from './CurrentNavigationRenderer'; import { CurrentNavigationInitialExpansion, CurrentNavigationRenderer } from './CurrentNavigationRenderer';
import './CurrentNavigation.module.scss'; import './CurrentNavigation.module.scss';
export interface ICurrentNavigationApplicationCustomizerProperties { export interface ICurrentNavigationApplicationCustomizerProperties {
@@ -18,43 +15,85 @@ export interface ICurrentNavigationApplicationCustomizerProperties {
cacheVersion?: string; cacheVersion?: string;
maxDepth?: number; maxDepth?: number;
showRoot?: boolean; showRoot?: boolean;
initialExpansion?: CurrentNavigationInitialExpansion;
debug?: boolean; debug?: boolean;
} }
export default class CurrentNavigationApplicationCustomizer export default class CurrentNavigationApplicationCustomizer
extends BaseApplicationCustomizer<ICurrentNavigationApplicationCustomizerProperties> { extends BaseApplicationCustomizer<ICurrentNavigationApplicationCustomizerProperties> {
private topPlaceholder: PlaceholderContent | undefined; private siteNavigation: HTMLElement | undefined;
private host: HTMLElement | undefined; private host: HTMLElement | undefined;
private observer: MutationObserver | undefined;
private mountPending: boolean = false;
private renderer: CurrentNavigationRenderer = new CurrentNavigationRenderer(); private renderer: CurrentNavigationRenderer = new CurrentNavigationRenderer();
private renderVersion: number = 0; private renderVersion: number = 0;
@override @override
public onInit(): Promise<void> { public onInit(): Promise<void> {
this.context.placeholderProvider.changedEvent.add(this, this.renderPlaceholders);
const application: any = this.context.application as any; // tslint:disable-line:no-any const application: any = this.context.application as any; // tslint:disable-line:no-any
if (application && application.navigatedEvent) { if (application && application.navigatedEvent) { application.navigatedEvent.add(this, this.onNavigated); }
application.navigatedEvent.add(this, this.onNavigated); this.observer = new MutationObserver(this.onDomChanged);
} this.observer.observe(document.body, { childList: true, subtree: true });
this.renderPlaceholders(); this.mountInSiteNavigation(true);
return Promise.resolve(); return Promise.resolve();
} }
private renderPlaceholders = (): void => { private onNavigated = (): void => { this.scheduleMount(true); }
if (!this.topPlaceholder) {
this.topPlaceholder = this.context.placeholderProvider.tryCreateContent( private onDomChanged = (): void => {
PlaceholderName.Top, { onDispose: this.onPlaceholderDisposed } if (!this.host || !document.body.contains(this.host)) { this.scheduleMount(true); }
);
}
if (!this.topPlaceholder) { return; }
if (!this.host) {
this.host = document.createElement('div');
this.host.id = 'CurrentNavigationHost';
this.topPlaceholder.domElement.appendChild(this.host);
}
this.renderNavigation();
} }
private onNavigated = (): void => { this.renderNavigation(); } private scheduleMount(forceRender: boolean): void {
if (this.mountPending) { return; }
this.mountPending = true;
window.setTimeout((): void => {
this.mountPending = false;
this.mountInSiteNavigation(forceRender);
}, 0);
}
private mountInSiteNavigation(forceRender: boolean): void {
const container: HTMLElement = this.findSiteNavigation();
if (!container) {
this.debug('The modern SiteNavigation container was not found.');
return;
}
if (this.siteNavigation !== container || !this.host || !container.contains(this.host)) {
this.detachHost();
this.siteNavigation = container;
this.host = document.createElement('div');
this.host.id = 'CurrentNavigationHost';
this.siteNavigation.appendChild(this.host);
forceRender = true;
}
if (forceRender) { this.renderNavigation(); }
}
private findSiteNavigation(): HTMLElement | undefined {
const selectors: string[] = [
'[data-automationid="SiteNavigation"]',
'[data-automation-id="SiteNavigation"]',
'nav[role="navigation"].ms-Nav',
'#spLeftNav',
'nav[aria-label="Site navigation"]',
'nav[aria-label="Websitenavigation"]'
];
for (let i: number = 0; i < selectors.length; i++) {
const candidates: NodeListOf<Element> = document.querySelectorAll(selectors[i]);
for (let j: number = 0; j < candidates.length; j++) {
const candidate: HTMLElement = candidates[j] as HTMLElement;
if (selectors[i] === 'nav[role="navigation"].ms-Nav' &&
!candidate.querySelector('.ms-Nav-navItems')) { continue; }
if (candidate.id !== 'CurrentNavigationHost' && !this.hostContains(candidate)) { return candidate; }
}
}
return undefined;
}
private hostContains(element: HTMLElement): boolean {
return !!(this.host && this.host.contains(element));
}
private async renderNavigation(): Promise<void> { private async renderNavigation(): Promise<void> {
const version: number = ++this.renderVersion; const version: number = ++this.renderVersion;
@@ -68,8 +107,7 @@ export default class CurrentNavigationApplicationCustomizer
} }
try { try {
const service: CurrentNavigationTaxonomyService = new CurrentNavigationTaxonomyService( const service: CurrentNavigationTaxonomyService = new CurrentNavigationTaxonomyService(
this.context, termSetId, this.cacheMinutes(), this.cacheVersion(), this.context, termSetId, this.cacheMinutes(), this.cacheVersion(), this.languageLcid(), this.isDebug()
this.languageLcid(), this.isDebug()
); );
const termSet: ITermSetResult = await service.getTerms(); const termSet: ITermSetResult = await service.getTerms();
if (version !== this.renderVersion || !this.host) { return; } if (version !== this.renderVersion || !this.host) { return; }
@@ -81,10 +119,14 @@ export default class CurrentNavigationApplicationCustomizer
termSet.Terms, root, this.context.pageContext.site.absoluteUrl, termSet.Terms, root, this.context.pageContext.site.absoluteUrl,
this.languageLcid(), this.maxDepth(), !!(this.properties && this.properties.showRoot) this.languageLcid(), this.maxDepth(), !!(this.properties && this.properties.showRoot)
); );
if (items.length) { this.renderer.render(this.host, items, strings.NavigationLabel); } if (items.length && this.host) {
} catch (error) { this.renderer.render(this.host, items, {
this.debug(strings.LoadError, error); navigationLabel: strings.NavigationLabel,
expand: strings.Expand,
collapse: strings.Collapse
}, this.initialExpansion(), this.context.pageContext.web.absoluteUrl);
} }
} catch (error) { this.debug(strings.LoadError, error); }
} }
private cacheMinutes(): number { private cacheMinutes(): number {
@@ -100,6 +142,11 @@ export default class CurrentNavigationApplicationCustomizer
? String(this.properties.cacheVersion).trim() : '1'; ? String(this.properties.cacheVersion).trim() : '1';
return value || '1'; return value || '1';
} }
private initialExpansion(): CurrentNavigationInitialExpansion {
const value: string = this.properties && this.properties.initialExpansion
? String(this.properties.initialExpansion) : 'activePath';
return value === 'expanded' || value === 'collapsed' ? value : 'activePath';
}
private languageLcid(): number { private languageLcid(): number {
const legacy: any = (this.context.pageContext as any).legacyPageContext; // tslint:disable-line:no-any const legacy: any = (this.context.pageContext as any).legacyPageContext; // tslint:disable-line:no-any
const value: number = legacy ? Number(legacy.currentLanguage) : 0; const value: number = legacy ? Number(legacy.currentLanguage) : 0;
@@ -109,17 +156,21 @@ export default class CurrentNavigationApplicationCustomizer
private debug(message: string, data?: any): void { // tslint:disable-line:no-any private debug(message: string, data?: any): void { // tslint:disable-line:no-any
if (this.isDebug() && window.console) { console.log('[CurrentNavigation] ' + message, data || ''); } if (this.isDebug() && window.console) { console.log('[CurrentNavigation] ' + message, data || ''); }
} }
private onPlaceholderDisposed = (): void => { private detachHost(): void {
if (this.host) { this.renderer.clear(this.host); } this.renderVersion++;
if (this.host) {
this.renderer.clear(this.host);
if (this.host.parentElement) { this.host.parentElement.removeChild(this.host); }
}
this.host = undefined; this.host = undefined;
this.topPlaceholder = undefined; this.siteNavigation = undefined;
} }
@override @override
protected onDispose(): void { protected onDispose(): void {
this.context.placeholderProvider.changedEvent.remove(this, this.renderPlaceholders);
const application: any = this.context.application as any; // tslint:disable-line:no-any const application: any = this.context.application as any; // tslint:disable-line:no-any
if (application && application.navigatedEvent) { application.navigatedEvent.remove(this, this.onNavigated); } if (application && application.navigatedEvent) { application.navigatedEvent.remove(this, this.onNavigated); }
this.onPlaceholderDisposed(); if (this.observer) { this.observer.disconnect(); this.observer = undefined; }
this.detachHost();
} }
} }
@@ -1,41 +1,216 @@
import { isCurrentNavigationUrl } from '../../services/CurrentNavigationCore';
import { ICurrentNavigationItem } from '../../services/CurrentNavigationTypes'; import { ICurrentNavigationItem } from '../../services/CurrentNavigationTypes';
import { normalizeNavigationPath } from '../../services/CurrentNavigationCore';
export interface ICurrentNavigationRendererStrings {
navigationLabel: string;
expand: string;
collapse: string;
}
export type CurrentNavigationInitialExpansion = 'activePath' | 'expanded' | 'collapsed';
export function isInitiallyExpanded(
initialExpansion: CurrentNavigationInitialExpansion,
containsCurrentItem: boolean
): boolean {
return initialExpansion === 'expanded' ||
(initialExpansion === 'activePath' && containsCurrentItem);
}
export class CurrentNavigationRenderer { export class CurrentNavigationRenderer {
public render(container: HTMLElement, items: ICurrentNavigationItem[], navigationLabel: string): void { private container: HTMLElement | undefined;
private keydownHandler: ((event: KeyboardEvent) => void) | undefined;
private strings: ICurrentNavigationRendererStrings;
private initialExpansion: CurrentNavigationInitialExpansion = 'activePath';
private currentWebUrl: string = '';
public render(
container: HTMLElement,
items: ICurrentNavigationItem[],
strings: ICurrentNavigationRendererStrings,
initialExpansion: CurrentNavigationInitialExpansion,
currentWebUrl?: string
): void {
this.clear(container); this.clear(container);
this.container = container;
this.strings = strings;
this.initialExpansion = initialExpansion;
this.currentWebUrl = currentWebUrl || window.location.href;
const nav: HTMLElement = document.createElement('nav'); const nav: HTMLElement = document.createElement('nav');
nav.className = 'current-navigation'; nav.className = 'current-navigation';
nav.setAttribute('aria-label', navigationLabel); nav.setAttribute('aria-label', strings.navigationLabel);
nav.appendChild(this.createList(items, 1)); nav.appendChild(this.createList(items, 1, true));
this.keydownHandler = this.onKeydown;
nav.addEventListener('keydown', this.keydownHandler);
container.appendChild(nav); container.appendChild(nav);
} }
public clear(container: HTMLElement): void { public clear(container: HTMLElement): void {
if (this.keydownHandler && this.container) {
const nav: HTMLElement = this.container.querySelector('.current-navigation') as HTMLElement;
if (nav) { nav.removeEventListener('keydown', this.keydownHandler); }
}
while (container && container.firstChild) { container.removeChild(container.firstChild); } while (container && container.firstChild) { container.removeChild(container.firstChild); }
this.keydownHandler = undefined;
this.container = undefined;
} }
private createList(items: ICurrentNavigationItem[], depth: number): HTMLUListElement { private createList(items: ICurrentNavigationItem[], depth: number, root: boolean): HTMLUListElement {
const list: HTMLUListElement = document.createElement('ul'); const list: HTMLUListElement = document.createElement('ul');
list.className = depth === 1 ? 'current-navigation-root' : 'current-navigation-children'; list.className = root ? 'current-navigation-tree' : 'current-navigation-group';
list.setAttribute('role', root ? 'tree' : 'group');
for (let i: number = 0; i < items.length; i++) { for (let i: number = 0; i < items.length; i++) {
const item: ICurrentNavigationItem = items[i]; list.appendChild(this.createItem(items[i], depth));
}
return list;
}
private createItem(item: ICurrentNavigationItem, depth: number): HTMLLIElement {
const entry: HTMLLIElement = document.createElement('li'); const entry: HTMLLIElement = document.createElement('li');
entry.className = item.items.length ? 'has-children' : ''; const hasChildren: boolean = item.items.length > 0;
const expanded: boolean = isInitiallyExpanded(this.initialExpansion, this.containsCurrentItem(item));
entry.className = hasChildren ? 'current-navigation-item has-children' : 'current-navigation-item';
entry.setAttribute('role', 'treeitem');
entry.setAttribute('aria-level', String(depth));
const row: HTMLDivElement = document.createElement('div');
row.className = 'current-navigation-row';
if (hasChildren) {
const toggle: HTMLButtonElement = document.createElement('button');
toggle.type = 'button';
toggle.className = 'current-navigation-toggle';
toggle.tabIndex = -1;
toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
toggle.setAttribute('aria-label', (expanded ? this.strings.collapse : this.strings.expand) + ' ' + item.label);
const chevron: HTMLSpanElement = document.createElement('span');
chevron.className = 'current-navigation-chevron';
chevron.setAttribute('aria-hidden', 'true');
toggle.appendChild(chevron);
toggle.addEventListener('click', (): void => {
this.setExpanded(entry, entry.getAttribute('aria-expanded') !== 'true', item.label);
});
row.appendChild(toggle);
entry.setAttribute('aria-expanded', expanded ? 'true' : 'false');
} else {
const spacer: HTMLSpanElement = document.createElement('span');
spacer.className = 'current-navigation-toggle-spacer';
spacer.setAttribute('aria-hidden', 'true');
row.appendChild(spacer);
}
const content: HTMLElement = item.url ? document.createElement('a') : document.createElement('span'); const content: HTMLElement = item.url ? document.createElement('a') : document.createElement('span');
content.className = 'current-navigation-label';
content.textContent = item.label; content.textContent = item.label;
if (item.url) { if (item.url) {
const link: HTMLAnchorElement = content as HTMLAnchorElement; const link: HTMLAnchorElement = content as HTMLAnchorElement;
link.href = item.url; link.href = item.url;
if (normalizeNavigationPath(window.location.href) === normalizeNavigationPath(item.url)) { if (this.isCurrentUrl(item.url)) {
link.setAttribute('aria-current', 'page'); link.setAttribute('aria-current', 'page');
link.className = 'is-current'; link.className += ' is-current';
}
} else {
content.setAttribute('tabindex', '0');
}
row.appendChild(content);
entry.appendChild(row);
if (hasChildren) {
const children: HTMLUListElement = this.createList(item.items, depth + 1, false);
children.hidden = !expanded;
entry.appendChild(children);
}
return entry;
}
private setExpanded(entry: HTMLLIElement, expanded: boolean, label?: string): void {
const row: HTMLElement = entry.firstElementChild as HTMLElement;
const group: HTMLUListElement = row && row.nextElementSibling as HTMLUListElement;
const toggle: HTMLButtonElement = row
? row.querySelector('.current-navigation-toggle') as HTMLButtonElement
: undefined;
entry.setAttribute('aria-expanded', expanded ? 'true' : 'false');
if (group && group.className.indexOf('current-navigation-group') >= 0) { group.hidden = !expanded; }
if (toggle) {
toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
toggle.setAttribute('aria-label', (expanded ? this.strings.collapse : this.strings.expand) + ' ' + (label || ''));
} }
} }
entry.appendChild(content);
if (item.items.length) { entry.appendChild(this.createList(item.items, depth + 1)); } private onKeydown = (event: KeyboardEvent): void => {
list.appendChild(entry); const target: HTMLElement = event.target as HTMLElement;
if (!target || target.className.indexOf('current-navigation-label') < 0) { return; }
const entry: HTMLLIElement = this.closestTreeItem(target);
if (!entry) { return; }
if (event.key === 'ArrowRight' && entry.hasAttribute('aria-expanded')) {
if (entry.getAttribute('aria-expanded') !== 'true') { this.setExpanded(entry, true, target.textContent); }
else { this.focusRelative(target, 1); }
event.preventDefault();
} else if (event.key === 'ArrowLeft') {
if (entry.getAttribute('aria-expanded') === 'true') { this.setExpanded(entry, false, target.textContent); }
else {
const parent: HTMLLIElement = this.parentTreeItem(entry);
if (parent) { (parent.querySelector('.current-navigation-label') as HTMLElement).focus(); }
} }
return list; event.preventDefault();
} else if (event.key === 'ArrowDown') {
this.focusRelative(target, 1); event.preventDefault();
} else if (event.key === 'ArrowUp') {
this.focusRelative(target, -1); event.preventDefault();
} else if (event.key === 'Home' || event.key === 'End') {
this.focusBoundary(event.key === 'Home'); event.preventDefault();
}
}
private visibleLabels(): HTMLElement[] {
if (!this.container) { return []; }
const labels: NodeListOf<Element> = this.container.querySelectorAll('.current-navigation-label');
const result: HTMLElement[] = [];
for (let i: number = 0; i < labels.length; i++) {
const label: HTMLElement = labels[i] as HTMLElement;
if (!this.hasHiddenParent(label)) { result.push(label); }
}
return result;
}
private focusRelative(current: HTMLElement, offset: number): void {
const labels: HTMLElement[] = this.visibleLabels();
const index: number = labels.indexOf(current);
if (index >= 0 && labels[index + offset]) { labels[index + offset].focus(); }
}
private focusBoundary(first: boolean): void {
const labels: HTMLElement[] = this.visibleLabels();
if (labels.length) { labels[first ? 0 : labels.length - 1].focus(); }
}
private hasHiddenParent(element: HTMLElement): boolean {
let current: HTMLElement = element.parentElement;
while (current && current !== this.container) {
if (current.hidden) { return true; }
current = current.parentElement;
}
return false;
}
private closestTreeItem(element: HTMLElement): HTMLLIElement {
let current: HTMLElement = element;
while (current && current !== this.container) {
if (current.getAttribute('role') === 'treeitem') { return current as HTMLLIElement; }
current = current.parentElement;
}
return undefined;
}
private parentTreeItem(entry: HTMLLIElement): HTMLLIElement {
let current: HTMLElement = entry.parentElement ? entry.parentElement.parentElement : undefined;
while (current && current !== this.container) {
if (current.getAttribute('role') === 'treeitem') { return current as HTMLLIElement; }
current = current.parentElement;
}
return undefined;
}
private containsCurrentItem(item: ICurrentNavigationItem): boolean {
if (item.url && this.isCurrentUrl(item.url)) { return true; }
for (let i: number = 0; i < item.items.length; i++) {
if (this.containsCurrentItem(item.items[i])) { return true; }
}
return false;
}
private isCurrentUrl(url: string): boolean {
return isCurrentNavigationUrl(url, window.location.href, this.currentWebUrl);
} }
} }
@@ -3,6 +3,8 @@ define([], function() {
"Title": "CurrentNavigationApplicationCustomizer", "Title": "CurrentNavigationApplicationCustomizer",
"NavigationLabel": "Navigation der aktuellen Website", "NavigationLabel": "Navigation der aktuellen Website",
"ConfigurationMissing": "Die aktuelle Navigation ist nicht konfiguriert.", "ConfigurationMissing": "Die aktuelle Navigation ist nicht konfiguriert.",
"LoadError": "Die aktuelle Navigation konnte nicht geladen werden." "LoadError": "Die aktuelle Navigation konnte nicht geladen werden.",
"Expand": "Aufklappen",
"Collapse": "Zuklappen"
}; };
}); });
@@ -3,6 +3,8 @@ define([], function() {
"Title": "CurrentNavigationApplicationCustomizer", "Title": "CurrentNavigationApplicationCustomizer",
"NavigationLabel": "Current site navigation", "NavigationLabel": "Current site navigation",
"ConfigurationMissing": "Current navigation is not configured.", "ConfigurationMissing": "Current navigation is not configured.",
"LoadError": "Current navigation could not be loaded." "LoadError": "Current navigation could not be loaded.",
"Expand": "Expand",
"Collapse": "Collapse"
}; };
}); });
+2
View File
@@ -3,6 +3,8 @@ declare interface ICurrentNavigationApplicationCustomizerStrings {
NavigationLabel: string; NavigationLabel: string;
ConfigurationMissing: string; ConfigurationMissing: string;
LoadError: string; LoadError: string;
Expand: string;
Collapse: string;
} }
declare module 'CurrentNavigationApplicationCustomizerStrings' { declare module 'CurrentNavigationApplicationCustomizerStrings' {
+57 -5
View File
@@ -36,35 +36,87 @@ export function resolveTermUrl(term: ICurrentNavigationTerm, siteCollectionUrl:
export function normalizeNavigationPath(value: string): string { export function normalizeNavigationPath(value: string): string {
let path: string = String(value || '').trim().toLowerCase(); let path: string = String(value || '').trim().toLowerCase();
path = path.replace(/^https?:\/\/[^/]+/i, '').split('#')[0].split('?')[0].replace(/\/+$/, ''); path = path.replace(/^https?:\/\/[^/]+/i, '').split('#')[0].split('?')[0].replace(/\/+$/, '');
try { path = decodeURIComponent(path); } catch (error) { /* Keep malformed URLs comparable as supplied. */ }
return path || '/'; return path || '/';
} }
export function normalizeRootWebPath(value: string): string {
let path: string = normalizeNavigationPath(value);
path = path.replace(/\/(?:sitepages|pages)\/.*\.aspx$/i, '');
path = path.replace(/\/(?:default|home)\.aspx$/i, '');
return path || '/';
}
export function isCurrentNavigationUrl(
navigationUrl: string,
currentPageUrl: string,
currentWebUrl: string
): boolean {
const navigationPath: string = normalizeNavigationPath(navigationUrl);
const pagePath: string = normalizeNavigationPath(currentPageUrl);
const webPath: string = normalizeNavigationPath(currentWebUrl);
return navigationPath === pagePath || navigationPath === webPath ||
normalizeRootWebPath(navigationUrl) === webPath;
}
export function selectCurrentNavigationRoot( export function selectCurrentNavigationRoot(
terms: ICurrentNavigationTerm[], terms: ICurrentNavigationTerm[],
currentWebUrl: string, currentWebUrl: string,
siteCollectionUrl: string siteCollectionUrl: string
): ICurrentNavigationTerm | undefined { ): ICurrentNavigationTerm | undefined {
const webPath: string = normalizeNavigationPath(currentWebUrl); const webPath: string = normalizeNavigationPath(currentWebUrl);
const hierarchyRoot: ICurrentNavigationTerm = selectRootByTermHierarchy(
terms, webPath, siteCollectionUrl
);
if (hierarchyRoot) { return hierarchyRoot; }
let selected: ICurrentNavigationTerm | undefined; let selected: ICurrentNavigationTerm | undefined;
let selectedScore: number = -1; let selectedScore: number = -1;
for (let i: number = 0; i < terms.length; i++) { for (let i: number = 0; i < terms.length; i++) {
if (!parseBoolean(getLocalProperty(terms[i], CurrentNavigationProperties.root))) { continue; } if (!parseBoolean(getLocalProperty(terms[i], CurrentNavigationProperties.root))) { continue; }
const url: string = resolveTermUrl(terms[i], siteCollectionUrl); const url: string = resolveTermUrl(terms[i], siteCollectionUrl);
if (!url) { continue; } if (!url) { continue; }
const candidatePath: string = normalizeNavigationPath(url); const candidatePath: string = normalizeRootWebPath(url);
let score: number = -1; let score: number = -1;
if (candidatePath === webPath) { if (candidatePath === webPath) {
score = 30000 + candidatePath.length; score = 30000 + candidatePath.length;
} else if (candidatePath.indexOf(webPath + '/') === 0) { } else if (isDescendantPath(webPath, candidatePath)) {
score = 20000 + webPath.length; score = 20000 + candidatePath.length;
} else if (webPath.indexOf(candidatePath + '/') === 0) {
score = 10000 + candidatePath.length;
} }
if (score > selectedScore) { selected = terms[i]; selectedScore = score; } if (score > selectedScore) { selected = terms[i]; selectedScore = score; }
} }
return selected; return selected;
} }
function selectRootByTermHierarchy(
terms: ICurrentNavigationTerm[],
webPath: string,
siteCollectionUrl: string
): ICurrentNavigationTerm | undefined {
let selected: ICurrentNavigationTerm | undefined;
let selectedDepth: number = -1;
for (let i: number = 0; i < terms.length; i++) {
const termUrl: string = resolveTermUrl(terms[i], siteCollectionUrl);
if (!termUrl || normalizeRootWebPath(termUrl) !== webPath) { continue; }
const currentPath: string = String(terms[i].PathOfTerm || '').toLowerCase();
for (let j: number = 0; j < terms.length; j++) {
if (!parseBoolean(getLocalProperty(terms[j], CurrentNavigationProperties.root))) { continue; }
const rootPath: string = String(terms[j].PathOfTerm || '').toLowerCase();
const isAncestor: boolean = currentPath === rootPath || currentPath.indexOf(rootPath + ';') === 0;
const rootDepth: number = pathDepth(rootPath);
if (isAncestor && rootDepth > selectedDepth) {
selected = terms[j];
selectedDepth = rootDepth;
}
}
}
return selected;
}
function isDescendantPath(path: string, parentPath: string): boolean {
return parentPath === '/' ? path !== '/' : path.indexOf(parentPath + '/') === 0;
}
export function buildCurrentNavigationItems( export function buildCurrentNavigationItems(
terms: ICurrentNavigationTerm[], terms: ICurrentNavigationTerm[],
root: ICurrentNavigationTerm, root: ICurrentNavigationTerm,
+60 -1
View File
@@ -1,7 +1,9 @@
import { import {
buildCurrentNavigationItems, getLocalProperty, parseBoolean, resolveTermUrl, selectCurrentNavigationRoot buildCurrentNavigationItems, getLocalProperty, isCurrentNavigationUrl, normalizeRootWebPath, parseBoolean, resolveTermUrl,
selectCurrentNavigationRoot
} from '../src/services/CurrentNavigationCore'; } from '../src/services/CurrentNavigationCore';
import { ICurrentNavigationTerm } from '../src/services/CurrentNavigationTypes'; import { ICurrentNavigationTerm } from '../src/services/CurrentNavigationTypes';
import { isInitiallyExpanded } from '../src/extensions/currentNavigation/CurrentNavigationRenderer';
let passed: number = 0; let passed: number = 0;
function assert(name: string, condition: boolean): void { function assert(name: string, condition: boolean): void {
@@ -26,6 +28,10 @@ const terms: ICurrentNavigationTerm[] = [
assert('German boolean is accepted', parseBoolean('ja')); assert('German boolean is accepted', parseBoolean('ja'));
assert('local property is read', getLocalProperty(terms[0], 'CurrentNavigation.Root') === 'true'); assert('local property is read', getLocalProperty(terms[0], 'CurrentNavigation.Root') === 'true');
assert('expanded opens every branch', isInitiallyExpanded('expanded', false));
assert('collapsed closes even the active branch', !isInitiallyExpanded('collapsed', true));
assert('activePath keeps the previous active branch behavior', isInitiallyExpanded('activePath', true));
assert('activePath keeps inactive branches closed', !isInitiallyExpanded('activePath', false));
assert('site collection token resolves', resolveTermUrl(term({ assert('site collection token resolves', resolveTermUrl(term({
LocalCustomProperties: { '_Sys_Nav_SimpleLinkUrl': '~sitecollection/start' } LocalCustomProperties: { '_Sys_Nav_SimpleLinkUrl': '~sitecollection/start' }
}), 'https://portal/sites/a') === 'https://portal/sites/a/start'); }), 'https://portal/sites/a') === 'https://portal/sites/a/start');
@@ -33,6 +39,23 @@ const root: ICurrentNavigationTerm = selectCurrentNavigationRoot(
terms, 'https://portal/sites/portal/hr', 'https://portal/sites/portal' terms, 'https://portal/sites/portal/hr', 'https://portal/sites/portal'
); );
assert('root is selected through its site home URL', !!root && root.Id === 'root'); assert('root is selected through its site home URL', !!root && root.Id === 'root');
assert('site home URL is normalized to its web',
normalizeRootWebPath('/sites/portal/hr/SitePages/Home.aspx') === '/sites/portal/hr');
assert('site URL is current while a modern home page is open', isCurrentNavigationUrl(
'http://dhs.contoso.local/organisation/cir/CIR_13/',
'http://dhs.contoso.local/organisation/cir/CIR_13/SitePages/Home.aspx',
'http://dhs.contoso.local/organisation/cir/CIR_13'
));
assert('configured site home URL matches another page in the same web', isCurrentNavigationUrl(
'http://dhs.contoso.local/organisation/cir/CIR_13/SitePages/Home.aspx',
'http://dhs.contoso.local/organisation/cir/CIR_13/SitePages/News.aspx',
'http://dhs.contoso.local/organisation/cir/CIR_13/'
));
assert('different web is not current', !isCurrentNavigationUrl(
'http://dhs.contoso.local/organisation/cir/CIR_3/',
'http://dhs.contoso.local/organisation/cir/CIR_13/SitePages/Home.aspx',
'http://dhs.contoso.local/organisation/cir/CIR_13/'
));
const items = buildCurrentNavigationItems(terms, root, 'https://portal/sites/portal', 1031, 3, false); const items = buildCurrentNavigationItems(terms, root, 'https://portal/sites/portal', 1031, 3, false);
assert('root is not rendered by default', items.length === 1 && items[0].id === 'docs'); assert('root is not rendered by default', items.length === 1 && items[0].id === 'docs');
assert('descendants are nested', items[0].items.length === 1 && items[0].items[0].id === 'forms'); assert('descendants are nested', items[0].items.length === 1 && items[0].items[0].id === 'forms');
@@ -44,4 +67,40 @@ const unordered = buildCurrentNavigationItems(
); );
assert('hierarchy is independent of taxonomy response order', assert('hierarchy is independent of taxonomy response order',
unordered.length === 1 && unordered[0].id === 'docs' && unordered[0].items[0].id === 'forms'); unordered.length === 1 && unordered[0].id === 'docs' && unordered[0].items[0].id === 'forms');
const inheritedTerms: ICurrentNavigationTerm[] = [
term({ Id: 'kits', Name: 'KITS', PathOfTerm: 'Organisation;CIR;KITS', LocalCustomProperties: {
'CurrentNavigation.Root': 'true',
'_Sys_Nav_SimpleLinkUrl': 'http://dhs.contoso.local/organisation/cir/CIR_3/'
}}),
term({ Id: 'team', Name: 'Team SharePoint', PathOfTerm: 'Organisation;CIR;KITS;Team SharePoint',
LocalCustomProperties: {
'TopNavigation.Hidden': 'true',
'_Sys_Nav_SimpleLinkUrl': 'http://dhs.contoso.local/organisation/cir/CIR_13/'
}}),
term({ Id: 'games', Name: 'Spielewiese', PathOfTerm: 'Organisation;CIR;KITS;Team SharePoint;Spielewiese',
LocalCustomProperties: {
'_Sys_Nav_SimpleLinkUrl': 'http://dhs.contoso.local/organisation/cir/CIR_21/'
}})
];
const inheritedRoot: ICurrentNavigationTerm = selectCurrentNavigationRoot(
inheritedTerms,
'http://dhs.contoso.local/organisation/cir/CIR_21/',
'http://dhs.contoso.local/organisation/cir'
);
assert('nearest taxonomy ancestor root applies to a web with a generic URL',
!!inheritedRoot && inheritedRoot.Id === 'kits');
const teamRoot: ICurrentNavigationTerm = selectCurrentNavigationRoot(
inheritedTerms,
'http://dhs.contoso.local/organisation/cir/CIR_13/',
'http://dhs.contoso.local/organisation/cir'
);
assert('taxonomy ancestor root also applies to the direct child web',
!!teamRoot && teamRoot.Id === 'kits');
const inheritedItems = buildCurrentNavigationItems(
inheritedTerms, inheritedRoot, 'http://dhs.contoso.local/organisation/cir', 1031, 3, false
);
assert('top-navigation hidden does not hide current navigation terms',
inheritedItems.length === 1 && inheritedItems[0].id === 'team' &&
inheritedItems[0].items.length === 1 && inheritedItems[0].items[0].id === 'games');
console.log('CurrentNavigation core tests passed:', passed); console.log('CurrentNavigation core tests passed:', passed);
+1
View File
@@ -7,6 +7,7 @@
"files": [ "files": [
"../src/services/CurrentNavigationTypes.ts", "../src/services/CurrentNavigationTypes.ts",
"../src/services/CurrentNavigationCore.ts", "../src/services/CurrentNavigationCore.ts",
"../src/extensions/currentNavigation/CurrentNavigationRenderer.ts",
"CurrentNavigationCore.test.ts" "CurrentNavigationCore.test.ts"
] ]
} }
+20 -3
View File
@@ -9,12 +9,29 @@ var manifest = json('src/extensions/currentNavigation/CurrentNavigationApplicati
var app = read('src/extensions/currentNavigation/CurrentNavigationApplicationCustomizer.ts'); var app = read('src/extensions/currentNavigation/CurrentNavigationApplicationCustomizer.ts');
var renderer = read('src/extensions/currentNavigation/CurrentNavigationRenderer.ts'); var renderer = read('src/extensions/currentNavigation/CurrentNavigationRenderer.ts');
var service = read('src/services/CurrentNavigationTaxonomyService.ts'); var service = read('src/services/CurrentNavigationTaxonomyService.ts');
assert('package version', packageJson.version === '1.0.0'); assert('package version', packageJson.version === '1.1.6');
assert('solution version', solution.version === '1.0.0.0'); assert('solution version', solution.version === '1.1.6.0');
assert('tenant deployment', solution.skipFeatureDeployment === true); assert('tenant deployment', solution.skipFeatureDeployment === true);
assert('component type', manifest.extensionType === 'ApplicationCustomizer'); assert('component type', manifest.extensionType === 'ApplicationCustomizer');
assert('safe renderer', renderer.indexOf('innerHTML') < 0 && app.indexOf('innerHTML') < 0); assert('safe renderer', renderer.indexOf('innerHTML') < 0 && app.indexOf('innerHTML') < 0);
assert('placeholder lifecycle cleanup', app.indexOf('changedEvent.remove') >= 0); assert('does not use Top placeholder', app.indexOf('PlaceholderName.Top') < 0);
assert('targets modern SiteNavigation', app.indexOf('data-automationid="SiteNavigation"') >= 0);
assert('targets SharePoint ms-Nav fallback', app.indexOf('nav[role="navigation"].ms-Nav') >= 0 &&
app.indexOf("querySelector('.ms-Nav-navItems')") >= 0);
assert('native navigation is not hidden', app.indexOf('current-navigation-managed') < 0 &&
read('src/extensions/currentNavigation/CurrentNavigation.module.scss').indexOf(':not(#CurrentNavigationHost)') < 0);
assert('whole row hover',
read('src/extensions/currentNavigation/CurrentNavigation.module.scss').indexOf('.current-navigation-row:hover') >= 0);
assert('custom navigation is appended below SiteNavigation', app.indexOf('this.siteNavigation.appendChild(this.host)') >= 0);
assert('observer cleanup', app.indexOf('observer.disconnect()') >= 0);
assert('treeview semantics', renderer.indexOf("setAttribute('role', root ? 'tree' : 'group')") >= 0 &&
renderer.indexOf("setAttribute('role', 'treeitem')") >= 0);
assert('configurable initial expansion', renderer.indexOf('isInitiallyExpanded(this.initialExpansion') >= 0 &&
renderer.indexOf("initialExpansion === 'expanded'") >= 0 && app.indexOf('this.initialExpansion()') >= 0);
assert('active path receives the current SharePoint web URL',
app.indexOf('this.context.pageContext.web.absoluteUrl') >= 0 &&
renderer.indexOf('isCurrentNavigationUrl(url, window.location.href, this.currentWebUrl)') >= 0);
assert('legacy selector compatibility', renderer.indexOf(':scope') < 0);
assert('local properties loaded', service.indexOf('LocalCustomProperties') >= 0); assert('local properties loaded', service.indexOf('LocalCustomProperties') >= 0);
assert('root property present', read('src/services/CurrentNavigationCore.ts').indexOf('CurrentNavigation.Root') >= 0); assert('root property present', read('src/services/CurrentNavigationCore.ts').indexOf('CurrentNavigation.Root') >= 0);
assert('hidden property present', read('src/services/CurrentNavigationCore.ts').indexOf('CurrentNavigation.Hidden') >= 0); assert('hidden property present', read('src/services/CurrentNavigationCore.ts').indexOf('CurrentNavigation.Hidden') >= 0);