Building Widgets
(with AngularJS)
Aaron N. Smith, Principal @ Modus Create
@aaron_n_smith | aaron@moduscreate.com
What do we mean by 'widgets'?
- Data visualizations
- Interactive
- Reusable
- Usually not full screen
- Think jQueryUI, Bootstrap
What Qualities do we seek?
- Functions consistently across a wide range of browsers
- Easy integration into applications
- Works reliably - i.e. as documented
- Good test coverage (unit + functional)
Why Angular?
This is where Angular should shine!
<sun-image/>
Custom markup with declarative
binding of data and event handlers.
binding of data and event handlers.
Which Angular?
- 1.0 & 1.1 - I certainly hope not
-
1.2 - Need to support IE8
-
1.3 - Don't need it in production for a bit
-
2.0 - Keep dreaming
The angular widget community
The problem with many Angular widgets
is that they aren't Angular widgets
Wrapping jQuery UI or Bootstrap widgets with Angular directives is not the same as creating Angular widgets.
This doesn't mean they're not valid
but we should aim higher.
but we should aim higher.
Enter AngularUI
http://angular-ui.github.io/
Great examples of widgets done right with Angular.
AngularUI Good and bad
-
Clean package structure
- Good setup and usage docs
- Good test coverage
- JS files combined too much
- JS and HTML are separated
- Not all production ready
Best practices
widgets are directives
So let's look at best practices for directives:
- Restrict to element or attribute
- Use isolate scope or transclude
- Expose clear, discrete inputs rather than complex objects
- Use external templates
-
Prefer discrete CSS classes over pseudo-elements
- Avoid monolithic directives - break them down!
Element vs. Attribute, etc.
Element is for widgets.
Attribute is for decorators.
Comment and Class are for the birds.
End of story.
app.directive('myWidget', function() {
return {
restrict: 'E'
//...
};
}
SCOPE
Isolate Scope
-
Helps you avoid conflicts
- Delivers a clear API
Inherited Scope
- Sometimes necessary with directives
- Bad idea for widgets
Transclude Scope
-
For wrapper widgets
-
Combine with Isolate Scope!
Inputs
-
Clear, discrete options for inputs
-
Inputs should have defaults
-
Allow global overrides via Providers
app.directive('myWidget', function(MyWidgetConfig) {
return {
restrict: 'E',
scope: {
param1: '=',
param2: '='
},
link: function(scope) {
scope.param1 = scope.param1 || MyWidgetConfig.param1;
scope.param2 = scope.param2 || MyWidgetConfig.param2;
}
};
});
Templates
- Use templateUrl instead of inline templates
- Bundle templates in $templateCache
app.directive('myWidget', function() {
return {
templateUrl: 'myWidget.tpl.html'
};
});
Markup/CSS
Use clear CSS classes instead of pseudo-elements.
Prefix names to avoid conflicts.
<div class="combo">
<div class="combo-label"></div> <!-- label would be bad here -->
<div class="combo-input"></div> <!-- input would be bad here -->
<div class="combo-trigger"></div>
</div>
Aim for flexibility.
PUtting it all together
- Dependencies
- Module organization
- Licensing
- GitHub
- Bower
Recommended organization
Source
- forms
- form.js
- form.spec.js
- form.tpl.html
- form.css
- input.js
- input.spec.js
- input.tpl.html
- input.css
- calendar
- calendar.js
- calendar.spec.js
- calendar.tpl.html
- calendar.css
- app.js
Output
ShinyWidgetBundle-0.0.1.js
ShinyWidgetBundle-tpl-0.0.1.js
QUestions?
Diving in
- Exposing functions
- Using 'track by'
- Transclusion
- Example walk-through
Exposing functions
// Directive definition
app.directive('superForm', {
return {
restrict: 'E',
scope: {
onSubmit: '&' // <--- & for binding functions
},
template: '<form><button ng-click="submit()">Submit</button></form>',
link: function(scope) {
scope.submit = function() {
scope.onSubmit({firstName: 'Aaron', lastName: 'Smith'});
};
}
};
});
<!-- Directive usage -->
<super-form onSubmit="submitHandler(firstName, lastName)"></super-form>
// Handler definition
$scope.submitHandler = function(firstName, lastName) {
console.log(firstName + ' ' + lastName); //Prints 'Aaron Smith'
});
Track by is your friend
// Allow dupes
<div ng-repeat="item in items track by $index">{{item.value}}</div>
// Avoid rewrites (and dupes)
<div ng-repeat="item in items track by item.id">{{item.value}}</div>
Regarding Transclude...
A simple, but often misunderstood, concept.
"That's not a word you'll find in a dictionary."
- Omkar Patil
- Omkar Patil
"Transclusion is almost a gift to people criticizing Angular."
- Matt Briggs
- Matt Briggs
Transclusion
Wikipedia says:
"In computer science, transclusion is the inclusion of a document or part of a document into another document by reference."
Therefore, a transclude directive wraps arbitrary content.
Think of a window that can contain anything.
See: AngularUI's modal directive for an example.
Example walk-through
Let's reinvent the wheel
a Naive <select> menu
In my experience, the template is a good place to start.
Naive <select> Template
<div class="combo">
<div class="combo-input">{{displayValue}}</div>
<div class="combo-trigger" ng-click="showOptions = !showOptions"></div>
<div class="combo-options" ng-show="showOptions">
<div class="combo-option"
ng-repeat="option in options track by option[valueField]"
ng-class="{selected: option[valueField] == value}"
ng-click="onSelectItem(option)">
{{option[displayField]}}
</div>
</div>
</div>
Naive <select> Directive
app.directive('combo', function(ComboConfig) {
return {
restrict: 'E',
scope: {
value: '=',
options: '=',
valueField: '=',
displayField: '=',
onChange: '&'
},
templateUrl: 'forms/combo.tpl.html',
link: function($scope, element, attrs, controller) {
//...
}
}
})
naive <select> Directive link()
$scope.valueField = $scope.valueField || ComboConfig.id;
$scope.displayField = $scope.displayField || ComboConfig.id;
$scope.options = $scope.options || [];
$scope.showOptions = false;
$scope.displayValue = updateDisplayValue();
$scope.$watch('value', updateDisplayValue);
$scope.onSelectItem = function(item) {
$scope.showOptions = false;
$scope.value = item[$scope.valueField];
$scope.onChange({value: item});
};
function updateDisplayValue() {
$scope.displayValue = ''; //Make sure it's reset
if ($scope.value) {
$scope.options.forEach(function(option) {
if (option[$scope.valueField] === $scope.value) {
$scope.displayValue = option[$scope.displayField];
}
});
}
}
Naive <select> CSS
.combo {
position: relative;
}
.combo-input {
border: 1px solid #555; padding: 4px;
}
.combo-trigger {
border: 1px solid #555; border-left-width: 0px;
}
.combo-options {
position: absolute; top: 30px; border: 1px solid #555;
}
.combo-option.selected {
background-color: #00c; color: #fff;
}
Naive <select> Usage
<combo value="country" options="countries"
on-change="onCountryChange(value)"></combo>
Now the customer says that some
combos need to 'autocomplete'...
Our combobox directive is monolithic.
What do we do?
In other frameworks, we might use inheritance.
In AngularJS, we use composition.
Options-List + xyz-Field
Now we can use different fields but reuse the options behavior without baking all our functionality into one giant widget.
<!-- our AutoComplete combo template -->
<div class="combo autocomplete">
<autocomplete-field></autocomplete-field>
<options-list></options-list>
</div>
<!-- our standard combobox template -->
<div class="combo">
<combo-field></combo-field>
<options-list></options-list>
</div>
Composition Over Inheritance FTW
<success-kid-meme/>
questions
Building Widgets
By Aaron N. Smith
Building Widgets
- 1,298