PHASE 8

i18n & Accessibility

NLS bundles, locale-aware formatting, ARIA roles, focus management, keyboard navigation, WCAG 2.1 AA

dojo/i18n NLS Bundles ARIA Focus WCAG 2.1 Keyboard Nav
Why this phase matters: Enterprise apps are often required to pass accessibility audits (Section 508, WCAG 2.1 AA) and support multiple locales. Dijit has built-in ARIA support — but only if you use it correctly. This phase shows you what's automatic and what you must add manually.
1

dojo/i18n — NLS Bundle Structure

NLS (National Language Support) bundles are AMD modules placed in a specific directory structure. The loader automatically selects the correct locale at runtime.

myapp/ └── nls/ �? all bundles live here ├── messages.js �? root bundle (English baseline + locale flags) ├── fr/ │ └── messages.js �? French overrides ├── ja/ │ └── messages.js �? Japanese overrides ├── de/ │ └── messages.js �? German overrides └── fr-ca/ └── messages.js �? Canadian French (more specific than fr/)
// 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
});
2

Root Bundle + Locale Overrides

// 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:      "読�?�込�?�中..."
});

Using Bundles in Widgets

// 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 });
}
en-us

Date: 6/11/2026
Number: 1,234.56
Currency: $1,234.56

fr

Date: 11/06/2026
Number: 1 234,56
Currency: 1 234,56 €

ja

Date: 2026/6/11
Number: 1,234.56
Currency: ¥1,235

de

Date: 11.06.2026
Number: 1.234,56
Currency: 1.234,56 €

3

Forcing Locale in Dev & Test

// 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);   // �?存
  });
});
4

Pluralization Patterns

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
5

dojo/date/locale — Date Formatting

require(["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" });
  }
});
6

dojo/number — Number Formatting

require(["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]);
    }
  });
});
7

Dijit ARIA Roles & States

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 WidgetARIA RoleDynamic States Set Automatically
dijit/form/Buttonbuttonaria-disabled
dijit/form/CheckBoxcheckboxaria-checked
dijit/form/Selectcomboboxaria-expanded, aria-activedescendant
dijit/form/ValidationTextBoxtextboxaria-invalid, aria-required
dijit/Dialogdialogaria-labelledby, focus trap on open
dijit/Menumenuaria-expanded, aria-haspopup
dijit/layout/TabContainertablist + tab + tabpanelaria-selected
dijit/Treetree + treeitemaria-expanded, aria-selected
dijit/ProgressBarprogressbararia-valuenow, aria-valuemin/max

Adding ARIA to Custom Widgets

// 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);
}
8

dijit/focus — Focus Management

require(["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);
  });
});
9

Keyboard Navigation

All Dijit widgets support keyboard navigation out of the box. Here's what each widget provides and how to extend it in custom widgets.

WidgetKeyboard Behaviour
dijit/form/ButtonEnter / Space = click
dijit/form/Select↑↓ = navigate options; Enter = select; Esc = close
dijit/layout/TabContainer�?→ = switch tabs; Tab = enter panel
dijit/DialogEsc = 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;
      }
    }
  });
});

Tab Order Management

// 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");
});
10

WCAG 2.1 AA Checklist for Dijit Apps

1.1.1 — Images have alt text

Every <img> in templates has alt="...". Decorative images use alt="".

1.3.1 — Form inputs have labels

All Dijit form widgets have a label property — always set it. Use aria-label when visible label isn't possible.

1.4.3 — Colour contrast ratio ≥ 4.5:1

Check your custom theme overrides with a contrast checker. Dijit's default claro theme meets this requirement.

2.1.1 — All functionality via keyboard

Dijit widgets are keyboard-accessible by default. Custom widgets must implement keydown handlers (Section 8.9).

2.4.3 — Focus order is logical

Avoid positive tabIndex values. Rely on DOM order. Layout containers (BorderContainer) preserve DOM order.

2.4.7 — Focus is always visible

Never add outline: none to focusable elements without a visible replacement. Dijit shows focus rings by default.

⚠�?
4.1.3 — Status messages have ARIA live regions

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;
});

🔧 Exercise 8.11 — Add i18n (EN/FR/JA) to Phase 4 Dashboard

Take the CRUD dashboard from Phase 4 and:

  • Create NLS bundles for EN, FR, and JA with all UI strings
  • Replace all hardcoded strings in widget templates with bundle references
  • Add a locale switcher dropdown that reloads the page with the selected locale
  • Use dojo/date/locale to format hire dates appropriately per locale
  • Use dojo/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" }) : "—";
        }
      }
    ]
  });
});

Phase 8 — Quick Reference

ConceptKey Rule
NLS bundle structurenls/messages.js root + nls/fr/messages.js overrides. Only override keys that differ.
Root bundle required keysDeclare all supported locales as fr: true in root.
Set localedojoConfig.locale before dojo.js loads.
String substitutionUse 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 DijitBuilt-in for all standard widgets. Add role, aria-label, aria-live to custom widgets.
Focus managementDialogs trap focus automatically. Use dijit/focus to save/restore focus manually.
Keyboard supportUse dojo/keys constants — never hardcode key codes.
Tab orderRely on DOM order. Only use tabIndex="-1" for programmatic focus.

Ready for Phase 9?

i18n and accessibility covered. Next: the Dojo build system — bundling all your modules into optimized layers for production.

Continue to Phase 9: Build System & Optimization →