GithubHelp home page GithubHelp logo

dojo / widget-core Goto Github PK

View Code? Open in Web Editor NEW
34.0 28.0 39.0 3.22 MB

:rocket: Dojo 2 - widget authoring system.

Home Page: http://dojo.io

License: Other

JavaScript 0.45% TypeScript 98.64% HTML 0.55% CSS 0.36%

widget-core's Introduction

The @dojo/widget-core repository has been deprecated and merged into @dojo/framework

You can read more about this change on our blog. We will continue providing patches for widget-core and other Dojo 2 repositories, and a CLI migration tool is available to aid in migrating projects from v2 to v3.


@dojo/widget-core

Build Status codecov npm version

widget-core is a library to create powerful, composable user interface widgets.

  • Reactive & unidirectional: widget-core follows core reactive principles to ensure predictable, consistent behavior.
  • Encapsulated Widgets: Create independent encapsulated widgets that can be wired together to create complex and beautiful user interfaces.
  • DOM Abstractions: widget-core provides API abstractions to avoid needing to access or manipulate the DOM outside of the reactive render life-cycle.
  • I18n & Themes: widget-core provides core mixins to enable internationalization and theming support for your widgets.

Installation

To use @dojo/widget-core, install the package along with its required peer dependencies:

npm install @dojo/widget-core

# peer dependencies
npm install @dojo/has
npm install @dojo/shim
npm install @dojo/core
npm install @dojo/i18n

You can also use the dojo cli to create a complete Dojo 2 skeleton application.

Features

Basic Widgets

Dojo 2 applications use the Virtual DOM (vdom) paradigm to represent what should be shown on the view. These vdom nodes are plain JavaScript objects that are more efficient to create from a performance perspective than browser DOM elements. Dojo 2 uses these vdom elements to synchronize and update the browser DOM so that the application shows the expected view.

There are two types of vdom within Dojo 2. The first type provides a pure representation of DOM elements, the fundamental building blocks of all Dojo 2 applications. These are called VNodes and are created using the v() function available from the @dojo/widget-core/d module.

The following will create a VNode that represents a simple div DOM element, with a text node child: Hello, Dojo 2!:

v('div', [ 'Hello, Dojo 2!' ])

The second vdom type, WNode, represent widgets. A widget is a class that extends WidgetBase from @dojo/widget-core/WidgetBase and implements a render function that returns one of the Dojo 2 vdom types (known as a DNode). Widgets are used to represent reusable, independent sections of a Dojo 2 application.

The following returns the VNode example from above from the render function:

class HelloDojo extends WidgetBase {
    protected render() {
        return v('div', [ 'Hello, Dojo 2!' ]);
    }
}

Rendering a Widget in the DOM

To display your new component in the view you will need to decorate it with some functionality needed to "project" the widget into the browser. This is done using the ProjectorMixin from @dojo/widget-core/mixins/Projector.

const Projector = ProjectorMixin(HelloDojo);
const projector = new Projector();

projector.append();

By default, the projector will attach the widget to the document.body in the DOM, but this can be overridden by passing a reference to the preferred parent DOM Element.

Consider the following in your HTML file:

<div id="my-app"></div>

You can target this Element:

const root = document.getElementById('my-app');
const Projector = ProjectorMixin(HelloDojo);
const projector = new Projector();

projector.append(root);

Widgets and Properties

We have created a widget used to project our VNodes into the DOM, however, widgets can be composed of other widgets and properties which are used to determine if a widget needs to be re-rendered.

Properties are available on the widget instance, defined by an interface and passed as a generic to the WidgetBase class when creating your custom widget. The properties interface should extend the base WidgetProperties provided from @dojo/widget-core/interfaces:

interface MyProperties extends WidgetProperties {
    name: string;
}

class Hello extends WidgetBase<MyProperties> {
    protected render() {
        const { name } = this.properties;

        return v('div', [ `Hello, ${name}` ]);
    }
}

New properties are compared with the previous properties to determine if a widget requires re-rendering. By default Dojo 2 uses the auto diffing strategy, that performs a shallow comparison for objects and arrays, ignores functions (except classes that extend WidgetBase) and a reference comparison for all other values.

Composing Widgets

As mentioned, often widgets are composed of other widgets in their render output. This promotes widget reuse across an application (or multiple applications) and promotes widget best practices.

To compose widgets, we need to create WNodes and we can do this using the w() function from @dojo/widget-core/d.

Consider the previous Hello widget that we created:

class App extends WidgetBase {
    protected render() {
        return v('div', [
            w(Hello, { name: 'Bill' }),
            w(Hello, { name: 'Bob' }),
            w(Hello, { name: 'Flower pot men' })
        ]);
    }
}

We can now use App with the ProjectorMixin to render the Hello widgets.

const Projector = ProjectorMixin(App);
const projector = new Projector();

projector.append();

Note: Widgets must return a single top-level DNode from the render method, which is why the Hello widgets were wrapped in a div element.

Decomposing Widgets

Splitting widgets into multiple smaller widgets is easy and helps to add extended functionality and promotes reuse.

Consider the following List widget, which has a simple property interface of an array of items consisting of content: string and highlighted: boolean.

interface ListProperties {
    items: {
        id: string;
        content: string;
        highlighted: boolean;
    };
}

class List extends WidgetBase<ListProperties> {
    protected render() {
        const { items } = this.properties;

        return v('ul', { classes: 'list' }, items.map((item) => {
            const { id, highlighted, content } = item;
            const classes = [ highlighted ? 'highlighted' : null ];

            return v('li', { key: id, classes }, [ content ]);
        });
    }
}

The List widget works as expected and displays the list in the browser but is difficult to reuse, modify, and/or extend.

Note: When working with children arrays with the same type of widget or Element, it is important to add a unique key property or attribute so that Dojo 2 can identify the correct node when updates are made.

To extend the List API with an event that needs to be called when an item is clicked with the item's id, we first update the properties interface:

interface ListProperties {
    items: {
        id: string;
        content: string;
        highlighted: boolean;
    };
    onItemClick: (id: string) => void;
}

If we try to use the onItemClick function in the current List widget, we would need to wrap it in another function in order to pass the item's id.

This would mean a new function would be created every render but Dojo 2 does not support changing listener functions after the first render and this would error.

To resolve this, the list item can be extracted into a separate widget:

interface ListItemProperties {
    id: string;
    content: string;
    highlighted: boolean;
    onItemClick: (id: string) => void;
}

class ListItem extends WidgetBase<ListItemProperties> {

    protected onClick(event: MouseEvent) {
        const { id } = this.properties;

        this.properties.onItemClick(id);
    }

    protected render() {
        const { id, content, highlighted } = this.properties;
        const classes = [ highlighted ? 'highlighted' : null ];

        return v('li', { key: id, classes, onclick: this.onClick }, [ content ]);
    }
}

Using the ListItem we can simplify the List slightly and also add the onclick functionality that we required:

interface ListProperties {
    items: {
        id: string;
        content: string;
        highlighted: boolean;
    };
    onItemClick: (id: string) => void;
}

class List extends WidgetBase<ListProperties> {
    protected render() {
        const { onItemClick, items } = this.properties;

        return v('ul', { classes: 'list' }, items.map(({ id, content, highlighted }) => {
            return w(ListItem, { key:id, content, highlighted, onItemClick });
        });
    }
}

Additionally, the ListItem is now reusable in other areas of our application(s).

Mixins

Dojo 2 makes use of mixins to decorate additional functionality and properties to existing widgets. Mixins provide a mechanism that allows loosely coupled design and composition of behaviors into existing widgets without having to change the base widget.

TypeScript supports mixins using a constructor type of new (...args: any[]) => any; that enables a class to be passed as a function argument and extended to add new functionality.

Example mixin that adds method setState and readonly state property:

// interface for the extended API
interface StateMixin {
    readonly state: Readonly<any>;
    setState(state: any): void;
}

// function that accepts a class that extends `WidgetBase` and returns the extended class with the `StateMixin`
// behavior
function StateMixin<T extends new(...args: any[]) => WidgetBase>(Base: T): T & (new(...args: any[]) => StateMixin) {
    return class extends Base {
        private _state: any;

        public setState(state: any): void {
            // shallow copy of the state
            this._state = { ...this._state, ...state };
            // invalidate the widget
            this.invalidate();
        }

        public get state(): any {
            return this._state;
        }
    };
}

Examples of Dojo 2 mixins can be seen with ThemedMixin and I18nMixin that are described in Styling & Theming and Internationalization sections.

Animation

Dojo 2 widget-core provides a WebAnimation meta to apply web animations to VNodes.

To specify the web animations pass an AnimationProperties object to the WebAnimation meta along with the key of the node you wish to animate. This can be a single animation or an array or animations.

Note: The Web Animations API is not currently available even in the latest browsers. To use the Web Animations API, a polyfill needs to be included. Dojo 2 does not include the polyfill by default, so will need to be added as a script tag in your index.html or alternatively imported in the application’s main.ts using import 'web-animations-js/web-animations-next-lite.min'; after including the dependency in your source tree, or by importing @dojo/shim/browser.

Basic Example

export default class AnimatedWidget extends WidgetBase {
    protected render() {
        const animate = {
            id: 'rootAnimation',
            effects: [
                { height: '10px' },
                { height: '100px' }
            ],
            controls: {
                play: true
            }
        };

        this.meta(WebAnimation).animate('root', animate);

        return v('div', {
            key: 'root'
        });
    }
}

controls and timing are optional properties and are used to setup and control the animation. The timing property can only be set once, but the controls can be changed to apply stop, start, reverse, and other actions on the web animation.

Changing Animation

Animations can be changed on each widget render in a reactive pattern, for example changing the animation from slideUp to slideDown on a title pane depending on the titlepane being open or not.

export default class AnimatedWidget extends WidgetBase {
    private _open = false;

    protected render() {
        const animate = this._open ? {
            id: 'upAnimation',
            effects: [
                { height: '100px' },
                { height: '0px' }
            ],
            controls: {
                play: true
            }
        } : {
            id: 'downAnimation',
            effects: [
                { height: '0px' },
                { height: '100px' }
            ],
            controls: {
                play: true
            }
        };

        this.meta(WebAnimation).animate('root', animate);

        return v('div', {
            key: 'root'
        })
    }
}

Passing an effects function

An effects function can be passed to the animation and evaluated at render time. This allows you to create programmatic effects such as those depending on measurements from the Dimensions Meta.

export default class AnimatedWidget extends WidgetBase {
    private _getEffect() {
        const { scroll } = this.meta(Dimensions).get('content');

        return [
            { height: '0px' },
            { height: `${scroll.height}px` }
        ];
    }

    protected render() {
        const animate = {
            id: 'upAnimation',
            effects: this._getEffect(),
            controls: {
                play: true
            }
        };

        this.meta(WebAnimation).animate('root', animate);

        return v('div', {
            key: 'root'
        })
    }
}

Get animation info

The WebAnimation meta provides a get function that can be used to retrieve information about an animation via its id. This info contains the currentTime, playState, playbackRate and startTime of the animation. If no animation is found or the animation has been cleared this will return undefined.

export default class AnimatedWidget extends WidgetBase {
    protected render() {
        const animate = {
            id: 'rootAnimation',
            effects: [
                { height: '10px' },
                { height: '100px' }
            ],
            controls: {
                play: true
            }
        };

        this.meta(WebAnimation).animate('root', animate);

        const info = this.meta(WebAnimation).get('rootAnimation');

        return v('div', {
            key: 'root'
        });
    }
}

Styling & Theming

Overview

Dojo 2 widget-core provides ThemedMixin to decorate a widget with theming functionality and a @theme decorator to specify the classes available to the widget. Both ThemedMixin and @theme are provided by @dojo/widget-core/mixins/Themed.

To specify the theme classes for a widget, an interface needs to be imported with named exports for each class and passed to the @theme decorator. Importing the interface provides IntelliSense / auto-complete for the class names and passing this via the @theme decorator informs the ThemedMixin which classes can be themed.

Example CSS classes interface
export const classNameOne: string;
export const classNameTwo: string;

Important: at runtime, a JavaScript file is required to provide the processed CSS class names.

The ThemedMixin provides a method available on the instance this.theme() takes a single argument that is either a string class name or an array of string class names and returns the theme's equivalent class names as either a single string or array of strings.

However, it is not always desirable to allow consumers to override styling that may be required for a widget to function correctly. These classes can be added directly to the classes array in VirtualDomProperties.

The following example passes css.root that will be themeable and css.rootFixed that cannot be overridden.

import * as css from './styles/myWidget.m.css';
import { ThemedMixin, theme } from '@dojo/widget-core/mixins/Themed';

@theme(css)
export default class MyWidget extends ThemedMixin(WidgetBase) {
    protected render() {
        return v('div', { classes: [ this.theme(css.root), css.rootFixed ] });
    }
}

If an array is passed to this.theme then an array will be returned. For example, this.theme([ css.root, css.other ]) will return an array containing the theme's class names [ 'themedRoot', 'themedOther' ].

Writing a theme

Themes are TypeScript modules that export an object that contains css-modules files keyed by a widgets CSS file name.

/* myTheme/styles/myWidget.m.css */
.root {
    color: blue;
}
// myTheme/theme.ts
import * as myWidget from './styles/myWidget.m.css';

export default {
    'myWidget': myWidget
}

In the above example, the new root class provided by the theme for myWidget will replace the root class that was provided by the original myWidget.m.css. This means the myWidget will now have its color set to blue, but will no longer receive the styles from the root class in the original CSS.

Applying a theme

To apply a theme to a widget, simply require it as a module and pass it to a widget via its properties. It is important to ensure that any child widgets created within your widget's render function are passed the theme or they will not be themed.

// app.ts
import myTheme from './myTheme/theme';

// ...
render() {
    return w(TabPanel, { theme: myTheme } });
}

Passing extra classes

Sometimes you may need to apply positioning or layout styles to a child widget. As it is not possible to pass classes directly to virtualized widget nodes. WNodes thus provide an extraClasses property to target themeable classes within its render function. In most cases, this should only target the root class and apply positioning adjustments. The classes passed via extraClasses are outside of the theming mechanism and thus will not be affected by a change in theme.

/* app.m.css */
.tabPanel {
    position: absolute;
    left: 50px;
    top: 50px;
}
// app.ts
import * as appCss from './styles/app.m.css';

// ...
render() {
    return w(TabPanel, { extraClasses: { 'root': appCss.tabPanel } });
}

In the above example, the tabPanel will receive its original root class in addition to the appCss.tabPanel class when used with this.theme.

Internationalization

Widgets can be internationalized by adding the I18nMixin mixin from @dojo/widget-core/mixins/I18n. Message bundles are localized by passing them to localizeBundle. Note that with this pattern it is possible for a widget to obtain its messages from multiple bundles; however, we strongly recommend limiting widgets to a single bundle whenever possible.

If the bundle supports the widget's current locale, but those locale-specific messages have not yet been loaded, then a bundle of blank message values is returned. Alternatively, the localizeBundle method accepts a second boolean argument, which, when true, causes the default messages to be returned instead of the blank bundle. The widget will be invalidated once the locale-specific messages have been loaded, triggering a re-render with the localized message content.

The object returned by localizeBundle contains the following properties and methods:

  • messages: An object containing the localized message key-value pairs. If the messages have not yet loaded, then messages will be either a blank bundle or the default messages, depending upon how localizeBundle was called.
  • isPlaceholder: a boolean property indicating whether the returned messages are the actual locale-specific messages (false) or just the placeholders used while waiting for the localized messages to finish loading (true). This is useful to prevent the widget from rendering at all if localized messages have not yet loaded.
  • format(key: string, replacements: { [key: string]: string }): a method that accepts a message key as its first argument and an object of replacement values as its second. For example, if the bundle contains greeting: 'Hello, {name}!', then calling format('greeting', { name: 'World' }) would return 'Hello, World!'.

Each widget can have its own locale by passing a property - properties.locale. If no locale is set, then the default locale, as set by @dojo/i18n, is assumed.

const MyWidgetBase = I18nMixin(WidgetBase);

class I18nWidget extends MyWidgetBase<I18nWidgetProperties> {
    render() {
        // Load the "greetings" messages for the current locale. If the locale-specific
        // messages have not been loaded yet, then the default messages are returned,
        // and the widget will be invalidated once the locale-specific messages have
        // loaded.
        const { format, isPlaceholder, messages } = this.localizeBundle(greetingsBundle);

        // In many cases it makes sense to postpone rendering until the locale-specific messages have loaded,
        // which can be accomplished by returning early if `isPlaceholder` is `true`.
        if (isPlaceholder) {
            return;
        }

        return v('div', { title: messages.hello }, [
            w(Label, {
                // Passing a message string to a child widget.
                label: messages.purchaseItems
            }),
            w(Button, {
                // Passing a formatted message string to a child widget.
                label: format('itemCount', { count: 2 })
            })
        ]);
    }
}

Once the I18n mixin has been added to a widget, the default bundle can be replaced with the i18nBundle property. Further, while we recommend against using multiple bundles in the same widget, there may be times when you need to consume a third-party widget that does so. As such, i18nBundle can also be a Map of default bundles to override bundles.

import { Bundle } from '@dojo/i18n/i18n';

// A complete bundle to replace WidgetA's message bundle
import overrideBundleForWidgetA from './nls/widgetA';

// Bundles for WidgetB
import widgetB1 from 'third-party/nls/widgetB1';
import overrideBundleForWidgetB from './nls/widgetB';

// WidgetB uses multiple bundles, but only `thirdy-party/nls/widgetB1` needs to be overridden
const overrideMapForWidgetB = new Map<Bundle<any>, Bundle<any>>();
map.set(widgetB1, overrideBundleForWidgetB);

export class MyWidget extends WidgetBase {
	protected render() {
		return [
			w(WidgetA, {
				i18nBundle: overrideBundleForWidgetA
			}),
			w(WidgetB, {
				i18nBundle: overrideMapForWidgetB
			}),
		];
	}
}

Key Principles

These are some of the important principles to keep in mind when creating and using widgets:

  1. The widget's __render__, __setProperties__, __setChildren__ functions should never be called or overridden.
    • These are the internal methods of the widget APIs and their behavior can change in the future, causing regressions in your application.
  2. Except for projectors, you should never need to deal directly with widget instances
    • The Dojo 2 widget system manages all instances required including caching and destruction, trying to create and manage other widgets will cause issues and will not work as expected.
  3. Never update properties within a widget instance, they should be considered pure.
    • Properties are considered read-only and should not be updated within a widget instance, updating properties could cause unexpected behavior and introduce bugs in your application.
  4. Hyperscript should always be written using the @dojo/widget-core/d#v() function.
    • The widget-core abstraction for Hyperscript is the only type of vdom that widget-core can process for standard DOM elements, any other mechanism will not work properly or at all.

Advanced Concepts

This section provides some details on more advanced Dojo 2 functionality and configuration that may be required to build more complex widgets and applications.

Handling Focus

Handling focus is an important aspect in any application and can be tricky to do correctly. To help with this issue, @dojo/widget-core provides a primitive mechanism built into the Virtual DOM system that enables users to focus a Virtual DOM node once it has been appended to the DOM. This uses a special property called focus on the VNodeProperties interface that can be passed when using v(). The focus property is either a boolean or a function that returns a boolean.

When passing a function, focus will be called when true is returned without comparing the value of the previous result. However, when passing a boolean, focus will only be applied if the property is true and the previous property value was not.

// Call focus for the node on the only first render
v('input', { type: 'text', focus: true })
// Call focus for the node on every render
v('input', { type: 'text', focus: () => true) })

This primitive is a base that enables further abstractions to be built to handle more complex behaviors. One of these is handling focus across the boundaries of encapsulated widgets. The FocusMixin should be used by widgets to provide focus to their children or to accept focus from a parent widget.

The FocusMixin adds focus and shouldFocus to a widget's API. shouldFocus checks if the widget is in a state to perform a focus action and will only return true once, until the widget's focus method has been called again. This shouldFocus method is designed to be passed to child widgets or nodes as the value of their focus property.

When shouldFocus is passed to a widget, it will be called as the properties are set on the child widget, meaning that any other usages of the parent's shouldFocus method will result in a return value of false.

An example usage controlling focus across child VNodes (DOM) and WNodes (widgets):

		interface FocusInputChildProperties {
			onFocus: () => void;
		}

		class FocusInputChild extends Focus(WidgetBase)<FocusInputChildProperties> {
			protected render() {
				// the child widget's `this.shouldFocus` is passed directly to the input node's focus property
				return v('input', { onfocus: this.properties.onFocus, focus: this.shouldFocus });
			}
		}

		class FocusParent extends Focus(WidgetBase) {
			private _focusedInputKey = 0;

			private _onFocus(key: number) {
				this._focusedInputKey = key;
				this.invalidate();
			}

			private _previous() {
				if (this._focusedInputKey === 0) {
					this._focusedInputKey = 4;
				} else {
					this._focusedInputKey--;
				}
				// calling focus resets the widget so that `this.shouldFocus`
				// will return true on its next use
				this.focus();
			}

			private _next() {
				if (this._focusedInputKey === 4) {
					this._focusedInputKey = 0;
				} else {
					this._focusedInputKey++;
				}
				// calling focus resets the widget so that `this.shouldFocus`
				// will return true on its next use
				this.focus();
			}

			protected render() {
				return v('div', [
					v('button', { onclick: this._previous }, ['Previous']),
					v('button', { onclick: this._next }, ['Next']),
					// `this.shouldFocus` is passed to the child that requires focus based on
					// some widget logic. If the child is a widget it can then deal with that
					// in whatever manner is necessary. The widget may also have internal
					// logic and pass its own `this.shouldFocus` down further or it could apply
					// directly to a VNode child.
					w(FocusInputChild, {
						key: 0,
						focus: this._focusedInputKey === 0 ? this.shouldFocus : undefined,
						onFocus: () => this._onFocus(0)
					}),
					w(FocusInputChild, {
						key: 1,
						focus: this._focusedInputKey === 1 ? this.shouldFocus : undefined,
						onFocus: () => this._onFocus(1)
					}),
					w(FocusInputChild, {
						key: 2,
						focus: this._focusedInputKey === 2 ? this.shouldFocus : undefined,
						onFocus: () => this._onFocus(2)
					}),
					w(FocusInputChild, {
						key: 3,
						focus: this._focusedInputKey === 3 ? this.shouldFocus : undefined,
						onFocus: () => this._onFocus(3)
					}),
					v('input', {
						key: 4,
						focus: this._focusedInputKey === 4 ? this.shouldFocus : undefined,
						onfocus: () => this._onFocus(4)
					})
				]);
			}
		}

See this focus example on codesandbox.io.

Advanced Properties

Controlling the diffing strategy can be done at an individual property level using the diffProperty decorator on a widget class.

widget-core provides a set of diffing strategy functions from @dojo/widget-core/diff.ts that can be used. When these functions do not provide the required functionality a custom diffing function can be provided. Properties that have been configured with a specific diffing type will be excluded from the automatic diffing.

Diff Function Description
always Always report a property as changed.
auto Ignore functions (except classes that extend WidgetBase), shallow compare objects, and reference compare all other values.
ignore Never report a property as changed.
reference Compare values by reference (old === new)
shallow Treat the values as objects and compare their immediate values by reference.

Important: All diffing functions should be pure functions and are called WITHOUT any scope.

// using a diff function provided by widget-core#diff
@diffProperty('title', reference)
class MyWidget extends WidgetBase<MyProperties> { }

//custom diff function; A pure function with no side effects.
function customDiff(previousProperty: string, newProperty: string): PropertyChangeRecord {
    return {
        changed: previousProperty !== newProperty,
        value: newProperty
    };
}

// using a custom diff function
@diffProperty('title', customDiff)
class MyWidget extends WidgetBase<MyProperties> { }
Property Diffing Reactions

It can be necessary to perform some internal logic when one or more properties change, this can be done by registering a reaction callback.

A reaction function is registered using the diffProperty decorator on a widget class method. This method will be called when the specified property has been detected as changed and receives both the old and new property values.

class MyWidget extends WidgetBase<MyProperties> {

    @diffProperty('title', auto)
    protected onTitleChange(previousProperties: any, newProperties: any): void {
        this._previousTitle = previousProperties.title;
    }
}

diffProperty decorators can be stacked on a single class method and will be called if any of the specified properties are considered changed.

class MyWidget extends WidgetBase<MyProperties> {

    @diffProperty('title', auto)
    @diffProperty('subtitle', auto)
    protected onTitleOrSubtitleChange(previousProperties: any, newProperties: any): void {
        this._titlesUpdated = true;
    }
}

For non-decorator environments (Either JavaScript/ES6 or a TypeScript project that does not have the experimental decorators configuration set to true in the tsconfig), the functions need to be registered in the constructor using the addDecorator API with diffProperty as the key.

class MyWidget extends WidgetBase {

    constructor() {
        super();
        diffProperty('foo', auto, this.diffFooReaction)(this);
    }

    diffFooReaction(previousProperty: any, newProperty: any) {
        // do something to reaction to a diff of foo
    }
}

Registry

The Registry provides a mechanism to define widgets and injectors (see the Containers & Injectors section), that can be dynamically/lazily loaded on request. Once the registry widget is loaded all widgets that need the newly loaded widget will be invalidated and re-rendered automatically.

A main registry can be provided to the projector, which will be automatically passed to all widgets within the tree (referred to as baseRegistry). Each widget also gets access to a private Registry instance that can be used to define registry items that are scoped to the widget. The locally defined registry items are considered a higher precedence than an item registered in the baseRegistry.

import { Registry } from '@dojo/widget-core/Registry';

import { MyWidget } from './MyWidget';
import { MyAppContext } from './MyAppContext';

const registry = new Registry();
registry.define('my-widget', MyWidget);
registry.defineInjector('my-injector', (invalidator) => {
	const appContext = new MyAppContext(invalidator);
	return () => appContext;
});
// ... Mixin and create Projector ...

projector.setProperties({ registry });

In some scenarios, it might be desirable to allow the baseRegistry to override an item defined in the local registry. Use true as the second argument of the registry.get function to override the local item.

The Registry will automatically detect and handle widget constructors as default exports for imported esModules for you.

Registry Decorator

A registry decorator is provided to make adding widgets to a local registry easier. The decorator can be stacked to register multiple entries.

// single entry
@registry('loading', LoadingWidget)
class MyWidget extends WidgetBase {
    render() {
        if (this.properties) {
            const LoadingWidget = this.registry.get('loading', true);
            return w(LoadingWidget, {});
        }
        return w(MyActualChildWidget, {});
    }
}

// multiple entries
@registry('loading', LoadingWidget)
@registry('heading', () => import('./HeadingWidget'))
class MyWidget extends WidgetBase {
    render() {
        if (this.properties) {
            const LoadingWidget = this.registry.get('loading', true);
            return w(LoadingWidget, {});
        }
        return w(MyActualChildWidget, {}, [
            w('heading', {})
        ]);
    }
}

Loading esModules

The registry can handle the detection of imported esModules for you that have the widget constructor as the default export. This means that your callback function can simply return the import call. If the widget constructor is not the default export you will need to pass it manually.

@registry('Button', () => import('./Button')) // default export
@registry('Table', async () => {
    const module = await import('./HeadingWidget');
    return module.table;
})
class MyWidget extends WidgetBase {}

Decorator Lifecycle Hooks

Occasionally, in a mixin or a widget class, it may be required to provide logic that needs to be executed before properties are diffed using beforeProperties, either side of a widget's render call using beforeRender & afterRender or after a constructor using afterContructor.

This functionality is provided by the beforeProperties, beforeRender, afterRender and afterConstructor decorators that can be found in the decorators directory.

Note: All lifecycle functions are executed in the order that they are specified from the superclass up to the final class.

beforeProperties

The beforeProperties lifecycle hook is called immediately before property diffing is executed. Functions registered for beforeProperties receive properties and are required to return any updated, changed properties that are mixed over (merged) the existing properties.

As the lifecycle is executed before the property diffing is completed, any new or updated properties will be included in the diffing phase.

An example that demonstrates adding an extra property based on the widgets current properties, using a function declared on the widget class myBeforeProperties:

class MyClass extends WidgetBase<MyClassProperties> {

    @beforeProperties()
    protected myBeforeProperties(properties: MyClassProperties): MyClassProperties {
        if (properties.type === 'myType') {
            return { extraProperty: 'foo' };
        }
        return {};
    }
}
AlwaysRender

The alwaysRender decorator is used to force a widget to always render regardless of whether the widget's properties are considered different.

@alwaysRender()
class MyClass extends WidgetBase {}
BeforeRender

The beforeRender lifecycle hook receives the widget's render function, properties, and children and is expected to return a function that satisfies the render API. The properties and children are passed to enable them to be manipulated or decorated prior to render being called.

Note: When properties are changed during the beforeRender lifecycle, they do not go through the standard property diffing provided by WidgetBase. If the changes to the properties need to go through diffing, consider using the beforeProperties lifecycle hook.

class MyClass extends WidgetBase {

    @beforeRender()
    protected myBeforeRender(renderFunc: () => DNode, properties: any, children: DNode[]): () => DNode {
        // decorate/manipulate properties or children.
        properties.extraAttribute = 'foo';
        // Return or replace the `render` function
        return () => {
            return v('my-replaced-attribute');
        };
    }
}
AfterRender

The afterRender lifecycle hook receives the returned DNodes from a widget's render call so that the nodes can be decorated, manipulated or swapped completely.

class MyBaseClass extends WidgetBase<WidgetProperties> {
    @afterRender()
    myAfterRender(result: DNode): DNode {
        // do something with the result
        return result;
    }
}

Method Lifecycle Hooks

These lifecycle hooks are used by overriding methods in a widget class. Currently, onAttach and onDetach are supported and provide callbacks for when a widget has been first attached and removed (destroyed) from the virtual dom.

onAttach

onAttach is called once when a widget is first rendered and attached to the DOM.

class MyClass extends WidgetBase {
    onAttach() {
        // do things when attached to the DOM
    }
}

onDetach

onDetach is called when a widget is removed from the widget tree and therefore the DOM. onDetach is called recursively down the tree to ensure that even if a widget at the top of the tree is removed all the child widgets onDetach callbacks are fired.

class MyClass extends WidgetBase {
    onDetach() {
        // do things when removed from the DOM
    }
}

Containers & Injectors

There is built-in support for side-loading/injecting values into sections of the widget tree and mapping them to a widget's properties. This is achieved by registering an injector factory with a registry and setting the registry as a property on the application's projector to ensure the registry instance is available to your application.

Create a factory function for a function that returns the required payload.

registry.defineInjector('my-injector', () => {
    return () => ({ foo: 'bar' });
});

The injector factory gets passed an invalidator function that can get called when something has changed that requires connected widgets to invalidate.

registry.defineInjector('my-injector', (invalidator) => {
    // This could be a store, but for this example it is simply an instance
    // that accepts the `invalidator` and calls it when any of its internal
    // state has changed.
    const appContext = new AppContext(invalidator);
    return () => appContext;
});

To connect the registered payload to a widget, we can use the Container HOC (higher order component) provided by widget-core. The Container accepts a widget constructor, injector label, and getProperties mapping function as arguments and returns a new class that returns the passed widget from its render function.

getProperties receives the payload returned from the injector function and the properties passed to the container HOC component. These are used to map into the wrapped widget's properties.

import { Container } from '@dojo/widget-core/Container';
import { MyWidget } from './MyWidget';

function getProperties(payload: any, properties: any) {
    return {
        foo: payload.foo
    };
}

export const MyWidgetContainer = Container(MyWidget, 'my-injector', getProperties);

The returned class from Container HOC is then used in place of the widget it wraps, the container assumes the properties type of the wrapped widget, however, they all considered optional.

// import { MyWidget } from './MyWidget';
import { MyWidgetContainer } from './MyWidgetContainer';

interface AppProperties {
    foo: string;
}

class App extends WidgetBase<AppProperties> {
    render() {
        return v('div', {}, [
            // w(MyWidget, { foo: 'bar' })
            w(MyWidgetContainer, {})
        ]);
    }
}

Decorators

All core decorators provided by widget-core, can be used in non-decorator environments (Either JavaScript/ES6 or a TypeScript project that does not have the experimental decorators configuration set to true in the tsconfig) programmatically by calling them directly, usually within a Widget class' constructor.

Example usages:

constructor() {
    beforeProperties(this.myBeforeProperties)(this);
    beforeRender(myBeforeRender)(this);
    afterRender(this.myAfterRender)(this);
    diffProperty('myProperty', this.myPropertyDiff)(this);
}

Meta Configuration

Widget meta is used to access additional information about the widget, usually information only available through the rendered DOM element - for example, the dimensions of an HTML node. You can access and respond to metadata during a widget's render operation.

class TestWidget extends WidgetBase<WidgetProperties> {
    render() {
        const dimensions = this.meta(Dimensions).get('root');

        return v('div', {
            key: 'root',
            innerHTML: `Width: ${dimensions.width}`
        });
    }
}

If an HTML node is required to calculate the meta information, a sensible default will be returned and your widget will be automatically re-rendered to provide more accurate information.

Dimensions

The Dimensions meta provides size/position information about a node.

const dimensions = this.meta(Dimensions).get('root');

In this simple snippet, dimensions would be an object containing offset, position, scroll, and size objects.

The following fields are provided:

Property Source
client.left node.clientLeft
client.top node.clientTop
client.width node.clientWidth
`client.height node.clientHeight
position.bottom node.getBoundingClientRect().bottom
position.left node.getBoundingClientRect().left
position.right node.getBoundingClientRect().right
position.top node.getBoundingClientRect().top
size.width node.getBoundingClientRect().width
size.height node.getBoundingClientRect().height
scroll.left node.scrollLeft
scroll.top node.scrollTop
scroll.height node.scrollHeight
scroll.width node.scrollWidth
offset.left node.offsetLeft
offset.top node.offsetTop
offset.width node.offsetWidth
offset.height node.offsetHeight

If the node has not yet been rendered, all values will contain 0. If you need more information about whether or not the node has been rendered you can use the has method:

const hasRootBeenRendered = this.meta(Dimensions).has('root');

Intersection

The Intersection Meta provides information on whether a Node is visible in the application's viewport using the Intersection Observer API.

Note: The Intersection Observer API is not available in all browsers. To use the Intersection Observer API in all browsers supported by Dojo 2, a polyfill needs to be included. Dojo 2 does not include the polyfill by default, so will need to be added as a script tag in your index.html or alternatively imported in the application’s main.ts using import 'intersection-observer'; after including the dependency in your source tree, or by importing @dojo/shim/browser.

This example renders a list with images, the image src is only added when the item is in the viewport which prevents needlessly downloading images until the user scrolls to them:

import { WidgetBase } from '@dojo/widget-core/WidgetBase';
import { v, w } from '@dojo/widget-core/d';
import { DNode } from '@dojo/widget-core/interfaces';
import { Intersection } from '@dojo/widget-core/meta/Intersection';

// Add image URLs here to load
const images = [];

class Item extends WidgetBase<{ imageSrc: string }> {
	protected render() {
		const { imageSrc } = this.properties;
		const { isIntersecting } = this.meta(Intersection).get('root');
		let imageProperties: any = {
			key: 'root',
			styles: {
				height: '200px',
				width: '200px'
			}
		};

        // Only adds the image source if the node is in the viewport
		if (isIntersecting) {
			imageProperties = { ...imageProperties, src: imageSrc };
		}

		return v('img', imageProperties);
	}
}

class List extends WidgetBase {
	protected render() {
		let items: DNode[] = [];
		for (let i = 0; i < images.length; i++) {
			items.push(v('ul', { key: i }, [ w(Item, { key: i, imageSrc: images[i] }) ]));
		}

		return v('div', items);
	}
}

Animations

See the Animation section more information.

Drag

The Drag meta allows a consuming widget to determine if its nodes are being dragged and by how much. The meta provider abstracts away the need for dealing with modeling specific mouse, pointer, and touch events to create a drag state.

const dragResult = this.meta(Drag).get('root');

The drag information returned contains the following properties:

Property Description
delta An x/y position that contains the number of pixels the pointer has moved since the last read of the drag state.
isDragging If the pointer is currently active in dragging the identified node.
start A position object that contains x/y positions for client, offset, page, and screen that provides the start positions that the delta movement refers to. Note that offset and page are not supported by all browsers and the meta provider does nothing to normalize this data, it simply copies it from the underlying events.

One common use case is to create a draggable node within a container:

interface ExampleWidget extends WidgetBaseProperties {
    height: number;
    top: number;
    onScroll?(delta: number): void;
}

class VerticalScrollBar extends WidgetBase {
    protected render() {
        const { height, top, onScroll } = this.properties;
        const dragResult = this.meta(Drag).get('slider');
        onScroll && onScroll(dragResult.delta.y);
        return v('div', {
            classes: [ css.root, dragResult.isDragging ? css.dragging : null ],
            key: 'root'
        }, [
            v('div', {
                classes: [ css.slider ],
                key: 'slider',
                styles: {
                    height: `${height}px`,
                    top: `${top}px`
                }
            })
        ])
    }
}

class VerticalScrollBarController extends WidgetBase {
    private _top: 0;
    private _onScroll(delta: number) {
        this._top += delta;
        if (this._top < 0) {
            this._top = 0;
        }
        this.invalidate();
    }

    protected render() {
        return w(VerticalScrollBar, {
            top: this._top,
            width: 10,
            onScroll: this._onScroll
        });
    }
}

As can be seen in the above code, the meta provider simply provides information which the widgets can react to. The implementation needs to react to these changes.

Focus

The Focus meta determines whether a given node is focused or contains document focus. Calling this.meta(Focus).get(key) returns the following results object:

Property Description
active A boolean indicating whether the specified node itself is focused.
containsFocus A boolean indicating whether one of the descendants of the specified node is currently focused. This will return true if active is true.

An example usage that opens a tooltip if the trigger is focused might look like this:

class MyWidget extends WidgetBase<WidgetProperties> {
    // ...
    render() {
        // run your meta
        const buttonFocus = this.meta(FocusMeta).get('button');
        return v('div', {
          w(Button, {
            key: 'button'
          }, [ 'Open Tooltip' ]),
          w(Tooltip, {
            content: 'Foo',
            open: buttonFocus.active
          }, [ 'modal content' ])
        });
    }
    // ...
}

The Focus meta also provides a set method to call focus on a given node. This is most relevant when it is necessary to shift focus in response to a user action, e.g. when opening a modal or navigating to a new page. You can use it like this:

class MyWidget extends WidgetBase<WidgetProperties> {
    // ...
    render() {
        // run your meta
        return v('div', {
          w(Button, {
            onClick: () => {
              this.meta(Focus).set('modal');
            }
          }, [ 'Open Modal' ]),
          v('div', {
            key: 'modal',
            tabIndex: -1
          }, [ 'modal content' ])
        });
    }
    // ...
}

Matches

The Matches meta determines if the target of a DOM event matches a particular virtual DOM key.

const matches = this.meta(Matches).get('root', evt);

This allows a widget to not have to know anything about the real DOM when dealing with events that may have bubbled up from child DOM. For example to determine if the containing node in the widget was clicked on, versus the child node, you would do something like this:

class TestWidget extends WidgetBase<WidgetProperties> {
    private _onclick(evt: MouseEvent) {
        if (this.meta(Matches).get('root', evt)) {
            console.log('The root node was clicked on.');
        }
    }

    render() {
        const dimensions = this.meta(Matches).get('root');

        return v('div', {
            key: 'root'
            onclick: this._onclick
        }, [
            v('div', {
                innerHTML: 'Hello World'
            })
        ]);
    }
}

Resize

The resize observer meta uses the latest ResizeObserver within Dojo 2 based widgets.

Note: The Resize Observer API is not available in all browsers. Native browser support is currently provided by Chrome 64+, other Dojo supported browsers work via polyfill. To use the Resize Observer API in all browsers supported by Dojo 2, either added as a script tag in your index.html pointing to the polyfill, or alternatively import the polyfill in the application’s main.ts after including the dependency in your source tree.

This allows you to observe resize events at the component level. The meta accepts an object of predicate functions which receive ContentRect dimensions and will be executed when a resize event has occured. The results are made available in a widget's render function. This is an incredibly powerful tool for creating responsive components and layouts.

function isMediumWidthPredicate(contentRect: ContentRect) {
    return contentRect.width < 500;
}

function isSmallHeightPredicate(contentRect: ContentRect) {
    return contentRect.height < 300;
}

class TestWidget extends WidgetBase<WidgetProperties> {
    render() {
        const { isMediumWidth, isSmallHeight } = this.meta(Resize).get('root', {
            isMediumWidth: isMediumWidthPredicate,
            isSmallHeight: isSmallHeightPredicate
        });

        return v('div', {
            key: 'root'
            classes: [
                isMediumWidth ? css.medium : css.large,
                isSmallHeight ? css.scroll : null
            ]
        }, [
            v('div', {
                innerHTML: 'Hello World'
            })
        ]);
    }
}
Implementing Custom Meta

You can create your own meta if you need access to DOM nodes.

import MetaBase from "@dojo/widget-core/meta/Base";

class HtmlMeta extends MetaBase {
    get(key: string): string {
        const node = this.getNode(key);
        return node ? node.innerHTML : '';
    }
}

And you can use it like:

class MyWidget extends WidgetBase<WidgetProperties> {
    // ...
    render() {
        // run your meta
        const html = this.meta(HtmlMeta).get('comment');

        return v('div', { key: 'comment', innerHTML: html });
    }
    // ...
}

Extending the base class found in meta/Base will provide the following functionality:

  • has - A method that accepts a key and returns a boolean to denote if the corresponding node exists in the rendered DOM.
  • getNode - A method that accepts a key string to inform the widget it needs a rendered DOM element corresponding to that key. If one is available, it will be returned immediately. If not, a callback is created which will invalidate the widget when the node becomes available. This uses the underlying nodeHandler event system.
  • invalidate - A method that will invalidate the widget.
  • afterRender - This provides a hook into the widget afterRender lifecycle that can be used to clear up any resources that the meta has created. This is used, for instance, in the WebAnimation meta to clear down unused animations.

Meta classes that require extra options should accept them in their methods.

import MetaBase from "@dojo/widget-core/meta/Base";

interface IsTallMetaOptions {
    minHeight: number;
}

class IsTallMeta extends MetaBase {
    isTall(key: string, { minHeight }: IsTallMetaOptions = { minHeight: 300 }): boolean {
        const node = this.getNode(key);
        if (node) {
            return node.offsetHeight >= minHeight;
        }
        return false;
    }
}

Inserting DOM nodes into the VDom Tree

The dom() function is used to wrap DOM that is created outside of Dojo 2. This is the only mechanism to integrate foreign DOM nodes into the virtual DOM system.

dom() works much like v() but instead of taking a tag it accepts an existing DOM node and creates a VNode that references the DOM node and the vdom system will reuse this node. Unlike v() a diffType can be passed that indicates the mode to use when determining if a property or attribute has changed and needs to be applied, the default is none.

  • none: This mode will always pass an empty object as the previous attributes and properties so the props and attrs passed to dom() will always be applied.
  • dom: This mode uses the attributes and properties from the DOM node for the diff.
  • vdom: This mode will use the previous VNode for the diff, this is the mode used normally during the vdom rendering.

Note: All modes use the events from the previous VNode to ensure that they are correctly removed and applied each render.

const node = document.createElement('div');

const vnode = dom({
    node,
    props: {
        foo: 'foo',
        bar: 1
    },
    attrs: {
        baz: 'baz'
    },
    on: {
        click: () => { console.log('clicker'); }
    },
    diffType: 'none' | 'dom' | 'vdom'
});

JSX Support

In addition to creating widgets functionally using the v() and w() functions from @dojo/widget-core/d, Dojo 2 optionally supports the use of the jsx syntax known as tsx in TypeScript.

To start to use jsx in your project, widgets need to be named with a .tsx extension and some configuration is required in the project's tsconfig.json:

Add the configuration options for jsx:

"jsx": "react",
"jsxFactory": "tsx",

Include .tsx files in the project:

 "include": [
     "./src/**/*.ts",
     "./src/**/*.tsx"
 ]

Once the project is configured, tsx can be used in a widget's render function simply by importing the tsx function as:

import { tsx } from '@dojo/widget-core/tsx';
class MyWidgetWithTsx extends Themed(WidgetBase)<MyProperties> {
    protected render(): DNode {
        const { clear, properties: { completed, count, activeCount, activeFilter } } = this;

        return (
            <footer classes={this.theme(css.footer)}>
                <span classes={this.theme(css.count)}>
                    <strong>{`${activeCount}`}</strong>
                    <span>{`${count}`}</span>
                </span>
                <TodoFilter activeFilter={activeFilter} />
                { completed ? ( <button onclick={clear} /> ) : ( null ) }
            </footer>
        );
    }
}

Note: Unfortunately tsx is not directly used within the module so the tsx module will report as an unused import, and will need to be ignored by linters.

Web Components

Widgets can be turned into Custom Elements with minimal extra effort.

The customElement decorator can be used to annotate the widget class that should be converted to a custom element,

interface MyWidgetProperties {
	onClick: (event: Event) => void;
	foo: string;
	bar: string;
}

@customElement<MyWidgetProperties>({
	tag: 'my-widget',
	attributes: [ 'foo', 'bar' ],
	events: [ 'onClick' ]
})
class MyWidget extends WidgetBase<MyWidgetProperties> {
// ...
}

Note: The Custom Elements API is not available in all browsers. To use Custom Elements in all browsers supported by Dojo 2, a polyfill needs to be included such as webcomponents/custom-elements/master/custom-elements.min.js. Dojo 2 does not include the polyfill by default, so will need to be added as a script tag in your index.html. Note that this polyfill cannot currently be ponyfilled like other polyfills used in Dojo 2, so it cannot be added with @dojo/shim/browser or imported using ES modules.

No additional steps are required. The custom element can be used in your application automatically. The decorator can be provided with configuration options to modify the functionality of the custom element.

Attributes

An array of attribute names that should be set as properties on the widget. The attribute name should be the same as the corresponding property on the widget.

Properties

An array of property names that will be accessible programmatically on the custom element but not as attributes. The property name must match the corresponding widget property.

Events

Some widgets have function properties, like events, that need to be exposed to your element. You can use the events array to specify widget properties to map to DOM events.

{
    events: [ 'onChange' ]
}

This will add a property to onChange that will emit the change custom event. You can listen like any other DOM event,

textWidget.addEventListener('change', function (event) {
    // do something
});

The name of the event is determined by removing the 'on' prefix from the name and lowercasing the resulting name.

Tag Name

Your widget will be registered with the browser using the provided tag name. The tag name must have a - in it.

Initialization

Custom logic can be performed after properties/attributes have been defined but before the projector is created. This allows you full control over your widget, allowing you to add custom properties, event handlers, work with child nodes, etc. The initialization function is run from the context of the HTML element.

{
    initialization(properties) {
        const footer = this.getElementsByTagName('footer');
        if (footer) {
            properties.footer = footer;
        }

        const header = this.getElementsByTagName('header');
        if (header) {
            properties.header = header;
        }
    }
}

It should be noted that children nodes are removed from the DOM when attached, and added as children to the widget instance.

Licensing Information

© 2018 JS Foundation. New BSD license.

widget-core's People

Contributors

agubler avatar bitpshr avatar bryanforbes avatar devpaul avatar dylans avatar edhager avatar jameslmilner avatar jdonaghue avatar kitsonk avatar maier49 avatar matt-gadd avatar mwistrand avatar nicknisi avatar nlwillia avatar novemberborn avatar pottedmeat avatar rishson avatar rorticus avatar schontz avatar sebilasse avatar smhigley avatar tomdye avatar umaar avatar vansimke avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

widget-core's Issues

Typings should allow `null` to passed in `d` children param

Bug

null should be able to be added to the children param of d so that logic like can be leveraged. Functionally this works, but the typings are not accurate to allow it.

return [
    d('div', {}, [
        this.state.active ? d('div.active') : null
    ])
]

Package Version: latest

Widget Specifications

We need to specify a list of tentative foundational widgets, so we can whittle them down and figure out the priority order to build them. We should create a table in the following format:

Class Name Description Type Sub Widget Classes
ClassName A simple description of the widget Simple/Composite/Container Sub or Child Widget Classes

Rendering and Rendering Children Nodes

In working with todo-mvc it has highlighted that the rendering .getChildrenNode() is not as downstream functional as might be. It is simply getting overridden, even when there are mixins that are trying to provide a generic method. It means that in a lot of final widget cases, the mixins aren't "helping".

The nodeAttributes functionality in #40 does feel like that is an improvement, where now the VNode attributes can be easily expressed and mixed into the final widget without just taking a hammer to .getNodeAttributes. In fact, no downstream developer would now need to touch .getNodeAttributes.

Also it was discussed with several of us offline that ideally, no downstream developer would actually want to touch .render() when creating a custom widget. While it is semantically most logical extension point, the widget system provides more functionality, like intelligent caching and translating parts of state into the rendered virtual Node, that end developers should really only be expressing their "additive" functionality in a final widget class.

This topic is for discussing this thinking and making any proposals to how we might improve this.

setTimeout in test cases

The only way currently we are able to easily test certain async processes in widgets is using setTimeout. Obviously this is fragile and should be fixed.

Styles and Classes on Widgets

There are two main types of styles and classes that need to be dealt with when rendering a widget:

  • Those bound to the class/factory
  • Those that are instance based

There actually should be no class bound styles, as any styles that are provided on render should be calculated from the instance.state and not from the class.

Therefore to facilitate this, we need to do the following:

  • Remove .styles from widget interfaces
  • Make .classes a readonly string[] with a static value of the merged array of string using the merge array feature of dojo/compose (see: dojo/compose#46)
  • Change the default render logic to use the .classes property as a base and mixin any classes provided in instance.state.classes.

Then when creating a class or mixin, the developer would just supply any class bound classes to be mixed in as a string[] in the prototype.

Improve Parent Widgets creating/managing Children

In StatefulChildrenMixin we should have a mechanism that allows this mixin to create new children which are then properly managed... The idea would be on the widget registry to allow a method that creates the child which can then be instantiated and then appended to the parent.

We will also need to add this functionality to dojo/app to allow the app to manage these created child widgets.

Widget Transitions / Animations

Maquette supports both programmatic and CSS animations so am opening this thread to discuss the best approach initially for Dojo2 within the widget architecture.

The Maquette documentation provides an example for both styles:

  1. Programmatically requires the use of a 3rd party library such as Velocity.js (although has broken typings) but in the future could use the Web Animations API.
  2. To support CSS animations, the consumer needs to load the css-animations.js provided by Maquette and then just use CSS as usual. updateAnimations are not supported by maquette when using cssTransitions.

Initially I think it'll will be simpler to support css transition by providing a mixin in Widgets that adds the transition classes to the node attributes which Maquette needs in order to perform the animations? Subsequently can look to support the programmatic API; adding sugar around which ever animations implementation is chosen? Question as to whether D2 should bundle an animation library such as Velocity or allow consumers to include their own write their own animation function that get attached to the Maquette Vdom?

Example code using Velocity's API

import createWidget from 'dojo-widgets/createWidget';
import { animate } from 'velocity-animate';

const createWidgetAnimation = createWidget
    .extend({
        nodeAttributes: [
            function(this: any): VNodeProperties {
                return {
                    enterAnimation: function (domNode: any) {
                        animate(domNode, 'slideDown', 1500, 'ease-in-out');
                    }
                };
            }
        ]
    });

Possible mixin solution for supporting CSS transitions:

import { VNodeProperties } from 'maquette';
import compose, { ComposeFactory } from 'dojo-compose/compose';
import { NodeAttributeFunction } from './createRenderMixin';
import createStateful, { State, Stateful, StatefulOptions } from 'dojo-compose/mixins/createStateful';

export type CssTransitionMixinState = State & {
    enterAnimation?: string;
    exitAnimation?: string;
}

export interface CssTransition {
    nodeAttributes: NodeAttributeFunction[];
}

export type CssTransitionMixin<S extends CssTransitionMixinState> = CssTransition & Stateful<S>;

export interface CssTransitionMixinFactory extends ComposeFactory<CssTransitionMixin<CssTransitionMixinState>, StatefulOptions<CssTransitionMixinState>> {};

const createCssTransitionMixin: CssTransitionMixinFactory = compose({
    nodeAttributes: [
        function (this: CssTransitionMixin<CssTransitionMixinState>): VNodeProperties {
            const { enterAnimation, exitAnimation } = this.state;
            return { enterAnimation, exitAnimation };
        }
    ]

})
.mixin(createStateful);

export default createCssTransitionMixin;
.blue {
    background-color: blue;
    width: 500px;
    height: 500px;
}

.slideDown {
    transition: 0.5s ease-out height;
    height: 0;
}

.slideDown.slideDown-active {
    height: 500px;
}

and the implementation using the mixin

import createMemoryStore from 'dojo-stores/createMemoryStore';
import createWidget from '../createWidget';
import createCssTransitionMixin from '../mixins/createCssTransitionMixin';
import { createProjector, Projector } from '../projector';

const widgetStore = createMemoryStore({
    data: [
        { id: 'title', label: 'dojo-widget Examples'},
        { id: 'animated-widget', classes: [ 'blue', 'animated' ], enterAnimation: 'fadeInRightBig'}
    ]
});

const projector: Projector = createProjector({ cssTransitions: true });

const createWidgetAnimation = createWidget.mixin(createCssTransitionMixin);

const title = createWidget({
    id: 'title',
    stateFrom: widgetStore,
    tagName: 'h1'
});

const animatedWidget = createWidgetAnimation({
    id: 'animated-widget',
    stateFrom: widgetStore
});

projector.append(title);
projector.attach().then(() => {
    projector.append(animatedWidget);
});

Alternatively the consumer can use animate.css or similar that provides transition(s) "out of the box", adding the transition class to the state properties and ensure that the widget has the class animated applied (for `animate.css).

slidedown

fadeinrightbig

Actions, dojo/app and State

Now that we have StatefulChildrenMixin which allows us to express references to children as a string which is resolved via a registry, we should consider that for actions as well. We could create a StatefulListenersMixin which would manage looking up and attaching actions as listeners. This would allow us to express the wiring widgets to actions in the state as well as provide an easy mechanism for altering those during the lifecycle of the widget.

The state would be expressed something like this:

{
    listeners: {
        click: 'click-action'
    }
}

The mixin would detect the label for the listener, resolve it via the action registry it was passed during instantiation, and set the listeners accordingly. This would be equivalent of:

widget.on('click', app.getAction('click-action'));

The interface for the registry would look like:

interface ChildrenRegistry<A extends Action> {
    get<B extends A>(id: string | symbol): Promise<B>;
    identify(value: A): string | symbol;
}

This would mean that the wiring of listeners to actions would not be required to be supplied in an application configuration object.

Migrate to strictNullChecks

As part of the conversion to TypeScript 2.0, there was too much heavy lifting to do in order to make it for the initial conversion.

We need to enable it and ensure that our code is "null check" safe.

Classes on CachedRenderMixin

CachedRenderMixin does not properly toggle classes... so if you drop a class out of the string[] it will drop it out of the VNode hash, but Maquette will not realise it has gone missing on subsequent renders... the CachedRenderMixin needs to persist those across renders and drop them out by setting them to false.

CSS Module to TypeScript

@kitsonk commented on Fri Aug 19 2016

As part of a discussion around dojo/meta#28, we have identified that the best pattern for consuming styles in a module fashion is to create a CSS Module to TypeScript tool that would generate a "built" CSS file containing the names paced classes and a TypeScript module that provides a map of the modules.

Given CSS Module named mycss.css and contents like this:

.foo {
    width: 100px;
}

The tool would generate a built CSS file (including a source-map) which a globally unique class name is generated:

._some_sort_of_uid_foo {
    width: 100px;
}

And generate a TypeScript module which maps the original class names to those used in the CSS Module. For example generate a mycss.ts which contains:

export default {
    'foo': '_some_sort_of_uid_foo'
}

This would then subsequently allow the module to be imported (like into a widget) and reference the name, knowing that the module would resolve to the approriate style name:

import mycss from './css/mycss';
import { h } from 'maquette';
import compose from 'dojo-compose/compose';

export default myWidgetFactory = compose({
    render() {
        return h('my-widget', { classes: { mycss.foo: true } });
    }
});

createMemoryStore#patch() should put() if ID is not in store

Patching the memory store fails if the ID isn't already in the store: https://github.com/dojo/widgets/blob/fbe4369cd9386674fb593bbee7ad83c70aa5b766/src/util/createMemoryStore.ts#L292.

This makes the following use case unnecessarily annoying:

const store = createMemoryStore();

createWidget({
  id: 'foo',
  stateFrom: store,
  state: {
    bar: 'baz'
  }
});

This fails silently unless:

const store = createMemoryStore({
  data: [
    { id: 'foo' }
  ]
});

Presumably we could just create the store entry?

HTML attributes

I believe @tomdye has raised this before, but when using createCachedRenderMixin their seems to be no other way than completely overriding getNodeAttributes for providing HTML attributes other than classes and styles (which have nearly identical getters and setters). Perhaps we should have a more generic attribute map?

InternalState Generation Id incremented too early

Bug

The generation vector should only be incremented after the currentChildrenIDs has been compared against internalState.current. If they are are equal then no further processing is assumed (returns from function) as the requested children are already associated to the parent.

Package Version: latest

Code

import createMemoryStore from 'dojo-stores/createMemoryStore';

import { createProjector } from 'dojo-widgets/projector';
import createWidget from 'dojo-widgets/createWidget';
import createPanel from 'dojo-widgets/createPanel';

import { RenderMixin, RenderMixinState } from 'dojo-widgets/mixins/createRenderMixin';
import Promise from 'dojo-shim/Promise';
import { Child, RegistryProvider } from 'dojo-widgets/mixins/interfaces';
import { ComposeFactory } from 'dojo-compose/compose';
import Map from 'dojo-shim/Map';
import WeakMap from 'dojo-shim/WeakMap';

export const widgetMap: Map<string | symbol, Child> = new Map<string | symbol, Child>();
const widgetIdMap: WeakMap<Child, string | symbol> = new WeakMap<Child, string | symbol>();
let widgetUID = 0;

const widgetRegistry = {
    get(id: string | symbol): Promise<RenderMixin<RenderMixinState>> {
        return Promise.resolve(widgetMap.get(id));
    },
    identify(value: RenderMixin<RenderMixinState>): string | symbol {
        const id = widgetIdMap.get(value);
        if (!id) {
            throw new Error('Cannot identify value');
        }
        else {
            return id;
        }
    },
    create<C extends RenderMixin<RenderMixinState>>(factory: ComposeFactory<C, any>, options?: any): Promise<[string | symbol, C]> {;
        return Promise.resolve<[ string, C ]>([options && options.id || `widget${widgetUID++}`, factory(options)]);
    }
};

const registryProvider: RegistryProvider<Child> = {
    get(type: string) {
        if (type === 'widgets') {
            return widgetRegistry;
        }
        throw new Error('Bad registry type');
    }
};

const widgetStore = createMemoryStore<any>({
    data: [
        {
            id: 'container'
        }
    ]
});

const projector = createProjector();
const container = createPanel({
    id: 'container',
    stateFrom: widgetStore,
    registryProvider
});

projector.append(container);
projector.attach().then(() => {
    // do stuff
    const children: string[] = [];
    for (let i = 0; i < 10; i++) {
        const id = `${i}`;
        children.push(id);
        widgetStore.put({id , label: 'This is label number ' + i}).then(() => {
            const widget = createWidget({ id, stateFrom: widgetStore });
            widgetMap.set(id, widget);
            widgetStore.patch({id: 'container', children});
        });
    }
});

Expected behavior:

The child widgets rendered.

Actual behavior:

No children rendered.

Create a static widget type - HTMLWidget / StaticWidget for plain html elements

Enhancement

Proposal is that we should create a plain widget (basically re-purpose createWidget or similar) such that we can use it within apps to create HTML elements.
It should be created such that we can have HTML Container and HTML TextNode elements with typed tagname.

  • createStaticContainer
  • createStaticTextNode

Reasoning

My reasoning is that when working on the monster app with Dojo 2 I feel that the code would be clearer if when creating html structures (rather than traditional widgets), they were named as such.

Separate "class" classes and "stateful" classes

Enhancement

Currently, this is no clear separation between classes that are statically bound and should be included in every render and classes that are "stateful" and should change based on the widget's state.

We should consider separating these clearly in widgets and using the array compostability features of dojo-compose to make it easy for a widget developer to allow classes to be mixed into the prototype for the widget.

Package Version: master

State Classes are not applied to widgets that have structural CSS classes applied

Bug

With the initial implementation of structural CSS, classes that have been configured with structural CSS will not ever have classes applied via the widgets state

I think this will probably be addressed by #69 but in the meantime the changes will break consumers (i.e. todoMVC (verified)/MonsterApp (not verified)).

Package Version: master

import createMemoryStore from 'dojo-stores/createMemoryStore';
import createButton from '../createButton';
import { createProjector } from '../projector';

const widgetStore = createMemoryStore({
    data: [
        { id: 'id', classes: [ 'fred' ], label: 'label' }
    ]
});

const button = createButton({
    id: 'id',
    stateFrom: widgetStore
});

const panelProjector = createProjector({ root: document.body });
panelProjector.append(button);
panelProjector.attach();

Expected

Expected the button to have class fred applied.

Actual

Only the structural css is applied to the button widget.

Function to convert/wrap Dijit constructors

We currently have createDijit which allows the use of Dojo 1 Dijits (and other DOM based constructor functions) to integrate into the Dojo 2 virtual DOM. This currently requires configuration on creation of the createDijit factory. This might make it more difficult to really integrate and reuse Dijits in a Dojo 2 world.

We should create a function that converts/wraps the Dijit constructor function into a Dojo 2 factory as well as "binds" to the Dijit constructor for easier reuse in an application. For example, you would expect a developer to be able to "convert" a Dijit like this:

// createDijitButton.ts
import * as Button from 'dijit/form/Button';
import convertToWidgetFactory from 'dojo-widgets/util/convertToWidgetFactory';

const createDijitButton = convertToWidgetFactory(Button);
export default createDijitButton;

// myApp.ts
import projector from 'dojo-widgets/Projector';
import createDijitButton from './createDijitButton';

projector.append(createDijitButton({ }));

Specify classes and styles in options

CachedRenderMixin supports classes and styles setters on instances, and configuration via the store. It'd be useful if these could also be configured via the creation options. This would enable dojo/app#20.

IMO the priority order should be options, mixins and then state. Once initialized, setters would try to update the state even if the value did not originally come from there. (Whether this behavior is correct probably merits a separate discussion, there are similar issues with the data visualization code.)

Race conditions between setting initial state and rendering

When creating widgets with the state option, the initial state is set asynchronously:

If Maquette renders before the state has propagated we may see errors. I suppose this depends on the interplay between the various micro-tasks and animation frames, as well as how these are polyfilled in older browsers.

It's not entirely clear to me why the memory store has to be asynchronous like this.

Base Classes for Widgets

Enhancement

Motivation

We have started articulating the concepts around the encapsulation of widgets:

  • Simple Widgets - These are the atoms of widgets, in the sense that from the outside the encapsulate a single state and the rest of the application interacts with them as a single instance, which renders up virtual DOM node.
  • Composite Widgets - For code reuse purposes, it maybe useful to create a widget out of sub-widgets. Externally, composite widgets should not leak these internal instances. The sub-widget state should not leak into the application state, where likely the outer composite widget stores its state. Externally, changes are made to the composite widget's state and internally the composite widget manages the state of the children without assistance from outside.
  • Container Widgets - These are widgets that contain/own child widgets, which are in turn containing widgets or single widgets. Container Widgets should be able to handle n number of child widgets. They may or may not constrain the classes of children they manage. The manage the children via an OrderedMap and by default render in the order they were added.

To provide more explanation of the above, the following are logical examples of widgets and how they fit the concepts above:

  • A text box is a single simple widget, in that it performs one function, to represent a single property of a business object (or application state).
  • A drop down select box is a either a simple or composite widget, in that externally, we would want to interact with it where it renders represents a single property of a business object (or application state) and we wouldn't want to care about its implementation. Realistically though, it would encapsulate the drop down button and the drop down list and the text box as separate widgets, of which the complex outer widget would manage. The individual state of whether the drop down list is displayed or not, aren't material to the application state and therefore should be encapsulated inside the widget.
  • A todo item is a composite widget, in that again, it represents a single instance of a business object, but has several sub-widgets which allow the user to further interact and view the state of the business entity it is representing. Again, though, externally, the state of the todo item is fully expressed in the state of the todo item widget, and the complex widget manages the state of the sub-widgets.
  • A panel is a container widget, in that it would take n number of arbitrary number of widgets which it would layout.
  • A tabbed panel, on the outside, would be a map container widget which would have two labelled widgets, the tab bar, which would be a list container of tab buttons and a panel container which would be a list container of panels.

Proposal

To that effect, we should have 3 base classes for widgets, which are then used to build all other widgets:

  • Widget which would build on the Stateful base class.
  • The d function will be used to express composite sub-widgets and Widget will be able to resolve and manage the return from the d function.
  • ContainerWidget which would build on Widget.

Other features can then be added to these bases via mixins.

Edit: Refined widget types
Edit: Combined ContainerListWidget and ContainerMapWidget
Edit: Combined CompositeWidget and Widget and added d concept.

CSS Modules

This issue is to track the basic capability of integrating CSS Modules with dojo/widgets

Incorporate `dojo-i18n` into the widgeting system.

Implement a createI18nMixin that incorporates the dojo-i18n functionality into the widgeting system:

import { Bundle, Messages } from 'dojo-i18n/i18n';

interface I18nMixin {
  // Array of bundles to register at instantiation.
  bundles?: Bundle<Messages>;
  // Return a localized dictionary for the current locale.
  localizeBundle(bundle: Bundle<Messages>): Messages;
  // Register a bundle with the widget.
  registerBundle(bundle: Bundle<Messages>): void;
}

Widgets like dojo-widgets/createButton can specify the bundles they expect within their definitions, or during instantiation:

import greetings from 'nls/greetings';

createRenderMixin
  .extend({
    bundles: [ greetings ]
  });

// More explicitly with `registerBundle`
createRenderMixin
  .mixin({
    initialize(instance) {
      instance.registerBundle(greetings);
    }
  });

// At instantiation
createWidget({
  bundles: [ greetings ]
});

Likewise, the dictionary messages used by a widget can be specified either in state.labels loaded directly (e.g., within getChildrenNodes) via the localizeBundle method:

import greetings from 'nls/greetings';
import navigation from 'nls/navigation';

// When registering a single bundle...
createWidget({
  bundles: [ greetings ],
  labels: {
    // Once the locale-specific dictionary is loaded, `setState` will be called with
    // `{ label: `${greetings.hello}` }`
    label: 'hello'
  }
});

// When registering multiple bundles
createWidget({
  bundles: [ greetings, navigation ],
  labels: {
    // When multiple bundles have been registered, labels must be prefaced with
    // `${bundlePath}:` in order to ensure the correct bundle is used.
    label: 'nls/greetings:hello'
  }
});

// From within `getChildrenNodes`
createRenderMixin
  .mixin({
    mixin: {
      getChildrenNodes(): VNode[] {
        const messages = this.localizeBundles(greetings);
        return h('h1', { 'class': 'title' }, [ messages.hello ]);
      }
    }
  });

Non-Leaky Virtual DOM Abstraction

While we don't have any plans to migrate from Maquette, one of our main goals was to abstract users of widgets largely from the VDOM implementation. That also led us to choose Maquette which generates DOM based on HyperScript, a popular alternative to JSX.

We should therefore re-export the HyperScript implementation in dojo-widgets (along with appropriate virtual DOM interfaces) and depend upon those re-exports so that if, for whatever reason, we wanted/needed to swap out implementations, the impact would be minimal.

Foundational Widgets

This issue is to discuss what widgets should be part of the foundational widgets, potentially located in this package, or in related packages, as we begin to develop the "out of the box" widgets.

Currently we have. Many of these were just created to be able to try to prototype and solve problems that other widgets may need and might be questionable in the final set:

  • Button - Analog of <button>
  • Container - Simply "owns" other widgets
  • Dijit - A wrapper class for Dojo 1 Dijits
  • LayoutContainer - Does a level of layout on contained widgets
  • List - A simple list UI component
  • Panel - A simple container
  • ResizePanel - Provides a handle that allows one of the contained widgets to be resized
  • TabbedPanel - All children owned are displayed as tabs
  • TextInput - Analog of <input type="text">
  • Widget - A generic "dumb" widget the includes all the foundational widget functionality and can render a tag and some content.

Maquette 2.4.X Regressions

Bug

It appears that Maquette 2.4.X has introduced some regressions in our code that was unexpected. Upon compiling Widgets now we get the following errors:

Using tsc v2.0.3
src/mixins/createFormFieldMixin.ts(112,6): error TS2450: Left-hand side of assignment expression cannot be a constant or a read-only property.
src/mixins/createFormFieldMixin.ts(115,5): error TS2450: Left-hand side of assignment expression cannot be a constant or a read-only property.
src/mixins/createFormFieldMixin.ts(117,6): error TS2450: Left-hand side of assignment expression cannot be a constant or a read-only property.
src/mixins/createFormFieldMixin.ts(120,5): error TS2450: Left-hand side of assignment expression cannot be a constant or a read-only property.
src/mixins/createRenderMixin.ts(230,6): error TS2450: Left-hand side of assignment expression cannot be a constant or a read-only property.
src/mixins/createRenderMixin.ts(231,6): error TS2450: Left-hand side of assignment expression cannot be a constant or a read-only property.
src/mixins/createRenderMixin.ts(232,6): error TS2450: Left-hand side of assignment expression cannot be a constant or a read-only property.
src/projector.ts(154,5): error TS2450: Left-hand side of assignment expression cannot be a constant or a read-only property.
src/projector.ts(160,4): error TS2450: Left-hand side of assignment expression cannot be a constant or a read-only property.
src/projector.ts(161,4): error TS2450: Left-hand side of assignment expression cannot be a constant or a read-only property.

The functional tests also appear to fail, but it is hard to tell if it is related to these complication errors or further breaking changes in Maquette.

Reverting to [email protected] appears to alleviate the problem.

Package Version: 2.0.0-alpha

Code

> npm install maquette@latest

Expected behavior:

It works, without issue...

Actual behavior:

It has issues.

Registry and stateFrom

Considering we now have the registry provider interface, should widgets support either a reference to observe their state or a ID to be resolved via the registry provider? This would further "delegate" responsibility to the application factory. Therefore options would look something like this:

type StatefulOptions<S extends State> = {
    state: S;
} || {
    id: string;
    stateFrom: ObservableState<S>;
} || {
    id: string;
    stateFrom: string;
    registryProvider: RegistryProvider<any>;
}

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.