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
125 changes: 125 additions & 0 deletions frontend/e2e/layout-integrity.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,131 @@ test('sidebar theme toggle button does not clip horizontally or vertically acros
}
});

test('sidebar renders one localized primary navigation landmark wrapping nav links and excluding non-navigation controls across desktop, collapsed, keyboard, RTL, and mobile states (#1097)', async ({
page,
}) => {
await page.goto(`${BASE}/auth/login`);
await page.locator('#email').fill('[email protected]');
await page.locator('#password').fill(E2E_PASSWORD);
await page.locator('button[type="submit"]').click();
await page.waitForURL(/\/pos/, { timeout: 20000 });

try {
await setLanguage(page, 'en');
await page.goto(`${BASE}/orders`);

const sidebar = page.locator('[data-slot="sidebar"]');
await expect(sidebar).toHaveAttribute('data-state', 'expanded');

// Desktop expanded (LTR): exactly one navigation landmark with localized label
await expect(page.getByRole('navigation')).toHaveCount(1);
const primaryNav = page.getByRole('navigation', { name: 'Menu', exact: true });
await expect(primaryNav).toBeVisible();
expect(await primaryNav.evaluate((el) => el.tagName)).toBe('NAV');

const expectedLinks = [
'POS',
'Dashboard',
'Orders',
'Products',
'Inventory',
'Expenses',
'Customers',
'Staff',
'Settings',
];
for (const label of expectedLinks) {
await expect(
primaryNav.getByRole('link', { name: label, exact: true }),
`desktop expanded nav contains link "${label}"`,
).toBeVisible();
}

// Header brand link, theme toggle, user profile menu, and resize rail stay outside <nav>
await expect(primaryNav.locator('[data-sidebar="header"]')).toHaveCount(0);
await expect(primaryNav.locator('[data-sidebar="footer"]')).toHaveCount(0);
await expect(primaryNav.getByTestId('sidebar-theme-toggle')).toHaveCount(0);
await expect(primaryNav.locator('[data-slot="dropdown-menu-trigger"]')).toHaveCount(0);
await expect(primaryNav.locator('[data-sidebar="rail"]')).toHaveCount(0);

const profileTrigger = page.locator(
'[data-sidebar="footer"] button[data-sidebar="menu-button"][data-slot="dropdown-menu-trigger"]',
);
await expect(profileTrigger).toBeVisible();
await profileTrigger.click();
await expect(page.getByRole('menuitem', { name: 'Support' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Logout' })).toBeVisible();
await expect(primaryNav.getByRole('menuitem')).toHaveCount(0);
await page.keyboard.press('Escape');

// Keyboard traversal and activation inside primary <nav>
const posLink = primaryNav.getByRole('link', { name: 'POS', exact: true });
const dashboardLink = primaryNav.getByRole('link', { name: 'Dashboard', exact: true });
await posLink.focus();
await expect(posLink).toBeFocused();
await page.keyboard.press('Tab');
await expect(dashboardLink).toBeFocused();

// Collapsed icon rail preserves the single landmark and keyboard focusability
await page.keyboard.press('Control+b');
await expect(sidebar).toHaveAttribute('data-state', 'collapsed');
await expect(sidebar).toHaveAttribute('data-collapsible', 'icon');
await expect(page.getByRole('navigation')).toHaveCount(1);
await expect(primaryNav).toBeVisible();
const ordersLink = primaryNav.getByRole('link', { name: 'Orders', exact: true });
await ordersLink.focus();
await expect(ordersLink).toBeFocused();

await page.keyboard.press('Control+b');
await expect(sidebar).toHaveAttribute('data-state', 'expanded');

// RTL (Persian): landmark label localizes and stays on the inline-start (right) edge
await setLanguage(page, 'fa');
await page.goto(`${BASE}/orders`);
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl');
await expect(page.getByRole('navigation')).toHaveCount(1);
const rtlNav = page.getByRole('navigation', { name: 'منو', exact: true });
await expect(rtlNav).toBeVisible();
await expect(rtlNav.getByRole('link', { name: 'صندوق فروش', exact: true })).toBeVisible();
await expect(rtlNav.getByRole('link', { name: 'سفارش‌ها', exact: true })).toBeVisible();
await expect(rtlNav.getByTestId('sidebar-theme-toggle')).toHaveCount(0);
const rtlBox = await rtlNav.boundingBox();
const viewportWidth = page.viewportSize()?.width ?? 1280;
expect(rtlBox, 'RTL nav has bounding box').not.toBeNull();
expect(
rtlBox!.x + rtlBox!.width,
'RTL nav sits on the right side of the viewport',
).toBeGreaterThan(viewportWidth - 25);

await setLanguage(page, 'en');
await page.goto(`${BASE}/orders`);

// Mobile drawer: landmark mounts inside the mobile sheet when opened and closes on link navigation
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole('navigation')).toHaveCount(0);

const openMobileButton = page.getByRole('button', { name: 'Open navigation' });
await expect(openMobileButton).toBeVisible();
await openMobileButton.click();

await expect(page.getByRole('navigation')).toHaveCount(1);
const mobileNav = page
.locator('[data-slot="sidebar"][data-mobile="true"]')
.getByRole('navigation', { name: 'Menu', exact: true });
await expect(mobileNav).toBeVisible();
await expect(mobileNav.getByRole('link', { name: 'POS', exact: true })).toBeVisible();
await expect(mobileNav.getByTestId('sidebar-theme-toggle')).toHaveCount(0);
await expect(mobileNav.locator('[data-slot="dropdown-menu-trigger"]')).toHaveCount(0);

await mobileNav.getByRole('link', { name: 'POS', exact: true }).click();
await page.waitForURL(/\/pos/, { timeout: 20000 });
await expect(page.locator('[data-slot="sidebar"][data-mobile="true"]')).toHaveCount(0);
} finally {
await page.setViewportSize({ width: 1280, height: 720 }).catch(() => {});
await setLanguage(page, 'en').catch(() => {});
}
});

/**
* Scroll assertions for the POS and Orders control surfaces.
*
Expand Down
82 changes: 59 additions & 23 deletions frontend/e2e/rtl-setup-auth-settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ async function logout(page: Page): Promise<void> {
});
}

test('login page is LTR in English and RTL in Persian with LTR email and end-aligned toggle', async ({ page }) => {
test('login page is LTR in English and RTL in Persian with LTR email and end-aligned toggle', async ({
page,
}) => {
// 1. English (LTR)
await page.goto(`${BASE}/auth/login`);
await expect(page.locator('html')).toHaveAttribute('dir', 'ltr');
Expand Down Expand Up @@ -93,7 +95,9 @@ test('login page is LTR in English and RTL in Persian with LTR email and end-ali
await captureScreenshot(page, 'auth-login-rtl-fa.png');
});

test('recover password page is LTR in English and RTL in Persian with .rtl-flip arrow and LTR email', async ({ page }) => {
test('recover password page is LTR in English and RTL in Persian with .rtl-flip arrow and LTR email', async ({
page,
}) => {
await page.route('**/api/auth/setup/status', (route) => {
route.fulfill({
status: 200,
Expand Down Expand Up @@ -126,7 +130,9 @@ test('recover password page is LTR in English and RTL in Persian with .rtl-flip
await captureScreenshot(page, 'auth-recover-rtl-fa.png');
});

test('setup wizard renders with logical navigation, .rtl-flip directional arrows, and all selectable language options', async ({ page }) => {
test('setup wizard renders with logical navigation, .rtl-flip directional arrows, and all selectable language options', async ({
page,
}) => {
await page.route('**/api/auth/setup/status', (route) => {
route.fulfill({
status: 200,
Expand All @@ -144,10 +150,20 @@ test('setup wizard renders with logical navigation, .rtl-flip directional arrows
const languageButtons = page.locator('button', { hasText: /English|Inglés|Inglês/ });
await expect(languageButtons.first()).toBeVisible();
const allButtonsText = await page.locator('button').allInnerTexts();
const hasPersianOption = allButtonsText.some((text) => text.includes('فارسی') || text.includes('FA'));
expect(hasPersianOption, 'Persian (fa) must be available as a selectable UI language').toBeTruthy();
const hasRussianOption = allButtonsText.some((text) => text.includes('Русский') || text.includes('RU'));
expect(hasRussianOption, 'Russian (ru) must be available as a selectable UI language').toBeTruthy();
const hasPersianOption = allButtonsText.some(
(text) => text.includes('فارسی') || text.includes('FA'),
);
expect(
hasPersianOption,
'Persian (fa) must be available as a selectable UI language',
).toBeTruthy();
const hasRussianOption = allButtonsText.some(
(text) => text.includes('Русский') || text.includes('RU'),
);
expect(
hasRussianOption,
'Russian (ru) must be available as a selectable UI language',
).toBeTruthy();

// Forward arrow has rtl-flip class
const continueArrow = page.locator('button svg.rtl-flip').first();
Expand All @@ -169,7 +185,10 @@ test('setup wizard renders with logical navigation, .rtl-flip directional arrows
await page.locator('.max-h-72 button').first().click();

// Advance to Step 2 (Master PIN)
await page.locator('button', { hasText: /ادامه|Continue/ }).first().click();
await page
.locator('button', { hasText: /ادامه|Continue/ })
.first()
.click();
await expect(page.locator('#master-pin')).toBeVisible();
await captureScreenshot(page, 'setup-step2-master-pin-rtl-fa.png');

Expand All @@ -178,7 +197,10 @@ test('setup wizard renders with logical navigation, .rtl-flip directional arrows
await page.locator('#master-pin-confirm').fill('1234');
await page.locator('#owner-approval-pin').fill('5678');
await page.locator('#owner-approval-pin-confirm').fill('5678');
await page.locator('button', { hasText: /ادامه|Continue/ }).first().click();
await page
.locator('button', { hasText: /ادامه|Continue/ })
.first()
.click();

// Step 3 (Owner Account)
await expect(page.locator('#email')).toBeVisible();
Expand All @@ -191,7 +213,9 @@ test('setup wizard renders with logical navigation, .rtl-flip directional arrows
test.describe('setup with a Persian browser language', () => {
test.use({ locale: 'fa-IR' });

test('setup wizard offers Persian as a language option when the browser language is fa', async ({ page }) => {
test('setup wizard offers Persian as a language option when the browser language is fa', async ({
page,
}) => {
const hydrationErrors: string[] = [];
page.on('console', (message) => {
if (/hydration/i.test(message.text())) hydrationErrors.push(message.text());
Expand Down Expand Up @@ -223,11 +247,15 @@ test.describe('setup with a Persian browser language', () => {
// Persian is preselected (browser locale fa) and switching languages
// moves the selected state. Options re-render with localized labels, so
// target them by the constant language-code subtitle (EN/FA).
const englishOption = page.locator('button').filter({ has: page.getByText('EN', { exact: true }) });
const englishOption = page
.locator('button')
.filter({ has: page.getByText('EN', { exact: true }) });
await expect(englishOption).toBeVisible();
await englishOption.click();
await expect(englishOption).toHaveClass(/border-primary/);
const persianOptionAfterSwitch = page.locator('button').filter({ has: page.getByText('FA', { exact: true }) });
const persianOptionAfterSwitch = page
.locator('button')
.filter({ has: page.getByText('FA', { exact: true }) });
await expect(persianOptionAfterSwitch).not.toHaveClass(/border-primary/);
await persianOptionAfterSwitch.click();
await expect(persianOptionAfterSwitch).toHaveClass(/border-primary/);
Expand All @@ -236,27 +264,33 @@ test.describe('setup with a Persian browser language', () => {
});
});

test('settings renders RTL without horizontal overflow, mirrors toggles and tabs, and isolates LTR data', async ({ page }) => {
test('settings renders RTL without horizontal overflow, mirrors toggles and tabs, and isolates LTR data', async ({
page,
}) => {
await loginAsManager(page);
await setLanguage(page, 'fa');

try {
// 1. Store tab in Persian (RTL)
await page.goto(`${BASE}/settings?tab=store`);
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl');
await expect(page.locator('nav')).toBeVisible();
const settingsNav = page.locator('[data-slot="tabs"] nav');
await expect(settingsNav).toBeVisible();

// The Settings left nav mirrors to the inline-end in RTL: radix Tabs must
// follow the document direction instead of forcing dir="ltr" (Refs #241).
await expect(page.locator('[data-slot="tabs"]')).toHaveAttribute('dir', 'rtl');
const settingsNavBox = await page.locator('nav').boundingBox();
const settingsNavBox = await settingsNav.boundingBox();
expect(settingsNavBox, 'settings nav must have a bounding box').not.toBeNull();
expect(settingsNavBox!.x, 'settings nav must sit on the right side in RTL').toBeGreaterThan(
(page.viewportSize()?.width ?? 0) / 2
(page.viewportSize()?.width ?? 0) / 2,
);

// Verify language dropdown in Settings offers all selectable languages.
const languageSelect = page.locator('select').filter({ has: page.locator('option[value="en"]') }).first();
const languageSelect = page
.locator('select')
.filter({ has: page.locator('option[value="en"]') })
.first();
await expect(languageSelect).toBeVisible();
const options = await languageSelect.locator('option').all();
const optionValues = await Promise.all(options.map((opt) => opt.getAttribute('value')));
Expand All @@ -273,9 +307,10 @@ test('settings renders RTL without horizontal overflow, mirrors toggles and tabs
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(storeOverflow.scrollWidth, 'settings store tab must not overflow horizontally in RTL').toBeLessThanOrEqual(
storeOverflow.clientWidth + 1
);
expect(
storeOverflow.scrollWidth,
'settings store tab must not overflow horizontally in RTL',
).toBeLessThanOrEqual(storeOverflow.clientWidth + 1);

await captureScreenshot(page, 'settings-store-rtl-fa.png');

Expand Down Expand Up @@ -310,9 +345,10 @@ test('settings renders RTL without horizontal overflow, mirrors toggles and tabs
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(accountOverflow.scrollWidth, 'settings account tab must not overflow horizontally in RTL').toBeLessThanOrEqual(
accountOverflow.clientWidth + 1
);
expect(
accountOverflow.scrollWidth,
'settings account tab must not overflow horizontally in RTL',
).toBeLessThanOrEqual(accountOverflow.clientWidth + 1);

await captureScreenshot(page, 'settings-account-rtl-fa.png');

Expand Down
52 changes: 27 additions & 25 deletions frontend/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,31 +295,33 @@ export default function AppSidebar() {
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{navItems.map((item) => {
const [hrefPath, hrefQuery] = item.href.split('?');
const isActive =
!hrefQuery && (pathname === hrefPath || pathname?.startsWith(hrefPath + '/'));
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton asChild isActive={isActive} tooltip={t(item.labelKey)}>
<Link href={item.href} onClick={closeMobile}>
<span className="relative flex size-4 shrink-0 items-center justify-center">
<item.icon className="size-4 shrink-0" />
{item.href === '/settings' && emailNeedsAttention && (
<span
aria-label="Email verification required"
className="absolute -end-1 -top-1 h-2 w-2 rounded-full bg-red-500 ring-2 ring-sidebar"
/>
)}
</span>
<span>{t(item.labelKey)}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
<nav aria-label={tCommon('menu')}>
<SidebarMenu>
{navItems.map((item) => {
const [hrefPath, hrefQuery] = item.href.split('?');
const isActive =
!hrefQuery && (pathname === hrefPath || pathname?.startsWith(hrefPath + '/'));
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton asChild isActive={isActive} tooltip={t(item.labelKey)}>
<Link href={item.href} onClick={closeMobile}>
<span className="relative flex size-4 shrink-0 items-center justify-center">
<item.icon className="size-4 shrink-0" />
{item.href === '/settings' && emailNeedsAttention && (
<span
aria-label="Email verification required"
className="absolute -end-1 -top-1 h-2 w-2 rounded-full bg-red-500 ring-2 ring-sidebar"
/>
)}
</span>
<span>{t(item.labelKey)}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</nav>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
Expand Down
Loading