Accessibility and i18n — ARIA Patterns, Keyboard Navigation, and Localisation

Accessibility and internationalisation are not optional enhancements — they are the difference between an application that works for everyone and one that excludes significant portions of potential users. Accessibility (a11y) ensures that users with disabilities (screen readers, keyboard-only navigation, motor impairments, colour blindness) can use the application effectively. Internationalisation (i18n) enables the application to serve users in different languages and locales. Both are significantly harder to retrofit than to build in from the start — this lesson establishes the accessibility foundation and the i18n architecture for the Task Manager.

Accessibility Checklist for Task Manager

Component Requirement Implementation
Task list Screen reader announces task count and status aria-label="25 tasks, 3 overdue" on list container
Priority badges Colour alone cannot convey information Text label + colour: “High” not just a red dot
Task form Errors must be announced to screen readers aria-describedby linking input to error message
Modal/dialog Focus must be trapped inside modal Angular CDK FocusTrap
Notifications panel New notification must be announced aria-live="polite" on notification container
Loading states Screen readers must know content is loading aria-busy="true" + aria-label="Loading tasks"
Icon buttons Icon-only buttons need accessible names aria-label="Delete task" on icon buttons
Note: Angular’s @angular/localize package provides build-time i18n — messages are extracted into XLIFF files, translated by translators, and the application is built once per locale. For a SaaS application where the user’s locale should switch dynamically (without a page reload), use ngx-translate or transloco instead — they load translations at runtime and switch locale reactively. The choice between compile-time (faster, better SEO) and runtime (flexible, no rebuild per locale) depends on your use case.
Tip: Run Lighthouse accessibility audit (npx lighthouse https://staging.taskmanager.io --only-categories=accessibility --output=json) in CI after every deployment. Target a score of 90+. Lighthouse catches the most common accessibility issues automatically: missing alt text, insufficient colour contrast, missing form labels, and keyboard navigation failures. Add a GitHub Actions step that fails the build if the accessibility score drops below 90 — this prevents gradual accessibility decay as features are added.
Warning: Do not use aria-hidden="true" on interactive elements — it removes them from the accessibility tree, making them invisible to screen readers without making them non-interactive. A user navigating by keyboard can still reach the element and activate it, but will hear nothing. Use disabled attribute or conditional rendering (@if) to truly remove interactive elements, not aria-hidden. Reserve aria-hidden for purely decorative elements that add no informational value (icons next to text labels).

Accessibility and i18n Implementation

// ── Accessible task form with aria attributes ─────────────────────────────
@Component({
    template: `
        <form [formGroup]="form" (ngSubmit)="onSubmit()"
              aria-label="Create task form">

            <div class="form-field">
                <label for="task-title" id="title-label">
                    Title <span aria-hidden="true">*</span>
                    <span class="sr-only">required</span>
                </label>

                <input
                    id="task-title"
                    formControlName="title"
                    type="text"
                    [attr.aria-describedby]="titleCtrl.invalid && titleCtrl.touched ? 'title-error' : null"
                    [attr.aria-invalid]="titleCtrl.invalid && titleCtrl.touched"
                    autocomplete="off">

                @if (titleCtrl.invalid && titleCtrl.touched) {
                    <span id="title-error" role="alert" class="field-error">
                        {{ titleCtrl.errors?.['required'] ? 'Title is required' : 'Title is too long' }}
                    </span>
                }
            </div>

            <!-- Loading button state -->
            <button type="submit"
                    [disabled]="form.invalid || saving()"
                    [attr.aria-busy]="saving()"
                    [attr.aria-label]="saving() ? 'Saving task, please wait' : (task ? 'Update task' : 'Create task')">
                {{ saving() ? 'Saving...' : (task ? 'Update Task' : 'Create Task') }}
            </button>
        </form>
    `,
})
export class TaskFormComponent { /* ... */ }

// ── Task list with ARIA live region for updates ─────────────────────────
@Component({
    template: `
        <!-- Screen reader announcement for list state -->
        <div class="sr-only" aria-live="polite" aria-atomic="true">
            @if (store.isLoading()) { Loading tasks... }
            @else if (store.isLoaded()) {
                {{ store.tasks().length }} tasks loaded.
                {{ store.overdueTasks().length > 0 ? store.overdueTasks().length + ' overdue.' : '' }}
            }
        </div>

        <!-- Task list -->
        <ul role="list" [attr.aria-busy]="store.isLoading()"
            [attr.aria-label]="'Tasks list, ' + store.tasks().length + ' items'">

            @for (task of store.tasks(); track task._id) {
                <li role="listitem">
                    <tm-task-card [task]="task"></tm-task-card>
                </li>
            }
        </ul>
    `,
})
export class TaskListComponent { /* ... */ }

// ── i18n with @angular/localize ────────────────────────────────────────────
// Marking strings for extraction:
// In templates:
// <p i18n="@@task.count">{{ count }} tasks</p>
// <button i18n="@@task.create|Create task action">Create Task</button>

// In TypeScript (using $localize):
const message = $localize`:@@notification.assigned:${assignerName} assigned you a task`;

// package.json scripts:
// "i18n:extract": "ng extract-i18n --output-path src/locale",
// "build:fr": "ng build --configuration production,fr",
// "build:all": "npm run build && npm run build:fr"

// ── Runtime i18n with ngx-translate ──────────────────────────────────────
// Alternative for dynamic locale switching without rebuild:
// import { TranslateModule, TranslateService } from '@ngx-translate/core';
//
// app.config.ts:
// provideTranslateService({ loader: { provide: TranslateLoader, useFactory: createTranslateLoader, deps: [HttpClient] } })
//
// Usage:
// template: {{ 'TASKS.CREATE' | translate }}
// ts: this.translate.instant('TASKS.CREATED', { title: task.title })
//
// en.json: { "TASKS": { "CREATE": "Create Task", "CREATED": "{{title}} created successfully" } }
// fr.json: { "TASKS": { "CREATE": "Créer une tâche", "CREATED": "{{title}} créé avec succès" } }

How It Works

The aria-describedby attribute creates a programmatic association between an input and its error message. When a screen reader user focuses the title input and the input is invalid, the screen reader reads the error message automatically — “Title, required, Title is required”. Without aria-describedby, the screen reader only reads the input’s label, missing the error. The attribute is only set when the error is visible (invalid + touched) — avoiding empty descriptions.

Step 2 — aria-live Announces Dynamic Content Changes

Screen readers do not announce DOM changes unless they are in an aria-live region. Setting aria-live="polite" on the status announcement div means when the task list loads (the text changes from “Loading…” to “25 tasks loaded”), the screen reader announces the change after the current announcement finishes. aria-atomic="true" ensures the entire announcement is read as a unit rather than just the changed portion.

Step 3 — Screen-Reader-Only Class Provides Context Without Visual Clutter

The sr-only CSS class (position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0)) makes text visible to screen readers but invisible on screen. Using it for the required field notice (“required” next to the asterisk), the list state announcement, and keyboard shortcut hints provides rich screen reader context without cluttering the visual UI.

Step 4 — Build-Time i18n Produces One Bundle Per Locale

Angular’s ng extract-i18n scans templates and TypeScript for i18n attributes and $localize calls, producing an XLIFF translation file with all extractable strings. Translators fill in the translations. ng build --configuration fr incorporates the French translations at build time — producing a separate dist/fr/ bundle with all strings already translated. nginx serves the correct bundle based on the Accept-Language header or a URL prefix (/fr/).

Step 5 — Runtime i18n Enables Dynamic Language Switching

For a multi-tenant SaaS where different users in the same browser session may prefer different languages, ngx-translate or transloco loads translation JSON files at runtime and switches locale without a page reload. The trade-off versus build-time i18n: slightly larger initial bundle (includes the translation engine), runtime JSON fetching for each locale, but no rebuild required to add a new language or update a translation string.

Quick Reference

Pattern Code
Link input to error [attr.aria-describedby]="isInvalid ? 'error-id' : null"
Mark invalid [attr.aria-invalid]="ctrl.invalid && ctrl.touched"
Loading state [attr.aria-busy]="isLoading()"
Live region <div aria-live="polite" aria-atomic="true">
Screen reader only class="sr-only" (visually hidden CSS)
Icon button label aria-label="Delete task" aria-hidden on the icon
Extract i18n strings ng extract-i18n --output-path src/locale
Build with locale ng build --configuration production,fr
Mark template string <span i18n="@@key">English text</span>
Mark TS string $localize\`:@@key:English text\`

🧠 Test Yourself

A priority badge displays a red dot with no text to indicate “High” priority. A colour-blind user cannot distinguish it from a “Low” priority green dot. What is the accessibility fix?