Skip to content

Commit 8f73ed0

Browse files
Copilotwtfzdotnet
andcommitted
feat(i18n): implement Arabic (ar-SA) RTL language support and fix test infrastructure
Co-authored-by: wtfzdotnet <639376+wtfzdotnet@users.noreply.github.com>
1 parent bca2acd commit 8f73ed0

11 files changed

Lines changed: 201 additions & 17 deletions

File tree

.github/copilot-instructions.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,9 @@ export const RTLTest: Story = {
139139
|--------|----------|--------|-------------|----------|-----------|
140140
| en-US | English | United States | Imperial | USD | LTR |
141141
| nl-NL | Dutch | Netherlands | Metric | EUR | LTR |
142+
| ar-SA | Arabic | Saudi Arabia | Metric | SAR | RTL |
142143

143-
*Future expansion planned for Arabic (ar-SA) with RTL support*
144+
*Arabic (ar-SA) demonstrates complete RTL layout support with right-to-left text direction*
144145

145146
## Atomic Design + Component-Driven Development
146147

CLAUDE.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ This project implements comprehensive internationalization with locale-based sys
1717
### Core i18n Architecture
1818

1919
#### Locale System
20-
- **Supported Locales**: `en-US` (Imperial, USD), `nl-NL` (Metric, EUR)
20+
- **Supported Locales**: `en-US` (Imperial, USD), `nl-NL` (Metric, EUR), `ar-SA` (Metric, SAR, RTL)
2121
- **Measurement Systems**: Automatic switching based on locale (Imperial vs Metric)
2222
- **Currency Integration**: Locale-based currency defaults with manual override
2323
- **Date/Number Formatting**: Region-specific formatting patterns
@@ -170,10 +170,14 @@ src/i18n/locales/
170170
│ ├── common.json # UI elements, buttons, navigation
171171
│ ├── nutrition.json # Domain-specific nutrition terms
172172
│ └── validation.json # Form validation messages
173-
└── nl-NL/
174-
├── common.json
175-
├── nutrition.json
176-
└── validation.json
173+
├── nl-NL/
174+
│ ├── common.json
175+
│ ├── nutrition.json
176+
│ └── validation.json
177+
└── ar-SA/
178+
├── common.json # Arabic translations with RTL support
179+
├── nutrition.json # Nutrition terms in Arabic
180+
└── validation.json # Arabic validation messages
177181
```
178182

179183
#### Translation Key Naming Conventions

src/components/molecules/language-dropdown/LanguageDropdown.stories.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
import { LocaleProvider } from '@/providers/LocaleProvider';
23
import { LanguageDropdown } from './language-dropdown';
34

45
const meta: Meta<typeof LanguageDropdown> = {
56
title: 'Molecules/LanguageDropdown',
67
component: LanguageDropdown,
8+
decorators: [
9+
(Story) => (
10+
<LocaleProvider defaultLocale="en-US">
11+
<Story />
12+
</LocaleProvider>
13+
),
14+
],
715
parameters: {
816
layout: 'centered',
917
docs: {
1018
description: {
11-
component: 'A language selection dropdown with flag icons and native language names. Automatically persists the selected language to localStorage and updates the entire application locale.',
19+
component: 'A language selection dropdown with flag icons and native language names. Automatically persists the selected language to localStorage and updates the entire application locale. Supports English (US), Dutch (NL), and Arabic (SA) with RTL layout.',
1220
},
1321
},
1422
},
@@ -82,4 +90,29 @@ export const InToolbar: Story = {
8290
},
8391
},
8492
},
93+
};
94+
95+
export const ArabicRTL: Story = {
96+
args: {
97+
className: 'bg-card border-border',
98+
},
99+
decorators: [
100+
(Story) => (
101+
<div dir="rtl" className="rtl">
102+
<LocaleProvider defaultLocale="ar-SA">
103+
<div className="flex items-center gap-4 p-4 bg-background border rounded-lg text-start">
104+
<span className="text-sm font-medium text-foreground">الإعدادات:</span>
105+
<Story />
106+
</div>
107+
</LocaleProvider>
108+
</div>
109+
),
110+
],
111+
parameters: {
112+
docs: {
113+
description: {
114+
story: 'Language dropdown in Arabic (RTL) layout demonstrating right-to-left text direction and proper layout adaptation.',
115+
},
116+
},
117+
},
85118
};

src/components/molecules/language-dropdown/LanguageDropdown.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ describe('LanguageDropdown', () => {
1010
const trigger = screen.getByRole('combobox');
1111
expect(trigger).toBeInTheDocument();
1212

13-
// Check if it shows English as default
14-
expect(screen.getByText('English')).toBeInTheDocument();
13+
// Check if it shows English (United States) as default
14+
expect(screen.getByText('English (United States)')).toBeInTheDocument();
1515

1616
// Check if globe icon is present
1717
const globeIcon = document.querySelector('.lucide-globe');
@@ -21,8 +21,8 @@ describe('LanguageDropdown', () => {
2121
it('renders with flag emoji', () => {
2222
render(<LanguageDropdown />);
2323

24-
// Check if US flag emoji is present for default English
25-
const flagElements = screen.getAllByLabelText('English');
24+
// Check if US flag emoji is present for default English (United States)
25+
const flagElements = screen.getAllByLabelText('English (United States)');
2626
expect(flagElements[0]).toBeInTheDocument();
2727
expect(flagElements[0]).toHaveTextContent('🇺🇸');
2828
});

src/components/organisms/nutrition-facts/NutritionFacts.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ describe('NutritionFacts', () => {
7272
expect(screen.getByText('Trans Fat')).toBeInTheDocument();
7373
expect(screen.getByText('Cholesterol')).toBeInTheDocument();
7474
expect(screen.getByText('Sodium')).toBeInTheDocument();
75-
expect(screen.getByText('Total Carbohydrate')).toBeInTheDocument();
75+
expect(screen.getByText('Total Carbohydrates')).toBeInTheDocument();
7676
expect(screen.getByText('Dietary Fiber')).toBeInTheDocument();
7777
expect(screen.getByText('Total Sugars')).toBeInTheDocument();
7878
expect(screen.getByText('Added Sugars')).toBeInTheDocument();
@@ -257,7 +257,7 @@ describe('NutritionFacts', () => {
257257

258258
expect(screen.getByText('Total Fat')).toBeInTheDocument();
259259
expect(screen.getByText('Protein')).toBeInTheDocument();
260-
expect(screen.getByText('Total Carbohydrate')).toBeInTheDocument();
260+
expect(screen.getByText('Total Carbohydrates')).toBeInTheDocument();
261261
});
262262
});
263263

src/constants/locale-config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,20 @@ export const LOCALE_CONFIGS: Record<SupportedLocale, LocaleConfig> = {
3232
maximumFractionDigits: 2,
3333
},
3434
},
35+
'ar-SA': {
36+
locale: 'ar-SA',
37+
name: 'العربية (السعودية)',
38+
flag: '🇸🇦',
39+
measurementSystem: 'metric',
40+
currency: 'SAR',
41+
dir: 'rtl',
42+
dateFormat: 'dd/MM/yyyy',
43+
numberFormat: {
44+
style: 'decimal',
45+
minimumFractionDigits: 0,
46+
maximumFractionDigits: 2,
47+
},
48+
},
3549
};
3650

3751
/**
@@ -49,6 +63,7 @@ export const CURRENCY_SYMBOLS = {
4963
CAD: 'C$',
5064
AUD: 'A$',
5165
JPY: '¥',
66+
SAR: 'ر.س',
5267
} as const;
5368

5469
/**

src/i18n/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import { DEFAULT_LOCALE } from '@/constants/locale-config';
66
// Import translation resources
77
import enUSCommon from './locales/en-US/common.json';
88
import nlNLCommon from './locales/nl-NL/common.json';
9+
import arSACommon from './locales/ar-SA/common.json';
910
import enUSNutrition from './locales/en-US/nutrition.json';
1011
import nlNLNutrition from './locales/nl-NL/nutrition.json';
12+
import arSANutrition from './locales/ar-SA/nutrition.json';
1113

1214
// Translation resources
1315
const resources = {
@@ -19,6 +21,10 @@ const resources = {
1921
common: nlNLCommon,
2022
nutrition: nlNLNutrition,
2123
},
24+
'ar-SA': {
25+
common: arSACommon,
26+
nutrition: arSANutrition,
27+
},
2228
};
2329

2430
const isTest = import.meta.env.VITEST;
@@ -49,6 +55,7 @@ i18nInstance
4955
lookupLocalStorage: 'recipeer-locale',
5056
convertDetectedLanguage: (lng: string) => {
5157
// Map detected language to supported locales
58+
if (lng.startsWith('ar')) return 'ar-SA';
5259
if (lng.startsWith('nl')) return 'nl-NL';
5360
if (lng.startsWith('en')) return 'en-US';
5461
return DEFAULT_LOCALE;

src/i18n/locales/ar-SA/common.json

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
{
2+
"language": {
3+
"label": "اختر اللغة",
4+
"english": "الإنجليزية",
5+
"dutch": "الهولندية",
6+
"arabic": "العربية"
7+
},
8+
"navigation": {
9+
"home": "الرئيسية",
10+
"recipes": "الوصفات",
11+
"search": "البحث",
12+
"profile": "الملف الشخصي"
13+
},
14+
"buttons": {
15+
"save": "حفظ",
16+
"cancel": "إلغاء",
17+
"edit": "تعديل",
18+
"delete": "حذف",
19+
"add": "إضافة",
20+
"close": "إغلاق",
21+
"submit": "إرسال",
22+
"back": "العودة",
23+
"next": "التالي",
24+
"previous": "السابق"
25+
},
26+
"sections": {
27+
"overview": "نظرة عامة",
28+
"details": "التفاصيل",
29+
"ingredients": "المكونات",
30+
"instructions": "التعليمات",
31+
"nutrition": "القيم الغذائية"
32+
},
33+
"forms": {
34+
"required": "هذا الحقل مطلوب",
35+
"email": {
36+
"label": "البريد الإلكتروني",
37+
"placeholder": "أدخل بريدك الإلكتروني",
38+
"invalid": "البريد الإلكتروني غير صحيح"
39+
},
40+
"password": {
41+
"label": "كلمة المرور",
42+
"placeholder": "أدخل كلمة المرور",
43+
"confirm": "تأكيد كلمة المرور"
44+
},
45+
"name": {
46+
"label": "الاسم",
47+
"placeholder": "أدخل اسمك"
48+
}
49+
},
50+
"time": {
51+
"minutes": "دقيقة",
52+
"hours": "ساعة",
53+
"seconds": "ثانية",
54+
"prep": "وقت التحضير",
55+
"cook": "وقت الطبخ",
56+
"total": "الوقت الإجمالي"
57+
},
58+
"loading": "جاري التحميل...",
59+
"error": "حدث خطأ",
60+
"success": "تم بنجاح",
61+
"confirmation": "تأكيد",
62+
"warning": "تحذير"
63+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
{
2+
"title": "حقائق غذائية",
3+
"servings": {
4+
"perContainer": "عدد الحصص في العبوة: {{count}}",
5+
"servingSize": "حجم الحصة: حصة واحدة"
6+
},
7+
"calories": "السعرات الحرارية",
8+
"dailyValue": "% القيمة اليومية*",
9+
"nutrients": {
10+
"totalFat": "إجمالي الدهون",
11+
"saturatedFat": "الدهون المشبعة",
12+
"transFat": "الدهون المتحولة",
13+
"cholesterol": "الكوليسترول",
14+
"sodium": "الصوديوم",
15+
"totalCarbohydrates": "إجمالي الكربوهيدرات",
16+
"dietaryFiber": "الألياف الغذائية",
17+
"totalSugars": "إجمالي السكريات",
18+
"addedSugars": "السكريات المضافة",
19+
"protein": "البروتين",
20+
"vitaminD": "فيتامين د",
21+
"calcium": "الكالسيوم",
22+
"iron": "الحديد",
23+
"potassium": "البوتاسيوم",
24+
"vitaminA": "فيتامين أ",
25+
"vitaminC": "فيتامين ج"
26+
},
27+
"units": {
28+
"grams": "ج",
29+
"milligrams": "مج",
30+
"micrograms": "مكج"
31+
},
32+
"compliance": {
33+
"keto": "كيتو",
34+
"lowCarb": "قليل الكربوهيدرات",
35+
"highProtein": "عالي البروتين",
36+
"lowFat": "قليل الدهون",
37+
"lowSodium": "قليل الصوديوم",
38+
"highFiber": "عالي الألياف"
39+
},
40+
"allergens": {
41+
"title": "معلومات مسببات الحساسية",
42+
"contains": "يحتوي على:",
43+
"mayContain": "قد يحتوي على:",
44+
"freeFrom": "خالي من:",
45+
"common": {
46+
"milk": "الحليب",
47+
"eggs": "البيض",
48+
"wheat": "القمح",
49+
"treeNuts": "المكسرات",
50+
"soy": "الصويا",
51+
"peanuts": "الفول السوداني",
52+
"fish": "السمك",
53+
"shellfish": "المحار"
54+
}
55+
},
56+
"dailyValueFooter": "* تخبرك النسبة المئوية للقيمة اليومية (DV) بمقدار مساهمة العنصر الغذائي في حصة من الطعام في النظام الغذائي اليومي. يُستخدم 2000 سعرة حرارية يومياً للحصول على نصائح التغذية العامة.",
57+
"dietaryCompliance": "التوافق الغذائي"
58+
}

src/test-utils.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,15 @@ import {
6262
import React, { ReactElement } from 'react';
6363
import { I18nextProvider } from 'react-i18next';
6464
import i18n from './i18n';
65+
import { LocaleProvider } from './providers/LocaleProvider';
6566

66-
// Test wrapper component that provides i18n context
67+
// Test wrapper component that provides i18n and locale context
6768
function TestWrapper({ children }: { children: React.ReactNode }) {
6869
return (
6970
<I18nextProvider i18n={i18n}>
70-
{children}
71+
<LocaleProvider defaultLocale="en-US">
72+
{children}
73+
</LocaleProvider>
7174
</I18nextProvider>
7275
);
7376
}

0 commit comments

Comments
 (0)