Update Current Navigation to version 1.1.0 with treeview support and improved accessibility

This commit is contained in:
Torsten Brendgen
2026-08-18 19:25:59 +02:00
parent 0a3219117a
commit 5725e1fe35
12 changed files with 372 additions and 122 deletions
+9 -2
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.0 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 ersetzt die Einträge im modernen SiteNavigation-Bereich durch einen Treeview
für das aktuelle Web. Beim Entladen der Extension wird die ursprüngliche SharePoint-Navigation wieder sichtbar.
## Term-Konfiguration ## Term-Konfiguration
@@ -14,6 +15,12 @@ Die URL des Root-Terms wird mit pageContext.web.absoluteUrl verglichen. Sowohl d
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 Root-Term wird standardmäßig nicht dargestellt; seine direkten
Kinder bilden die erste Navigationsebene. Kinder bilden die erste Navigationsebene.
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 die native
SharePoint-Sitenavigation unverändert sichtbar.
## Build und Bereitstellung ## Build und Bereitstellung
Voraussetzungen: Node.js 8.17.0, npm 6.x und lokales Gulp 3.9.1. Voraussetzungen: Node.js 8.17.0, npm 6.x und lokales Gulp 3.9.1.
+3 -1
View File
@@ -1,9 +1,11 @@
# CurrentNavigation – Umsetzungsstand # CurrentNavigation – Umsetzungsstand
Zielversion: 1.0.0 Zielversion: 1.1.0
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] Rendering im modernen SiteNavigation-Container statt im Top-Placeholder
- [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
+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.0.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.0",
"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.0",
"private": true, "private": true,
"main": "lib/index.js", "main": "lib/index.js",
"engines": { "engines": {
@@ -1,12 +1,16 @@
@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 { .current-navigation-managed > :not(#CurrentNavigationHost) {
position: relative; display: none !important;
z-index: 299; }
#CurrentNavigationHost {
width: 100%;
}
.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: $ms-font-family-fallbacks;
font-size: $ms-font-size-s; font-size: $ms-font-size-s;
@@ -18,74 +22,93 @@
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-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: $ms-color-neutralSecondary;
cursor: pointer;
}
.current-navigation-toggle:hover {
background: $ms-color-neutralLighter;
color: $ms-color-neutralPrimary;
}
.current-navigation-chevron {
width: 7px;
height: 7px;
border-right: 1px solid currentColor;
border-bottom: 1px solid currentColor;
transform: rotate(-45deg);
transition: transform 120ms ease;
}
.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 {
color: $ms-color-themePrimary;
background: $ms-color-neutralLighter; background: $ms-color-neutralLighter;
color: $ms-color-themePrimary;
} }
.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 (prefers-reduced-motion: reduce) {
top: 0; .current-navigation-chevron { transition: none; }
left: 100%;
} }
.current-navigation li:hover > .current-navigation-children, @media (forced-colors: active) {
.current-navigation li:focus-within > .current-navigation-children { .current-navigation-label:focus { outline: 2px solid CanvasText; }
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,42 +20,81 @@ 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"]',
'#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 (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;
if (!this.host) { return; } if (!this.host) { return; }
if (this.siteNavigation) { this.siteNavigation.classList.remove('current-navigation-managed'); }
this.renderer.clear(this.host); this.renderer.clear(this.host);
const termSetId: string = this.properties && this.properties.termSetId const termSetId: string = this.properties && this.properties.termSetId
? String(this.properties.termSetId).trim() : ''; ? String(this.properties.termSetId).trim() : '';
@@ -68,8 +104,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 +116,15 @@ 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) { if (this.siteNavigation) { this.siteNavigation.classList.add('current-navigation-managed'); }
this.debug(strings.LoadError, error); this.renderer.render(this.host, items, {
navigationLabel: strings.NavigationLabel,
expand: strings.Expand,
collapse: strings.Collapse
});
} }
} catch (error) { this.debug(strings.LoadError, error); }
} }
private cacheMinutes(): number { private cacheMinutes(): number {
@@ -109,17 +149,22 @@ 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); }
}
if (this.siteNavigation) { this.siteNavigation.classList.remove('current-navigation-managed'); }
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' {
+11 -3
View File
@@ -9,12 +9,20 @@ 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.0');
assert('solution version', solution.version === '1.0.0.0'); assert('solution version', solution.version === '1.1.0.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('restores native navigation', app.indexOf("classList.remove('current-navigation-managed')") >= 0);
assert('native navigation is only replaced after successful load',
app.indexOf("classList.add('current-navigation-managed')") > app.indexOf('if (items.length'));
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);