AngularJS: custom text input component
In Angular world, it’s common to enclose common behavior in separated component. In this post I’ll present to you text input component with AngularJS 1.6 and Bootstrap 3, with different types of bindings and usage of component hooks. If you are impatient, here’s Plunker: Angular JS custom text input component.
Why even do it?
Easier to find a bug
If you have a 5 text input approximately per form, and you have 10 forms in your large application — you have 50 text inputs. 50 places to make bug, to mess up with paddings, margins, CSS styles at all… If you create a component which encloses behavior, you have everything in one place, styles are the same, no way to forget for
label attribute etc… Even if some of inputs differ in behavior, for example some of them have typehead related to them, you can add another version of component — still you have few version of text input, not 50.
Few prerequisites
Goal — example usage of text component
Here is the final look of the component, how it should be used inside form
tag:
<ra-text-input
name="firstName"
label="First name (large)"
min-length="2"
max-length="10"
value="$ctrl.element.firstName"
on-change="$ctrl.updateModel(name, value)"
form="elementForm"
required="true"
submitted="elementForm.$submitted"
size="lg"
character-counter="true"
></ra-text-input>
Few words of explanation: elementForm
is passed to component and also elementForm.$submitted
is passed — looks like submitted binding is superfluous? Not exactly — I found in few projects that form submission is done without ng-submit
, due to logic responsible for displaying validation states. If your code rely on ng-submit
, without custom code for submission, you can delete submitted
binding and use $ctrl.form.$submitted
inside component.
Styling
Component uses Bootstrap forms styles. One thing worth to mention, in bootstrap-override.css
you can find:
.form-horizontal .form-group-sm .control-label {
font-size: 12px;
}.form-horizontal .form-group-lg .control-label {
font-size: 18px;
}
Because sizes work in quite unexpected way for me. So, to see label
font change on small devices these styles have to be used — you can easily reproduce it in Plunker by manipulating browser window size with above styles removed.
How is it done?
Acting on value changes inside text component (and the other way)
Changes are emitted to parent on every ng-model
value change:
$ctrl.change = function () {
$ctrl.onChange({
name: $ctrl.name,
value: $ctrl.internalValue,
});
}
onChange
function is passed to component as expression (&) binding:
on-change="$ctrl.updateModel(name, value)"
It’s very important to notice that keys used in component’s onChange
call (name and value) has to be the same as declared in HTML.
Inside text component’s $onChanges component hook, value passed by parent is copied to internal value:
if (changes.value) {
$ctrl.internalValue = $window.angular.copy($ctrl.value);
}
Why? Despite the fact that here value is string
, when the value would be an object
or array
, mutation inside component will mutate value in parent. To forget about differences between passing primitives vs non-primitives, it’s good practice to copy inputs value inside component to avoid unexpected behavior.
Copying occurs inside $onChanges hook. $onInit hook has its use-cases, mainly related to initialization logic, but $onChanges deals great with 2 common situations, where $onInit is powerless:
- Acting on changes from user input.
- Acting on input values loaded asynchronously, for example from server with XMLHttpRequest.
Especially the second one is important, cause happens many times in every “non-Hello World” applications. To prove that it works, click green button on top of the page in Plunker to simulate server request with $q.when().
Providing feedback for validation states
Providing visible feedback is easy with Bootstrap’s validation states ability not only to use proper color, but also to show feedback icon. To see validation in actions, try to violate constraints to see error states or type something valid, to see valid states. What is important here, validation states and errors has to be accessible, so screen reader only descriptions are provided:
<small
id=”{{$ctrl.name + ‘_validation’}}”
class=”sr-only”
aria-role=”alert”
aria-live=”assertive”
>
<span ng-if=”$ctrl.isValid()”>
Field has correct value
</span>
<span ng-if=”$ctrl.isNotValid()”>
Field has incorrect value
</span>
</small>
But this is not enough — it has to be also connected in some way to inputs, then they can be read by screen reader if something happen.
ARIA aria-describedby attribute
Aria-describedby is what we need. At first I was confused which attribute I should use — describedby
or labelledby
, but then I found Aaron Gustafson’s article where he wrote that labelledby
replaces label provided for input, which is definitely not what I want.
aria-describedby
value is list of IDs with space as a delimiter, for example:
aria-describedby=”middleName_validation middleName_validationErrors middleName_validation”
Its value is calculated by Angular with a little bit too long expression which can be found in ra-text-input.component.html
. It should be moved to controller :)
Character counter
When you specify character-counter="true"
you will enable character counter made with $doCheck hook (explained).
Conclusion
Component-based approach code repetition, template size and possibility to repeat mistakes in many places in application.