Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 })));
Expand Down Expand Up @@ -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');
Expand Down
33 changes: 32 additions & 1 deletion frontend/src/i18n/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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;
57 changes: 57 additions & 0 deletions frontend/src/responsive.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
30 changes: 30 additions & 0 deletions frontend/src/test/rtlDirection.test.js
Original file line number Diff line number Diff line change
@@ -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'];
});
});
Loading