NLS bundles, locale-aware formatting, ARIA roles, focus management, keyboard navigation, WCAG 2.1 AA
dojo/i18n — NLS Bundle Structuredojo/date/locale — Date Formattingdojo/number — Number Formattingdijit/focus — Focus Managementdojo/i18n — NLS Bundle StructureNLS (National Language Support) bundles are AMD modules placed in a specific directory structure. The loader automatically selects the correct locale at runtime.
// myapp/nls/messages.js — ROOT BUNDLE
// Must export: { root: { ... }, locale: true/false, ... }
define({
root: {
// All string keys that your app uses
appTitle: "Employee Manager",
greeting: "Hello, ${name}", // ${name} = placeholder
save: "Save",
cancel: "Cancel",
delete: "Delete",
confirmDelete: "Are you sure you want to delete ${name}?",
employees: "Employees",
noResults: "No results found",
loading: "Loading...",
errorGeneric: "An error occurred. Please try again.",
// Pluralization key — see section 8.4
employeeCount: "${count} employee",
employeeCountPlural: "${count} employees"
},
// Declare which locales have override files
// true = file exists in that locale subfolder
fr: true,
ja: true,
de: true,
"fr-ca": true
});
// myapp/nls/fr/messages.js — FRENCH OVERRIDES
// Only define the keys that differ from root — others fall back to root
define({
appTitle: "Gestionnaire d'Employés",
greeting: "Bonjour, ${name}",
save: "Enregistrer",
cancel: "Annuler",
delete: "Supprimer",
confirmDelete:"Voulez-vous vraiment supprimer ${name} ?",
employees: "Employés",
noResults: "Aucun résultat trouvé",
loading: "Chargement...",
errorGeneric: "Une erreur s'est produite. Veuillez réessayer.",
employeeCount: "${count} employé",
employeeCountPlural: "${count} employés"
// Keys not listed here fall back to root (English) values
});
// myapp/nls/ja/messages.js — JAPANESE OVERRIDES
define({
appTitle: "従業員管�?�",
greeting: "${name}�?�ん�?�?�ん�?��?��?�",
save: "�?存",
cancel: "キャンセル",
delete: "削除",
employees: "従業員",
noResults: "�?果�?�見�?��?�り�?��?�ん",
loading: "読�?�込�?�中..."
});
// Any module — widget or otherwise
define([
"dojo/_base/declare",
"dijit/_Widget",
"dijit/_TemplatedMixin",
"dojo/i18n!myapp/nls/messages", // �? plugin loads correct locale automatically
"dojo/text!./templates/Header.html"
], function(declare, _Widget, _TemplatedMixin, strings, template) {
return declare([_Widget, _TemplatedMixin], {
templateString: template,
// Pass string keys into the template via postMixInProperties
postMixInProperties: function() {
this.inherited(arguments);
// Make all strings available as ${...} in the template
this.i18n = strings;
}
});
});
// templates/Header.html — use strings directly
// <div class="header">
// <h1>${i18n.appTitle}</h1>
// <span data-dojo-attach-point="greetingNode"></span>
// </div>
// Dynamic string with substitution
postCreate: function() {
this.inherited(arguments);
var greeting = strings.greeting.replace("${name}", this.userName);
this.greetingNode.textContent = greeting;
// Or use dojo/_base/lang.replace:
// lang.replace(strings.greeting, { name: this.userName });
}
Date: 6/11/2026
Number: 1,234.56
Currency: $1,234.56
Date: 11/06/2026
Number: 1 234,56
Currency: 1 234,56 €
Date: 2026/6/11
Number: 1,234.56
Currency: ¥1,235
Date: 11.06.2026
Number: 1.234,56
Currency: 1.234,56 €
// Force locale via dojoConfig BEFORE dojo.js loads
var dojoConfig = {
async: true,
parseOnLoad: false,
locale: "fr", // force French — overrides browser's Accept-Language
// For testing multiple locales, change this value and reload
extraLocale: ["ja", "de"] // pre-load additional locales so you can switch at runtime
};
// At runtime — detect from browser or user preference
var dojoConfig = {
locale: (navigator.language || "en-us").toLowerCase().replace("_", "-")
};
// Runtime locale switch (requires extraLocale loaded at boot)
require(["dojo/i18n", "dojo/_base/kernel"], function(i18n, kernel) {
// Switching locale at runtime re-requires all i18n bundles
// This is an expensive operation — best done at page load or app init
kernel.locale = "ja"; // change kernel locale
// Then require your i18n bundle again — it will return the new locale's strings
require(["dojo/i18n!myapp/nls/messages"], function(strings) {
console.log(strings.save); // �?存
});
});
NLS bundles don't have built-in pluralization support — but you can implement it with a naming convention or a helper function.
// Pattern 1: Separate singular/plural keys in the bundle
// nls/messages.js root:
// employeeCount: "${count} employee"
// employeeCountPlural: "${count} employees"
function formatCount(count, strings) {
var key = count === 1 ? "employeeCount" : "employeeCountPlural";
return strings[key].replace("${count}", count);
}
console.log(formatCount(1, strings)); // "1 employee"
console.log(formatCount(5, strings)); // "5 employees"
// Pattern 2: Inline conditional (works for simple cases)
function employeeLabel(count) {
return count + " employee" + (count !== 1 ? "s" : "");
// Not i18n-safe but useful for English-only apps
}
// Pattern 3: ICU MessageFormat (for apps needing full pluralization)
// Dojo doesn't have native ICU support — use a separate library or
// store format strings as: "{count, plural, one {# employee} other {# employees}}"
// and parse with a minimal ICU formatter
dojo/date/locale — Date Formattingrequire(["dojo/date/locale"], function(locale) {
var d = new Date(2026, 5, 11); // June 11 2026
// ── Format (Date → String) ─────────────────────────────────────
// datePattern options: "short","medium","long","full"
locale.format(d, { datePattern: "short" }); // en-us: "6/11/26"
locale.format(d, { datePattern: "medium" }); // en-us: "Jun 11, 2026"
locale.format(d, { datePattern: "long" }); // en-us: "June 11, 2026"
locale.format(d, { datePattern: "full" }); // en-us: "Thursday, June 11, 2026"
// Custom pattern
locale.format(d, { datePattern: "yyyy-MM-dd" }); // "2026-06-11"
locale.format(d, { datePattern: "dd/MM/yyyy" }); // "11/06/2026"
// Time formatting
var dt = new Date();
locale.format(dt, { timePattern: "HH:mm:ss" }); // "14:35:22"
locale.format(dt, { timePattern: "h:mm a" }); // "2:35 PM"
// Both date and time
locale.format(dt, { datePattern: "medium", timePattern: "short" });
// Force a specific locale (not the app default)
locale.format(d, { datePattern: "long", locale: "ja" }); // "2026年6月11日"
locale.format(d, { datePattern: "long", locale: "de" }); // "11. Juni 2026"
// ── Parse (String → Date) ──────────────────────────────────────
var parsed = locale.parse("Jun 11, 2026", { datePattern: "medium" });
console.log(parsed instanceof Date); // true
// ── Relative time (no built-in, but easy to build) ─────────────
function timeAgo(date) {
var diff = Date.now() - date.getTime();
var mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return mins + " minute" + (mins > 1 ? "s" : "") + " ago";
var hrs = Math.floor(mins / 60);
if (hrs < 24) return hrs + " hour" + (hrs > 1 ? "s" : "") + " ago";
return locale.format(date, { datePattern: "medium" });
}
});
dojo/number — Number Formattingrequire(["dojo/number"], function(number) {
// ── Format ─────────────────────────────────────────────────────
number.format(1234567.89); // "1,234,567.89" (en-us)
number.format(1234567.89, { places: 0 }); // "1,234,568" (rounded)
number.format(1234567.89, { places: 2 }); // "1,234,567.89"
number.format(0.1234, { type: "percent" }); // "12%"
number.format(0.1234, { type: "percent", places: 1 }); // "12.3%"
number.format(1234.56, { type: "currency", currency: "USD" }); // "$1,234.56"
number.format(1234.56, { type: "currency", currency: "EUR", locale: "de" }); // "1.234,56 €"
// ── Parse (String → Number) ────────────────────────────────────
var n = number.parse("1,234.56"); // 1234.56
var p = number.parse("12%", { type: "percent" }); // 0.12
// ── Round (locale-aware) ───────────────────────────────────────
number.round(1234.5678, 2); // 1234.57
// ── In a widget — live format on blur ─────────────────────────
var salaryField = new ValidationTextBox({
name: "salary",
label: "Salary",
// Override _setValueAttr to format on display
_setDisplayedValueAttr: function(v) {
this.inherited(arguments, [v ? number.format(+v, { places: 0 }) : v]);
}
});
});
All Dijit widgets ship with correct ARIA roles and states. They update automatically as state changes — you get this for free. Understanding what Dijit does helps you avoid breaking it and add correct ARIA to your custom widgets.
| Dijit Widget | ARIA Role | Dynamic States Set Automatically |
|---|---|---|
dijit/form/Button | button | aria-disabled |
dijit/form/CheckBox | checkbox | aria-checked |
dijit/form/Select | combobox | aria-expanded, aria-activedescendant |
dijit/form/ValidationTextBox | textbox | aria-invalid, aria-required |
dijit/Dialog | dialog | aria-labelledby, focus trap on open |
dijit/Menu | menu | aria-expanded, aria-haspopup |
dijit/layout/TabContainer | tablist + tab + tabpanel | aria-selected |
dijit/Tree | tree + treeitem | aria-expanded, aria-selected |
dijit/ProgressBar | progressbar | aria-valuenow, aria-valuemin/max |
// Custom widget — add ARIA manually
var StatusPanel = declare([_Widget, _TemplatedMixin], {
templateString:
'<div role="region" ' +
' aria-label="${i18n.statusPanelLabel}" ' +
' aria-live="polite">' + // screen reader reads changes
' <p data-dojo-attach-point="messageNode"></p>' +
'</div>',
_setMessageAttr: function(msg) {
this._set("message", msg);
if (this.messageNode) {
this.messageNode.textContent = msg;
// aria-live="polite" means screen reader will announce the change
}
}
});
// For widgets with loading states:
postCreate: function() {
this.inherited(arguments);
this.domNode.setAttribute("aria-busy", "false");
},
_setLoading: function(loading) {
domAttr.set(this.domNode, "aria-busy", loading ? "true" : "false");
domClass.toggle(this.domNode, "is-loading", loading);
}
dijit/focus — Focus Managementrequire(["dijit/focus", "dijit/registry"], function(focus, registry) {
// ── Get the currently focused widget ──────────────────────────
var currentWidget = focus.curNode && registry.getEnclosingWidget(focus.curNode);
console.log("Focused widget:", currentWidget && currentWidget.declaredClass);
// ── Focus a widget programmatically ───────────────────────────
var myInput = dijit.byId("nameInput");
myInput.focus();
// ── Focus the first invalid field in a form ────────────────────
function focusFirstInvalid(form) {
var children = form.getDescendants ? form.getDescendants() : [];
for (var i = 0; i < children.length; i++) {
if (children[i].isValid && !children[i].isValid()) {
children[i].focus();
return;
}
}
}
// ── Watch focus changes ────────────────────────────────────────
var handle = focus.watch("curNode", function(name, oldNode, newNode) {
var oldWidget = oldNode && registry.getEnclosingWidget(oldNode);
var newWidget = newNode && registry.getEnclosingWidget(newNode);
console.log("Focus moved from", oldWidget && oldWidget.id,
"to", newWidget && newWidget.id);
});
handle.unwatch(); // stop watching
// ── Dialog focus trap ──────────────────────────────────────────
// Dijit Dialog automatically traps focus inside while open
// When closed, focus returns to the element that opened it
var openingNode = document.activeElement; // save before opening
var dlg = new Dialog({ ... });
dlg.show();
// On close, Dijit returns focus to openingNode automatically
// ── Custom focus trap (for non-Dijit overlays) ─────────────────
require(["dijit/focus"], function(focus) {
var savedFocus = focus.curNode;
// Show custom overlay...
// On close: restore focus
focus.focus(savedFocus);
});
});
All Dijit widgets support keyboard navigation out of the box. Here's what each widget provides and how to extend it in custom widgets.
| Widget | Keyboard Behaviour |
|---|---|
dijit/form/Button | Enter / Space = click |
dijit/form/Select | ↑↓ = navigate options; Enter = select; Esc = close |
dijit/layout/TabContainer | �?→ = switch tabs; Tab = enter panel |
dijit/Dialog | Esc = close; Tab trapped inside dialog |
dijit/Menu | ↑↓ = navigate; Enter/Space = activate; Esc = close; Tab = close |
dijit/Tree | ↑↓ = navigate; →�? = expand/collapse; Enter = activate |
// Adding keyboard support to a custom widget
require(["dojo/keys", "dojo/on"], function(keys, on) {
var MyList = declare([_Widget], {
postCreate: function() {
this.inherited(arguments);
// Make the container focusable
this.domNode.setAttribute("tabIndex", "0");
this.domNode.setAttribute("role", "listbox");
this.own(
on(this.domNode, "keydown", lang.hitch(this, "_onKeyDown"))
);
},
_onKeyDown: function(e) {
switch (e.keyCode) {
case keys.DOWN_ARROW:
e.preventDefault();
this._moveSelection(1);
break;
case keys.UP_ARROW:
e.preventDefault();
this._moveSelection(-1);
break;
case keys.ENTER:
case keys.SPACE:
e.preventDefault();
this._activateSelected();
break;
case keys.HOME:
e.preventDefault();
this._selectIndex(0);
break;
case keys.END:
e.preventDefault();
this._selectIndex(this._items.length - 1);
break;
case keys.ESCAPE:
this.emit("cancel");
break;
}
}
});
});
// Control tab order explicitly when default DOM order isn't right
// tabIndex="0" = participates in natural tab order
// tabIndex="-1" = focusable programmatically but NOT in tab flow
// tabIndex="n" (positive) = explicit order — avoid, hard to maintain
// For a toolbar: only the active button is in tab flow; others are -1
// User navigates within toolbar with arrow keys
// Tab moves to next section entirely
toolbarButtons.forEach(function(btn, i) {
btn.domNode.setAttribute("tabIndex", i === activeIndex ? "0" : "-1");
});
Every <img> in templates has alt="...". Decorative images use alt="".
All Dijit form widgets have a label property — always set it. Use aria-label when visible label isn't possible.
Check your custom theme overrides with a contrast checker. Dijit's default claro theme meets this requirement.
Dijit widgets are keyboard-accessible by default. Custom widgets must implement keydown handlers (Section 8.9).
Avoid positive tabIndex values. Rely on DOM order. Layout containers (BorderContainer) preserve DOM order.
Never add outline: none to focusable elements without a visible replacement. Dijit shows focus rings by default.
Notifications, loading indicators, and validation messages must use aria-live="polite" or role="alert" so screen readers announce them.
// Accessible notification widget
var Notification = declare([_Widget], {
templateString:
'<div role="status" aria-live="polite" aria-atomic="true"' +
' class="notification" data-dojo-attach-point="containerNode"></div>',
show: function(message, type) {
// type: "success" | "error" | "info"
if (type === "error") {
this.containerNode.setAttribute("role", "alert"); // immediate announcement
} else {
this.containerNode.setAttribute("role", "status"); // polite announcement
}
this.containerNode.textContent = message;
domClass.add(this.containerNode, "notification--" + type);
setTimeout(lang.hitch(this, function() {
this.containerNode.textContent = "";
domClass.remove(this.containerNode, "notification--" + type);
}), 5000);
}
});
// Audit your Dijit app from the console
// 1. Check all widgets have meaningful labels
dijit.registry.forEach(function(widget) {
var label = widget.get ? widget.get("label") : null;
var ariaLabel = widget.domNode.getAttribute("aria-label");
var ariaLabelledby = widget.domNode.getAttribute("aria-labelledby");
if (!label && !ariaLabel && !ariaLabelledby) {
console.warn("Widget missing label:", widget.declaredClass, widget.id);
}
});
// 2. Check for images without alt text
document.querySelectorAll("img:not([alt])").forEach(function(img) {
console.warn("Image missing alt:", img.src);
});
// 3. Check heading hierarchy
var prevLevel = 0;
document.querySelectorAll("h1,h2,h3,h4,h5,h6").forEach(function(h) {
var level = +h.tagName[1];
if (level > prevLevel + 1) {
console.warn("Heading level skip:", h.tagName, h.textContent);
}
prevLevel = level;
});
Take the CRUD dashboard from Phase 4 and:
dojo/date/locale to format hire dates appropriately per localedojo/number to format salary numbers locale-aware// Step 1: Create bundle files
// myapp/nls/messages.js, myapp/nls/fr/messages.js, myapp/nls/ja/messages.js
// Step 2: Add locale switcher to header
var LocaleSwitcher = declare([_Widget], {
postCreate: function() {
var select = new Select({
options: [
{ label: "English", value: "en-us" },
{ label: "Français", value: "fr" },
{ label: "日本語", value: "ja" }
],
value: dojoConfig.locale || "en-us",
onChange: function(locale) {
// Reload page with locale in URL or cookie
document.cookie = "locale=" + locale + "; path=/";
location.reload();
}
}).placeAt(this.domNode);
select.startup();
}
});
// Step 3: Read locale from cookie at boot
var dojoConfig = {
locale: (document.cookie.match(/locale=([^;]+)/) || [])[1] || "en-us",
async: true,
parseOnLoad: false
};
// Step 4: Format dates and numbers in grid column renderers
require(["dojo/date/locale", "dojo/number",
"dojo/i18n!myapp/nls/messages"],
function(dateLocale, number, strings) {
var grid = new OnDemandGrid({
columns: [
{ field: "name", label: strings.name },
{ field: "dept", label: strings.department },
{ field: "salary", label: strings.salary,
formatter: function(v) { return number.format(v, { type: "currency", currency: "USD" }); }
},
{ field: "hireDate", label: strings.hireDate,
formatter: function(v) {
return v ? dateLocale.format(new Date(v), { datePattern: "medium" }) : "—";
}
}
]
});
});
| Concept | Key Rule |
|---|---|
| NLS bundle structure | nls/messages.js root + nls/fr/messages.js overrides. Only override keys that differ. |
| Root bundle required keys | Declare all supported locales as fr: true in root. |
| Set locale | dojoConfig.locale before dojo.js loads. |
| String substitution | Use lang.replace(str, { key: val }) for ${key} placeholders. |
dojo/date/locale.format() | Pass datePattern: "medium" — never format dates with raw JS. |
dojo/number.format() | Use for currency and percentages — respects locale decimal/grouping separators. |
| ARIA in Dijit | Built-in for all standard widgets. Add role, aria-label, aria-live to custom widgets. |
| Focus management | Dialogs trap focus automatically. Use dijit/focus to save/restore focus manually. |
| Keyboard support | Use dojo/keys constants — never hardcode key codes. |
| Tab order | Rely on DOM order. Only use tabIndex="-1" for programmatic focus. |
Time: 5–6 hrs
Pages: 14–18
Exercises: 1
WCAG items: 7