Skip to main content

Track P: Accessibility & Internationalization Evidence

Project: BIO-QMS Platform (Regulated SaaS QMS for Biotech/Pharma) Compliance Requirements: FDA 21 CFR Part 11, HIPAA, SOC 2, WCAG 2.1 AA Tech Stack: NestJS + Prisma + PostgreSQL, React + Vite, GCP Deployment Regions: NA (us-central1), EU (europe-west1), APAC (asia-southeast1)

Document Purpose: Production-quality implementation guide for frontend and platform teams implementing accessibility and internationalization features across a global, multi-tenant, regulated QMS platform.


Table of Contents


P.1: WCAG 2.1 AA Compliance

Overview: Implement Web Content Accessibility Guidelines (WCAG) 2.1 Level AA compliance across all user-facing interfaces to ensure the BIO-QMS platform is accessible to users with disabilities, including those using assistive technologies.

Regulatory Context: While WCAG compliance is not explicitly required by FDA 21 CFR Part 11, it is a best practice for enterprise SaaS platforms and may be required by customer procurement policies (especially in public sector and large enterprises). Section 508 compliance (U.S. federal contracts) maps to WCAG 2.0 Level AA.

Success Metrics:

  • 100% of pages pass axe-core automated scans with zero critical violations
  • Manual keyboard navigation audit: 100% of interactive elements accessible
  • Screen reader testing: All workflows completable with NVDA/JAWS/VoiceOver
  • Color contrast ratios: 4.5:1 (normal text), 3:1 (large text, UI components)

P.1.1: Semantic HTML and ARIA Implementation

WCAG Success Criteria Addressed:

  • 1.3.1 Info and Relationships (Level A): Information, structure, and relationships conveyed through presentation can be programmatically determined
  • 4.1.2 Name, Role, Value (Level A): Name and role can be programmatically determined for all UI components

Implementation Strategy:

Landmark Roles and Document Structure

Use HTML5 semantic elements with implicit ARIA roles. Only add explicit ARIA roles when semantic HTML is insufficient.

// ✅ CORRECT: Semantic HTML with implicit landmark roles
// src/components/layout/AppLayout.tsx

import React from 'react';
import { Outlet } from 'react-router-dom';

export const AppLayout: React.FC = () => {
return (
<div className="app-container">
{/* <header> implicitly has role="banner" */}
<header className="app-header">
<div className="logo-container">
<img src="/logo.svg" alt="BIO-QMS Quality Management System" />
</div>

{/* <nav> implicitly has role="navigation" */}
<nav aria-label="Primary navigation">
<ul>
<li><a href="/work-orders">Work Orders</a></li>
<li><a href="/assets">Assets</a></li>
<li><a href="/compliance">Compliance</a></li>
</ul>
</nav>

<div className="user-menu">
{/* ARIA label for context */}
<button aria-label="User menu" aria-haspopup="true" aria-expanded={false}>
<UserIcon />
</button>
</div>
</header>

{/* <main> implicitly has role="main" */}
<main className="app-main">
<Outlet />
</main>

{/* <aside> implicitly has role="complementary" */}
<aside className="app-sidebar" aria-label="Notifications and quick actions">
<NotificationPanel />
</aside>

{/* <footer> implicitly has role="contentinfo" */}
<footer className="app-footer">
<p>&copy; 2026 BIO-QMS. FDA 21 CFR Part 11 Validated System.</p>
</footer>
</div>
);
};
// ❌ INCORRECT: Over-reliance on divs with explicit ARIA roles
// (Only use when semantic HTML is truly unavailable)

<div role="banner"> {/* Should be <header> */}
<div role="navigation"> {/* Should be <nav> */}
...
</div>
</div>

ARIA Widget Patterns

Implement complex interactive components using established ARIA design patterns from the ARIA Authoring Practices Guide (APG).

Example 1: Data Table with Sorting

// src/components/tables/WorkOrderTable.tsx

import React, { useState } from 'react';

interface Column {
key: string;
label: string;
sortable: boolean;
}

interface WorkOrder {
id: string;
title: string;
status: string;
priority: string;
dueDate: string;
}

export const WorkOrderTable: React.FC<{ data: WorkOrder[] }> = ({ data }) => {
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');

const columns: Column[] = [
{ key: 'id', label: 'WO ID', sortable: true },
{ key: 'title', label: 'Title', sortable: true },
{ key: 'status', label: 'Status', sortable: true },
{ key: 'priority', label: 'Priority', sortable: true },
{ key: 'dueDate', label: 'Due Date', sortable: true },
];

const handleSort = (columnKey: string) => {
if (sortColumn === columnKey) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(columnKey);
setSortDirection('asc');
}
};

return (
<table aria-label="Work Orders" className="data-table">
<thead>
<tr>
{columns.map((col) => (
<th
key={col.key}
scope="col"
aria-sort={
sortColumn === col.key
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: undefined
}
>
{col.sortable ? (
<button
onClick={() => handleSort(col.key)}
className="sort-button"
aria-label={`Sort by ${col.label}`}
>
{col.label}
{sortColumn === col.key && (
<span aria-hidden="true">
{sortDirection === 'asc' ? ' ▲' : ' ▼'}
</span>
)}
</button>
) : (
col.label
)}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((wo) => (
<tr key={wo.id}>
<td>
<a href={`/work-orders/${wo.id}`}>{wo.id}</a>
</td>
<td>{wo.title}</td>
<td>
<span className={`status-badge status-${wo.status.toLowerCase()}`}>
{wo.status}
</span>
</td>
<td>{wo.priority}</td>
<td>{new Date(wo.dueDate).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
);
};

Example 2: Combobox (Autocomplete Select)

// src/components/forms/AssetSelector.tsx

import React, { useState, useRef, useEffect } from 'react';

interface Asset {
id: string;
name: string;
assetTag: string;
}

interface AssetSelectorProps {
assets: Asset[];
onSelect: (asset: Asset | null) => void;
label: string;
}

export const AssetSelector: React.FC<AssetSelectorProps> = ({
assets,
onSelect,
label,
}) => {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const [selectedAsset, setSelectedAsset] = useState<Asset | null>(null);

const inputRef = useRef<HTMLInputElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);

const filteredAssets = assets.filter(
(asset) =>
asset.name.toLowerCase().includes(query.toLowerCase()) ||
asset.assetTag.toLowerCase().includes(query.toLowerCase())
);

const handleSelect = (asset: Asset) => {
setSelectedAsset(asset);
setQuery(asset.name);
setIsOpen(false);
onSelect(asset);
};

const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setIsOpen(true);
setActiveIndex((prev) =>
prev < filteredAssets.length - 1 ? prev + 1 : prev
);
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex((prev) => (prev > 0 ? prev - 1 : 0));
break;
case 'Enter':
e.preventDefault();
if (activeIndex >= 0 && filteredAssets[activeIndex]) {
handleSelect(filteredAssets[activeIndex]);
}
break;
case 'Escape':
setIsOpen(false);
setActiveIndex(-1);
break;
}
};

// Scroll active option into view
useEffect(() => {
if (activeIndex >= 0 && listboxRef.current) {
const activeOption = listboxRef.current.children[activeIndex] as HTMLElement;
activeOption?.scrollIntoView({ block: 'nearest' });
}
}, [activeIndex]);

return (
<div className="combobox-container">
<label id="asset-selector-label" htmlFor="asset-selector-input">
{label}
</label>

<div className="combobox-wrapper">
<input
id="asset-selector-input"
ref={inputRef}
type="text"
role="combobox"
aria-autocomplete="list"
aria-expanded={isOpen}
aria-controls="asset-selector-listbox"
aria-activedescendant={
activeIndex >= 0
? `asset-option-${filteredAssets[activeIndex]?.id}`
: undefined
}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
setActiveIndex(-1);
}}
onFocus={() => setIsOpen(true)}
onBlur={() => setTimeout(() => setIsOpen(false), 200)}
onKeyDown={handleKeyDown}
/>

{isOpen && filteredAssets.length > 0 && (
<ul
id="asset-selector-listbox"
ref={listboxRef}
role="listbox"
aria-label={`${label} options`}
className="combobox-listbox"
>
{filteredAssets.map((asset, index) => (
<li
key={asset.id}
id={`asset-option-${asset.id}`}
role="option"
aria-selected={index === activeIndex}
className={index === activeIndex ? 'active' : ''}
onClick={() => handleSelect(asset)}
>
<strong>{asset.name}</strong>
<span className="asset-tag">{asset.assetTag}</span>
</li>
))}
</ul>
)}
</div>
</div>
);
};

ARIA Live Regions

Use live regions to announce dynamic content updates to screen reader users without requiring focus changes.

// src/components/notifications/StatusAnnouncer.tsx

import React, { useEffect, useState } from 'react';
import { useWorkOrderStatus } from '@/hooks/useWorkOrderStatus';

export const StatusAnnouncer: React.FC<{ workOrderId: string }> = ({
workOrderId,
}) => {
const { status, previousStatus } = useWorkOrderStatus(workOrderId);
const [announcement, setAnnouncement] = useState('');

useEffect(() => {
if (previousStatus && status !== previousStatus) {
setAnnouncement(
`Work order ${workOrderId} status changed from ${previousStatus} to ${status}`
);

// Clear announcement after 5 seconds to prevent stale messages
const timer = setTimeout(() => setAnnouncement(''), 5000);
return () => clearTimeout(timer);
}
}, [status, previousStatus, workOrderId]);

return (
<>
{/* Polite: non-urgent announcements (status changes) */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement}
</div>

{/* Assertive: urgent announcements (errors, validation) */}
{/* Use sparingly - interrupts screen reader */}
<div
role="alert"
aria-live="assertive"
aria-atomic="true"
className="sr-only"
>
{/* Error messages go here */}
</div>
</>
);
};
/* src/styles/accessibility.css */

/* Screen reader only (sr-only) utility class */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}

/* Focus-visible support (skip on mouse clicks, show on keyboard navigation) */
.sr-only-focusable:not(:focus):not(:focus-within) {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}

Form Validation with ARIA

// src/components/forms/WorkOrderForm.tsx

import React, { useState } from 'react';
import { useForm } from 'react-hook-form';

interface WorkOrderFormData {
title: string;
description: string;
priority: 'low' | 'medium' | 'high' | 'critical';
dueDate: string;
}

export const WorkOrderForm: React.FC = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<WorkOrderFormData>();

const [formError, setFormError] = useState<string | null>(null);

const onSubmit = async (data: WorkOrderFormData) => {
try {
// API call
await createWorkOrder(data);
} catch (error) {
setFormError('Failed to create work order. Please try again.');
}
};

return (
<form
onSubmit={handleSubmit(onSubmit)}
aria-labelledby="form-title"
noValidate
>
<h2 id="form-title">Create Work Order</h2>

{/* Form-level error announcement */}
{formError && (
<div role="alert" className="form-error">
{formError}
</div>
)}

{/* Title field with validation */}
<div className="form-group">
<label htmlFor="wo-title">
Title <span aria-label="required">*</span>
</label>
<input
id="wo-title"
type="text"
{...register('title', {
required: 'Title is required',
minLength: {
value: 5,
message: 'Title must be at least 5 characters',
},
})}
aria-required="true"
aria-invalid={errors.title ? 'true' : 'false'}
aria-describedby={errors.title ? 'title-error' : undefined}
/>
{errors.title && (
<span id="title-error" role="alert" className="field-error">
{errors.title.message}
</span>
)}
</div>

{/* Description field */}
<div className="form-group">
<label htmlFor="wo-description">Description</label>
<textarea
id="wo-description"
{...register('description')}
aria-describedby="description-hint"
rows={4}
/>
<span id="description-hint" className="field-hint">
Provide detailed information about the work to be performed.
</span>
</div>

{/* Priority radio group */}
<fieldset className="form-group">
<legend>
Priority <span aria-label="required">*</span>
</legend>
<div className="radio-group">
{['low', 'medium', 'high', 'critical'].map((priority) => (
<label key={priority} className="radio-label">
<input
type="radio"
value={priority}
{...register('priority', { required: 'Priority is required' })}
/>
<span>{priority.charAt(0).toUpperCase() + priority.slice(1)}</span>
</label>
))}
</div>
{errors.priority && (
<span role="alert" className="field-error">
{errors.priority.message}
</span>
)}
</fieldset>

<button type="submit" className="btn-primary">
Create Work Order
</button>
</form>
);
};

Testing Checklist for P.1.1:

  • All landmark regions present: <header>, <nav>, <main>, <aside>, <footer>
  • Headings form proper hierarchy (h1 → h2 → h3, no skipped levels)
  • Interactive elements have accessible names (visible label, aria-label, or aria-labelledby)
  • Form fields associated with labels (<label for="id"> or aria-labelledby)
  • Tables use <th scope="col|row"> for header cells
  • Lists use <ul>, <ol>, or <dl> (not just divs styled as lists)
  • ARIA widget patterns match APG specifications
  • Live regions (role="status", role="alert") used for dynamic updates
  • No ARIA attribute has invalid value (check with axe DevTools)

P.1.2: Keyboard Navigation

WCAG Success Criteria Addressed:

  • 2.1.1 Keyboard (Level A): All functionality available via keyboard
  • 2.1.2 No Keyboard Trap (Level A): Keyboard focus can be moved away from any component
  • 2.4.3 Focus Order (Level A): Focus order is logical and preserves meaning
  • 2.4.7 Focus Visible (Level AA): Keyboard focus indicator is visible

Implementation Strategy:

Focus Management

// src/hooks/useFocusTrap.ts

import { useEffect, useRef } from 'react';

/**
* Custom hook to trap focus within a modal/dialog
* WCAG 2.1.2: Ensures users can exit focus trap with Escape key
*/
export const useFocusTrap = (isActive: boolean) => {
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!isActive || !containerRef.current) return;

const container = containerRef.current;
const focusableElements = container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
);

const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];

const handleTabKey = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;

if (e.shiftKey) {
// Shift+Tab: moving backwards
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
// Tab: moving forwards
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
};

// Set initial focus to first element
firstElement?.focus();

container.addEventListener('keydown', handleTabKey);
return () => container.removeEventListener('keydown', handleTabKey);
}, [isActive]);

return containerRef;
};
// src/components/modals/ConfirmationDialog.tsx

import React from 'react';
import { useFocusTrap } from '@/hooks/useFocusTrap';

interface ConfirmationDialogProps {
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
}

export const ConfirmationDialog: React.FC<ConfirmationDialogProps> = ({
isOpen,
title,
message,
onConfirm,
onCancel,
}) => {
const dialogRef = useFocusTrap(isOpen);

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel();
}
};

if (!isOpen) return null;

return (
<>
{/* Backdrop to prevent clicks outside modal */}
<div
className="dialog-backdrop"
onClick={onCancel}
aria-hidden="true"
/>

<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
onKeyDown={handleKeyDown}
className="dialog"
>
<h2 id="dialog-title">{title}</h2>
<p id="dialog-description">{message}</p>

<div className="dialog-actions">
<button
onClick={onCancel}
className="btn-secondary"
>
Cancel
</button>
<button
onClick={onConfirm}
className="btn-danger"
autoFocus
>
Confirm
</button>
</div>
</div>
</>
);
};
// src/components/layout/SkipLinks.tsx

import React from 'react';

/**
* Skip links allow keyboard users to bypass repetitive navigation
* WCAG 2.4.1: Bypass Blocks (Level A)
*/
export const SkipLinks: React.FC = () => {
return (
<div className="skip-links">
<a href="#main-content" className="skip-link">
Skip to main content
</a>
<a href="#primary-navigation" className="skip-link">
Skip to navigation
</a>
<a href="#search" className="skip-link">
Skip to search
</a>
</div>
);
};
/* src/styles/skip-links.css */

.skip-links {
position: relative;
}

.skip-link {
position: absolute;
top: -40px;
left: 0;
z-index: 9999;
padding: 8px 16px;
background: var(--color-primary);
color: var(--color-white);
text-decoration: none;
border-radius: 4px;
transition: top 0.2s;
}

.skip-link:focus {
top: 8px;
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}

Keyboard Shortcuts

// src/hooks/useKeyboardShortcuts.ts

import { useEffect } from 'react';

interface ShortcutConfig {
key: string;
ctrl?: boolean;
shift?: boolean;
alt?: boolean;
handler: () => void;
description: string;
}

/**
* Global keyboard shortcut manager
* Must provide escape hatch (Ctrl+/) to disable shortcuts
*/
export const useKeyboardShortcuts = (
shortcuts: ShortcutConfig[],
enabled: boolean = true
) => {
useEffect(() => {
if (!enabled) return;

const handleKeyDown = (e: KeyboardEvent) => {
// Don't trigger shortcuts when typing in form fields
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return;
}

shortcuts.forEach((shortcut) => {
const ctrlMatch = shortcut.ctrl ? e.ctrlKey || e.metaKey : !e.ctrlKey && !e.metaKey;
const shiftMatch = shortcut.shift ? e.shiftKey : !e.shiftKey;
const altMatch = shortcut.alt ? e.altKey : !e.altKey;

if (
e.key.toLowerCase() === shortcut.key.toLowerCase() &&
ctrlMatch &&
shiftMatch &&
altMatch
) {
e.preventDefault();
shortcut.handler();
}
});
};

window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [shortcuts, enabled]);
};
// src/components/layout/KeyboardShortcutsHelp.tsx

import React, { useState } from 'react';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';

export const KeyboardShortcutsHelp: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);

const shortcuts = [
{ key: 'n', ctrl: true, handler: () => {/* Create new WO */}, description: 'Create new work order' },
{ key: 's', ctrl: true, handler: () => {/* Save */}, description: 'Save current work order' },
{ key: 'f', ctrl: true, handler: () => {/* Search */}, description: 'Focus search' },
{ key: '/', handler: () => setIsOpen(true), description: 'Show keyboard shortcuts' },
];

useKeyboardShortcuts(shortcuts);

return (
<>
<button
onClick={() => setIsOpen(true)}
className="shortcuts-help-button"
aria-label="Show keyboard shortcuts"
title="Keyboard shortcuts (/)"
>
?
</button>

{isOpen && (
<div role="dialog" aria-labelledby="shortcuts-title">
<h2 id="shortcuts-title">Keyboard Shortcuts</h2>
<table>
<thead>
<tr>
<th>Shortcut</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{shortcuts.map((sc, idx) => (
<tr key={idx}>
<td>
<kbd>{sc.ctrl && 'Ctrl+'}{sc.key}</kbd>
</td>
<td>{sc.description}</td>
</tr>
))}
</tbody>
</table>
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
)}
</>
);
};

Focus Indicators

/* src/styles/focus-indicators.css */

/**
* WCAG 2.4.7: Focus Visible (Level AA)
* Focus indicator must have 3:1 contrast ratio against background
*/

:root {
--focus-outline-color: #005fcc;
--focus-outline-width: 2px;
--focus-outline-offset: 2px;
}

/* Global focus styles */
*:focus {
outline: var(--focus-outline-width) solid var(--focus-outline-color);
outline-offset: var(--focus-outline-offset);
}

/* Remove outline on mouse click, keep on keyboard navigation */
*:focus:not(:focus-visible) {
outline: none;
}

*:focus-visible {
outline: var(--focus-outline-width) solid var(--focus-outline-color);
outline-offset: var(--focus-outline-offset);
}

/* High contrast focus for buttons */
button:focus-visible,
a:focus-visible {
outline: 3px solid var(--focus-outline-color);
outline-offset: 2px;
box-shadow: 0 0 0 4px rgba(0, 95, 204, 0.1);
}

/* Focus within containers (for custom dropdowns) */
.dropdown-menu:focus-within {
outline: 2px solid var(--focus-outline-color);
}

Testing Checklist for P.1.2:

  • All interactive elements reachable via Tab key
  • Tab order matches visual order (left-to-right, top-to-bottom)
  • Skip links present and functional
  • Focus trapped in modals/dialogs (with Escape to exit)
  • Focus indicators visible with 3:1 contrast ratio
  • No keyboard traps (can exit all components)
  • Keyboard shortcuts documented and accessible
  • Custom widgets support arrow keys, Enter, Escape as appropriate

P.1.3: Color Contrast and Visual Design

WCAG Success Criteria Addressed:

  • 1.4.3 Contrast (Minimum) (Level AA): 4.5:1 for normal text, 3:1 for large text
  • 1.4.11 Non-text Contrast (Level AA): 3:1 for UI components and graphical objects
  • 1.4.1 Use of Color (Level A): Color not used as the only visual means of conveying information

Implementation Strategy:

Color System with Accessible Contrast Ratios

/* src/styles/tokens/colors.css */

/**
* BIO-QMS Color System
* All combinations tested against WCAG AA standards
* Contrast checker: https://webaim.org/resources/contrastchecker/
*/

:root {
/* Primary palette */
--color-primary-900: #003366; /* Darkest - use for text on light bg */
--color-primary-700: #005fcc; /* Primary action color */
--color-primary-500: #0080ff;
--color-primary-300: #66b3ff;
--color-primary-100: #e6f2ff; /* Lightest - use for backgrounds */

/* Neutral palette */
--color-neutral-900: #1a1a1a; /* Text primary (contrast 14.5:1 on white) */
--color-neutral-700: #4d4d4d; /* Text secondary (contrast 9.7:1) */
--color-neutral-500: #808080; /* Text tertiary (contrast 4.5:1 - minimum AA) */
--color-neutral-300: #cccccc; /* Borders (contrast 2.4:1 - fails AA for text) */
--color-neutral-100: #f5f5f5; /* Backgrounds */
--color-neutral-50: #fafafa;

/* Semantic colors */
--color-success-700: #0f7d3d; /* 4.5:1 on white */
--color-success-100: #e6f5ed;

--color-warning-700: #b35f00; /* 4.6:1 on white */
--color-warning-100: #fff4e6;

--color-error-700: #c41c1c; /* 5.9:1 on white */
--color-error-100: #ffe6e6;

--color-info-700: #005299; /* 6.3:1 on white */
--color-info-100: #e6f2ff;

/* Status indicators (for work order status badges) */
/* All tested for 3:1 contrast on white background (WCAG AA for UI components) */
--status-draft-bg: #e6e6e6;
--status-draft-text: #1a1a1a;

--status-pending-bg: #fff4e6;
--status-pending-text: #663d00;

--status-approved-bg: #e6f5ed;
--status-approved-text: #0a4d23;

--status-in-progress-bg: #e6f2ff;
--status-in-progress-text: #003366;

--status-completed-bg: #e6f5ed;
--status-completed-text: #0f7d3d;

--status-rejected-bg: #ffe6e6;
--status-rejected-text: #7a0000;
}

/* High contrast mode overrides */
@media (prefers-contrast: high) {
:root {
--color-primary-700: #0047b3;
--color-neutral-500: #666666; /* Boost to 5.7:1 */
--focus-outline-width: 3px;
}
}

/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

Non-Color Indicators

// src/components/status/StatusBadge.tsx

import React from 'react';

type Status = 'draft' | 'pending' | 'approved' | 'in-progress' | 'completed' | 'rejected';

const STATUS_ICONS: Record<Status, string> = {
'draft': '○', // Circle outline
'pending': '◐', // Half-filled circle
'approved': '✓', // Checkmark
'in-progress': '↻', // Circular arrow
'completed': '✓✓', // Double checkmark
'rejected': '✗', // X mark
};

interface StatusBadgeProps {
status: Status;
}

/**
* Status badge with color + icon/text for WCAG 1.4.1
* Color is not the only means of conveying status
*/
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
return (
<span
className={`status-badge status-${status}`}
role="status"
aria-label={`Status: ${status.replace('-', ' ')}`}
>
<span aria-hidden="true" className="status-icon">
{STATUS_ICONS[status]}
</span>
<span className="status-text">
{status.charAt(0).toUpperCase() + status.slice(1).replace('-', ' ')}
</span>
</span>
);
};
/* src/styles/components/status-badge.css */

.status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
border: 1px solid transparent;
}

.status-badge .status-icon {
font-size: 1rem;
line-height: 1;
}

/* Draft */
.status-draft {
background-color: var(--status-draft-bg);
color: var(--status-draft-text);
border-color: var(--status-draft-text);
}

/* Pending */
.status-pending {
background-color: var(--status-pending-bg);
color: var(--status-pending-text);
border-color: var(--status-pending-text);
}

/* Approved */
.status-approved {
background-color: var(--status-approved-bg);
color: var(--status-approved-text);
border-color: var(--status-approved-text);
}

/* In Progress */
.status-in-progress {
background-color: var(--status-in-progress-bg);
color: var(--status-in-progress-text);
border-color: var(--status-in-progress-text);
}

/* Completed */
.status-completed {
background-color: var(--status-completed-bg);
color: var(--status-completed-text);
border-color: var(--status-completed-text);
}

/* Rejected */
.status-rejected {
background-color: var(--status-rejected-bg);
color: var(--status-rejected-text);
border-color: var(--status-rejected-text);
}

Chart Accessibility (Color-Blind Safe Palette)

// src/utils/charts/accessible-colors.ts

/**
* Color-blind safe palette for data visualization
* Based on Paul Tol's color schemes: https://personal.sron.nl/~pault/
*
* Tested with:
* - Protanopia (red-blind)
* - Deuteranopia (green-blind)
* - Tritanopia (blue-blind)
*/

export const CHART_COLORS = {
qualitative: [
'#4477AA', // Blue
'#EE6677', // Red
'#228833', // Green
'#CCBB44', // Yellow
'#66CCEE', // Cyan
'#AA3377', // Purple
'#BBBBBB', // Gray
],

sequential: {
blue: ['#f7fbff', '#deebf7', '#c6dbef', '#9ecae1', '#6baed6', '#4292c6', '#2171b5', '#08519c', '#08306b'],
green: ['#f7fcf5', '#e5f5e0', '#c7e9c0', '#a1d99b', '#74c476', '#41ab5d', '#238b45', '#006d2c', '#00441b'],
orange: ['#fff5eb', '#fee6ce', '#fdd0a2', '#fdae6b', '#fd8d3c', '#f16913', '#d94801', '#a63603', '#7f2704'],
},
};

/**
* Use patterns/textures in addition to color for maximum accessibility
*/
export const CHART_PATTERNS = [
'solid',
'dots',
'diagonal-lines',
'horizontal-lines',
'vertical-lines',
'cross-hatch',
];
// src/components/charts/AccessibleBarChart.tsx

import React from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { CHART_COLORS } from '@/utils/charts/accessible-colors';

interface DataPoint {
name: string;
value: number;
}

export const AccessibleBarChart: React.FC<{ data: DataPoint[] }> = ({ data }) => {
return (
<div role="img" aria-label="Bar chart showing work order completion by month">
<ResponsiveContainer width="100%" height={400}>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="name"
tick={{ fill: '#1a1a1a' }}
label={{ value: 'Month', position: 'insideBottom', offset: -5 }}
/>
<YAxis
tick={{ fill: '#1a1a1a' }}
label={{ value: 'Completed Work Orders', angle: -90, position: 'insideLeft' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: '1px solid #ccc',
borderRadius: '4px',
}}
/>
<Legend />
<Bar
dataKey="value"
fill={CHART_COLORS.qualitative[0]}
name="Completed Work Orders"
/>
</BarChart>
</ResponsiveContainer>

{/* Accessible data table fallback */}
<details className="chart-data-table">
<summary>View data table</summary>
<table>
<thead>
<tr>
<th>Month</th>
<th>Completed Work Orders</th>
</tr>
</thead>
<tbody>
{data.map((item) => (
<tr key={item.name}>
<td>{item.name}</td>
<td>{item.value}</td>
</tr>
))}
</tbody>
</table>
</details>
</div>
);
};

Testing Checklist for P.1.3:

  • All text meets 4.5:1 contrast (normal), 3:1 (large text ≥18pt)
  • UI components (buttons, form controls) meet 3:1 contrast
  • Color-blind simulation testing (use Chrome DevTools or Stark plugin)
  • Information not conveyed by color alone (use icons, patterns, text labels)
  • Charts include data table alternative
  • High contrast mode support tested
  • Reduced motion preference respected

P.1.4: Screen Reader Compatibility

WCAG Success Criteria Addressed:

  • 1.1.1 Non-text Content (Level A): Text alternatives for non-text content
  • 2.4.4 Link Purpose (In Context) (Level A): Purpose of each link determined from link text
  • 3.3.2 Labels or Instructions (Level A): Labels provided for user input

Implementation Strategy:

Image Alternative Text

// src/components/media/ResponsiveImage.tsx

import React from 'react';

interface ResponsiveImageProps {
src: string;
alt: string;
decorative?: boolean;
caption?: string;
}

/**
* Responsive image with proper alt text handling
* - Decorative images: alt="" (announced as "image" by SR, then skipped)
* - Informative images: descriptive alt text
* - Complex images (charts/diagrams): alt + long description via aria-describedby
*/
export const ResponsiveImage: React.FC<ResponsiveImageProps> = ({
src,
alt,
decorative = false,
caption,
}) => {
const imageId = React.useId();
const descriptionId = `${imageId}-description`;

return (
<figure>
<img
src={src}
alt={decorative ? '' : alt}
aria-describedby={caption ? descriptionId : undefined}
loading="lazy"
/>
{caption && (
<figcaption id={descriptionId}>
{caption}
</figcaption>
)}
</figure>
);
};

/**
* Example usage:
*
* // Decorative image (purely visual, no information)
* <ResponsiveImage src="/decorative-pattern.svg" alt="" decorative />
*
* // Informative image (conveys info)
* <ResponsiveImage src="/asset-photo.jpg" alt="Bioreactor vessel B-301 in cleanroom 4" />
*
* // Complex image (needs detailed description)
* <ResponsiveImage
* src="/process-flow-diagram.png"
* alt="Manufacturing process flow for batch production"
* caption="Process starts with raw material receipt, proceeds through mixing, fermentation, purification, and fill-finish stages. Critical control points marked with red diamonds."
* />
*/
// src/components/navigation/BreadcrumbNav.tsx

import React from 'react';
import { Link } from 'react-router-dom';

interface Breadcrumb {
label: string;
href: string;
}

/**
* Breadcrumb navigation with descriptive links
* Avoids "Click here" anti-pattern
*/
export const BreadcrumbNav: React.FC<{ breadcrumbs: Breadcrumb[] }> = ({
breadcrumbs,
}) => {
return (
<nav aria-label="Breadcrumb">
<ol className="breadcrumb-list">
{breadcrumbs.map((crumb, index) => (
<li key={crumb.href} className="breadcrumb-item">
{index < breadcrumbs.length - 1 ? (
<>
<Link to={crumb.href}>{crumb.label}</Link>
<span aria-hidden="true"> / </span>
</>
) : (
<span aria-current="page">{crumb.label}</span>
)}
</li>
))}
</ol>
</nav>
);
};

/**
* Example breadcrumb:
* Home / Work Orders / WO-2026-0042 / Edit
*
* ✅ Good: Each link is descriptive ("Work Orders", "WO-2026-0042")
* ❌ Bad: "Home / Click here / View / Click here"
*/
// src/components/cards/AssetCard.tsx

import React from 'react';
import { Link } from 'react-router-dom';

interface Asset {
id: string;
name: string;
assetTag: string;
status: string;
}

/**
* Asset card with descriptive link text
*/
export const AssetCard: React.FC<{ asset: Asset }> = ({ asset }) => {
return (
<article className="asset-card" aria-labelledby={`asset-${asset.id}-name`}>
<h3 id={`asset-${asset.id}-name`}>{asset.name}</h3>
<p>Asset Tag: {asset.assetTag}</p>
<p>Status: {asset.status}</p>

{/* ✅ Descriptive link text */}
<Link to={`/assets/${asset.id}`}>
View details for {asset.name}
</Link>

{/* ❌ Non-descriptive (avoid this) */}
{/* <Link to={`/assets/${asset.id}`}>Click here</Link> */}
</article>
);
};

Form Instructions and Error Messages

// src/components/forms/PasswordField.tsx

import React, { useState } from 'react';

export const PasswordField: React.FC = () => {
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<string[]>([]);

const validatePassword = (value: string) => {
const newErrors: string[] = [];
if (value.length < 12) newErrors.push('Password must be at least 12 characters');
if (!/[A-Z]/.test(value)) newErrors.push('Password must contain an uppercase letter');
if (!/[a-z]/.test(value)) newErrors.push('Password must contain a lowercase letter');
if (!/[0-9]/.test(value)) newErrors.push('Password must contain a number');
if (!/[^A-Za-z0-9]/.test(value)) newErrors.push('Password must contain a special character');
setErrors(newErrors);
};

return (
<div className="form-group">
<label htmlFor="password">
Password <span aria-label="required">*</span>
</label>

{/* Instructions linked via aria-describedby */}
<div id="password-requirements" className="field-hint">
<p>Password must meet the following requirements:</p>
<ul>
<li>At least 12 characters</li>
<li>One uppercase letter</li>
<li>One lowercase letter</li>
<li>One number</li>
<li>One special character</li>
</ul>
</div>

<input
type="password"
id="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
validatePassword(e.target.value);
}}
aria-required="true"
aria-invalid={errors.length > 0}
aria-describedby="password-requirements password-errors"
/>

{/* Error messages announced to screen readers */}
{errors.length > 0 && (
<div id="password-errors" role="alert" className="field-error">
<ul>
{errors.map((error, idx) => (
<li key={idx}>{error}</li>
))}
</ul>
</div>
)}
</div>
);
};

Screen Reader Announcements for Dynamic Content

// src/components/work-orders/WorkOrderStatusUpdater.tsx

import React, { useState } from 'react';

interface WorkOrder {
id: string;
title: string;
status: string;
}

export const WorkOrderStatusUpdater: React.FC<{ workOrder: WorkOrder }> = ({
workOrder: initialWorkOrder,
}) => {
const [workOrder, setWorkOrder] = useState(initialWorkOrder);
const [announcement, setAnnouncement] = useState('');
const [isLoading, setIsLoading] = useState(false);

const updateStatus = async (newStatus: string) => {
setIsLoading(true);
setAnnouncement('Updating work order status...');

try {
const response = await fetch(`/api/v1/work-orders/${workOrder.id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus }),
});

if (response.ok) {
const updated = await response.json();
setWorkOrder(updated);
setAnnouncement(
`Work order ${workOrder.id} status updated to ${newStatus}. Page will refresh.`
);
} else {
setAnnouncement('Error: Failed to update work order status. Please try again.');
}
} catch (error) {
setAnnouncement('Error: Network error. Please check your connection and try again.');
} finally {
setIsLoading(false);
}
};

return (
<div className="status-updater">
<h3>Update Work Order Status</h3>
<p>
Current status: <strong>{workOrder.status}</strong>
</p>

<div className="status-actions">
<button
onClick={() => updateStatus('in-progress')}
disabled={isLoading}
>
Start Work
</button>
<button
onClick={() => updateStatus('completed')}
disabled={isLoading}
>
Mark Complete
</button>
</div>

{/* Polite announcement for non-urgent updates */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement}
</div>

{/* Loading spinner with accessible label */}
{isLoading && (
<div role="status" aria-live="polite">
<span className="spinner" aria-hidden="true"></span>
<span className="sr-only">Updating work order status...</span>
</div>
)}
</div>
);
};

Screen Reader Testing Matrix:

Screen ReaderOSBrowserTest Frequency
NVDAWindowsChrome, FirefoxEvery release
JAWSWindowsChrome, IE11 (if supported)Quarterly
VoiceOvermacOSSafariEvery release
VoiceOveriOSSafariEvery release
TalkBackAndroidChromeEvery release

Testing Checklist for P.1.4:

  • All images have appropriate alt text (descriptive, "", or aria-describedby)
  • Links have descriptive text (avoid "click here")
  • Form fields have labels (visible or aria-label)
  • Error messages announced via role="alert" or aria-live
  • Loading states announced to screen readers
  • Icons have accessible names (aria-label or sr-only text)
  • Page title updates on route change (<title> element)
  • Tested with NVDA, JAWS, and VoiceOver

P.1.5: Responsive and Adaptive Layout

WCAG Success Criteria Addressed:

  • 1.4.4 Resize Text (Level AA): Text can be resized up to 200% without loss of content or functionality
  • 1.4.10 Reflow (Level AA): Content reflows without horizontal scrolling at 320px width
  • 2.5.5 Target Size (Level AAA): Touch targets at least 44x44 CSS pixels

Implementation Strategy:

Responsive Typography and Layout

/* src/styles/base/typography.css */

/**
* Fluid typography that scales with viewport
* Base font size: 16px
* Scale: 1.25 (Major Third)
*/

:root {
/* Minimum and maximum font sizes */
--font-size-min: 16px;
--font-size-max: 20px;

/* Viewport range for fluid scaling */
--viewport-min: 320px;
--viewport-max: 1920px;
}

html {
font-size: clamp(
var(--font-size-min),
1rem + 0.25vw,
var(--font-size-max)
);
}

body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
line-height: 1.6;
color: var(--color-neutral-900);
}

/* Type scale */
h1 {
font-size: clamp(2rem, 1.5rem + 2vw, 3rem);
line-height: 1.2;
margin-bottom: 1rem;
}

h2 {
font-size: clamp(1.5rem, 1.25rem + 1vw, 2.25rem);
line-height: 1.3;
margin-bottom: 0.75rem;
}

h3 {
font-size: clamp(1.25rem, 1.125rem + 0.5vw, 1.75rem);
line-height: 1.4;
margin-bottom: 0.5rem;
}

p {
margin-bottom: 1rem;
max-width: 70ch; /* Optimal line length for readability */
}

/* Support for user font size preferences (WCAG 1.4.4) */
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}
/* src/styles/layout/responsive-grid.css */

/**
* Responsive grid system
* - Mobile-first approach
* - Breakpoints: 640px (sm), 768px (md), 1024px (lg), 1280px (xl)
*/

.container {
width: 100%;
max-width: 1280px;
margin-left: auto;
margin-right: auto;
padding-left: 1rem;
padding-right: 1rem;
}

@media (min-width: 640px) {
.container {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}

@media (min-width: 1024px) {
.container {
padding-left: 2rem;
padding-right: 2rem;
}
}

/* Responsive grid */
.grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}

@media (min-width: 768px) {
.grid {
gap: 1.5rem;
}
}

/* Reflow at 320px width (WCAG 1.4.10) */
@media (max-width: 320px) {
.grid {
grid-template-columns: 1fr;
}

/* Stack multi-column layouts */
.two-column,
.three-column {
flex-direction: column;
}
}

Touch Target Sizes

/* src/styles/components/buttons.css */

/**
* WCAG 2.5.5: Target Size (Level AAA)
* Minimum 44x44px for touch targets
*
* Note: Level AAA is aspirational for this project, but we meet it for critical actions
*/

.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
min-width: 44px;
padding: 12px 24px;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
}

.btn:active {
transform: scale(0.98);
}

/* Primary button */
.btn-primary {
background-color: var(--color-primary-700);
color: white;
}

.btn-primary:hover {
background-color: var(--color-primary-900);
}

.btn-primary:focus-visible {
outline: 3px solid var(--color-primary-700);
outline-offset: 2px;
}

/* Icon-only buttons need extra padding to meet 44x44 */
.btn-icon {
min-height: 44px;
min-width: 44px;
padding: 10px;
}

/* Mobile: Increase touch targets further */
@media (max-width: 768px) {
.btn {
min-height: 48px;
min-width: 48px;
padding: 14px 28px;
}

.btn-icon {
min-height: 48px;
min-width: 48px;
}
}
// src/components/navigation/MobileNavigation.tsx

import React, { useState } from 'react';
import { Link } from 'react-router-dom';

/**
* Mobile navigation with accessible touch targets
*/
export const MobileNavigation: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);

return (
<nav aria-label="Mobile navigation">
{/* Hamburger menu button: 48x48px on mobile */}
<button
onClick={() => setIsOpen(!isOpen)}
className="mobile-menu-button"
aria-expanded={isOpen}
aria-controls="mobile-menu"
aria-label="Toggle navigation menu"
>
<span className="hamburger-icon" aria-hidden="true">
{isOpen ? '✕' : '☰'}
</span>
</button>

{isOpen && (
<ul id="mobile-menu" className="mobile-menu">
<li>
<Link
to="/work-orders"
className="mobile-menu-link"
onClick={() => setIsOpen(false)}
>
Work Orders
</Link>
</li>
<li>
<Link
to="/assets"
className="mobile-menu-link"
onClick={() => setIsOpen(false)}
>
Assets
</Link>
</li>
<li>
<Link
to="/compliance"
className="mobile-menu-link"
onClick={() => setIsOpen(false)}
>
Compliance
</Link>
</li>
</ul>
)}
</nav>
);
};
/* src/styles/components/mobile-navigation.css */

.mobile-menu-button {
min-height: 48px;
min-width: 48px;
padding: 12px;
background: transparent;
border: 1px solid var(--color-neutral-300);
border-radius: 4px;
cursor: pointer;
}

.hamburger-icon {
font-size: 24px;
line-height: 1;
}

.mobile-menu {
list-style: none;
margin: 0;
padding: 0;
}

.mobile-menu-link {
display: block;
min-height: 48px;
padding: 14px 16px;
color: var(--color-neutral-900);
text-decoration: none;
border-bottom: 1px solid var(--color-neutral-300);
}

.mobile-menu-link:hover,
.mobile-menu-link:focus {
background-color: var(--color-neutral-100);
}

Orientation Support

// src/hooks/useOrientation.ts

import { useState, useEffect } from 'react';

export const useOrientation = () => {
const [orientation, setOrientation] = useState<'portrait' | 'landscape'>(
window.innerHeight > window.innerWidth ? 'portrait' : 'landscape'
);

useEffect(() => {
const handleOrientationChange = () => {
setOrientation(
window.innerHeight > window.innerWidth ? 'portrait' : 'landscape'
);
};

window.addEventListener('resize', handleOrientationChange);
window.addEventListener('orientationchange', handleOrientationChange);

return () => {
window.removeEventListener('resize', handleOrientationChange);
window.removeEventListener('orientationchange', handleOrientationChange);
};
}, []);

return orientation;
};
// src/components/charts/ResponsiveChart.tsx

import React from 'react';
import { useOrientation } from '@/hooks/useOrientation';

/**
* Chart that adapts to device orientation
* Portrait: vertical layout, reduced height
* Landscape: horizontal layout, full height
*/
export const ResponsiveChart: React.FC = () => {
const orientation = useOrientation();

const chartHeight = orientation === 'portrait' ? 300 : 500;

return (
<div className={`chart-container orientation-${orientation}`}>
{/* Chart component with dynamic height */}
<div style={{ height: chartHeight }}>
{/* Chart implementation */}
</div>
</div>
);
};

Testing Checklist for P.1.5:

  • Content reflows without horizontal scroll at 320px width
  • Text resizable to 200% without loss of functionality (browser zoom test)
  • Touch targets minimum 44x44px (48x48px on mobile preferred)
  • Responsive breakpoints tested: 320px, 640px, 768px, 1024px, 1280px
  • Portrait and landscape orientations supported
  • Mobile navigation accessible via keyboard and touch
  • No content hidden at 200% zoom
  • Tables scroll horizontally (not page) when overflowing

P.2: Internationalization Framework

Overview: Implement a robust internationalization (i18n) framework to support global deployment of the BIO-QMS platform across multiple languages and locales, with particular focus on regulatory terminology accuracy and tenant-specific locale preferences.

Supported Locales (Phase 1):

  • en-US (English - United States) - Default
  • en-GB (English - United Kingdom)
  • de-DE (German - Germany)
  • fr-FR (French - France)
  • es-ES (Spanish - Spain)
  • ja-JP (Japanese - Japan)
  • zh-CN (Chinese - Simplified, China)

Phase 2 Locales (Planned):

  • ko-KR (Korean), pt-BR (Portuguese - Brazil), it-IT (Italian), nl-NL (Dutch), ar-SA (Arabic - Saudi Arabia, RTL)

P.2.1: i18n Architecture

Decision: react-intl (FormatJS) over i18next

Rationale:

  • React-specific, better TypeScript support
  • ICU MessageFormat for pluralization, gender, select
  • Built-in number/date/time formatting
  • Smaller bundle size for React apps
  • Strong ecosystem (Crowdin, Lokalise integration)

Alternative Considered: i18next (more feature-rich, framework-agnostic, but heavier)

Installation and Setup

# Package installation
npm install react-intl
npm install --save-dev @formatjs/cli @formatjs/ts-transformer
// src/i18n/provider.tsx

import React from 'react';
import { IntlProvider } from 'react-intl';
import { useUserLocale } from '@/hooks/useUserLocale';
import { messages } from './messages';

interface I18nProviderProps {
children: React.ReactNode;
}

/**
* Global i18n provider
* - Loads locale from user preferences (tenant.locale or browser default)
* - Provides messages for selected locale
* - Falls back to en-US if locale not available
*/
export const I18nProvider: React.FC<I18nProviderProps> = ({ children }) => {
const { locale, isLoading } = useUserLocale();

if (isLoading) {
return <div>Loading localization...</div>;
}

return (
<IntlProvider
locale={locale}
messages={messages[locale] || messages['en-US']}
defaultLocale="en-US"
onError={(err) => {
// Log missing translations in development
if (process.env.NODE_ENV === 'development') {
console.warn('IntlProvider error:', err);
}
}}
>
{children}
</IntlProvider>
);
};
// src/hooks/useUserLocale.ts

import { useState, useEffect } from 'react';
import { useAuthContext } from '@/contexts/AuthContext';

export const useUserLocale = () => {
const { user, tenant } = useAuthContext();
const [locale, setLocale] = useState<string>('en-US');
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
// Priority: user preference > tenant default > browser locale > en-US
const determinedLocale =
user?.preferences?.locale ||
tenant?.defaultLocale ||
navigator.language ||
'en-US';

setLocale(determinedLocale);
setIsLoading(false);
}, [user, tenant]);

const changeLocale = async (newLocale: string) => {
// Persist to user preferences via API
await fetch('/api/v1/users/me/preferences', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locale: newLocale }),
});

setLocale(newLocale);
window.location.reload(); // Force reload to apply new locale
};

return { locale, isLoading, changeLocale };
};

Message Catalogs

// src/i18n/messages/index.ts

import enUS from './en-US.json';
import enGB from './en-GB.json';
import deDE from './de-DE.json';
import frFR from './fr-FR.json';
import esES from './es-ES.json';
import jaJP from './ja-JP.json';
import zhCN from './zh-CN.json';

export const messages: Record<string, Record<string, string>> = {
'en-US': enUS,
'en-GB': enGB,
'de-DE': deDE,
'fr-FR': frFR,
'es-ES': esES,
'ja-JP': jaJP,
'zh-CN': zhCN,
};
// src/i18n/messages/en-US.json

{
"app.title": "BIO-QMS Quality Management System",
"nav.workOrders": "Work Orders",
"nav.assets": "Assets",
"nav.compliance": "Compliance",
"nav.users": "Users",

"workOrder.create.title": "Create Work Order",
"workOrder.create.success": "Work order {workOrderId} created successfully",
"workOrder.create.error": "Failed to create work order: {errorMessage}",

"workOrder.status.draft": "Draft",
"workOrder.status.pending": "Pending Approval",
"workOrder.status.approved": "Approved",
"workOrder.status.inProgress": "In Progress",
"workOrder.status.completed": "Completed",
"workOrder.status.rejected": "Rejected",

"workOrder.priority.low": "Low",
"workOrder.priority.medium": "Medium",
"workOrder.priority.high": "High",
"workOrder.priority.critical": "Critical",

"workOrder.field.title": "Title",
"workOrder.field.description": "Description",
"workOrder.field.priority": "Priority",
"workOrder.field.dueDate": "Due Date",
"workOrder.field.assignedTo": "Assigned To",

"workOrder.validation.titleRequired": "Title is required",
"workOrder.validation.titleMinLength": "Title must be at least {minLength} characters",
"workOrder.validation.priorityRequired": "Priority is required",
"workOrder.validation.dueDatePast": "Due date cannot be in the past",

"common.save": "Save",
"common.cancel": "Cancel",
"common.delete": "Delete",
"common.edit": "Edit",
"common.view": "View",
"common.search": "Search",
"common.filter": "Filter",
"common.export": "Export",

"common.confirmDelete": "Are you sure you want to delete {itemName}?",
"common.loading": "Loading...",
"common.noResults": "No results found",

"error.network": "Network error. Please check your connection and try again.",
"error.unauthorized": "You are not authorized to perform this action.",
"error.notFound": "The requested resource was not found.",
"error.serverError": "Server error. Please try again later.",

"compliance.cfr21Part11": "FDA 21 CFR Part 11 Validated System",
"compliance.hipaa": "HIPAA Compliant",
"compliance.soc2": "SOC 2 Type II Certified",

"auditTrail.user": "User",
"auditTrail.action": "Action",
"auditTrail.timestamp": "Timestamp",
"auditTrail.details": "Details",
"auditTrail.reason": "Reason",

"pagination.showing": "Showing {start} to {end} of {total} {itemType}",
"pagination.previous": "Previous",
"pagination.next": "Next",
"pagination.page": "Page {page}"
}
// src/i18n/messages/de-DE.json

{
"app.title": "BIO-QMS Qualitätsmanagementsystem",
"nav.workOrders": "Arbeitsaufträge",
"nav.assets": "Anlagen",
"nav.compliance": "Compliance",
"nav.users": "Benutzer",

"workOrder.create.title": "Arbeitsauftrag erstellen",
"workOrder.create.success": "Arbeitsauftrag {workOrderId} erfolgreich erstellt",
"workOrder.create.error": "Fehler beim Erstellen des Arbeitsauftrags: {errorMessage}",

"workOrder.status.draft": "Entwurf",
"workOrder.status.pending": "Genehmigung ausstehend",
"workOrder.status.approved": "Genehmigt",
"workOrder.status.inProgress": "In Bearbeitung",
"workOrder.status.completed": "Abgeschlossen",
"workOrder.status.rejected": "Abgelehnt",

"workOrder.priority.low": "Niedrig",
"workOrder.priority.medium": "Mittel",
"workOrder.priority.high": "Hoch",
"workOrder.priority.critical": "Kritisch",

"workOrder.field.title": "Titel",
"workOrder.field.description": "Beschreibung",
"workOrder.field.priority": "Priorität",
"workOrder.field.dueDate": "Fälligkeitsdatum",
"workOrder.field.assignedTo": "Zugewiesen an",

"workOrder.validation.titleRequired": "Titel ist erforderlich",
"workOrder.validation.titleMinLength": "Der Titel muss mindestens {minLength} Zeichen lang sein",
"workOrder.validation.priorityRequired": "Priorität ist erforderlich",
"workOrder.validation.dueDatePast": "Das Fälligkeitsdatum darf nicht in der Vergangenheit liegen",

"common.save": "Speichern",
"common.cancel": "Abbrechen",
"common.delete": "Löschen",
"common.edit": "Bearbeiten",
"common.view": "Ansehen",
"common.search": "Suchen",
"common.filter": "Filtern",
"common.export": "Exportieren",

"common.confirmDelete": "Möchten Sie {itemName} wirklich löschen?",
"common.loading": "Laden...",
"common.noResults": "Keine Ergebnisse gefunden",

"error.network": "Netzwerkfehler. Bitte überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
"error.unauthorized": "Sie sind nicht berechtigt, diese Aktion auszuführen.",
"error.notFound": "Die angeforderte Ressource wurde nicht gefunden.",
"error.serverError": "Serverfehler. Bitte versuchen Sie es später erneut.",

"compliance.cfr21Part11": "FDA 21 CFR Part 11 Validiertes System",
"compliance.hipaa": "HIPAA-konform",
"compliance.soc2": "SOC 2 Typ II zertifiziert",

"auditTrail.user": "Benutzer",
"auditTrail.action": "Aktion",
"auditTrail.timestamp": "Zeitstempel",
"auditTrail.details": "Details",
"auditTrail.reason": "Grund",

"pagination.showing": "Zeige {start} bis {end} von {total} {itemType}",
"pagination.previous": "Zurück",
"pagination.next": "Weiter",
"pagination.page": "Seite {page}"
}

Usage in Components

// src/components/work-orders/WorkOrderCreateForm.tsx

import React from 'react';
import { useIntl, FormattedMessage } from 'react-intl';
import { useForm } from 'react-hook-form';

export const WorkOrderCreateForm: React.FC = () => {
const intl = useIntl();
const { register, handleSubmit, formState: { errors } } = useForm();

const onSubmit = async (data: any) => {
try {
const response = await createWorkOrder(data);

// Formatted message with variable
alert(
intl.formatMessage(
{ id: 'workOrder.create.success' },
{ workOrderId: response.id }
)
);
} catch (error) {
alert(
intl.formatMessage(
{ id: 'workOrder.create.error' },
{ errorMessage: error.message }
)
);
}
};

return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* FormattedMessage for static text */}
<h2>
<FormattedMessage id="workOrder.create.title" />
</h2>

<div className="form-group">
{/* Use intl.formatMessage for dynamic attributes (labels, placeholders) */}
<label htmlFor="title">
<FormattedMessage id="workOrder.field.title" />
</label>
<input
id="title"
type="text"
placeholder={intl.formatMessage({ id: 'workOrder.field.title' })}
{...register('title', {
required: intl.formatMessage({ id: 'workOrder.validation.titleRequired' }),
minLength: {
value: 5,
message: intl.formatMessage(
{ id: 'workOrder.validation.titleMinLength' },
{ minLength: 5 }
),
},
})}
/>
{errors.title && <span className="error">{errors.title.message}</span>}
</div>

<button type="submit">
<FormattedMessage id="common.save" />
</button>
</form>
);
};

Message Extraction and Management

// package.json scripts

{
"scripts": {
"i18n:extract": "formatjs extract 'src/**/*.{ts,tsx}' --out-file src/i18n/extracted/en-US.json --id-interpolation-pattern '[sha512:contenthash:base64:6]'",
"i18n:compile": "formatjs compile-folder --ast src/i18n/messages src/i18n/compiled",
"i18n:check": "formatjs compile-folder --ast --check src/i18n/messages"
}
}

Testing Checklist for P.2.1:

  • All user-facing strings externalized to message catalogs
  • No hardcoded English strings in components
  • Locale switching tested (user preference persistence)
  • Fallback to en-US when locale unavailable
  • Message extraction automated in CI/CD
  • Missing translation warnings logged (development only)

(Document continues with P.2.2, P.2.3, P.2.4, P.3.1-P.3.4, P.4.1-P.4.3, P.5.1-P.5.2 in subsequent parts due to length...)