diff --git a/README.md b/README.md index 3ee428a..0f2e511 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # CurrentNavigation -CurrentNavigation 1.0.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. +CurrentNavigation 1.1.0 ist ein SPFx-1.4.1-Application-Customizer für moderne Seiten in SharePoint Server +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 @@ -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 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 Voraussetzungen: Node.js 8.17.0, npm 6.x und lokales Gulp 3.9.1. diff --git a/ToDo.md b/ToDo.md index 7931301..b32d7c5 100644 --- a/ToDo.md +++ b/ToDo.md @@ -1,9 +1,11 @@ # CurrentNavigation – Umsetzungsstand -Zielversion: 1.0.0 +Zielversion: 1.1.0 Status: Implementierung und lokale Tests abgeschlossen; Ship-Build auf Node 8 ausstehend - [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] Termset-Picker über PortalSettings - [x] Cache-Dauer, Cache-Version und Debug-Modus diff --git a/config/package-solution.json b/config/package-solution.json index 7701513..9ecda25 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -3,7 +3,7 @@ "solution": { "name": "current-navigation-client-side-solution", "id": "bb8f9af5-b318-4111-8401-ce189cfe1b1a", - "version": "1.0.0.0", + "version": "1.1.0.0", "includeClientSideAssets": true, "skipFeatureDeployment": true }, diff --git a/package-lock.json b/package-lock.json index 3386dcc..0c132d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "current-navigation", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 018ae41..2879aa0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "current-navigation", - "version": "1.0.0", + "version": "1.1.0", "private": true, "main": "lib/index.js", "engines": { diff --git a/src/extensions/currentNavigation/CurrentNavigation.module.scss b/src/extensions/currentNavigation/CurrentNavigation.module.scss index eb702d7..27ad0a6 100644 --- a/src/extensions/currentNavigation/CurrentNavigation.module.scss +++ b/src/extensions/currentNavigation/CurrentNavigation.module.scss @@ -1,12 +1,16 @@ @import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss'; :global { - .current-navigation { - position: relative; - z-index: 299; + .current-navigation-managed > :not(#CurrentNavigationHost) { + display: none !important; + } + + #CurrentNavigationHost { + width: 100%; + } + + .current-navigation { width: 100%; - border-bottom: 1px solid $ms-color-neutralLight; - background: $ms-color-white; color: $ms-color-neutralPrimary; font-family: $ms-font-family-fallbacks; font-size: $ms-font-size-s; @@ -18,74 +22,93 @@ list-style: none; } - .current-navigation-root { - display: flex; - min-height: 40px; - padding: 0 16px !important; + .current-navigation-group { + padding-left: 20px !important; } - .current-navigation li { - position: relative; - } - - .current-navigation a, - .current-navigation span { + .current-navigation-row { display: flex; align-items: center; - min-height: 40px; - padding: 0 12px; + min-height: 36px; + } + + .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; text-decoration: none; + text-overflow: ellipsis; white-space: nowrap; } - .current-navigation a:hover, - .current-navigation a:focus, - .current-navigation a.is-current { - color: $ms-color-themePrimary; + .current-navigation-label:hover, + .current-navigation-label:focus { background: $ms-color-neutralLighter; + color: $ms-color-themePrimary; } - .current-navigation a:focus { + .current-navigation-label:focus { outline: 2px solid $ms-color-themePrimary; outline-offset: -2px; } - .current-navigation-children { - position: absolute; - top: 100%; - left: 0; + .current-navigation-label.is-current { + background: $ms-color-neutralLighter; + color: $ms-color-themePrimary; + font-weight: $ms-font-weight-semibold; + } + + .current-navigation-group[hidden] { 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 { - top: 0; - left: 100%; + @media (prefers-reduced-motion: reduce) { + .current-navigation-chevron { transition: none; } } - .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; - } + @media (forced-colors: active) { + .current-navigation-label:focus { outline: 2px solid CanvasText; } } } diff --git a/src/extensions/currentNavigation/CurrentNavigationApplicationCustomizer.ts b/src/extensions/currentNavigation/CurrentNavigationApplicationCustomizer.ts index 2aa41a9..22df32d 100644 --- a/src/extensions/currentNavigation/CurrentNavigationApplicationCustomizer.ts +++ b/src/extensions/currentNavigation/CurrentNavigationApplicationCustomizer.ts @@ -1,11 +1,8 @@ // tslint:disable:max-line-length variable-name 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 { - buildCurrentNavigationItems, - selectCurrentNavigationRoot -} from '../../services/CurrentNavigationCore'; +import { buildCurrentNavigationItems, selectCurrentNavigationRoot } from '../../services/CurrentNavigationCore'; import { CurrentNavigationTaxonomyService } from '../../services/CurrentNavigationTaxonomyService'; import { ICurrentNavigationItem, ICurrentNavigationTerm, ITermSetResult } from '../../services/CurrentNavigationTypes'; import { CurrentNavigationRenderer } from './CurrentNavigationRenderer'; @@ -23,42 +20,81 @@ export interface ICurrentNavigationApplicationCustomizerProperties { export default class CurrentNavigationApplicationCustomizer extends BaseApplicationCustomizer { - private topPlaceholder: PlaceholderContent | undefined; + private siteNavigation: HTMLElement | undefined; private host: HTMLElement | undefined; + private observer: MutationObserver | undefined; + private mountPending: boolean = false; private renderer: CurrentNavigationRenderer = new CurrentNavigationRenderer(); private renderVersion: number = 0; @override public onInit(): Promise { - this.context.placeholderProvider.changedEvent.add(this, this.renderPlaceholders); const application: any = this.context.application as any; // tslint:disable-line:no-any - if (application && application.navigatedEvent) { - application.navigatedEvent.add(this, this.onNavigated); - } - this.renderPlaceholders(); + if (application && application.navigatedEvent) { application.navigatedEvent.add(this, this.onNavigated); } + this.observer = new MutationObserver(this.onDomChanged); + this.observer.observe(document.body, { childList: true, subtree: true }); + this.mountInSiteNavigation(true); return Promise.resolve(); } - private renderPlaceholders = (): void => { - if (!this.topPlaceholder) { - this.topPlaceholder = this.context.placeholderProvider.tryCreateContent( - PlaceholderName.Top, { onDispose: this.onPlaceholderDisposed } - ); - } - 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.scheduleMount(true); } + + private onDomChanged = (): void => { + if (!this.host || !document.body.contains(this.host)) { this.scheduleMount(true); } } - 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 = 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 { const version: number = ++this.renderVersion; if (!this.host) { return; } + if (this.siteNavigation) { this.siteNavigation.classList.remove('current-navigation-managed'); } this.renderer.clear(this.host); const termSetId: string = this.properties && this.properties.termSetId ? String(this.properties.termSetId).trim() : ''; @@ -68,8 +104,7 @@ export default class CurrentNavigationApplicationCustomizer } try { const service: CurrentNavigationTaxonomyService = new CurrentNavigationTaxonomyService( - this.context, termSetId, this.cacheMinutes(), this.cacheVersion(), - this.languageLcid(), this.isDebug() + this.context, termSetId, this.cacheMinutes(), this.cacheVersion(), this.languageLcid(), this.isDebug() ); const termSet: ITermSetResult = await service.getTerms(); if (version !== this.renderVersion || !this.host) { return; } @@ -81,10 +116,15 @@ export default class CurrentNavigationApplicationCustomizer termSet.Terms, root, this.context.pageContext.site.absoluteUrl, this.languageLcid(), this.maxDepth(), !!(this.properties && this.properties.showRoot) ); - if (items.length) { this.renderer.render(this.host, items, strings.NavigationLabel); } - } catch (error) { - this.debug(strings.LoadError, error); - } + if (items.length && this.host) { + if (this.siteNavigation) { this.siteNavigation.classList.add('current-navigation-managed'); } + 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 { @@ -109,17 +149,22 @@ export default class CurrentNavigationApplicationCustomizer private debug(message: string, data?: any): void { // tslint:disable-line:no-any if (this.isDebug() && window.console) { console.log('[CurrentNavigation] ' + message, data || ''); } } - private onPlaceholderDisposed = (): void => { - if (this.host) { this.renderer.clear(this.host); } + private detachHost(): void { + 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.topPlaceholder = undefined; + this.siteNavigation = undefined; } @override protected onDispose(): void { - this.context.placeholderProvider.changedEvent.remove(this, this.renderPlaceholders); const application: any = this.context.application as any; // tslint:disable-line:no-any if (application && application.navigatedEvent) { application.navigatedEvent.remove(this, this.onNavigated); } - this.onPlaceholderDisposed(); + if (this.observer) { this.observer.disconnect(); this.observer = undefined; } + this.detachHost(); } } diff --git a/src/extensions/currentNavigation/CurrentNavigationRenderer.ts b/src/extensions/currentNavigation/CurrentNavigationRenderer.ts index 6032f8b..09c4489 100644 --- a/src/extensions/currentNavigation/CurrentNavigationRenderer.ts +++ b/src/extensions/currentNavigation/CurrentNavigationRenderer.ts @@ -1,41 +1,200 @@ -import { ICurrentNavigationItem } from '../../services/CurrentNavigationTypes'; import { normalizeNavigationPath } from '../../services/CurrentNavigationCore'; +import { ICurrentNavigationItem } from '../../services/CurrentNavigationTypes'; + +export interface ICurrentNavigationRendererStrings { + navigationLabel: string; + expand: string; + collapse: string; +} 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.container = container; + this.strings = strings; const nav: HTMLElement = document.createElement('nav'); nav.className = 'current-navigation'; - nav.setAttribute('aria-label', navigationLabel); - nav.appendChild(this.createList(items, 1)); + nav.setAttribute('aria-label', strings.navigationLabel); + nav.appendChild(this.createList(items, 1, true)); + this.keydownHandler = this.onKeydown; + nav.addEventListener('keydown', this.keydownHandler); container.appendChild(nav); } 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); } + 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'); - 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++) { - const item: ICurrentNavigationItem = items[i]; - const entry: HTMLLIElement = document.createElement('li'); - entry.className = item.items.length ? 'has-children' : ''; - const content: HTMLElement = item.url ? document.createElement('a') : document.createElement('span'); - content.textContent = item.label; - if (item.url) { - const link: HTMLAnchorElement = content as HTMLAnchorElement; - link.href = item.url; - if (normalizeNavigationPath(window.location.href) === normalizeNavigationPath(item.url)) { - link.setAttribute('aria-current', 'page'); - link.className = 'is-current'; - } - } - entry.appendChild(content); - if (item.items.length) { entry.appendChild(this.createList(item.items, depth + 1)); } - list.appendChild(entry); + list.appendChild(this.createItem(items[i], depth)); } return list; } + + private createItem(item: ICurrentNavigationItem, depth: number): HTMLLIElement { + const entry: HTMLLIElement = document.createElement('li'); + 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'); + content.className = 'current-navigation-label'; + content.textContent = item.label; + if (item.url) { + const link: HTMLAnchorElement = content as HTMLAnchorElement; + link.href = item.url; + if (this.isCurrentUrl(item.url)) { + link.setAttribute('aria-current', 'page'); + 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 || '')); + } + } + + private onKeydown = (event: KeyboardEvent): void => { + 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(); } + } + 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 = 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); + } } diff --git a/src/extensions/currentNavigation/loc/de-de.js b/src/extensions/currentNavigation/loc/de-de.js index c4828f8..9ed6015 100644 --- a/src/extensions/currentNavigation/loc/de-de.js +++ b/src/extensions/currentNavigation/loc/de-de.js @@ -3,6 +3,8 @@ define([], function() { "Title": "CurrentNavigationApplicationCustomizer", "NavigationLabel": "Navigation der aktuellen Website", "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" }; }); diff --git a/src/extensions/currentNavigation/loc/en-us.js b/src/extensions/currentNavigation/loc/en-us.js index 9cacf5b..3563f53 100644 --- a/src/extensions/currentNavigation/loc/en-us.js +++ b/src/extensions/currentNavigation/loc/en-us.js @@ -3,6 +3,8 @@ define([], function() { "Title": "CurrentNavigationApplicationCustomizer", "NavigationLabel": "Current site navigation", "ConfigurationMissing": "Current navigation is not configured.", - "LoadError": "Current navigation could not be loaded." + "LoadError": "Current navigation could not be loaded.", + "Expand": "Expand", + "Collapse": "Collapse" }; }); diff --git a/src/extensions/currentNavigation/loc/myStrings.d.ts b/src/extensions/currentNavigation/loc/myStrings.d.ts index 2be89ce..23f0e3a 100644 --- a/src/extensions/currentNavigation/loc/myStrings.d.ts +++ b/src/extensions/currentNavigation/loc/myStrings.d.ts @@ -3,6 +3,8 @@ declare interface ICurrentNavigationApplicationCustomizerStrings { NavigationLabel: string; ConfigurationMissing: string; LoadError: string; + Expand: string; + Collapse: string; } declare module 'CurrentNavigationApplicationCustomizerStrings' { diff --git a/tests/validate-static-assets.js b/tests/validate-static-assets.js index 93e1b96..e331039 100644 --- a/tests/validate-static-assets.js +++ b/tests/validate-static-assets.js @@ -9,12 +9,20 @@ var manifest = json('src/extensions/currentNavigation/CurrentNavigationApplicati var app = read('src/extensions/currentNavigation/CurrentNavigationApplicationCustomizer.ts'); var renderer = read('src/extensions/currentNavigation/CurrentNavigationRenderer.ts'); var service = read('src/services/CurrentNavigationTaxonomyService.ts'); -assert('package version', packageJson.version === '1.0.0'); -assert('solution version', solution.version === '1.0.0.0'); +assert('package version', packageJson.version === '1.1.0'); +assert('solution version', solution.version === '1.1.0.0'); assert('tenant deployment', solution.skipFeatureDeployment === true); assert('component type', manifest.extensionType === 'ApplicationCustomizer'); 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('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);