From 4c601330b5703fe2b669a177f298433e0e6aadca Mon Sep 17 00:00:00 2001 From: iheomadev Date: Thu, 30 Jul 2026 03:39:48 +0000 Subject: [PATCH] feat(i18n): add RTL layout support groundwork (#1061) - Add LOCALE_DIRECTIONS map and getLocaleDirection() to i18n/index.js so each locale declares its text direction in one place - Apply dir= and lang= to on init and on every languageChanged event (both in i18n/index.js and reactively in AppContent via useTranslation hook) - Audit Navbar, Marketplace grid, and Pagination for hardcoded physical CSS directional properties; document findings in responsive.css - Add [dir="rtl"] overrides in responsive.css using logical properties (margin-inline-start, flex-direction: row-reverse, etc.) so layouts are ready the moment an RTL locale is added - Add rtlDirection.test.js covering ltr defaults, unknown-locale fallback, and the rtl path for a future Arabic locale Follow-up: add ar/he locale files and uncomment their entries in LOCALE_DIRECTIONS (tracked in #1061). --- frontend/src/App.jsx | 14 +++++++ frontend/src/i18n/index.js | 33 ++++++++++++++- frontend/src/responsive.css | 57 ++++++++++++++++++++++++++ frontend/src/test/rtlDirection.test.js | 30 ++++++++++++++ 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 frontend/src/test/rtlDirection.test.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0c46b181..9b5d439b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,7 @@ import React, { lazy, Suspense, useEffect, useContext } from 'react'; import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { HelmetProvider } from 'react-helmet-async'; +import { useTranslation } from 'react-i18next'; import { AuthProvider, useAuth } from './context/AuthContext'; import { FavoritesProvider } from './context/FavoritesContext'; import { CompareProvider } from './context/CompareContext'; @@ -12,6 +13,7 @@ import AnnouncementBanner from './components/AnnouncementBanner'; import LoadingSpinner from './components/LoadingSpinner'; import PageLoader from './components/PageLoader'; import { initSentry } from './utils/sentry'; +import { getLocaleDirection } from './i18n'; const LoginPage = lazy(() => import('./pages/Auth').then(m => ({ default: m.LoginPage }))); const RegisterPage = lazy(() => import('./pages/Auth').then(m => ({ default: m.RegisterPage }))); @@ -48,12 +50,24 @@ function AppContent() { const { startLoading, stopLoading } = useContext(LoadingContext); const { logout } = useAuth(); const location = useLocation(); + const { i18n } = useTranslation(); useEffect(() => { setLoadingCallback((isStart) => isStart ? startLoading() : stopLoading()); setLogoutCallback(logout); }, [startLoading, stopLoading, logout]); + // Keep document dir/lang in sync with the active i18n language + useEffect(() => { + const apply = (lng) => { + document.documentElement.setAttribute('dir', getLocaleDirection(lng)); + document.documentElement.setAttribute('lang', lng); + }; + apply(i18n.language); + i18n.on('languageChanged', apply); + return () => i18n.off('languageChanged', apply); + }, [i18n]); + // Announce page changes to screen readers useEffect(() => { const announcer = document.getElementById('page-announcer'); diff --git a/frontend/src/i18n/index.js b/frontend/src/i18n/index.js index 139ee395..f3504e14 100644 --- a/frontend/src/i18n/index.js +++ b/frontend/src/i18n/index.js @@ -3,6 +3,26 @@ import { initReactI18next } from 'react-i18next'; import en from './en.json'; import sw from './sw.json'; +/** + * Per-locale text-direction configuration. + * Add new locales here as they are introduced. + * RTL locales (Arabic, Hebrew, etc.) should be listed with 'rtl'. + * + * Follow-up: Add 'ar' (Arabic) and 'he' (Hebrew) entries once those + * locale translation files land (tracked in issue #1061). + */ +export const LOCALE_DIRECTIONS = { + en: 'ltr', + sw: 'ltr', + // ar: 'rtl', // uncomment when Arabic locale is added + // he: 'rtl', // uncomment when Hebrew locale is added +}; + +/** Returns the text direction for the given locale code, defaulting to 'ltr'. */ +export function getLocaleDirection(lng) { + return LOCALE_DIRECTIONS[lng] ?? 'ltr'; +} + i18n .use(initReactI18next) .init({ @@ -12,6 +32,17 @@ i18n interpolation: { escapeValue: false }, }); -i18n.on('languageChanged', (lng) => localStorage.setItem('lang', lng)); +i18n.on('languageChanged', (lng) => { + localStorage.setItem('lang', lng); + // Apply dir attribute to document root whenever the language changes + const dir = getLocaleDirection(lng); + document.documentElement.setAttribute('dir', dir); + document.documentElement.setAttribute('lang', lng); +}); + +// Apply direction on initial load +const initialLng = localStorage.getItem('lang') || 'en'; +document.documentElement.setAttribute('dir', getLocaleDirection(initialLng)); +document.documentElement.setAttribute('lang', initialLng); export default i18n; diff --git a/frontend/src/responsive.css b/frontend/src/responsive.css index 6978da9b..c806df29 100644 --- a/frontend/src/responsive.css +++ b/frontend/src/responsive.css @@ -91,3 +91,60 @@ width: 100% !important; } } + +/* ── RTL (right-to-left) layout support (#1061) ───────────────────────────── + * + * AUDIT NOTES — hardcoded physical directional properties found and addressed: + * + * Navbar (Navbar.jsx inline styles): + * - `justifyContent: 'space-between'` is direction-neutral — OK. + * - `padding: '12px 24px'` is shorthand (block/inline) — OK. + * - The flex row reverses naturally under `dir="rtl"` for logical ordering. + * + * Marketplace grid (.product-grid): + * - CSS Grid auto-fill is direction-neutral — OK. + * + * Pagination (Pagination.jsx inline styles): + * - `marginLeft: 8` on the results info span is a physical property. + * Override with logical equivalent below using `[dir="rtl"]`. + * + * General: + * - `text-align: left` is replaced with `text-align: start` for new rules. + * - Components using `margin-left`/`margin-right` directly in JSX inline + * styles are overridden below. Future code should prefer logical + * properties (margin-inline-start, padding-inline-end, etc.). + * + * Follow-up: Once an actual RTL locale (ar/he) is added, run a full visual + * audit and replace remaining physical margin/padding/border-radius usages + * in component inline styles with logical properties. + * ────────────────────────────────────────────────────────────────────────── */ + +/* Flip physical margin on Pagination results info span under RTL */ +[dir="rtl"] .pagination-info { + margin-inline-start: 8px; + margin-inline-end: 0; +} + +/* Ensure nav flex row respects reading direction */ +[dir="rtl"] .nav-links { + flex-direction: row-reverse; +} + +/* Filter row — start-align items logically */ +[dir="rtl"] .filter-row { + flex-direction: row-reverse; +} + +/* Text inputs align to reading direction */ +[dir="rtl"] .form-input { + text-align: right; +} + +/* RTL mobile drawer — appear from the right */ +@media (max-width: 600px) { + [dir="rtl"] .nav-links { + flex-direction: column; + right: 0; + left: auto; + } +} diff --git a/frontend/src/test/rtlDirection.test.js b/frontend/src/test/rtlDirection.test.js new file mode 100644 index 00000000..76a1e2f1 --- /dev/null +++ b/frontend/src/test/rtlDirection.test.js @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { getLocaleDirection, LOCALE_DIRECTIONS } from '../i18n/index.js'; + +describe('RTL layout support (#1061)', () => { + it('returns ltr for English', () => { + expect(getLocaleDirection('en')).toBe('ltr'); + }); + + it('returns ltr for Swahili', () => { + expect(getLocaleDirection('sw')).toBe('ltr'); + }); + + it('defaults to ltr for an unknown locale', () => { + expect(getLocaleDirection('xx')).toBe('ltr'); + }); + + it('LOCALE_DIRECTIONS map is defined and contains expected entries', () => { + expect(LOCALE_DIRECTIONS).toBeDefined(); + expect(LOCALE_DIRECTIONS.en).toBe('ltr'); + expect(LOCALE_DIRECTIONS.sw).toBe('ltr'); + }); + + it('returns rtl for Arabic when added to the config', () => { + // This documents the expected behaviour once ar is enabled. + // Temporarily patch LOCALE_DIRECTIONS to verify the lookup works. + LOCALE_DIRECTIONS['ar'] = 'rtl'; + expect(getLocaleDirection('ar')).toBe('rtl'); + delete LOCALE_DIRECTIONS['ar']; + }); +});