This is the last post of three that explain how to add dependent properties to Client Web Part Property Pane.
As an example of such properties I decided to use Lists dropdown and Views dropdown. Views dropdown is populated based on selected List. As a result of these three posts we'll have a fully working web part with ability to select List View in Property Pane:

Here is a content of 3 posts:
- SPFx. Client Web Part Property Pane Dependent Properties. Part I: Preparation.
- SPFx. Client Web Part Property Pane Dependent Properties. Part II: Dependent Properties Control.
- SPFx. Client Web Part Property Pane Dependent Properties. Part III: Styling Dependent Properties Control as Office UI Fabric dropdowns. Knockout version. (current post)
I'm using Visual Studio Code IDE for the tutorial.
This post describes creation of custom Knockout bindings, custom Knockout components and styling your components in Office UI Fabric manner.
In previous post we created a custom field that consists of two dependent dropdowns. Everything works good but looks not so good. It would be much better if our dropdowns look similar to Office UI Fabric Dropdown. Unfortunately there is no Knockout version of Office UI Fabric Dropdown and a Dropdown from Fabric JS is pretty static - it creates <ul> markup based on a <select>. But if you change something in your select dynamically it won't reflect UL until you recreate the Fabric JS dropdown.
So let's create our own Knockout.js component styled as Office UI Fabric Dropdown. You can find documentation on Knockout components here so I won't spend time to describe it deeply. In few words we need to create a view, a viewmodel and register the component to be able to use it in our app.
First of all, we need to create some interface that will describe a single dropdown item. We need value, text and selected flag to describe an item (/webparts/depProps/components/dropdown/viewmodel):
/**
* Dropdown option item
*/
export interface IDropdownOption {
value: string | number;
text: string;
selected: boolean;
}
It also would be great if our component's bindings look similar to standard <select> bindings. It means that we could provide
- options - observable array of dropdown items
- optionsCaption - dummy option to prefix a list of dropdown items
- value - selected value
- disabled - flag if a dropdown disabled or not
<our-component params="options: listOfOptions, optionsCaption: caption, value: currentValue, disabled: isDisabled"></our-component>
/**
* Parameters that can be provided to Dropdown component
*/
export interface IDropdownViewModelParams {
/**
* options collection as KnockoutObservableArray
*/
options: KnockoutObservableArray<idropdownoption>;
/**
* Dropdown empty element text and text of Dropdown label
*/
optionsCaption: string;
/**
* Selected value
*/
value: KnockoutObservable<string>;
/**
* Is Dropdown disabled
*/
disabled?: KnockoutObservable<boolean>;
}
</boolean></string></idropdownoption>
<div>
<label class="ms-Label">Label goes here</label>
<div class="ms-Dropdown">
<span class="ms-Dropdown-title">Selected Option</span>
<i class="ms-Dropdown-caretDown ms-Icon ms-Icon--ChevronDown"></i>
<ul class="ms-Dropdown-items">
<li class="ms-Dropdown-item" role="option">Option 1</li>
<li class="ms-Dropdown-item">Option 2</li>
<!-- etc. -->
</ul>
</div>
</div>
Second one is to create a custom binding similar to out-of-the-box options binding.
I selected the second option to practice in custom bindings.
Let's call the binding class as MsDropdownOptions and put it in /webparts/depProps/bindings/MsDropdownOptions.ts:
import * as ko from 'knockout';
/**
* Knockout 'msoptions' binding.
* Allows to bind Fabric UI dropdown options in the similar way to standard select options binding
*/
class MsDropdownOptions {
constructor() {
this.init = this.init.bind(this);
this.update = this.update.bind(this);
}
/**
* This will be called when the binding is first applied to an element
*/
public init(element: any, valueAccessor: () => any, allBindingsAccessor?: KnockoutAllBindingsAccessor, viewModel?: any, bindingContext?: KnockoutBindingContext): void | { controlsDescendantBindings: boolean; } {
// check if binding is applied to the correct element
if (element.tagName.toLowerCase() !== 'div' || element.className.indexOf('ms-Dropdown') === -1)
throw new Error('msoptions binding applies only to <div class="ms-Dropdown"> elements');
// removing previous content if any
this.removePreviousContent(element);
return { 'controlsDescendantBindings': true };
}
/**
* Removes previous content of dropdown
*/
private removePreviousContent(element: any) {
const titleEl: HTMLSpanElement = element.querySelector('span');
if (titleEl) {
titleEl.textContent = '';
}
const listEl = element.querySelector('ul');
if (!listEl)
throw new Error('Incorrect markup in ms-Dropdown element');
while (listEl.children.length) {
listEl.children[0].remove();
}
}
/**
* This method adds all the options and also updates selected item and text
*/
private addNewContent(element: any, options: {}[], selectedValue: KnockoutObservable<string>, itemSelected: (evt: MouseEvent) => any): void {
const titleEl: HTMLSpanElement = element.querySelector('span');
const listEl = element.querySelector('ul');
let selectedValueUnwrapped: string = selectedValue && ko.utils.unwrapObservable(selectedValue);
let selectedValueChanged: boolean = false;
if (!listEl || !titleEl)
throw new Error('Incorrect markup in ms-Dropdown element');
for (let i: number = 0, len: number = options.length; i < len; i++) {
const liEl: HTMLLIElement = document.createElement('li');
const option = options[i];
liEl.textContent = option['text'];
liEl.setAttribute('aria-value', option['value']);
liEl.setAttribute('role', 'option');
liEl.setAttribute('aria-text', option['text']);
liEl.className = 'ms-Dropdown-item';
let isSelected: boolean = false;
if (selectedValueUnwrapped && selectedValueUnwrapped === option['value'])
isSelected = true;
else {
isSelected = option['selected'] === true || option['selected'] === true || option['selected'] === 'selected';
if (isSelected) {
selectedValueUnwrapped = option['value'];
selectedValueChanged = true;
}
}
liEl.setAttribute('aria-selected', isSelected + '');
if (isSelected) {
titleEl.textContent = option['text'];
liEl.className += ' is-selected';
}
if (itemSelected) {
liEl.addEventListener('click', itemSelected); // itemSelected is provided from binding as well
}
listEl.appendChild(liEl);
}
if (!titleEl.textContent && options.length > 0) {
titleEl.textContent = options[0]['text'];
listEl.children[0].setAttribute('aria-selected', 'true');
selectedValueUnwrapped = options[0]['value'];
selectedValueChanged = true;
}
if (selectedValueChanged && selectedValue)
selectedValue(selectedValueUnwrapped);
}
/**
* This will be called once when the binding is first applied to an element,
* and again whenever any observables/computeds that are accessed change
*/
public update(element: any, valueAccessor: () => any, allBindingsAccessor?: KnockoutAllBindingsAccessor,
viewModel?: any, bindingContext?: KnockoutBindingContext): void {
this.removePreviousContent(element);
let unwrappedArray: any = ko.utils.unwrapObservable(valueAccessor());
let captionValue: any;
let filteredArray: {}[] = [];
let itemSelected: (evt: MouseEvent) => any;
if (unwrappedArray) {
if (typeof unwrappedArray.length == "undefined") // Coerce single value into array
unwrappedArray = [unwrappedArray];
// Filter out any entries marked as destroyed
filteredArray = ko.utils.arrayFilter(unwrappedArray, (item) => {
return item === undefined || item === null || !ko.utils.unwrapObservable(item['_destroy']);
});
// If caption is included, add it to the array
if (allBindingsAccessor['has']('optionsCaption')) {
captionValue = ko.utils.unwrapObservable(allBindingsAccessor.get('optionsCaption'));
// If caption value is null or undefined, don't show a caption
if (captionValue !== null && captionValue !== undefined) {
filteredArray.unshift({ value: "-1", text: captionValue });
}
}
} else {
// If a falsy value is provided (e.g. null), we'll simply empty the select element
}
if (allBindingsAccessor['has']('itemSelected'))
itemSelected = allBindingsAccessor.get('itemSelected') as (evt: MouseEvent) => any;
this.addNewContent(element, filteredArray, allBindingsAccessor.get('value'), itemSelected);
}
}
/// <reference path="../../../../typings/knockout/knockout.d.ts">
interface KnockoutBindingHandlers {
msoptions: KnockoutBindingHandler; // custom binding for Knockout Fabric UI dropdown options
}
</reference>
And also we'll create a function that will register our custom binding (/webparts/depProps/bindings/MsDropdownOptions.ts):
/**
* This API adds custom 'msoptions' binding to Knockout bindingHandlers object
*/
export function addMsDropdownBindingHandler(): void {
if (!ko.bindingHandlers.msoptions)
ko.bindingHandlers.msoptions = new MsDropdownOptions();
}
/**
* Dropdown markup (Fabric UI style)
*/
export default class DrowdownView {
public static templateHtml: string = `
<div>
<label class="ms-label" data-bind="text: optionsCaption"></label>
<div class="ms-Dropdown" data-bind="click: onOpenDropdown, msoptions: options, optionsCaption: optionsCaption, value: value, itemSelected: onItemSelected, css: { 'is-open': isOpen(), 'is-disabled': disabled() }">
<span class="ms-Dropdown-title" data-bind=""></span>
<i class="ms-Dropdown-caretDown ms-Icon ms-Icon--ChevronDown"></i>
<ul class="ms-Dropdown-items" role="listbox">
</ul>
</div>
</div>
`;
}
As you can see there are some methods in the binding (onOpenDropdown, onItemSelected, isOpen, disabled). All these methods should be implemented in a viewmodel. So let's create the viewmodel (/webpars/depProps/components/dropdown/viewmodel.ts).
import * as ko from 'knockout';
/**
* ViewModel of custom Dropdown component
*/
export class DropdownViewModel {
/**
* Last opened Dropdown ViewModel
*/
private static _openedDropdownVM: DropdownViewModel;
/**
* Component initial parameters
*/
private _params: IDropdownViewModelParams;
/**
* Dropdown options
*/
protected options: KnockoutObservableArray<IDropdownOption>;
/**
* Options caption
*/
protected optionsCaption: string;
/**
* Current selected value
*/
protected value: KnockoutObservable<string>;
/**
* Is Dropdown opened
*/
protected isOpen: KnockoutObservable<boolean>;
/**
* Is Dropdown disabled
*/
protected disabled: KnockoutObservable<boolean>;
/**
* ctor
*/
constructor(params: IDropdownViewModelParams) {
this._params = params;
this.options = params.options;
this.optionsCaption = params.optionsCaption;
this.value = params.value;
if (params.disabled)
this.disabled = params.disabled;
else
this.disabled = ko.observable<boolean>(false);
// initally dropdown is closed
this.isOpen = ko.observable<boolean>(false);
//
// binding all handlers to current instance
//
this.onItemSelected = this.onItemSelected.bind(this);
this.onOpenDropdown = this.onOpenDropdown.bind(this);
this._onDocClick = this._onDocClick.bind(this);
}
/**
* item selected handler
*/
public onItemSelected(evt: MouseEvent) {
var selectedItem = evt.srcElement;
this.value(selectedItem.getAttribute('aria-value'));
}
/**
* Open-close dropdown handler
*/
public onOpenDropdown(vm: DropdownViewModel, evt: MouseEvent) {
if (this.disabled())
return;
const isOpen: boolean = ko.utils.unwrapObservable(this.isOpen);
evt.stopPropagation();
this.isOpen(!isOpen);
if (!isOpen) {
if (DropdownViewModel._openedDropdownVM && DropdownViewModel._openedDropdownVM !== this) {
DropdownViewModel._openedDropdownVM._onDocClick(null);
}
DropdownViewModel._openedDropdownVM = this;
document.addEventListener('click', this._onDocClick);
}
}
/**
* document.onclick handler
*/
private _onDocClick(evt: MouseEvent) {
this.isOpen(false);
document.removeEventListener('click', this._onDocClick);
}
}
import DropdownView from './view';
import { DropdownViewModel } from './viewmodel';
import * as ko from 'knockout';
import { addMsDropdownBindingHandler } from '../../bindings/MsDropdownOptions';
/**
* custom dropdown component name
*/
export const DROPDOWN_COMPONENT: string = 'spfx_dropdown';
/**
* API to register custom dropdown component
*/
export function registerDropdown(): boolean {
if (!ko.bindingHandlers.msoptions)
addMsDropdownBindingHandler();
if (!ko.components.isRegistered(DROPDOWN_COMPONENT)) {
ko.components.register(DROPDOWN_COMPONENT, {
template: DropdownView.templateHtml,
viewModel: DropdownViewModel
});
}
return true;
}
And finally use it in our custom Property Pane field. For that:
- import component name in PropertyPaneViewSelectorView.ts
import { DROPDOWN_COMPONENT } from '../components/dropdown/Dropdown';
- change the _template variable of PropertyPaneViewSelectorView class to:
private _template: string = ` <div class="view-selector-component"> <${DROPDOWN_COMPONENT} params="options: lists, optionsCaption: listLabel, value: currentList"></${DROPDOWN_COMPONENT}> <${DROPDOWN_COMPONENT} params="options: views, optionsCaption: viewLabel, value: currentView, disabled: noListSelection"></${DROPDOWN_COMPONENT}> </div> `;
- and change PropertyPaneViewSelector class to register the component in constructor:
// somewhere at the beginning of the file import { registerDropdown } from '../components/dropdown/Dropdown'; /** * ctor */ public constructor(_targetProperty: string, _properties: IPropertyPaneViewSelectorFieldPropsInternal) { // add this to the end of the constructor method // registering used custom Knockout components (dropdown) this._registerComponents(); } /** * Registers used custom Knockout components */ private _registerComponents(): void { registerDropdown(); }

Have fun!
Comments