README.md 43.9 KB

4.x Widget Development Guide [INTERNAL]

Widgets are reusable user-interface components and are key to providing a rich user experience. The ArcGIS for JavaScript API provides a set of ready-to-use widgets and also provides a foundation for you to create custom widgets.

This document will walk through developing a custom widget and also how to use a widget in an ArcGIS for JavaScript API application.

Widget development requirements

Node

Node is a JavaScript runtime environment and it powers some of the tooling used for widget development. It'll mostly be used to install all development dependencies and to compile our TypeScript and Sass.

TypeScript

TypeScript JavaScript that scales. TypeScript is a typed superset of JavaScript that compiles to plain JavaScript. Any browser. Any host. Any OS. Open source.

Widget development is done in TypeScript and the following are some of its main features:

  • statically-typed
  • transpiles ES6 to ES5 (our current target)
  • class-based

The TypeScript Tutorial and Sitepen's Definitive Guide to TypeScript are excellent resources to start learning TypeScript.

Although not required, a code editor that supports TypeScript will ease development. See TypeScript Editor support for more details.

JSX

JSX is a JavaScript extension syntax that allows us to describe our widget UIs similarly to HTML.

See JSX in depth for more information.

Note: Not all concepts from React resources are applicable.

esri/core/Accessor

Accessor is one of the core features of 4.0 and it is the base for all classes. It is the foundation for widgets, so knowledge of Accessor will come pretty handy. It is strongly recommended to read learn Accessor TypeScript usage patterns to become familiar with best practices using Accessor + TypeScript.

Sass

Sass is a CSS preprocessor that allows us to use variables, mixins, and functions. Widget CSS is authored in Sass in an effort to make it easier to style and to ensure a consistent look across widgets.

Widget development setup

Note: The following steps assume the developer is working with the arcgis-js-api 4.x repo and that submodules have been initialized.

  • Run npm install && npm run start
  • Each widget belongs in a .tsx file, which allows us to use JSX to define our UIs.
  • Each widget should have a reference to it in tsconfig.json. Place entries in alphabetical order. Note: this step will no longer be needed when TypeScript 2.0 rolls out (see Glob support in tsconfig.json).

esri/widgets/Widget

This module is the base for all ArcGIS for JavaScript API widgets. Like most 4.0 modules, it extends Accessor. We are also able to leverage JSX to define our UIs.

The core principle for widgets is that the UI is created and updated inside render(). This method will rely on widget properties and methods (typically from the viewModel) for rendering.

It is recommended to design your widget to minimize relying on state to reduce complexity. Although this is not always possible, strive for simplicity when building your widget.

Lifecycle

  • constructor(params) – †
  • postInitialize() – this method is called when the widget's properties are ready, but before rendering
  • startup() – deprecated; added for backwards-compatibility only
  • destroy() – this method should be used to free up widget resources to ensure proper garbage collection

Methods

  • render() – method where the UI is rendered
  • scheduleRender() – invalidates the UI and schedules a subsequent render
  • on(eventType, listener) – method used to register event listeners
  • emit(event, eventObject) – method used to emit events

Properties

  • viewModel – the widget's viewModel

srcNodeRef is only needed when consumed outside of a Widget see Composite widgets

Note: This is not a 1:1 replacement for React/Dijit, etc...

As a developer, you will typically implement postInitialize, destroy and render; as well as define custom widget methods/properties.

Also, unlike dijit/_WidgetBase, esri/widgets/Widget will automatically call destroy on all superclasses when destroyed.

Building HelloWorld widget

The following shows how to build HelloWorld widget.

Note This section assumes knowledge of Accessor TypeScript usage patterns

var helloWorld = new HelloWorld({}, "helloWorldDiv");

// renders <div>Hello, my name is Art Vandelay!</div>

Simple HelloWorld

HelloWorld.tsx

/// <amd-dependency path="../../../core/tsSupport/declareExtendsHelper" name="__extends" />
/// <amd-dependency path="../../../core/tsSupport/decorateHelper" name="__decorate" />

import {subclass, declared} from "../../../core/accessorSupport/decorators";

import Widget = require("./Widget");
import {jsxFactory} from "./support/widget";

@subclass("esri.widgets.HelloWorld")
class HelloWorld extends declared(Widget) {
  render() {
    return (
      <div>Hello, my name is Art Vandelay!</div>
    );
  }
}

export = HelloWorld;

Let's focus on the following section:

@subclass("esri.widgets.HelloWorld")
class HelloWorld extends declared(Widget) {
  render() {
    return (
      <div>Hello, my name is Art Vandelay!</div>
    );
  }
}

We are extending esri/widgets/Widget and defining our UI in the render method. We can leverage JSX to define our UI and it should be straightforward to see that we are creating a div element with Hello World! as its content.

You may have noticed that this snippet has some lines using a triple-slash syntax: ///. In TypeScript, this is known as a triple-slash directive. You can find more information on this here.

Defining properties

This section assumes knowledge on Accessor TypeScript usage patterns.

HelloWorld.tsx

// previous sections omitted for brevity

import {subclass, declared, property} from "../../../core/accessorSupport/decorators";

@subclass("esri.widgets.HelloWorld")
class HelloWorld extends declared(Widget) {

  @property({
    value: "Art"
  })
  firstName: string;

  @property({
    value: "Vandelay"
  })
  lastName: string;

  render() {
    return (
      <div>{`Hello, my name is ${this.firstName} ${this.lastName}!`}</div>
    );
  }
}

Note: previous snippet uses using a template literal for readability

Defining properties for widgets are the same as defining them for any Accessor-based class written in TypeScript. Once defined, they can be used inside the render method if applicable.

usage

widget = new HelloWorld({
  firstName: "Eduardo",
  lastName: "Corrochio"
}, "widgetDiv");

// renders <div>Hello, my name is Eduardo Corrochio!</div>

Defining methods

HelloWorld.tsx

// previous sections omitted for brevity

@subclass("esri.widgets.HelloWorld")
class HelloWorld extends declared(Widget) {

  greet() {
    const greeting = this._getGreeting();
    console.log(greeting);
  }

  private _getGreeting() {
    return `Hello, my name is ${this.firstName} ${this.lastName}!`;
  }

  render() {
    return (
      <div>{this._getGreeting()}</div>
    );
  }
}

Responding to DOM events

HelloWorld.tsx

// previous sections omitted for brevity

@subclass("esri.widgets.HelloWorld")
class HelloWorld extends declared(Widget) {

  private _handleClick() {
    this.greet();
  }

  render() {
    return (
      <div onclick={this._handleClick}>{this._getGreeting()}</div>
    );
  }
}

Listening for DOM events requires setting the corresponding event listener on your node.

Note: event listener attributes are lowercased and not camelcased as React resources may show.

Responding to synthetic events

Synthetic events rely on using emit:

HelloWorld.tsx

// previous sections omitted for brevity

@subclass("esri.widgets.HelloWorld")
class HelloWorld extends declared(Widget) {

  greet() {
    const greeting = this._getGreeting();
    console.log(greeting);

    this.emit("greeted", {
      greeting: greeting
    });
  }

}

Consumers can then listen to the emitted event:

widget.on("greeted", function(payload) {
  console.log("greeted: ", payload);
});

widget.greet();  // 'greeted: Hello, my name is Art Vandelay!'

Styling HelloWorld widget

HelloWorld.tsx

// previous sections omitted for brevity

const CSS = {
  base: "esri-hello-world"
};

@subclass("esri.widgets.HelloWorld")
class HelloWorld extends declared(Widget) {

  render() {
    return (
      <div class={CSS.base}>
        {this._getGreeting()}
      </div>
    );
  }
}

We apply the base CSS in our JSX by using the class attribute. We are also leveraging a CSS lookup object that holds all CSS used by our widget. This allows us to keep all of the CSS in the same place. Also, note that our classes follow the BEM naming convention. See CSS for more details on how to name your CSS classes.

The previous snippet focused on a class that won't change, but what if we have some classes that need to be toggled at runtime? The answer is to use the special classes attribute.

The classes attribute expects an object where the key represents the CSS class to toggle. The class is added if its value is truthy, and it will be removed if its value is falsey.

// previous sections omitted for brevity

const CSS = {
  base: "esri-hello-world",
  emphasis: "esri-hello-world--emphasis"
};

@subclass("esri.widgets.HelloWorld")
class HelloWorld extends declared(Widget) {

  @property({
    value: true
  })
  emphasized: boolean;

  render() {
    const dynamicClasses = {
      [CSS.emphasis]: this.emphasized
    };

    return (
      <div class={CSS.base}
           classes={dynamicClasses}>
        {this._getGreeting()}
      </div>
    );
  }
}

Note: The previous snippet uses computed property names. This allows us to use CSS values as keys for our dynamic classes object. See Dynamic CSS classes for more details.

Internationalizing HelloWorld widget

HelloWorld.tsx

// previous sections omitted for brevity

import * as i18n from "dojo/i18n!../nls/HelloWorld";

@subclass("esri.widgets.HelloWorld")
class HelloWorld extends declared(Widget) {
  render() {
    return (
      <div title={i18n.caption}>{this._getGreeting()}</div>
    );
  }
}

HelloWorld.js

define({
  root: ({
    helloWorld: "Hello World"
  }),
  ar: 1,
  // ...
  "zh-tw": 1
});

Internationalization leverages dojo/i18n and follows the same conventions denoted here

In order for us to use that module, we need to define an amd-dependency and declare a constant to use it inside our widget.

The way we bring dojo/i18n may look different, but using the localization bundle stays the same.

Making HelloWorld accessible

HelloWorld.tsx

// previous sections omitted for brevity

import {ENTER, SPACE} from "dojo/keys";

@subclass("esri.widgets.HelloWorld")
class HelloWorld extends declared(Widget) {

  private _handleClick(event: MouseEvent) {
    this.greet();
  }

  private _handleKeyDown(event: KeyboardEvent) {
    if (event.keyCode === ENTER || event.keyCode === SPACE) {
      this.greet();
    }
  }

  render() {
    return (
      <div onclick={this._handleClick}
           onkeydown={this._handleKeyDown}
           tabIndex="0">
         {this._getGreeting()}
      </div>
    );
  }
}

Here we can make our widget keyboard accessible by allowing it to be used via keyboard. This is possible by using tabIndex and handling keydown events.

See our Accessibility wiki page for more info on accesssibility.

Note: The @accessibleHandler decorator can help us simplify our accessible event setup. See TypeScript widget decorators for more details.

Dynamic UI

HelloWorld.tsx

// previous sections omitted for brevity

import {subclass, declared, property} from "../../../core/accessorSupport/decorators";
import {renderable} from "./support/widget";

@subclass("esri.widgets.HelloWorld")
class HelloWorld extends declared(Widget) {

  @property({
    value: "Art"
  })
  @renderable()
  firstName: string;

  @property({
    value: "Vandelay"
  })
  @renderable()
  lastName: string;

  render() {
    return (
      <div>{`Hello, my name is ${this.firstName} ${this.lastName}!`}</div>
    );
  }
}

The previous examples so far, have focused on rendering the initial state of the widget. Most widgets will require the UI to update during their lifespan. As you may now know, render() is where the UI is created. We can cause our widget to re-render by doing any of the following.

By using the @renderable decorator, our widget will now re-render whenever the decorated properties change.

Note:** @renderable is only applicable to properties. If your widget requires re-rendering by another means, say responding to an event, scheduleRender may be used instead.

HelloWorld.tsx

// previous sections omitted for brevity

import topic = require("dojo/topic");

@subclass("esri.widgets.HelloWorld")
class HelloWorld extends declared(Widget) {

  postInitialize() {
    const handle = topic.subscribe("external-event-emitted", () => {
      this.scheduleRender();
    });

    // removes handle when destroyed
    this.own(handle);
  }

}

@renderable also accepts a property chain as an argument if you have nested object properties that your UI depends on:

HelloWorld.tsx

// previous sections omitted for brevity

import {subclass, declared, property} from "../../../core/accessorSupport/decorators";
import {renderable} from "./support/widget";

@subclass("esri.widgets.HelloWorld")
class HelloWorld extends declared(Widget) {

  @property()
  @renderable("person.firstName")
  @renderable("person.lastName")
  person: Person;

  render() {
    return (
      <div>{`Hello, my name is ${this.person.firstName} ${this.person.lastName}!`}</div>
    );
  }
}

Putting it all together

/// <amd-dependency path="../../../core/tsSupport/declareExtendsHelper" name="__extends" />
/// <amd-dependency path="../../../core/tsSupport/decorateHelper" name="__decorate" />

import {ENTER, SPACE} from "dojo/keys";

import {subclass, declared, property} from "../../../core/accessorSupport/decorators";

import Widget = require("../../../widgets/Widget");

import {renderable, jsxFactory} from "../../../widgets/support/widget";

import * as i18n from "dojo/i18n!../nls/HelloWorld";

const CSS = {
  base: "esri-hello-world",
  emphasis: "esri-hello-world--emphasis"
};

@subclass("esri.widgets.HelloWorld")
class HelloWorld extends declared(Widget) {

  //--------------------------------------------------------------------------
  //
  //  Properties
  //
  //--------------------------------------------------------------------------

  //----------------------------------
  //  firstName
  //----------------------------------

  @property({
    value: "Art"
  })
  @renderable()
  firstName: string;

  //----------------------------------
  //  lastName
  //----------------------------------

  @property({
    value: "Vandelay"
  })
  @renderable()
  lastName: string;

  //----------------------------------
  //  emphasized
  //----------------------------------

  @property({
    value: false
  })
  @renderable()
  emphasized: boolean;

  //--------------------------------------------------------------------------
  //
  //  Public Methods
  //
  //--------------------------------------------------------------------------

  greet() {
    const greeting = this._getGreeting();
    console.log(greeting);
    this.emit("greeted", {
      greeting: greeting
    });
  }

  render() {
    const greeting = this._getGreeting();
    const classes = {
      [CSS.emphasis]: this.emphasized
    };

    return (
      <div aria-role="button"
           bind={this}
           class={CSS.base}
           classes={classes}
           onclick={this._handleClick}
           onkeydown={this._handleKeyDown}
           tabIndex="0"
           title={i18n.caption}>
      {greeting}
      </div>
    );
  }

  //--------------------------------------------------------------------------
  //
  //  Private Methods
  //
  //--------------------------------------------------------------------------

  private _getGreeting() {
    return `Hello, my name is ${this.firstName} ${this.lastName}!`;
  }

  private _handleClick() {
    this.greet();
  }

  private _handleKeyDown(event: KeyboardEvent) {
    if (event.keyCode === ENTER || event.keyCode === SPACE) {
      this.greet();
    }
  }

}

export = HelloWorld;

Using HelloWorld widget

var widget;

require([
  "esri/widgets/examples/widgets/HelloWorld",
  "dojo/domReady!"
], function(
  HelloWorld
) {

  var names = [
    {
      firstName: "Kenny",
      lastName: "Banya"
    },
    {
      firstName: "Jackie",
      lastName: "Chiles"
    },
    {
      firstName: "Joe",
      lastName: "Devola"
    }
  ],
  nameIndex = 0;

  widget = new HelloWorld(names[nameIndex], "widgetDiv");

  function changeName() {
    widget.set(names[++nameIndex % names.length]);
  }

  setInterval(changeName, 1000);
});

See it in action.

Advanced concepts

Working with a viewModel

If your widget has an associated ViewModel, you can delegate properties, methods, and events easily by using widget view model decorators (see TypeScript widget decorators for more info).

Extending our HelloWorld example, if we have a HelloWorldViewModel with the following API:

firstName: string;
lastName: string;
greet(): void;
getGreeting(): string; // emits "greeted" event with { greeting: greeting } as its payload

We could delegate to the view model as follows

// previous sections omitted for brevity

import HelloWorldViewModel = require("./HelloWorld/HelloWorldViewModel");

@subclass("esri.widgets.HelloWorld")
class HelloWorld extends declared(Widget) {

  // delegates to viewmodel property
  @aliasOf("viewModel.firstName")
  @renderable()
  firstName: string;

  // delegates to viewmodel property
  @aliasOf("viewModel.lastName")
  @renderable()
  lastName: string;

  // redispatches the event when emitted from the view model
  @vmEvent("greeted")
  @property({
    type: HelloWorldViewModel
  })
  viewModel: HelloWorldViewModel = new HelloWorldViewModel()

  // delegates to the viewmodel method
  @aliasOf("viewModel.greet")
  greet(): void {}

  render() {
    const greeting = this.viewModel.getGreeting(); // from viewModel

    // render() parts omitted for brevity

    return (
      <div>
        {greeting}
      </div>
    );
  }

}

render()

  • This method must return a valid UI representation (VNode). If you need to toggle some content, consider wrapping the content in another node (see Render relevant elements below).
render() {
  // invalid – `render` cannot return null
  return this.visible ? (
    <div>...</div>
  ) : null;
}

Render relevant elements only

In each render call, create only relevant UI pieces. This will eliminate the need to hide/remove them explicitly with JavaScript or CSS.

Rendering non-applicable elements (don't)

render() {
  // assume these methods always return some UI structure
  const title = this._createTitle();
  const body = this._createBody();

  // the following produces HTML for title and content
  return (
    <div>
      {title}
      {body}
    </div>
  );
}

Not rendering non-applicable elements (do)

render() {
  const title = this._hasTitle ? this._createTitle() : null;
  const body = this._hasBody ? this._createBody() : null;

  // produces HTML for title and content ONLY if there is truly content
  return (
    <div>
      {title}
      {body}
    </div>
  );
}

Binding

By default, functions referenced in your elements will have this set to the actual element.

render() {
  return (
    <div onclick={whatIsThis}>`this` is the node</div>
  );
}

private _whatIsThis(): void {
  console.log(`this === node: ${this}`);
}

You can use the special bind attribute to change this.

render() {
  return (
    <div bind={this} onclick={this._whatIsThis}>`this` is the widget instance</div>
  );
}

private _whatIsThis(): void {
  console.log(`this === widget: ${this}`);
}

Distinguishable children

If you have sibling elements with the same selector and the elements will be added/removed dynamically, they need to be made distinguishable.

We do this by using the special key attribute:

render() {
  const top = this.hasTop ? <li class={CSS.item} key="top">Top</header> : null;
  const middle = this.hasMiddle ? <li class={CSS.item} key="middle">Middle</section> : null;
  const bottom = this.hasBottom ? <li class={CSS.item} key="bottom">Bottom</footer> : null;

  return (
    <ol>
      {top}
      {middle}
      {bottom}
    </ol>
  );
}

Note: key can also be a number or object.

Composite widgets

One of the main highlights of widgets is reusability. Composing widgets out of other widgets is a good way to promote reusability and not have to worry about reinventing the wheel.

Note that subcomponents must be created outside of render.

HelloWorld.tsx

// previous sections omitted for brevity

import {subclass, declared, property} from "../../../core/accessorSupport/decorators";
import {renderable} from "./support/widget";

import RandomEmoji = require("./RandomEmoji");

@subclass("esri.widgets.HelloWorld")
class HelloWorld extends declared(Widget) {

  private _emoji = new RandomEmoji();

  render() {
    return (
      <div>
      {this._emoji.render()}  // we call the subcomponent's render() method to get its UI representation
      </div>
    );
  }
}

If your subcomponent has state, you can update it within render():

// previous sections omitted for brevity

import Emphasis = require("./Emphasis");

@subclass("esri.widgets.HelloWorld")
class HelloWorld extends declared(Widget) {

  private _emphasis: Emphasis = new Emphasis();

  render() {
    this._emphasis.text = `Hello, my name is ${this.firstName} ${this.lastName}!`;
    return (
      <div>
      {this._emphasis.render()}  // we call the subcomponent's render() method to get its UI representation
      </div>
    );
  }
}

Note: Whenever using subcomponents, you do not need to provide container/srcRefNode. You will only use render() to get your widget's UI representation and place it within the owner's render() method appropriately.

Dynamic CSS classes

One thing to keep in mind is that class cannot be changed within render(). If you have dynamic classes, you'll need to use the special classes attribute.

changing class during render

// will throw a runtime error because `class` cannot be changed
render() {
  const baseClass = this.isBold && this.isItalic ? `${CSS.base} ${CSS.bold} ${CSS.italic}` :
                    this.isBold ? `${CSS.base} ${CSS.bold}` :
                    this.isItalic ? `${CSS.base} ${CSS.italic}` :
                    CSS.base;

  return (
    <div class={baseClass}>Hello World!</div>
  );
}

using classes

render() {
  const dynamicClass = {
    [CSS.bold]: this.isBold,
    [CSS.italic]: this.isItalic
  };

  return (
    <div class={CSS.base} classes={dynamicClass}>Hello World!</div>
  );
}

without computed property syntax

/*
 * Assuming:
 *
 * CSS = {
 *   bold: "esri-example--bold",
 *   italic: "esri-example--italic"
 * }
 *
 * The following pattern is error-prone due to duplication
 */

const dynamicClass = {
  "esri-example--bold": this.isBold,
  "esri-example--italic": this.isItalic
};

with computed property syntax

const dynamicClass = {
  [CSS.bold]: this.isBold
  [CSS.italic]: this.isItalic
};

Dynamic inline styles

Similar to classes, styles helps us apply styles dynamically:

render() {
  const dynamicStyles = {
    background-color: this.__hasBackgroundColor ? "chartreuse" : ""
  };

  return (
    <div styles={dynamicStyles}>Hello World!</div>
  );
}

Note: Style values must be strings

Spreading properties/attributes

// previous sections omitted for brevity

// `assignHelper` is required for spreading
/// <amd-dependency path="../../../core/tsSupport/assignHelper" name="__assign" />

@subclass("esri.widgets.HelloWorld")
class HelloWorld extends declared(Widget) {

  _staticProps: any = {
    "aria-role": "button",
    tabIndex: "0",
    title: "static-title"
  }

  render() {
    // will render: <div aria-role="button" tabindex="0" title="static-title">Hello World!</div>
    return (
      <div {...this._staticProps}>Hello World!</div>
    );
  }

}

The spread operator is a useful pattern if you already have an object with properties that you want to apply to a node.

TypeScript widget decorators

The following decorators are available to ease widget development in TypeScript.

Properties

  • @aliasOf – used to delegate a property or method

Properties

  @aliasOf("viewModel.name")
  name: string;

Methods

  @aliasOf("viewModel.toLabel")
  toLabel(id: string): Label { return null; }

* [deprecated - see `@aliasOf`] ~~`@vmProperty()`~~ – used to delegate a property to a view model

  ```tsx
  @vmProperty()
  foo: string;
  • @renderable() – used to automatically schedule renders when a property is modified
  @renderable()
  partVisible = true;

A property chain can be passed as an argument if the property has a child property that should trigger a render.

  @renderable("viewModel.isActive")
  viewModel: ViewModelType = new ViewModelType()

The following signatures are also supported:

  @renderable("viewModel.propA, viewModel.propB")
  viewModel: ViewModelType = new ViewModelType()
  @renderable([
    "viewModel.propA",
    "viewModel.propB"
  ])
  viewModel: ViewModelType;
  • @vmEvent – used to delegate a view model event
  @vmEvent("vm-event")
  viewModel: ViewModelType = new ViewModelType();

Methods

  • @accessibleHandler - used to execute a method when the space or enter key are pressed. (Note: triggers on keydown event)
    @accessibleHandler()
    private _doSomething() {
      // ...
    }

    render() {
      return (
        <div onclick={this._doSomething}
             onkeydown={this._doSomething}
             tabIndex="0">:)</div>
      );
    }
  }
  • [deprecated - see @aliasOf] @vmMethod – used to delegate a method to a view model (NOTE: requires method stub);
  @vmMethod()
  bar() {}

Gotchas

  • Make sure your widget file uses the tsx extension. Otherwise, TypeScript will not recognize JSX.

  • Remember to import tsx! It is required when using JSX. Note: IDEs may complain about the import not being used.

  • JSX attributes vs. props – whenever you set an attribute with a string value, it'll render as an attribute. Otherwise, as a property:

  _afterCreate(element: Element) {
    console.log(element);   // <div data-custom-attr="100">Hello World!</div>

    console.log(element.getAttribute("data-custom-attr"));  // '100'
    console.log(element.getAttribute("data-custom-prop"));  // 'undefined'

    console.log((element as any)["data-custom-attr"]);  // 'undefined'
    console.log((element as any)["data-custom-prop"]);  // 100
  }

  render() {
    return (
      <div afterCreate={this._afterCreate}
           data-custom-attr={"100"}
           data-custom-prop={100}>
           Hello World!
      </div>
    );
  }

By the way, a VNode-property will only become an attribute if its value is a string. Other type of values will become properties and can be read using evt.target[]

See maquette/issues/29

  • Do not modify any renderable properties within render(), afterCreate or afterUpdate. Doing so, will cause an infinite loop.

The only exception is when you can prevent unnecessary updates within afterCreate and afterUpdate:

  afterCreate(node: Element) {
    if (this._somethingReallyChanged(node)) {
      this.scheduleRender();
    }
  }
  • Using space-delimited CSS dynamic classes throws an error.
render() {

  // CSS.multi = "esri-example esri-example--active"
  const dynamicClass = {
    [CSS.multi]: this.someCondition
  };

  // throws error
  return (
    <div classes={dynamicClass}>Hello World!</div>
  );
}

On the other hand, space-delimited static classes are allowed.

 render() {

   // CSS.base = "esri-example esri-widget"

   return (
     <div class={CSS.base}>Hello World!</div>
   );
 }
  • Avoid complex setups in the constructor. Use postInitialize instead.

Widget file structure

The following is a high-level view of a widget's structure in the API.

esri/
    themes/
        base/
            widgets/
                <WidgetName>.scss
            _Core.scss // has option to include widget and its respective import
    widgets/
        <WidgetName>/
            nls/
                <WidgetName>.js
            <WidgetName>ViewModel.{js, ts}
        <WidgetName>.tsx

The next sections give more detail on this structure.

Styling

Sass

Sass files reside separately from the other widget files to allow us to distribute all of the Sass files as a submodule. This will give users full control over the CSS.

Assume we're styling HelloWorld. Its scss file would be located in esri/themes/base/widgets/HelloWorld.scss

esri/themes/base/widgets/HelloWorld.scss

@mixin HelloWorld(){
  // HelloWorld styles
}

@if $include_HelloWorld == true {
  @include HelloWorld();
}

Which is imported in esri/themes/base/Core.scss with a corresponding 'include' variable.

esri/themes/base/_Core.scss

/*
  Core Settings and Imports
*/

// Widgets (sorted alphabetically)
$include_Attribution      : true !default;
// ...
$include_HelloWorld       : true !default;

// ...

// Widgets (sorted alphabetically)
@import "widgets/Attribution";
// ...
@import "widgets/HelloWorld";

The API provides an NPM script to build all styles:

npm run build:styles

See 4.0 – styles for more info.

CSS

CSS for widgets should follow BEM (Block Element Modifier) conventions. This has the following benefits:

  • Uses delimiters to separate block, element, modifiers
  • Semantics (albeit verbose)
  • Keeps specificity low
  • Scopes styles to blocks
 /* block */
.example-widget {}

/* block__element */
.example-widget__input {}
.example-widget__submit {}

/* block--modifier */
.example-widget--loading {}

/* block__element--modifier */
.example-widget__submit--disabled {}

For JS API widgets, the block must be prefixed with esri-, for example .esri-compass for the Compass widget.

ViewModel

Public widgets (i.e., documented in the SDK) that have reusable behavior should have an associated view model. Internal widgets do not require a view model.

https://devtopia.esri.com/WebGIS/arcgis-js-api/wiki/4.0-Widget-'ViewModel'-Pattern

Best Practices

  • Be expressive! Be, be expressive!

Example 1

Compare

  add("david", 30, 5.8, 195);

to

  const name = "david";
  const age = 30;
  const heightInFeet = 5.98;
  const weightInPounds = 195;

  add(name, age, heightInFeet, weightInPounds);

Example 2

Compare

  test(i: string) {
    return i.test(/^([\w\.\-_]+)?\w+@[\w-_]+(\.\w+){1,}$/);
  }

to

  isEmail(input: string): boolean {
    const emailPattern = /^([\w\.\-_]+)?\w+@[\w-_]+(\.\w+){1,}$/;
    return input.test(emailPattern);
  }

Example4

Compare

  // renderable.ts

  export function renderable(nestedProperties?: string | string[]) {
    const nestedProps = typeof nestedProperties === "string" ?
      [...nestedProperties
        .split(",")
        .map(chain => chain.trim())
      ] :
      nestedProperties;

    return function (target: any, propertyName: string): void {
      if (!target._renderableProps) {
        target._renderableProps = [];
      }

      if (nestedProps) {
        target._renderableProps = [
          ...target._renderableProps,
          ...nestedProps.map((prop) => {
            prop = prop.replace(`${propertyName}.`, "");
            return `${propertyName}.${prop}`;
          })
        ];
      }
      else {
        target._renderableProps.push(propertyName);
      }
    };
  }

to

  // renderable.ts

  function splitProps(props: string): string[] {
    return props.split(",")
      .map(chain => chain.trim());
  }

  function normalizePropNames(names: string[], sourceName: string): string[] {
    return names.map(name => normalizePropName(name, sourceName));
  }

  function normalizePropName(name: string, sourceName: string): string {
    if (name.indexOf(sourceName) === 0) {
      return name;
    }

    return `${sourceName}.${name}`;
  }

  export function renderable(nestedProperties?: string | string[]): PropertyDecorator {
    const nestedProps = typeof nestedProperties === "string" ?
      splitProps(nestedProperties) :
      nestedProperties;

    return function (target: any, propertyName: string): void {
      if (!target._renderableProps) {
        target._renderableProps = [];
      }

      const renderableProps = target._renderableProps;

      if (!nestedProps) {
        renderableProps.push(propertyName);
        return;
      }

      renderableProps.push.apply(
        renderableProps,
        normalizePropNames(nestedProps, propertyName)
      );
    };
  }

Tips

  • In JSX, any element tag can be self-closed. This can be helpful if you have simple elements with no children:
  return (
    <div class={CSS.base}>
      <div class={CSS.selfClosing} />
    </div>
   );

Behind the scenes

Widget rendering is powered by Maquette.

The following resources are highly recommended:

Although we use JSX for defining our UI, we are not using React. This is because the transpiled JSX is processed by a custom JSX factory that uses Maquette's h function to create the VDOM. See Maquette 2.2 now supports JSX for more information.

Q&A

  • What happened to HTML template files?

Template files are long gone. Reasons:

  • Using JSX allows us to express our UI and gives context to how it'll behave. Templates obscure this by being separate.
  • For simple HTML, creating a separate HTML file introduces overhead.
  • Allowing JSX prevents anti-patterns where a complex widget uses a main template file and defines supporting widget templates inline.
  • No more exposed references to the widget's CSS & i18n variables.

    • I have to use external text content, which may not be safe for innerHTML. How can I use textContent?

Maquette uses textContent internally and appends Text nodes, so HTML is never parsed. See maquette/issues/10

The only way innerHTML would be used is if provided as an attribute, which is an anti-pattern:

ANTI-PATTERN

  render() {
    return (
      <div>
        {/*ANTI-PATTERN: DO NOT DO THIS!!!*/}
        <div innerHTML={this.maliciousText}></div>
      </div>
    );
  }
  • Why Maquette?

    • VDOM - UI lib
    • Fast, easy to learn, easy to debug and predictable.
    • small 3kb (gzipped)
    • Sitepen is adopting it for Dojo 2 widgets and will provide support for us
    • Project is actively maintained
    • Supports JSX
    • Flexible
    • Stable
    • Inspired by React & Mithril
  • How do I know when my widget's been attached to the DOM?

http://maquettejs.org/docs/typedoc/interfaces/maquette.vnodeproperties.html#aftercreate

  • How do I use comments in JSX?
  const content = (
    <div class={CSS.base}>
      {/* child comment */}
      <div class={CSS.child}
           /*
            * multi
            * line
            */
           tabIndex="0"  // end of line
           title="my-title"></div>
    </div>
  );
  • How do I know if my widget has been destroyed?

You can use Widget#destroyed.

  • How do I reference a widget's DOM node?

Only widgets that are instantiated programmatically: e.g., new Widget(args, container); will have a reference to the root DOM node via container/srcRefNode.

Within render(), you can access the real DOM node with afterCreate():

  render() {
    return (
      <div afterCreate={this._doSomethingWithRootNode}>Hello World!</div>
    );
  }

afterCreate() can also be used per-element:

  render() {
    return (
      <div afterCreate={this._doSomethingWithRootNode}>
        <span afterCreate={this._doSomethingWithChildNode}>Hello World!<span>
      </div>
    );
  }

Note: Storing a reference to the node reference passed to afterCreate is not advised since it is not likely to be the same:

TODO: INVESTIGATE!

  _previousNode: Element;

  _compareWithPrevious(node: Element) {
    console.log("same as previous?", this._previousNode === node);
    this._previousNode = node;
  }

  render() {
    return (
      <div afterCreate={this._compareWithPrevious} bind={this}>
        <span afterCreate={this._compareWithPrevious} bind={this}>Hello World!<span>
      </div>
    );
  }

  // logs true for the root node, false for the child

Additional Examples

You can take a look at the 4x-widget-snippets repo to look at some focused snippets.

Additional References

Unresolved/Workarounds

  • RTL approach/helpers – for now widget's can check document.dir?
  • Storing and accessing current widget DOMNode – the only way to do this is via afterCreate, which needs to be added to the UI definition (JSX/h syntax). Storing this may not be feasible since it'll change if the DOM is recreated.
  • React-ish way of passing props down or customizing components is not possible:
  // not possible because JSX expects the SubComponent to expose the React.Component API
  render() {
    return (
      <div class="root">
        <SubComponent class="parent-given-class" />
      </div>
    )
  }
  render() {
    const subComponentNode = this._subComponent.render();

     // not possible because modifying VDOM breaks maquette's API contract
    subComponentNode.class = "parent-given-class";

    return (
      <div class="root">
        <SubComponent />
      </div>
    )
  }
  render() {
     // this could work, but it makes the API slightly more complex
    this._subComponent.overrides = {
      class: "parent-given-class"
    };

    return (
      <div class="root">
        {this._subComponent.render()}
      </div>
    )
  }

Known issues (working on it!)

* destroy not unmounting widget fixed

Breaking changes

  • The visible property from Widget has been removed. Widget subclasses will need to implement their own visible property if necessary.

Notes

  • In order to support dynamically adding/removing DOM event/listeners an event map proxy needs to be added to the appropriate node inside render(). Doing so has the unfortunate side-effect of causing multiple renders when these DOM events are fired. This may be fixed in the future by leveraging an advanced projector.