Yesterday I got a request for my Westwind.Globalization library about better support for Right To Left (RTL) language editing. The request was a simple one: When editing resources in our resource editor the editor should support RTL display for any locale that requires RTL, which makes good sense. I’m as guilty as the next guy to sometimes ignore forget that not all languages use left to right to display and edit text.
Westwind.Globalization is a bit unique in its use of localized resources in that the front end app ends up displaying any number of resource locales simultaneously since we display all of the localized versions for each resource Id for editing.
After some experimentation on how to actually provide the RTL information to the client application I ended up with an UI that looks like this:
Notice the Arabic and Hebrew languages showing with Right to Left display and that can be edited that way as well.
ASP.NET RTL Language Detection
So how can you detect Right To Left support? In this Web Resource Editor resources are served from the server running an ASP.NET Web application and the backend has a routine that returns all resources matching a given resource id. So as I navigate resources a service call is made to return an array of all the matching resources. One of the properties returned for each resource is whether the locale Id requires RTL display.
The actual routine that returns a list of resources works like this:
[CallbackMethod()] public IEnumerable<ResourceItemEx> GetResourceItems(dynamic parm) { string resourceId = parm.ResourceId; string resourceSet = parm.ResourceSet; return Manager.GetResourceItems(resourceId, resourceSet, true).ToList(); }
The key is the ResourceItemEx class which is serialized to JSON in the result. Specifically ResourceItemEx contains an IsRtl property looks up RTL status based on the LocaleId using the following code:
public bool IsRtl { get { var li = LocaleId; if (string.IsNullOrEmpty(LocaleId)) li = CultureInfo.InstalledUICulture.IetfLanguageTag; var ci = CultureInfo.GetCultureInfoByIetfLanguageTag(LocaleId); _isRtl = ci.TextInfo.IsRightToLeft; return _isRtl.Value; } set { _isRtl = value; } } private bool? _isRtl;
This code looks up a Culture by its locale ID and queries the TextInfo.IsRightToLeft property to determine whether the language supports RTL or not which is the set on the internal value. This calculated value is then read for each of the resources, when the resource list is serialized.
The end result is this JSON that is served to the Angular client app:
[ { "IsRtl": false, "ResourceList": null, "ResourceId": "HelloWorld", "Value": "Hello Cruel World", "Comment": null, "Type": "", "LocaleId": "", "ValueType": 0, "Updated": "2015-05-24T02:14:21.4396383Z", "ResourceSet": "Resources" }, { "IsRtl": true, "ResourceList": null, "ResourceId": "HelloWorld", "Value": "مرحبا العالم القاسي", "Comment": null, "Type": null, "LocaleId": "ar", "ValueType": 0, "Updated": "2015-05-24T02:14:21.4396383Z", "ResourceSet": "Resources", }, … ]
This data is consumed by an Angular Service and Controller which eventually binds the data into the HTML UI.
RTL in the Browser
Browsers have Right To Left support using the dir HTML attribute that you can place on any HTML element or container.
<body dir="rtl">
The other options are ltr and auto the latter of which is the default and will be used depending on the user’s current locale configured in the browser.
You can also control RTL using the direction CSS tag:
.rtl { direction: rtl; }
In most applications you are likely to apply explicit text direction either by automatically letting the browser take care of it or setting the value globally at a top level element like body or html.
However, in my Web Resource Editor I need to display values for multiple locales in a single page so I have to specify the dir attribute (or CSS class that uses the direction style) on particular controls.
When the server returns the list of resources the HTML pages uses an ng-repeat loop to create a ‘list’ of controls that make up each ‘row’ for each resource id that consists of the locale Id label, the textarea and the save and translate buttons.
The RTL setting specifically needs to be assigned to the textarea control, and my first cut of this used a slightly messy Angular expression in the dir attribute:
<textarea id="value_{{$index}}" name="value_{{$index}}" class="form-control" data-localeid="{{resource.LocaleId}}" ng-model="resource.Value" dir="{{resource.IsRtl ? 'rtl' : '' }}"> </textarea>
resource in this $scope context is the ng-repeat item that’s the resource item retrieved from the service and resource.IsRtl holds the value to set binding to.
It works and sets the binding properly and I get my RTL bindings for the HE and AR text as shown in the original picture.
Creating a ww-rtl Angular Directive
While the above works fine, it’s kinda messy. You have to write conditional expression and use expression syntax. It turns out that this initial fix wasn’t the only place where this is needed. There are 5 or 6 other places (and counting) that also needed to apply this same behavior, so I figured it’d be nice to build something more reusable.
Ideally I’d want to simple say:
ww-rtl="resource.IsRtl"
The directive takes an expression that should evaluate to a boolean value. If the expression is true the control or element should get the dir=”rtl” attribute set, otherwise the attribute should be removed or blank.
While I’ve been using Angular for a while, I’ve not been creating a lot of directives, so it took me a little bit to figure out exactly how to watch a model value and detect when the model changes. The logic is quite simple actually, but it’s not quite so straightforward arriving at that simple solution due to the quirky API that Angular directives use (and which is why I haven’t been using it a lot).
The ww-rtl directive is essentially a binding directive, meaning that it needs to watch a binding value and then change DOM behavior when the value changes – specifically by applying the dir attribute to the element with the appropriate value.
Here’s the directive:
app.directive('wwRtl', function() { return { restrict: "A", replace: true, scope: { wwRtl: "@" }, link: function($scope, $element, $attrs) { var expr = $scope.wwRtl; $scope.$parent.$watch(expr, function(isRtl) { var rtl = isRtl ? "rtl" : ""; $element.attr("dir", rtl); }); } } });
Pretty small… and cryptic, yes? Let me explain :-)
This creates a directive for ww-rtl (wwRtl), which looks only at attributes (restrict: "A"). The attribute itself is replaced (with nothing in this case). I create a private scope for this control and I bind the ww-rtl attribute to an wwRtl property on the scope.
The meat is in the link() function which sets up a watch that monitors the expression. The expression is the attribute value that I can just grab of the scope ($scope.wwRtl). I can assign that expression to the scope $watch() function which now monitors this expression for changes. Note I use the watch on the parent scope which contains the actual expression to evaluate (resource.IsRtl).
The $watch() function gets a callback whenever the watched expression changes and passes the new value into the callback. This value the result of the evaluated expression – ie. true or false in this case. Based on that value I can now change the elements dir attribute to rtl or blank and voila the Right to Left display of the control will change.
Here’s what the applied directive now looks like:
<textarea id="value_{{$index}}" name="value_{{$index}}" class="form-control" data-localeid="{{resource.LocaleId}}" ng-model="resource.Value" ww-rtl="resource.IsRtl"> </textarea>
And it works the same as the previous code but looks a lot nicer with more obvious intent.
Adding a Resource and RTL
As mentioned there are a few other places where RTL needs to be displayed. For example here’s the Add/Edit Resource form which also displays resource text:
and I can easily reuse the attribute here. When editing a resource, the ww-rtl attribute works great – I simply bind the existing resource.IsRtl value and it just works.
But – it’s not so straight forward with a new resource. The problem is that the resource.IsRtl property is not set from the server when a new resource is created, so IsRtl is not actually set accurately.
To fix this I added a server callback that’s fired when the use exits the locale field:
[CallbackMethod] public bool IsRtl(string localeId) { try { var li = localeId; if (string.IsNullOrEmpty(localeId)) li = CultureInfo.InstalledUICulture.IetfLanguageTag; var ci = CultureInfo.GetCultureInfoByIetfLanguageTag(localeId); return ci.TextInfo.IsRightToLeft; } catch {} return false; }
Note the catch block used in case the user puts in a locale that’s not supported on the server in which case we assume the default mode of LTR is used.
On the client side this is hooked up to a blur operation of the Locale Id text box:
<input type="text" class="form-control" ng-model="view.activeResource.LocaleId" placeholder="Locale Id" ng-blur="view.onLocaleIdBlur()" />
which is then hooked up with this controller method:
vm.onLocaleIdBlur = function(localeId) { if (!localeId) localeId = vm.activeResource.LocaleId; localizationService.isRtl(localeId) .success(function(isRtl) { vm.activeResource.IsRtl = isRtl; }); },
The code uses a localizationService that fronts all the $http calls to the backend service which in this case is nothing more than an $http.get() call that handles any errors.
This works great – so now when the user enters a RTL locale ID (or the locale is already set to RTL) the textbox switches to RTL mode. Type an LTR locale and it flips right back to that format.
Simplify?
Using an API callback for this might be overkill. In my application which is an admin interface the overhead of an API call is minor. If it’s not for you you can try to just hardcode the handful of top level locales that are Right to Left:
ar,dv,fa,he,ku,nqo,pa,prs,ps,sd,syr,ug,ur
And cache them in an array. You can then check newly entered locale ids against the values in the array.
If you want to see exactly what specific locales on your machine are available that support RightToLeft you can try running this code (in LinqPad of course!):
void Main() { foreach(var culture in CultureInfo.GetCultures(CultureTypes.AllCultures) .Where(c=> c.TextInfo.IsRightToLeft)) { Console.WriteLine( culture.IetfLanguageTag + " " + culture.EnglishName); } }
which should give you a good idea what locales require RTL.
No comments:
Post a Comment