Wijmo 5 was released almost a year ago, and we've been adding controls and features to it ever since. A lot of our roadmap is driven by customer requests, and we recently decided it was time to add a "multi-select" control to the wijmo.input module. This article describes the process of designing and implementing the new MultiSelect control. We hope it inspires you to create your own Wijmo controls. It's really easy!
Designing the Control
The first step in developing a new control is design. We start by doing some market research to see what features are commonly offered, the terminology commonly used to describe the features, the object models, and the overall quality of similar tools available. Once the market research is finished, we decide which features are important and which are non-goals. At this stage, it's important to resist the urge to include every possible feature, at least in the first release of the control. Adding too many items can make the control bloated and over-complicated. It is better to focus on the main functionality at first and make sure the control delivers it really well. In our case, we decided we needed a list of items to be shown in a drop-down. Each item should display some content and a checkbox to toggle the item's selection state. The list should remain visible until the control loses focus or the user presses the escape key or the toggle button. The control header should be able to display some placeholder text when no items are selected (same pattern as the HTML input element), and customizable text to indicate the current selection state.
Selecting the Base Class
Wijmo is written in TypeScript, and takes full advantage of object orientation. It also offers a rich set of controls that can be used as base classes and extended. Our MultiSelect is similar to a Menu control. Both show a non-editable header, a drop-down button and a drop-down list with items. But the Menu control has a lot of properties related to commands which do not apply to the MultiSelect control. Therefore we decided to use the ComboBox control as our base class (the ComboBox is the base class for the Menu control as well).
Defining the Object Model
Once the base class was selected, much of the object model was automatically defined. The ComboBox control defines a set of properties, events, and methods that would also be available to the MultiSelect. The MultiSelect control needed only a few extra properties.
- checkedMemberPath: This property gets or sets the name of the property that determines whether a data item is currently selected. It maps directly to the checkedMemberPath property of the ListBox displayed in the drop-down. One caveat here is the fact that we also wanted to support binding to string arrays, where items are simple strings and have no properties. In this case, the user would not leave the checkedMemberPath property empty and the control should deal with it.
- checkedItems: This property gets or sets an array with the items that are currently checked. We debated whether to call this property “selectedItems” or “checkedItems” and decided for the latter because the control already has “selectedItem” and “selectedIndex” properties which could cause some confusion (the item that is currently selected may or may not be checked).
- checkedItemsChanged: This is an event that fires when the value of the “checkedItems” property changes. Change events are important in MVVM scenarios.
In addition to these selection-related properties, we needed another group of properties to deal with the text displayed in the static part of the control (the header):
- maxHeaderItems: This property gets or sets the maximum number of items that will be enumerated in the header. By default, it is set to 2, so if the user checks items “a” and “b”, the header will show the string “a, b”.
- headerFormat: This property gets or sets the format string used to build the header when the number of checked items exceeds maxHeaderItems. By default, it is set to “{count:n0} items selected”, so if the user selects 10 items the header will show the string “10 items selected”. You may want to use this property to make the message more specific. For example, if you are selecting countries, you could set the headerFormat property to “{count:n0} countries selected”.
- headerFormatter: This property gets or sets a function that can be used to customize the text shown in the header in case the headerFormat template is not flexible enough.
That completes the object model for the MultiSelect control. We decided not to add features available on some multi-select controls such as search filters, auto-complete, editable control headers, and a 'select all' button. These features may be added later if users request them.
Implementing the Control
Now we can declare our new MultiSelect class:
module wijmo.input {
'use strict';
export class MultiSelect extends ComboBox {
}
}
The “extends ComboBox” statement ensures our control inherits all functionality from the base ComboBox class, including properties, events, methods, and also all internal/private members. Notice that Wijmo controls have a controlTemplate property that is responsible for defining the control content (as an HTML fragment) and its constituent elements (via the “wj-part” attribute). Because we are extending the ComboBox class, and don’t need to customize the template, this step is not required here. For more information about control templates, see the documentation and the samples. Now it is time to start adding the things that make the MultiSelect different from a ComboBox.
Make the input element in the header read-only.
The ComboBox control has an input element where the user can type values. In our MultiSelect control, that element should show the header, and the user should not be able to type into it. We can achieve that in the control’s constructor, by adding a “readonly” attribute to the input element, which is accessible through the inputElement property:
constructor(element: any, options?) {
super(element);
// make header element read-only
this.inputElement.setAttribute('readonly', '');
Clicking on the header should toggle the drop-down list.
The header of the ComboBox is an editable element; clicking on it gives it the focus. The MultiSelect has a non-editable header, so clicking on it should toggle the drop-down instead:
// toggle drop-down when clicking on the header
var self = this;
this.addEventListener(this.inputElement, 'click', function () {
self.isDroppedDown = !self.isDroppedDown;
});
This code uses the Control.addEventListener method to handle click events and toggle the isDroppedDown property. The Control.addEventListener method is better than the standard JavaScript addEventListener in this case because it keeps track of all the listeners that were added so they can be easily (and automatically) removed when the control is destroyed, avoiding memory leaks.
Handle the keyboard
The base class already provides most of the keyboard handling we want (toggle the drop-down on F4, close it on Escape, etc.). But we need to extend the keyboard support a little. We would like to uncheck all the items when the user presses the Delete key and to toggle the selected item when the user presses the space key when the list is dropped-down. We could use the addEventListener method to listen to the keyboard, but in this case there’s a better option. The base class already has an event handler attached, so we can override the _keydown method to add the extra functionality we want:
// clear selection on Delete key, toggle checkbox on space
_keydown(e: KeyboardEvent) {
super._keydown(e);
if (!e.defaultPrevented) {
switch (e.keyCode) {
case Key.Delete:
e.preventDefault();
this.checkedItems = [];
break;
case Key.Space:
if (this.isDroppedDown) {
var idx = this.selectedIndex;
if (idx > -1) {
this._lbx.toggleItemChecked(idx);
}
}
e.preventDefault();
break;
}
}
}
Notice that the name of the _keydown method starts with an underscore. This means the method is not public, and that’s why it is not documented. But it is protected, not private. This means derived classes can override it, as we are doing in this case. Our implementation starts by calling the base class (super._keydown), and unless the event has been prevented, it handles the Delete and Space keys and prevents further processing of those keys.
Ensure there are checkboxes on the list
To achieve this, we will add a checkedMemberPath property to the control. This property maps to the checkedMemberPath property of the child ListBox control.
// make listbox a multi-select
this.checkedMemberPath = null;
This is a little tricky, because we want to ensure that the list always has checkboxes, even if the user does not the checkedMemberPath property. This little detail is handled by the getter and setter of the checkedMemberPath property, which we will look at later.
Keep the drop-down open when the user clicks the list
Our base class, the ComboBox, closes automatically when the user clicks an item on the list to select it. The MultiSelect should not do this, because it would make it hard to select multiple items if the list closed after each click. The obvious thing to do in this case would be to add a method to the base class that would handle the clicks, and to override that method in the MultiSelect to keep it open. But there is another approach that doesn't require any changes to the base class. We can simply remove the event listener that is responsible for closing the drop-down:
// do NOT close the drop-down when the user clicks to select an item
this.removeEventListener(this.dropDown, 'click');
Handle header updates
The control header should be initialized and updated when the list of items changes or when items are selected/checked:
// update header now, when the itemsSource changes,
// and when items are selected
var self = this;
self._updateHeader();
self.listBox.itemsChanged.addHandler(function () {
self._updateHeader();
});
self.listBox.checkedItemsChanged.addHandler(function () {
self._updateHeader();
self.onCheckedItemsChanged();
});
Apply initialization options
All Wijmo controls allow the user to pass in an "options" object with property initializers. This is normally done after the control has been fully instantiated, so the end of our constructor looks like this:
// initialize control options
this.initialize(options);
} // end of constructor
Note that the initialize method does not simply expand an object by adding properties to it (as jQueryUI does). Instead, initialize examines each value in the options parameter to ensure that it corresponds to a valid property or event. Invalid names trigger exceptions which are logged on the console, as well as values of incompatible types. This can prevent hard-to-find bugs.
Implement the checkedMemberPath property
The checkedMemberPath property maps to the checkedMemberPath property of the ListBox in the drop-down. However, in this case we want to ensure the ListBox always shows checkboxes, to the property is implemented as follows:
static \_DEF\_CHECKED_PATH = '$checked';
get checkedMemberPath(): string {
var p = this.listBox.checkedMemberPath;
return p != MultiSelect.\_DEF\_CHECKED_PATH ? p : null;
}
set checkedMemberPath(value: string) {
value = asString(value);
this.listBox.checkedMemberPath = value
? value
: MultiSelect.\_DEF\_CHECKED_PATH;
}
When setting the property, we replace null values with a special 'flag' value. When getting the property, we do the opposite. So if the user sets the MultiSelect.checkedMemberPath to null or to an empty string, the ListBox.checkedMemberPath will be set to '$checked' instead.
Implement other simple properties
The control has a few more simple properties that have pretty standard implementations. The checkedItems property maps directly to the ListBox, and the maxHeaderItems, headerFormat, and headerFormatter are all used in the _updateHeader method.
Implement the checkedItemsChanged event
The checkedItemsChanged event occurs when items are checked or unchecked. Its implementation follows the pattern used in all Wijmo events:
checkedItemsChanged = new Event();
onCheckedItemsChanged(e?: EventArgs) {
this.checkedItemsChanged.raise(this, e);
}
The event itself is just a public field of type Event. And it is followed by a method called “on
self.listBox.checkedItemsChanged.addHandler(function () {
self._updateHeader();
self.onCheckedItemsChanged();
});
Implement the method that updates the header content
The control header must be updated in a number of situations. The code we listed so far does this by invoking the _updateHeader method, which is very flexible. The method starts by checking whether the user provided a custom headerFormatter function. If so, the function is called and is expected to return an HTML string that will be displayed in the header element. If a custom formatter is not applied, the content will depend on the number of items currently selected. If no items are selected, the placeholder string is displayed. If fewer than maxHeaderItems items are selected, we get the display values for each selected item and join them with commas. Finally, if more than maxHeaderItems items are selected, we use the headerFormat string format to build the display string. Here is the implementation of the _updateHeader method:
// update the value of the control header
_updateHeader() {
if (this._hdrFormatter) {
this.\_hdr.innerHTML = this.\_hdrFormatter();
} else {
var items = this.checkedItems;
var hdr = '';
if (items.length > 0) {
if (items.length <= this._maxHdrItems) {
if (this.displayMemberPath) {
for (var i = 0; i < items.length; i++) {
items[i] = items[i][this.displayMemberPath];
}
}
hdr = items.join(', ');
} else {
hdr = format(this.headerFormat, {
count: items.length
});
}
}
this.inputElement.value = hdr;
}
The control is complete. After minification its size is only 3.2kb. Not bad for a fully functional multi-select drop-down. Of course, most of the work is done by the ComboBox and ListBox classes.
Being a good Wijmo citizen
It's important that all Wijmo controls follow some conventions so users have a consistent experience and rely on certain patterns. One of these conventions is the pattern for declaring events. All Wijmo events should have a corresponding method called “on
checkedItemsChanged = new Event();
onCheckedItemsChanged(e?: EventArgs) {
this.checkedItemsChanged.raise(this, e);
}
Another important aspect of Wijmo controls is their invalidate/refresh cycle. The Control class has invalidate and refresh methods that work together. Calls to invalidate cause asynchronous calls to refresh. When changes are applied to the control that affect the way it is displayed, you should call the invalidate method. This will cause the control to call the refresh method after a delay. This improves performance because changes often happen in batches, and multiple calls to invalidate are consolidated and result into a single call to refresh. If you are implementing a control, you typically don’t have to worry about the invalidate method, but you should ensure that the control refreshes property when the refresh method is called. In our MultiSelect control, we want to make sure the header is up-to-date when the user calls refresh. This can be done by overriding the method and adding a call to the _updateHeader method:
// update header when refreshing
refresh(fullUpdate = true) {
super.refresh(fullUpdate);
this._updateHeader();
}
In most cases, the call to _updateHeader won’t change the control since we are already calling it from all the relevant places. But it is important to follow the convention and update the control by applying any state that the base class is not aware of. Typical examples of this are situations where controls are hidden and later shown, or where the user changes the culture file dynamically. In both cases, the user will call the invalidate method and expect the control to re-render itself property to reflect the new layout/culture settings.
Testing, Polishing, Improving the Control
Once the control is implemented, the next step is to finish the unit tests and implement samples. The tests and samples should be used to find bugs, and also to identify usability issues, and should cover desktop and on mobile devices. This is by far the most time-consuming task. Details really matter to end-users, and they can make all the difference. For example, in our implementation, we decided that we needed to improve keyboard support in our ListBox control. The original implementation handled key presses by selecting the next item based on the key that was pressed. This works OK, but if you have long lists with many items that start with the same letter the keyboard becomes nearly useless. To address this, we implemented auto-search feature to the ListBox. So as you type the word 'Canary', the control will select 'Cambodia', then 'Canada', and finally 'Canary Islands' when you press the 'r'. This makes searching for items very quick and convenient. This also illustrates an important point about extending and re-using controls. Improvements made to the derived/parent control often result in improvements throughout the control set. To finish this article, let us take a quick look at what the control looks like in all its glory. We implemented an Angular directive for it, so adding a control to an HTML page is as simple as this: And the result is this: You can see this example live here: http://demos.componentone.com/wijmo/5/Angular/Explorer/Explorer/#/input/multiselect