Yesterday, I took my first look at building custom input controls with ngModel and ngModelController in AngularJS. While it's a two-way data binding mechanism at its core, the benefits of using ngModel appear to be many. Not only does it encapsulate the monitoring of the View-Model, it also integrates with the FormController to provide a robust validation and input-state management system. Needless to say, I was excited and wanted more. So, this post is another ngModel exploration. This time, I'm trying to create a Select-inspired dropdown menu that marries scope references to custom HTML structures.
If you just need a simple data-driven select-menu, the native Select and ngOptions directives, in AngularJS, are great; they seamlessly bind your scope to the input control. But, the downside of the native Select directive is that you give up a lot of control over the how the select element is rendered. Part of this is due to the nature of the directive; part of this is due to the native behavior (and limitations) of select menus in the browser.
To compensate for these limitations, people have traditionally used jQuery plugins like Uniform and Bootstrap-Select. But, these don't really help you in an AngularJS application because they're either still limited by the underlying select element or, they revert to using "simple values", which goes against the whole AngularJS philosophy which is intended to use a View-Model.
Ideally, it would be nice to have the power of ngModel combined with the flexibility of a totally custom HTML dropdown menu. And that's exactly what I want to try to build in this exploration. I'm calling it bnDropdown and the basic format of it is this:
<div bn-dropdown="myModelReference"> <ul> <li option="anotherScope"> Anything I want here. </li> </ul> </div>
You need to define the bnDropdown container, which is expected to contain a single UL (in this iteration) and nested LI elements. You'll notice that I'm not actually requiring the user to include the ngModel directive. My first approach did that (required ngModel) and, well, I thought it looked unattractive. So, this directive will translate the bnDropdown attribute into an ngModel directive which gets implicitly compiled. Not only is this more attractive, I think it serves for a more interesting demo.
The "option" attributes, of the LI elements, are not static values - they are scope references. If you select one of the options, the option attribute is evaluated in the context of local scope and then used to set to the current $viewValue on the ngModelController. This is how we marry the custom HTML to the View-Model.
In the following demo, I'm creating a single dropdown for the selection of a "Best friend". The dropdown menu contains static options, ngRepeat-driven options, and an ngIf-driven option. Because the list of options is, itself, based on the View-Model, we have to be conscious about timing when we render the selected state. The underlying ngModelController registeres its $watch() binding before the linking phase. As such, it will register its watchers before ngRepeat or ngIf get a chance to. This means that the ngModelController will ask the ngModel consumer to render before the ngRepeat and ngIf directives have rendered their elements. As such, we have to render our state asynchronously, giving the nested directives a chance to catch up.
That said, let's look at the code:
You notice that the bnDropdown directive is spread across two different compilation and linking priorities. This is because the ngModel attribute is optional; if you don't provide it, I will inject it for you. But, since this is taking place on the same element node, the ngModelController wouldn't be available. As such, the "require" of ngModel's controller has to be done in a different compilation and linking phase, at a lower priority.
The compile-phase of the bnDropdown directive injects some additional HTML and CSS classes. And, since these are injected as part of the compilation phase, they will automatically be cloned if any of the nested LI elements are using dynamic directives like ngRepeat or ngIf (as I am doing in this demo). Doesn't that freakin' blow your mind?!? AngularJS is the bee's knees!
Earlier, I mentioned that using ngModel provides a more robust solution than simple two-way data-binding. This is why I am able to use the "required" and "ng-change" attributes, even though this isn't a native form control - these are automatically consumed by the ngModelController and the parent FormController. And, that's just the beginning. ngModel gives you a whole lot of functionality for free.
This exploration was super exciting to build. I really feel like using ngModel gave me all the customization I wanted (in the HTML) while still adhering to the "Angular way." And, having it seamlessly integrate with the state-machine of the Form makes me realize how much power is here that I have yet to even wrap my head around.
Want to use code from this post? Check out the license.