4 Commits
14 changed files with 478 additions and 135 deletions
+18 -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.4 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,21 @@ 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.
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
+4 -1
View File
@@ -1,14 +1,17 @@
# CurrentNavigation – Umsetzungsstand # CurrentNavigation – Umsetzungsstand
Zielversion: 1.0.0 Zielversion: 1.1.4
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] 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.4.0",
"includeClientSideAssets": true, "includeClientSideAssets": true,
"skipFeatureDeployment": true "skipFeatureDeployment": true
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "current-navigation", "name": "current-navigation",
"version": "1.0.0", "version": "1.1.4",
"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.4",
"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,11 +1,8 @@
// 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 { CurrentNavigationRenderer } from './CurrentNavigationRenderer';
@@ -23,38 +20,79 @@ export interface ICurrentNavigationApplicationCustomizerProperties {
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 +106,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 +118,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
});
} }
} catch (error) { this.debug(strings.LoadError, error); }
} }
private cacheMinutes(): number { private cacheMinutes(): number {
@@ -109,17 +150,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,200 @@
import { ICurrentNavigationItem } from '../../services/CurrentNavigationTypes';
import { normalizeNavigationPath } from '../../services/CurrentNavigationCore'; import { normalizeNavigationPath } from '../../services/CurrentNavigationCore';
import { ICurrentNavigationItem } from '../../services/CurrentNavigationTypes';
export interface ICurrentNavigationRendererStrings {
navigationLabel: string;
expand: string;
collapse: string;
}
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;
public render(
container: HTMLElement,
items: ICurrentNavigationItem[],
strings: ICurrentNavigationRendererStrings
): void {
this.clear(container); this.clear(container);
this.container = container;
this.strings = strings;
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 activeBranch: boolean = 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', activeBranch ? 'true' : 'false');
toggle.setAttribute('aria-label', (activeBranch ? 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', activeBranch ? '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 = !activeBranch;
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 normalizeNavigationPath(window.location.href) === normalizeNavigationPath(url);
} }
} }
@@ -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' {
+45 -5
View File
@@ -36,6 +36,14 @@ 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 || '/';
}
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 || '/'; return path || '/';
} }
@@ -45,26 +53,58 @@ export function selectCurrentNavigationRoot(
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,
+40 -1
View File
@@ -1,5 +1,6 @@
import { import {
buildCurrentNavigationItems, getLocalProperty, parseBoolean, resolveTermUrl, selectCurrentNavigationRoot buildCurrentNavigationItems, getLocalProperty, normalizeRootWebPath, parseBoolean, resolveTermUrl,
selectCurrentNavigationRoot
} from '../src/services/CurrentNavigationCore'; } from '../src/services/CurrentNavigationCore';
import { ICurrentNavigationTerm } from '../src/services/CurrentNavigationTypes'; import { ICurrentNavigationTerm } from '../src/services/CurrentNavigationTypes';
@@ -33,6 +34,8 @@ 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');
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 +47,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);
+15 -3
View File
@@ -9,12 +9,24 @@ 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.4');
assert('solution version', solution.version === '1.0.0.0'); assert('solution version', solution.version === '1.1.4.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('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);