SPFx - PropertyPane Custom Controls - page 2

In this page, I show how to create the control as a reusable module.

So let's create a new project for this control. Create a folder named numberpicker, find it it at the command line and run the command:
 npm init

Fill the prompts and you have initialized the project. Now, open the created package.json file and replace it's contents:
  
{
  "name": "spfx-number-picker",
  "version": "1.0.1",
  "description": "",
  "main": "numberPicker.js",
  "dependencies": {
    "typescript": "^3.3.1"
  },
  "devDependencies": {
    "@microsoft/sp-webpart-base": "^1.4.1",
    "node-sass": "^4.11.0"
  },
  "scripts": {
    "scss": "node-sass --output-style compressed ./scss/numberPicker.module.scss > ./css/numberPicker.css"
  },
  "author": "",
  "license": "ISC"
}

  • Now, create 2 subfolders for the styles: [css] and  [scss]
  • At the root of the project, create an empty .ts file. Call it numberPicker.ts.
  • Create a tsconfig.json file with the following content:
  
{
    "compileOnSave": true,
    "compilerOptions": {
        "target": "es5",
        "module": "commonjs",
        "declaration": true,
        "preserveConstEnums": true,
        "skipLibCheck": true,
        "sourceMap": true
    },
    "typeRoots": [
        "./node_modules/@types",
        "./node_modules/@microsoft"
    ],
    "exclude": [
        "node_modules"
    ],
    "include": [
        "./"
    ]
}

  • Now, install dependencies by running the command:
  npm install

  • Inside the scss folder, create a file called numberPicker.module.scss.
  • Fill it with the following code:
  

$neutralLighter: '[theme:neutralLighter, default:white]';
$neutralLight: '[theme:neutralLight, default:lightgray]';
$neutralDark: '[theme:neutralDark, default:darkgray]';
$black: '[theme:black, default:black]';
$white: '[theme:white, default:white]';
$errorText: '[theme:errorText, default:red]';

.number-picker{

    margin: 10px 0;
    position: relative;
    height: auto;
  
    .wrapper{
      position: relative;
      margin: 5px 0;
      height:31px;
      width:100%;
    }
    .picker{
      margin: 0;
      position: absolute;
      top: 0;
      height: auto;
      width: 50px;
    }
    
    .apply-wrapper{
      width: 50%;
      height:auto;
      position: absolute;
      top: 0;
      left: 80px;
    }
    
    .apply-wrapper button{
      font-weight: 600;
      width:85px;
      height:34px;
      font-size:12px;
    }
    
    .wrapper .picker button{
      margin:0;
      position:absolute;
      width:25px;
      height:17px;
      display:block;
    }
    
    .wrapper button,
    .wrapper button:active,
    .wrapper button:focus{
      cursor:pointer;
      border:0;
      outline:0;
      background: $neutralLighter;
      color:$black;
      &:disabled{
        background: $neutralLighter;
        color:$neutralLight;
      }
    }
    
    .wrapper button:not([disabled]):hover{
      background:$neutralLight;
    }
    
    .wrapper .up-arrow{
      top:0;
      right:-25px;
    } 
    .wrapper .down-arrow{
      bottom:0;
      right:-25px;
    } 
    
    .wrapper input{
      border:0;
      color:$black;
      background:$white;
      font-size:16px;
      text-align:center;
      width:100%;
      height:30px;
      border: 1px solid;
      border-color: $neutralLight;
      &:focus{
        outline:0;
      }
    }
  
    .description{
      font-size: 11px;
      color:$neutralDark;
    }
  
    .error-msg{
      font-size: 11px;
      color:$errorText;
      display: none;
    }
  }
  
  /* END OF NUMBER PICKER */

  • Compile the .scss file with the command:
 npm run scss

This will compile the .scss on a .css file.
Note: Everytime you change the .scss file you need to compile it again.

You should see the compiled .css file in the [css] directory. This is the stylesheet we will require on the .ts file.

  • Now, paste the following code to the empty numberPicker.ts file


import { IPropertyPaneField, IPropertyPaneCustomFieldProps, PropertyPaneFieldType } from '@microsoft/sp-webpart-base';
require ('./css/numberPicker.css');

/**
 * Public interface that exposes this control's custom properties
 * @property {object} properties              - The WebPart's properties Object (this.properties).
 * @property {string} label                   - A label for this control.
 * @property {string} [description]           - A description for this control.
 * @property {number} min                     - The minimum value this control allows.
 * @property {number} max                     - The maximum value this control allows.
 * @property {boolean} applyOnClick           - Validate and apply changes on a button click (only useful for ReactivePropertyChanges mode).
 * @property {number} deferredValidationTime  - TimeOut in milisseconds to validate and apply changes (only useful for ReactivePropertyChanges mode)
 */
export interface NumberPickerFieldProps {
  properties:any;
  label:string;
  description?:string;
  min: number;
  max: number;
  applyOnClick?: boolean;
  deferredValidationTime? : number;
}

/**
 * The public NumberPicker Object
 */
export class NumberPicker implements IPropertyPaneField<IPropertyPaneCustomFieldProps>{

  type: any = PropertyPaneFieldType.Custom;
  targetProperty:string;
  properties: IPropertyPaneCustomFieldProps;
  shouldFocus:boolean;
  private config : NumberPickerFieldProps;
  private timeOut : any;

  constructor(targetProperty:string, config : NumberPickerFieldProps, context?:any, shouldFocus?:boolean){
    this.targetProperty = targetProperty;
    this.shouldFocus = shouldFocus;
    this.properties = {
      key: "myNumberPicker",
      context: context,
      onRender : this.render.bind(this),
      onDispose : this.dispose.bind(this)
    }
    this.config = config;
  }

  /**
   * Drawing of the control
   * @param element the element of the PropertyPane where this control will be drawn
   * @param context the webpart context (if we passe it to the NumberPicker Object)
   * @param changeCallback the callbakk to apply the property changes
   */
  private render(element:HTMLElement, context:any, changeCallback:(targetProperty:string, newValue:any)=>void){
    let currentValue = this.config.properties[this.targetProperty] || this.config.min;
    let html = 
    `<div class="number-picker"> 
      <label class="ms-Label">${this.config.label}</label>
      <div class="wrapper"> 
        <div class="picker">
          <input type="text" value="${currentValue}"></input>   
          <button type="button" class="up-arrow">
            <i class="ms-Icon ms-Icon--CaretSolidUp"></i>
          </button>
          <button type="button" class="down-arrow">
            <i class="ms-Icon ms-Icon--CaretSolidDown"></i>
          </button>    
        </div>
      `;
  if (this.config.applyOnClick) {
    html += `
        <div class="apply-wrapper">
          <button type="button" class="apply-btn">Apply</button>
        </div>`;
  }
  html +=`
      </div>
      <span class="error-msg"></span>
      <span class="description">${this.config.description||""}</span>
    </div>`;

    element.innerHTML = html;

    let arrowUpbutton : HTMLButtonElement = <HTMLButtonElement>element.getElementsByClassName('up-arrow')[0];
    let arrowDownbutton : HTMLButtonElement = <HTMLButtonElement>element.getElementsByClassName('down-arrow')[0];
    
    if (currentValue-1<this.config.min){
      arrowDownbutton.disabled = true;
    }

    if (currentValue+1>this.config.max){
      arrowUpbutton.disabled = true;
    }

    this.addEvents(element, changeCallback);
  }

  private addEvents(element:HTMLElement, callback:(targetProperty:string, newValue:any)=>void ){

    let inputTextElement : HTMLInputElement = element.getElementsByTagName('input')[0];
    let arrowUpbutton : HTMLButtonElement = <HTMLButtonElement>element.getElementsByClassName('up-arrow')[0];
    let arrowDownbutton : HTMLButtonElement = <HTMLButtonElement>element.getElementsByClassName('down-arrow')[0];
    let applyButton : HTMLButtonElement = <HTMLButtonElement>element.getElementsByClassName('apply-btn')[0];

    arrowUpbutton.onclick=()=>{
        let oldValue = parseInt(inputTextElement.value);
        if (isNaN(oldValue)) oldValue = this.config.min;
        
        if (oldValue+1 <= this.config.max){
          inputTextElement.value = "" + (oldValue + 1);
          arrowDownbutton.disabled = false;
          if (oldValue+1 == this.config.max) arrowUpbutton.disabled = true;
          if (!this.config.applyOnClick) this.applyChanges(element, inputTextElement, callback);
        }else{
          arrowUpbutton.disabled = true;
        }
    }

    arrowDownbutton.onclick=()=>{
        let oldValue = parseInt(inputTextElement.value);
        if (isNaN(oldValue)) oldValue = this.config.min + 1;
        
        if (oldValue-1 >= this.config.min){
          inputTextElement.value = "" + (oldValue - 1);
          arrowUpbutton.disabled = false;
          if (oldValue-1 == this.config.min) arrowDownbutton.disabled = true;
          if (!this.config.applyOnClick) this.applyChanges(element, inputTextElement, callback);
        }else{
          arrowDownbutton.disabled = true;
        }
        
    }


    if (this.config.applyOnClick){
      applyButton.onclick=()=>{
          this.applyChanges(element, inputTextElement, callback);
        }
    }else{
      inputTextElement.oninput=()=>{
        this.applyChanges(element, inputTextElement, callback);
      }
    }
  }

  private applyChanges(element: HTMLElement, inputTextElement : HTMLInputElement, callback:(targetProperty:string, newValue:any)=>void ){
    let newValue = parseInt(inputTextElement.value);
    if (!newValue || isNaN(newValue)) newValue = this.config.min;
    let errorSpan : HTMLSpanElement = <HTMLSpanElement> element.getElementsByClassName('error-msg')[0];

    let time = this.config.deferredValidationTime || 0;
    if (time < 0 || this.config.applyOnClick) time = 0;

    clearTimeout(this.timeOut);
    this.timeOut = setTimeout(()=>{

      if (this.validateValue(newValue)) {
        errorSpan.style.display = 'none';
        callback(this.targetProperty, newValue);
      }else{
        this.renderValidationError(errorSpan);
      }

    }, time);
  }

  private validateValue(value:number){
    return value >= this.config.min && value <= this.config.max;
  }

  private renderValidationError(errorSpan : HTMLSpanElement){
    errorSpan.textContent = `Invalid number: min:${this.config.min}, max:${this.config.max}`;
    errorSpan.style.display = 'block';
  }

  private dispose(element:HTMLElement){
    element.innerHTML="";
  }
}

  • Finally, compile the module, with the command:
tsc

The module is ready! 

Note: Everytime you change the numberPicker.ts file, you need to run the "tsc" command again, to compile the module.

In the final step, let's import this module to the helloworld project.

Comments

Popular posts from this blog

Property Pane dynamic fields

Handling theme changes on a MS Teams Tab WebPart

Sharing Dynamic Data between WebParts