Thursday, December 22, 2016

Watching the Children

One of the things I've been working on is an AngularJS component that groups elements into a single field...which I thought would be simple. As it turned out, I learned more than I thought I would.

First, let me describe the scenario. The original block of code, that came out of a collaboration between designers and front-end developers, was inadequate. Here's what the code was (sort of) like...


Original Angular Markup

<div class="row">   <label>{{fieldLabel}}</label>   <select name="{{id}}_day" ng-model="day">     <option ng-repeat="option in ::days track by ::$index">       {{option}}     </option>   </select>   <select name="{{id}_month" ng-model="month">     <option ng-repeat="option in ::months track by ::$index">       {{option}}     </option>   </select>   <select name="{{id}}_year" ng-model="year">     <option ng-repeat="option in ::years track by ::$index">       {{option}}     </option>   </select>   <tooltip ></tooltip> </div>


Looking at the code I immediately knew that this was not written by someone who was familiar with several concepts every engineer must keep in mind - remember, we are getting paid to think - things like flexibility for localization, and more importantly, accessibility.

My first lesson

My first thought (beyond the obvious lack of accessibility practice, like having a linked label) was "why are they not using an input with a date type attribute". When I did some accessibility testing of my own, however, I learned that the user experience with an HTML5 date type input was far below what I would consider sub-par. Sure, the HTML5 date type has a better visual experience, but if you're relying on something like VoiceOver™ it's very unclear how you're even supposed to interact with the input. If you're relying on VoiceOver™, you'd have a much better experience if the input format (e.g., year/month/day or year-month-day) were described and you were left to enter it in a text type input.

Granted, from an accessibility standpoint there is little we could do that would not improve the experience. I was, however, limited in options - I had to retain the dropdown lists with the only label associated with the group as the primary (visual) interface - so I started where I could. After the first pass - to address the accessibility issues - the code was a little better, even if I did have to add a method to the scope object to set the date value using the three date parts. (Ok, I didn't have to add that, I could still pass the date parts, but adding it and gives me a single point of reference for the date, making validation and other stuff a little easier.)


Accessible Angular Markup

<div class="row">   <label for="{{id}}">{{fieldLabel}}</label>   <input class="visually-hidden" id="{{id}}" name="{{id}}" type="text">   <div aria-hidden="true">     <select name="{{id}}_day"      ng-change="setDate()"      ng-model="day">       <option ng-repeat="option in ::days track by ::$index">         {{option}}       </option>     </select>     <select name="{{id}_month"      ng-change="setDate()"      ng-model="month">       <option ng-repeat="option in ::months track by ::$index">         {{option}}       </option>     </select>     <select name="{{id}}_year"      ng-change="setDate()"      ng-model="year">       <option ng-repeat="option in ::years track by ::$index">         {{option}}       </option>     </select>   </div>   <tooltip ></tooltip> </div>


Although I won't delve into the tooltip directive here, I also made sure it had the ARIA role "status" so when the date was invalid (like a Date of Birth less than 13 years ago for Facebook), I could put that in the tooltip and it would announce to anyone using assistive technology as well as being displayed for anyone.

My second lesson

With the majority of the accessibility issues fixed, I moved on to improving the interface in other ways. There was a significant problem in that the order of the SELECT elements was fixed even though the day-month-year pattern applies to only a few cultures. The directive, as it was coded, would not be flexible enough to handle different formats - different ordering of the date parts - that are common across the globe.

Also, to improve the interface, it would be nice if the number of days were automatically adjusted based on the month and year. Not only does it reduce the cognitive load for poorly labelled elements, it reduces the number of accidental invalid dates like February 30th. Since Angular has two-way binding, and a way to watch properties in a scope for changes, this little modification should have been fairly simple. The good news is that it was. All I had to do was add $watch to the month in the directive and modify the days...oh, and $watch the year as well to account for February's 29th day in leap years.

With the ability to watch the year and month for changes and because JavaScript already has pretty slick slicing and dicing methods for arrays, modifying the number of days was easy peasy, and using days as an array on the scope also meant that passing that array into the ng-options attribute, made perfect sense.

Since I wanted to be able to pass the months array in (to ease localization) this was the perfect time to make that little switch and add a date format to the scope that enabled me to switch around the order of the dropdown lists. A little parsing of the date format that was passed in and I have an order property on the scope that I can use for another of Angular's features - ng-switch.

After these little changes, I had something that looked like this...


Localizable Angular Markup

<div class="row">   <label for="{{id}}">{{fieldLabel}}</label>   <input class="visually-hidden" id="{{id}}" name="{{id}}" type="text">   <div aria-hidden="true">     <ng-switch on="::order[0]">       <div class="datepart" ng-switch-when="d">         <ng-include src="'dd.html'"></ng-include>       </div>       <div class="datepart" ng-switch-when="m">         <ng-include src="'mm.html'"></ng-include>       </div>       <div class="datepart" ng-switch-when="y">         <ng-include src="'yy.html'"></ng-include>       </div>     </ng-switch>     <ng-switch on="::order[1]">       <div class="datepart" ng-switch-when="d">         <ng-include src="'dd.html'"></ng-include>       </div>       <div class="datepart" ng-switch-when="m">         <ng-include src="'mm.html'"></ng-include>       </div>       <div class="datepart" ng-switch-when="y">         <ng-include src="'yy.html'"></ng-include>       </div>     </ng-switch>     <ng-switch on="::order[2]">       <div class="datepart" ng-switch-when="d">         <ng-include src="'dd.html'"></ng-include>       </div>       <div class="datepart" ng-switch-when="m">         <ng-include src="'mm.html'"></ng-include>       </div>       <div class="datepart" ng-switch-when="y">         <ng-include src="'yy.html'"></ng-include>       </div>     </ng-switch>   </div>   <tooltip ></tooltip> </div>


Included Markup Files - dd.html

<select name="{{id}}_day"   ng-change="setDate()"   ng-model="day"   ng-options="days.indexOf(day) as day for day in days"> </select>


Included Markup Files - mm.html

<select name="{{id}_month"   ng-change="setDate()"   ng-model="month"   ng-options="months.indexOf(month) as month for month in months"> </select>


Included Markup Files - yy.html

<select name="{{id}}_year"   ng-change="setDate()"   ng-model="year"   ng-options="years.indexOf(year) as year for year in years"> </select>


Using this ng-switch pattern with parsing code similar to...


Format Parsing

var format = (/^([ymd]*)\W?([ymd]*)\W?([ymd]*)/i)        .exec($scope.dateformat).slice(1, 4); $scope.order = [   format[0].replace(/y+/i, 'y').replace(/m+/i, 'm').replace(/d+/, 'd'),   format[1].replace(/y+/i, 'y').replace(/m+/i, 'm').replace(/d+/, 'd'),   format[2].replace(/y+/i, 'y').replace(/m+/i, 'm').replace(/d+/, 'd') ];


...in the directive means that even a two-part date format, like month/year, will be displayed correctly, and you can use the same datepart identifiers as are used in Angular's date filter.

Third lesson

So now it was worlds better - accessible and ready for localization. That tooltip, though, was going to give me fits. Getting it to display when the input had focus and hide when it didn't was easy...but the SELECT elements are not siblings of the tooltip (preventing CSS from working) and the focus event doesn't bubble so I couldn't add a listener to the element containing the SELECTs that was a sibling to the tooltip. To make matters worse, in Angular, the focus event doesn't fire for a SELECT element until the change event fires. It was like some grand puzzle preventing me from getting the tooltip to hide and show when the focus was on any one of the date parts.

So, how did I solve this little riddle?

First, I thought to use $watch on the SELECT elements in some way. Of course the first problem in attempting that is that by using the ng-switch to help with localization I had prevented the SELECT elements from being present in a way that would allow me to get a hook into them. Add to this the fact that the focus event wasn't firing and I thought I was blocked...but then I remembered I was getting paid to think.

I added the onfocus and onblur attributes to the SELECT items in the markup to by-pass Angular. That fixed the timing problem - the onfocus and onblur fire immediately when focus or blur occur, not when the change event occurs, and I thought I could use them to change the class to indicate focus and use $watch in the directive to watch the class attribute of the SELECT. Unfortunately, that didn't work. Even though I was able to get the class to change by adding onfocus="this.className+='focused'" and onblur="this.className=this.className.replace(/\bfocused\b/, '')" to the SELECT tags, the element instance in the DOM was out of sync with the element instance in the directive. I knew the onfocus and onblur event handlers were key, though, because they allowed me to get the focus event without the change event.

Given that I had to figure out how to get the two references in sync, what I needed was a way to get the onfocus and onblur attributes to reference the directive scope. Referencing the directive scope wasn't enough, however, because I also had to be able to call $digest to get any changes to the scope made outside of Angular (which native event handlers are) recognized. To make that sync easier, I added methods to the scope - one to set a scope property (focused) to true, and one to set the same property to false - and this is key - made sure those methods also called $digest. Next, I changed onfocus and onblur to call the methods I had added to the directive scope in the SELECT elements, and then added the ng-class attribute to the DIV containing the SELECT elements to identify that the field had focus any time the property was true (i.e., ng-class="{'focused': focused}").


Included Markup Files - dd.html

<select name="{{id}}_day"   onblur="angular.element(this).scope().blur();"   onfocus="angular.element(this).scope().focus();"   ng-change="setDate()"   ng-model="day"   ng-options="days.indexOf(day) as day for day in days"> </select>

So, at this point, I was pretty sure it was all set...and, just like in the LEGO movie, everything is awesome.

So, there you go...a quick way to watch the children of a DOM element, sort of.

Happy coding.

No comments:

Post a Comment