Page Section Navigation updates: SPFx 1.8, comments, additional theme (#819)

* Page Section Navigation updates: SPFx 1.8, comments, additional theme

* readme

* readme version

* final changes for 1.8
This commit is contained in:
Alex Terentiev 2019-04-08 02:36:08 -07:00 committed by Vesa Juvonen
parent cd22efec38
commit ebc203b7d5
15 changed files with 4512 additions and 4134 deletions

View File

@ -5,7 +5,7 @@ Sample web parts allowing to add sections navigation to the SharePoint page.
![Navigation configuration](./assets/page-nav.gif)
## Used SharePoint Framework Version
![drop](https://img.shields.io/badge/drop-1.7.1-green.svg)
![drop](https://img.shields.io/badge/drop-1.8.0-green.svg)
## Applies to
@ -23,6 +23,7 @@ page-sections-navigation|Alex Terentiev (MVP, [Sharepointalist Inc.](http://www.
Version|Date|Comments
-------|----|--------
1.0|February 27, 2019|Initial release
1.1|March 22, 2019| Update to SPFx 1.8, additional theme, comments
## Disclaimer
**THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**

File diff suppressed because it is too large Load Diff

View File

@ -11,10 +11,10 @@
"test": "gulp test"
},
"dependencies": {
"@microsoft/sp-core-library": "1.7.1",
"@microsoft/sp-lodash-subset": "1.7.1",
"@microsoft/sp-office-ui-fabric-core": "1.7.1",
"@microsoft/sp-webpart-base": "1.7.1",
"@microsoft/sp-core-library": "1.8.0",
"@microsoft/sp-lodash-subset": "1.8.0",
"@microsoft/sp-office-ui-fabric-core": "1.8.0",
"@microsoft/sp-webpart-base": "1.8.0",
"@types/es6-promise": "0.0.33",
"@types/react": "16.4.2",
"@types/react-dom": "16.0.5",
@ -28,10 +28,11 @@
"@types/react": "16.4.2"
},
"devDependencies": {
"@microsoft/sp-build-web": "1.7.1",
"@microsoft/sp-tslint-rules": "1.7.1",
"@microsoft/sp-module-interfaces": "1.7.1",
"@microsoft/sp-webpart-workbench": "1.7.1",
"@microsoft/sp-build-web": "1.8.0",
"@microsoft/sp-tslint-rules": "1.8.0",
"@microsoft/sp-module-interfaces": "1.8.0",
"@microsoft/sp-webpart-workbench": "1.8.0",
"@microsoft/rush-stack-compiler-3.3": "0.1.7",
"gulp": "~3.9.1",
"@types/chai": "3.4.34",
"@types/mocha": "2.2.38",

View File

@ -1,6 +1,18 @@
/**
* Anchor interface to be transferred to the "master" web part
*/
export interface IAnchorItem {
/**
* Title
*/
title?: string;
/**
* Unique Id
*/
uniqueId?: string;
//scrollTop?: number;
/**
* DOM element
*/
domElement?: HTMLElement;
}

View File

@ -1,3 +1,14 @@
/**
* Possible positions of the navigation:
* - section - inside the section where the web part has been added
* - top - on top of page canvas
*/
export type NavPosition = 'section' | 'top';
export type NavTheme = 'light' | 'dark';
/**
* Possible "themes" for the navigation element
*/
export type NavTheme = 'light' | 'dark' | 'theme';
/**
* Navigation alignment
*/
export type NavAlign = 'flex-start' | 'center' | 'flex-end';

View File

@ -10,6 +10,7 @@ define([], function () {
"ThemeLabel": "Navigation Theme color",
"ThemeLight": "Light",
"ThemeDark": "Dark",
"ThemeTheme": "Theme color",
"AlignLabel": "Navigation align",
"AlignLeft": "Left",
"AlignCenter": "Center",

View File

@ -9,6 +9,7 @@ declare interface IPageSectionsNavigationStrings {
ThemeLabel: string;
ThemeLight: string;
ThemeDark: string;
ThemeTheme: string;
AlignLabel: string;
AlignLeft: string;
AlignCenter: string;

View File

@ -23,8 +23,8 @@
"properties": {
"scrollBehavior": "auto",
"position": "section",
"isDark": false,
"align": "left"
"align": "left",
"theme": "light"
}
}]
}

View File

@ -5,7 +5,6 @@ import {
BaseClientSideWebPart,
IPropertyPaneConfiguration,
PropertyPaneDropdown,
PropertyPaneToggle,
PropertyPaneChoiceGroup,
PropertyPaneCheckbox,
PropertyPaneTextField
@ -15,12 +14,13 @@ import * as strings from 'PageSectionsNavigationStrings';
import { PageSectionsNavigation, IPageSectionsNavigationProps } from './components/PageSectionsNavigation';
import { IDynamicDataSource, IDynamicDataCallables, IDynamicDataPropertyDefinition } from '@microsoft/sp-dynamic-data';
import { IAnchorItem } from '../../common/model';
import { NavPosition, NavAlign } from '../../common/types';
import { NavPosition, NavAlign, NavTheme } from '../../common/types';
export interface IPageSectionsNavigationWebPartProps {
scrollBehavior: ScrollBehavior;
position: NavPosition;
isDark: boolean;
isDark?: boolean;
theme: NavTheme;
align: NavAlign;
showHomeItem: boolean;
homeItemText: string;
@ -28,20 +28,21 @@ export interface IPageSectionsNavigationWebPartProps {
}
export default class PageSectionsNavigationWebPart extends BaseClientSideWebPart<IPageSectionsNavigationWebPartProps> implements IDynamicDataCallables {
// "Anchor" data sources
private _dataSources: IDynamicDataSource[] = [];
//private _anchors: IAnchorItem[];
protected onInit(): Promise<void> {
const { customCssUrl } = this.properties;
this._onAnchorChanged = this._onAnchorChanged.bind(this);
// getting data sources that have already been added on the page
this._initDataSources();
// registering for changes in available datasources
this.context.dynamicDataProvider.registerAvailableSourcesChanged(this._initDataSources.bind(this, true));
this._addCustomCss(customCssUrl);
// registering current web part as a data source
this.context.dynamicDataSourceManager.initializeSource(this);
return super.onInit();
@ -54,6 +55,7 @@ export default class PageSectionsNavigationWebPart extends BaseClientSideWebPart
scrollBehavior,
position,
isDark,
theme,
align,
showHomeItem,
homeItemText
@ -64,22 +66,30 @@ export default class PageSectionsNavigationWebPart extends BaseClientSideWebPart
anchors: anchors,
scrollBehavior: scrollBehavior,
position: position,
theme: isDark ? 'dark' : 'light',
theme: theme ? theme : (isDark ? 'dark' : 'light'),
align: align,
isEditMode: this.displayMode === DisplayMode.Edit,
homeItem: showHomeItem && homeItemText
homeItem: showHomeItem ? homeItemText : ''
}
);
ReactDom.render(element, this.domElement);
}
/**
* implementation of getPropertyDefinitions from IDynamicDataCallables
*/
public getPropertyDefinitions(): ReadonlyArray<IDynamicDataPropertyDefinition> {
return [{
id: 'position',
title: 'position'
}];
}
/**
* implementation of getPropertyValue from IDynamicDataCallables
* @param propertyId property Id
*/
public getPropertyValue(propertyId: string): NavPosition {
switch (propertyId) {
case 'position':
@ -97,6 +107,14 @@ export default class PageSectionsNavigationWebPart extends BaseClientSideWebPart
return Version.parse('1.0');
}
/**
* Manual handling of changed properties.
* If position has been changed we need to notify subscribers
* If custom css has been changed we need to add new CSS to the page
* @param propertyPath
* @param oldValue
* @param newValue
*/
protected onPropertyPaneFieldChanged(propertyPath: string, oldValue: any, newValue: any) {
if (propertyPath === 'position') {
this.context.dynamicDataSourceManager.notifyPropertyChanged('position');
@ -108,7 +126,7 @@ export default class PageSectionsNavigationWebPart extends BaseClientSideWebPart
if (oldValue) {
const oldCssLink = this._getCssLink(oldValue);
if (oldCssLink) {
oldCssLink.parentElement.removeChild(oldCssLink);
oldCssLink.parentElement!.removeChild(oldCssLink);
}
}
@ -118,6 +136,15 @@ export default class PageSectionsNavigationWebPart extends BaseClientSideWebPart
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
const align = this.properties.align || 'left';
const {
scrollBehavior,
position,
theme,
isDark,
showHomeItem,
homeItemText,
customCssUrl
} = this.properties;
return {
pages: [
{
@ -137,7 +164,7 @@ export default class PageSectionsNavigationWebPart extends BaseClientSideWebPart
key: 'smooth',
text: strings.SmoothScrollBehavior
}],
selectedKey: this.properties.scrollBehavior || 'auto'
selectedKey: scrollBehavior || 'auto'
}),
PropertyPaneDropdown('position', {
label: strings.PositionLabel,
@ -148,13 +175,21 @@ export default class PageSectionsNavigationWebPart extends BaseClientSideWebPart
key: 'top',
text: strings.PositionTop
}],
selectedKey: this.properties.position || 'top'
selectedKey: position || 'top'
}),
PropertyPaneToggle('isDark', {
PropertyPaneDropdown('theme', {
label: strings.ThemeLabel,
offText: strings.ThemeLight,
onText: strings.ThemeDark,
checked: !!this.properties.isDark
options: [{
key: 'light',
text: strings.ThemeLight
}, {
key: 'theme',
text: strings.ThemeTheme
}, {
key: 'dark',
text: strings.ThemeDark
}],
selectedKey: theme ? theme : isDark ? 'dark' : 'light'
}),
PropertyPaneChoiceGroup('align', {
label: strings.AlignLabel,
@ -183,15 +218,15 @@ export default class PageSectionsNavigationWebPart extends BaseClientSideWebPart
}),
PropertyPaneCheckbox('showHomeItem', {
text: strings.HomeNavItemCbxLabel,
checked: this.properties.showHomeItem
checked: showHomeItem
}),
PropertyPaneTextField('homeItemText', {
label: strings.HomeNavItemTextLabel,
value: this.properties.homeItemText || strings.HomeNavItemDefaultText
value: homeItemText || strings.HomeNavItemDefaultText
}),
PropertyPaneTextField('customCssUrl', {
label: strings.CustomCSSLabel,
value: this.properties.customCssUrl
value: customCssUrl
})
]
}
@ -201,10 +236,16 @@ export default class PageSectionsNavigationWebPart extends BaseClientSideWebPart
};
}
/**
* Initializes collection of "Anchor" data soures based on collection of existing page's data sources
* @param reRender specifies if the web part should be rerendered
*/
private _initDataSources(reRender?: boolean) {
// all data sources on the page
const availableDataSources = this.context.dynamicDataProvider.getAvailableSources();
if (availableDataSources && availableDataSources.length) {
// "Ahchor" data sources cached in the web part from prev call
const dataSources = this._dataSources;
//
// removing deleted data sources if any
@ -234,23 +275,20 @@ export default class PageSectionsNavigationWebPart extends BaseClientSideWebPart
if (!dataSources || !dataSources.filter(ds => ds.id === dataSource.id).length) {
dataSources.push(dataSource);
this.context.dynamicDataProvider.registerPropertyChanged(dataSource.id, 'anchor', this._onAnchorChanged);
//this._anchors.push(dataSource.getPropertyValue('anchor') as IAnchorItem);
}
}
}
// TODO: unregister events for deleted data sources
//this._dataSources = availableDataSources;
if (reRender) {
this.render();
}
}
/**
* Fired when any of anchors has been changed
*/
private _onAnchorChanged() {
this.render();
//console.log(ds.getPropertyValue('anchor'));
}
private _addCustomCss(customCssUrl: string) {

View File

@ -37,6 +37,22 @@
}
}
&.theme {
background: "[theme: themePrimary, default: #0078d4]";
border-bottom-color: "[theme:themePrimary, default: #0078d4]";
.nav {
.navItem {
.navItemLink {
color: "[theme:white, default: #fff]";
&:hover {
color: "[theme:neutralLighter, default: #f4f4f4]"
}
}
}
}
}
&.dark {
background: "[theme: neutralDark, default: #212121]";
border-bottom-color: "[theme:white, default: #fff]";

View File

@ -27,13 +27,18 @@ export interface IPageSectionsNavigationState {
export class PageSectionsNavigation extends React.Component<IPageSectionsNavigationProps, IPageSectionsNavigationState> {
// layer div to host navigation elements OUTSIDE of normal DOM hierarchy
private _layerElement: HTMLElement | undefined;
// parent node where the current component should be renderred
private _host: Node;
// span DOM element that is renderered IN normal DOM hierarchy
private _sectionHostSpanRef = React.createRef<HTMLSpanElement>();
// first scrollable parent in normal DOM hierarchy. Needed for Home click implementation
private _scrollableParent: Element;
// page canvas DOM element id
private readonly _pageCanvasId = 'spPageCanvasContent';
// page layout element selector
private readonly _pageLayoutSelector = '[class*="layoutWrapper_"]';
constructor(props: IPageSectionsNavigationProps) {
@ -51,10 +56,12 @@ export class PageSectionsNavigation extends React.Component<IPageSectionsNavigat
public componentWillUpdate(nextProps: IPageSectionsNavigationProps) {
if (nextProps.position !== this.props.position) {
// updating layer based on position
this._removeLayerElement();
this._layerElement = this._getLayerElement(nextProps.position);
}
else if (!this._layerElement) {
// creating layer if not exist
this._layerElement = this._getLayerElement();
}
}
@ -81,8 +88,11 @@ export class PageSectionsNavigation extends React.Component<IPageSectionsNavigat
if (theme === 'dark') {
rootDivClassNames[styles.dark] = true;
}
else if (theme === 'theme') {
rootDivClassNames[styles.theme] = true;
}
const navItems: JSX.Element[] = this.props.anchors.map((anchor, index) => {
const navItems: JSX.Element[] = this.props.anchors.map((anchor) => {
return <li className={css(styles.navItem, 'psn-navItem')}>
<a className={css(styles.navItemLink, 'psn-navItemLink')} onClick={this._onClick.bind(this, anchor)}>{anchor.title}</a>
</li>;
@ -93,6 +103,9 @@ export class PageSectionsNavigation extends React.Component<IPageSectionsNavigat
</li>);
}
//
// React Portal component is used to render navigation outside of normal div hierarchy
//
return (
<span ref={this._sectionHostSpanRef}>
{
@ -116,6 +129,7 @@ export class PageSectionsNavigation extends React.Component<IPageSectionsNavigat
}
private _onHomeClick() {
// home click
if (!this._scrollableParent) {
this._initScrollParent();
}
@ -127,6 +141,7 @@ export class PageSectionsNavigation extends React.Component<IPageSectionsNavigat
private _onClick(anchor: IAnchorItem, index: number) {
// click on one of anchor's nav items
if (anchor.domElement) {
anchor.domElement.scrollIntoView({
behavior: this.props.scrollBehavior,
@ -135,6 +150,10 @@ export class PageSectionsNavigation extends React.Component<IPageSectionsNavigat
}
}
/**
* creates layer element to host the navigation outside of normal DOM hierarchy
* @param position - current position value
*/
private _getLayerElement(position?: NavPosition): HTMLElement | undefined {
const host = this._getHost(position);
@ -172,11 +191,19 @@ export class PageSectionsNavigation extends React.Component<IPageSectionsNavigat
}
}
private _getHost(position: NavPosition): Node | undefined {
/**
* gets host DOM element based on position property
* @param position - current position value
*/
private _getHost(position?: NavPosition): Node | undefined {
const navPos = position || this.props.position;
const doc = getDocument();
if (!doc) {
return undefined;
}
let hostNode: Node;
if (navPos === 'section') {

View File

@ -4,7 +4,6 @@ import { Version } from '@microsoft/sp-core-library';
import {
BaseClientSideWebPart,
IPropertyPaneConfiguration,
PropertyPaneTextField,
PropertyPaneCheckbox
} from '@microsoft/sp-webpart-base';
@ -27,8 +26,10 @@ export interface IPageSectionsNavigationAnchorWebPartProps {
export default class PageSectionsNavigationAnchorWebPart extends BaseClientSideWebPart<IPageSectionsNavigationAnchorWebPartProps> implements IDynamicDataCallables {
// anchor data object related to the current web part
private _anchor: IAnchorItem;
private _pageNavDataSource: IDynamicDataSource;
// "Master" data source
private _pageNavDataSource: IDynamicDataSource | undefined;
protected onInit(): Promise<void> {
@ -42,8 +43,11 @@ export default class PageSectionsNavigationAnchorWebPart extends BaseClientSideW
uniqueId: uniqueId
};
// getting data sources that have already been added on the page
this._initDataSource();
// registering for changes in available datasources
this.context.dynamicDataProvider.registerAvailableSourcesChanged(this._initDataSource.bind(this));
// registering current web part as a data source
this.context.dynamicDataSourceManager.initializeSource(this);
if (!uniqueId) {
@ -65,8 +69,8 @@ export default class PageSectionsNavigationAnchorWebPart extends BaseClientSideW
showTitle: showTitle,
updateProperty: this._onTitleChanged.bind(this),
anchorElRef: (el => {
this._anchor.domElement = el; //this.domElement;
//this._anchor.scrollTop = this.domElement.scrollTop;
// notifying subscribers that the anchor component has been rendered
this._anchor.domElement = el;
this.context.dynamicDataSourceManager.notifyPropertyChanged('anchor');
}),
navPosition: position
@ -77,12 +81,20 @@ export default class PageSectionsNavigationAnchorWebPart extends BaseClientSideW
}
/**
* implementation of getPropertyDefinitions from IDynamicDataCallables
*/
public getPropertyDefinitions(): ReadonlyArray<IDynamicDataPropertyDefinition> {
return [{
id: 'anchor',
title: 'Anchor'
}];
}
/**
* implementation of getPropertyValue from IDynamicDataCallables
* @param propertyId property Id
*/
public getPropertyValue(propertyId: string): IAnchorItem {
switch (propertyId) {
case 'anchor':
@ -125,11 +137,19 @@ export default class PageSectionsNavigationAnchorWebPart extends BaseClientSideW
private _onTitleChanged(title: string) {
this._anchor.title = this.properties.title = title;
// notifying that web part's title has been changed
this.context.dynamicDataSourceManager.notifyPropertyChanged('anchor');
}
/**
* Initializes "master" data source
*/
private _initDataSource(): void {
// all data sources on the page
const availableDataSources = this.context.dynamicDataProvider.getAvailableSources();
//
// searching for "master" data source
//
let hasPageNavDataSource = false;
for (let i = 0, len = availableDataSources.length; i < len; i++) {
let dataSource = availableDataSources[i];

View File

@ -1,42 +1,45 @@
.webPartTitle {
position: relative;
font-size: 14px;
font-weight: 100;
margin-bottom: 11px;
color: "[theme:neutralPrimary, default: #333333]";
@media (min-width: 320px) {
font-size: 21px;
}
&.visible {
font-size: 14px;
font-weight: 100;
margin-bottom: 11px;
color: "[theme:neutralPrimary, default: #333333]";
@media (min-width: 480px) {
font-size: 24px;
}
@media (min-width: 320px) {
font-size: 21px;
}
// Edit mode
textarea {
background-color: transparent;
border: none;
box-sizing: border-box;
color: inherit;
display: block;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
height: 40px;
line-height: inherit;
margin: 0;
outline: 0;
overflow: hidden;
resize: none;
text-align: inherit;
white-space: pre;
width: 100%;
}
@media (min-width: 480px) {
font-size: 24px;
}
// View mode
span {
font-weight: 300;
// Edit mode
textarea {
background-color: transparent;
border: none;
box-sizing: border-box;
color: inherit;
display: block;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
height: 40px;
line-height: inherit;
margin: 0;
outline: 0;
overflow: hidden;
resize: none;
text-align: inherit;
white-space: pre;
width: 100%;
}
// View mode
span {
font-weight: 300;
}
}
.anchorEl {

View File

@ -35,7 +35,7 @@ export class PageSectionsNavigationAnchor extends React.Component<IPageSectionsN
if (displayMode === DisplayMode.Edit || showTitle) {
return (
<div className={css(styles.webPartTitle, 'psn-anchorTitle')}>
<div className={css(styles.webPartTitle, styles.visible, 'psn-anchorTitle')}>
<div className={css(anchorElClassNames)} ref={anchorElRef}></div>
{
displayMode === DisplayMode.Edit
@ -48,8 +48,13 @@ export class PageSectionsNavigationAnchor extends React.Component<IPageSectionsN
</div>
);
}
return null;
else {
return (
<div className={styles.webPartTitle}>
<div className={css(anchorElClassNames)} ref={anchorElRef}></div>
</div>
);
}
}
/**

View File

@ -1,4 +1,5 @@
{
"extends": "./node_modules/@microsoft/rush-stack-compiler-3.3/includes/tsconfig-web.json",
"compilerOptions": {
"target": "es5",
"forceConsistentCasingInFileNames": true,