mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
Add E2E tests for internationalization across multiple pages
- Implemented navigation.spec.ts to test language switching and translation of navigation elements. - Created sessions-page.spec.ts to verify translations on the sessions page, including headers, status badges, and date formatting. - Developed settings-page.spec.ts to ensure settings page content is translated and persists across sessions. - Added skills-page.spec.ts to validate translations for skill categories, action buttons, and empty states.
This commit is contained in:
203
ccw/frontend/src/components/layout/Header.test.tsx
Normal file
203
ccw/frontend/src/components/layout/Header.test.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
// ========================================
|
||||
// Header Component Tests - i18n Focus
|
||||
// ========================================
|
||||
// Tests for the header component with internationalization
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@/test/i18n';
|
||||
import { Header } from './Header';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
// Mock useTheme hook
|
||||
vi.mock('@/hooks', () => ({
|
||||
useTheme: () => ({
|
||||
isDark: false,
|
||||
toggleTheme: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Header Component - i18n Tests', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store state before each test
|
||||
useAppStore.setState({ locale: 'en' });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('language switcher visibility', () => {
|
||||
it('should render language switcher', () => {
|
||||
render(<Header />);
|
||||
|
||||
const languageSwitcher = screen.getByRole('combobox', { name: /select language/i });
|
||||
expect(languageSwitcher).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render language switcher in compact mode', () => {
|
||||
render(<Header />);
|
||||
|
||||
const languageSwitcher = screen.getByRole('combobox', { name: /select language/i });
|
||||
expect(languageSwitcher).toHaveClass('w-[110px]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('translated aria-labels', () => {
|
||||
it('should have translated aria-label for menu toggle', () => {
|
||||
render(<Header onMenuClick={vi.fn()} />);
|
||||
|
||||
const menuButton = screen.getByRole('button', { name: /toggle navigation/i });
|
||||
expect(menuButton).toBeInTheDocument();
|
||||
expect(menuButton).toHaveAttribute('aria-label');
|
||||
});
|
||||
|
||||
it('should have translated aria-label for theme toggle', () => {
|
||||
render(<Header />);
|
||||
|
||||
const themeButton = screen.getByRole('button', { name: /switch to dark mode/i });
|
||||
expect(themeButton).toBeInTheDocument();
|
||||
expect(themeButton).toHaveAttribute('aria-label');
|
||||
});
|
||||
|
||||
it('should have translated aria-label for user menu', () => {
|
||||
render(<Header />);
|
||||
|
||||
const userMenuButton = screen.getByRole('button', { name: /user menu/i });
|
||||
expect(userMenuButton).toBeInTheDocument();
|
||||
expect(userMenuButton).toHaveAttribute('aria-label');
|
||||
});
|
||||
|
||||
it('should have translated aria-label for refresh button', () => {
|
||||
render(<Header onRefresh={vi.fn()} />);
|
||||
|
||||
const refreshButton = screen.getByRole('button', { name: /refresh workspace/i });
|
||||
expect(refreshButton).toBeInTheDocument();
|
||||
expect(refreshButton).toHaveAttribute('aria-label');
|
||||
});
|
||||
});
|
||||
|
||||
describe('translated text content', () => {
|
||||
it('should display translated brand name', () => {
|
||||
render(<Header />);
|
||||
|
||||
const brandLink = screen.getByRole('link', { name: /ccw/i });
|
||||
expect(brandLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should update aria-label when locale changes', async () => {
|
||||
const { rerender } = render(<Header />);
|
||||
|
||||
// Initial locale is English
|
||||
const themeButtonEn = screen.getByRole('button', { name: /switch to dark mode/i });
|
||||
expect(themeButtonEn).toBeInTheDocument();
|
||||
|
||||
// Change locale to Chinese and re-render
|
||||
useAppStore.setState({ locale: 'zh' });
|
||||
rerender(<Header />);
|
||||
|
||||
// After locale change, the theme button should be updated
|
||||
// In Chinese, it should say "切换到深色模式"
|
||||
const themeButtonZh = screen.getByRole('button', { name: /切换到深色模式|switch to dark mode/i });
|
||||
expect(themeButtonZh).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('translated navigation items', () => {
|
||||
it('should display translated settings link in user menu', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Header />);
|
||||
|
||||
// Click user menu to show dropdown
|
||||
const userMenuButton = screen.getByRole('button', { name: /user menu/i });
|
||||
await user.click(userMenuButton);
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await waitFor(() => {
|
||||
const settingsLink = screen.getByRole('link', { name: /settings/i });
|
||||
expect(settingsLink).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display translated logout button in user menu', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Header />);
|
||||
|
||||
// Click user menu to show dropdown
|
||||
const userMenuButton = screen.getByRole('button', { name: /user menu/i });
|
||||
await user.click(userMenuButton);
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await waitFor(() => {
|
||||
const logoutButton = screen.getByRole('button', { name: /logout/i });
|
||||
expect(logoutButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('locale switching integration', () => {
|
||||
it('should reflect locale change in language switcher', async () => {
|
||||
const { rerender } = render(<Header />);
|
||||
|
||||
const languageSwitcher = screen.getByRole('combobox', { name: /select language/i });
|
||||
expect(languageSwitcher).toHaveTextContent('English');
|
||||
|
||||
// Change locale in store
|
||||
useAppStore.setState({ locale: 'zh' });
|
||||
|
||||
// Re-render header
|
||||
rerender(<Header />);
|
||||
|
||||
expect(languageSwitcher).toHaveTextContent('中文');
|
||||
});
|
||||
});
|
||||
|
||||
describe('translated project path display', () => {
|
||||
it('should display translated fallback when no project path', () => {
|
||||
render(<Header projectPath="" />);
|
||||
|
||||
// Header should render correctly even without project path
|
||||
const header = screen.getByRole('banner');
|
||||
expect(header).toBeInTheDocument();
|
||||
|
||||
// Brand link should still be present
|
||||
const brandLink = screen.getByRole('link', { name: /ccw/i });
|
||||
expect(brandLink).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display project path when provided', () => {
|
||||
render(<Header projectPath="/test/path" />);
|
||||
|
||||
// Should show the path indicator
|
||||
const pathDisplay = screen.getByTitle('/test/path');
|
||||
expect(pathDisplay).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility with i18n', () => {
|
||||
it('should maintain accessible labels across locales', () => {
|
||||
render(<Header />);
|
||||
|
||||
// Check specific buttons have proper aria-labels
|
||||
const themeButton = screen.getByRole('button', { name: /switch to dark mode/i });
|
||||
expect(themeButton).toHaveAttribute('aria-label');
|
||||
|
||||
const userMenuButton = screen.getByRole('button', { name: /user menu/i });
|
||||
expect(userMenuButton).toHaveAttribute('aria-label');
|
||||
});
|
||||
|
||||
it('should have translated title attributes', () => {
|
||||
render(<Header />);
|
||||
|
||||
// Theme button should have title attribute
|
||||
const themeButton = screen.getByRole('button', { name: /switch to dark mode/i });
|
||||
expect(themeButton).toHaveAttribute('title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('header role with i18n', () => {
|
||||
it('should have banner role for accessibility', () => {
|
||||
render(<Header />);
|
||||
|
||||
const header = screen.getByRole('banner');
|
||||
expect(header).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Workflow,
|
||||
Menu,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useTheme } from '@/hooks';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
|
||||
export interface HeaderProps {
|
||||
/** Callback to toggle mobile sidebar */
|
||||
@@ -36,6 +38,7 @@ export function Header({
|
||||
onRefresh,
|
||||
isRefreshing = false,
|
||||
}: HeaderProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
@@ -47,7 +50,7 @@ export function Header({
|
||||
// Get display path (truncate if too long)
|
||||
const displayPath = projectPath.length > 40
|
||||
? '...' + projectPath.slice(-37)
|
||||
: projectPath || 'No project selected';
|
||||
: projectPath || formatMessage({ id: 'navigation.header.noProject' });
|
||||
|
||||
return (
|
||||
<header
|
||||
@@ -62,7 +65,7 @@ export function Header({
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
onClick={onMenuClick}
|
||||
aria-label="Toggle navigation menu"
|
||||
aria-label={formatMessage({ id: 'common.aria.toggleNavigation' })}
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</Button>
|
||||
@@ -73,8 +76,8 @@ export function Header({
|
||||
className="flex items-center gap-2 text-lg font-semibold text-primary hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Workflow className="w-6 h-6" />
|
||||
<span className="hidden sm:inline">Claude Code Workflow</span>
|
||||
<span className="sm:hidden">CCW</span>
|
||||
<span className="hidden sm:inline">{formatMessage({ id: 'navigation.header.brand' })}</span>
|
||||
<span className="sm:hidden">{formatMessage({ id: 'navigation.header.brandShort' })}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -96,8 +99,8 @@ export function Header({
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label="Refresh workspace"
|
||||
title="Refresh workspace"
|
||||
aria-label={formatMessage({ id: 'common.aria.refreshWorkspace' })}
|
||||
title={formatMessage({ id: 'common.aria.refreshWorkspace' })}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn('w-5 h-5', isRefreshing && 'animate-spin')}
|
||||
@@ -105,13 +108,22 @@ export function Header({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Language switcher */}
|
||||
<LanguageSwitcher compact />
|
||||
|
||||
{/* Theme toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
aria-label={isDark
|
||||
? formatMessage({ id: 'common.aria.switchToLightMode' })
|
||||
: formatMessage({ id: 'common.aria.switchToDarkMode' })
|
||||
}
|
||||
title={isDark
|
||||
? formatMessage({ id: 'common.aria.switchToLightMode' })
|
||||
: formatMessage({ id: 'common.aria.switchToDarkMode' })
|
||||
}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="w-5 h-5" />
|
||||
@@ -126,8 +138,8 @@ export function Header({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
aria-label="User menu"
|
||||
title="User menu"
|
||||
aria-label={formatMessage({ id: 'common.aria.userMenu' })}
|
||||
title={formatMessage({ id: 'common.aria.userMenu' })}
|
||||
>
|
||||
<User className="w-5 h-5" />
|
||||
</Button>
|
||||
@@ -140,7 +152,7 @@ export function Header({
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-foreground hover:bg-hover transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>Settings</span>
|
||||
<span>{formatMessage({ id: 'navigation.header.settings' })}</span>
|
||||
</Link>
|
||||
<hr className="my-1 border-border" />
|
||||
<button
|
||||
@@ -151,7 +163,7 @@ export function Header({
|
||||
}}
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Exit Dashboard</span>
|
||||
<span>{formatMessage({ id: 'navigation.header.logout' })}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
245
ccw/frontend/src/components/layout/LanguageSwitcher.test.tsx
Normal file
245
ccw/frontend/src/components/layout/LanguageSwitcher.test.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
// ========================================
|
||||
// LanguageSwitcher Component Tests
|
||||
// ========================================
|
||||
// Tests for the language switcher component
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@/test/i18n';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
describe('LanguageSwitcher Component', () => {
|
||||
beforeEach(() => {
|
||||
// Reset store state before each test
|
||||
useAppStore.setState({ locale: 'en' });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the select component', () => {
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display current locale value', () => {
|
||||
useAppStore.setState({ locale: 'en' });
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toHaveTextContent('English');
|
||||
});
|
||||
|
||||
it('should display Chinese locale when set', () => {
|
||||
useAppStore.setState({ locale: 'zh' });
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toHaveTextContent('中文');
|
||||
});
|
||||
|
||||
it('should have aria-label for accessibility', () => {
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toHaveAttribute('aria-label', 'Select language');
|
||||
});
|
||||
|
||||
it('should render in compact mode', () => {
|
||||
render(<LanguageSwitcher compact />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toBeInTheDocument();
|
||||
expect(select).toHaveClass('w-[110px]');
|
||||
});
|
||||
|
||||
it('should render in default mode', () => {
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toBeInTheDocument();
|
||||
expect(select).toHaveClass('w-[160px]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('language options', () => {
|
||||
it('should display English option', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
await user.click(select);
|
||||
|
||||
// Wait for dropdown to appear and check for option role
|
||||
await waitFor(() => {
|
||||
const englishOption = screen.getByRole('option', { name: /English/ });
|
||||
expect(englishOption).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display Chinese option', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
await user.click(select);
|
||||
|
||||
// Wait for dropdown to appear
|
||||
await waitFor(() => {
|
||||
const chineseOption = screen.getByRole('option', { name: /中文/ });
|
||||
expect(chineseOption).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display flag icons for options', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
await user.click(select);
|
||||
|
||||
// Check for flag emojis in options
|
||||
await waitFor(() => {
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options.length).toBe(2);
|
||||
const optionsText = options.map(opt => opt.textContent).join(' ');
|
||||
expect(optionsText).toContain('🇺🇸');
|
||||
expect(optionsText).toContain('🇨🇳');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('language switching behavior', () => {
|
||||
it('should call setLocale when option is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
await user.click(select);
|
||||
|
||||
// Wait for Chinese option and click it
|
||||
await waitFor(() => {
|
||||
const chineseOption = screen.getByText('中文');
|
||||
user.click(chineseOption);
|
||||
});
|
||||
|
||||
// Verify locale was updated in store
|
||||
await waitFor(() => {
|
||||
expect(useAppStore.getState().locale).toBe('zh');
|
||||
});
|
||||
});
|
||||
|
||||
it('should switch to English when selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
useAppStore.setState({ locale: 'zh' });
|
||||
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toHaveTextContent('中文');
|
||||
|
||||
await user.click(select);
|
||||
|
||||
// Wait for English option and click it
|
||||
await waitFor(() => {
|
||||
const englishOption = screen.getByText('English');
|
||||
user.click(englishOption);
|
||||
});
|
||||
|
||||
// Verify locale was updated in store
|
||||
await waitFor(() => {
|
||||
expect(useAppStore.getState().locale).toBe('en');
|
||||
});
|
||||
});
|
||||
|
||||
it('should persist locale selection to store', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
await user.click(select);
|
||||
|
||||
await waitFor(() => {
|
||||
const chineseOption = screen.getByText('中文');
|
||||
user.click(chineseOption);
|
||||
});
|
||||
|
||||
// Check that store was updated
|
||||
await waitFor(() => {
|
||||
const storeLocale = useAppStore.getState().locale;
|
||||
expect(storeLocale).toBe('zh');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom className', () => {
|
||||
it('should apply custom className', () => {
|
||||
render(<LanguageSwitcher className="custom-class" />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should be keyboard navigable', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
select.focus();
|
||||
|
||||
expect(select).toHaveFocus();
|
||||
|
||||
// Open with Enter
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
// Should show options after opening
|
||||
await waitFor(() => {
|
||||
const englishOption = screen.getByRole('option', { name: /English/ });
|
||||
expect(englishOption).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should maintain focus management', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
await user.click(select);
|
||||
|
||||
// Focus should remain on select or move to options
|
||||
await waitFor(() => {
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with useLocale hook', () => {
|
||||
it('should reflect current locale from store', () => {
|
||||
useAppStore.setState({ locale: 'zh' });
|
||||
render(<LanguageSwitcher />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toHaveTextContent('中文');
|
||||
});
|
||||
|
||||
it('should update when store locale changes externally', async () => {
|
||||
const { rerender } = render(<LanguageSwitcher />);
|
||||
|
||||
const select = screen.getByRole('combobox');
|
||||
expect(select).toHaveTextContent('English');
|
||||
|
||||
// Update store externally
|
||||
useAppStore.setState({ locale: 'zh' });
|
||||
|
||||
// Re-render to reflect change
|
||||
rerender(<LanguageSwitcher />);
|
||||
|
||||
expect(select).toHaveTextContent('中文');
|
||||
});
|
||||
});
|
||||
});
|
||||
70
ccw/frontend/src/components/layout/LanguageSwitcher.tsx
Normal file
70
ccw/frontend/src/components/layout/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
// ========================================
|
||||
// Language Switcher Component
|
||||
// ========================================
|
||||
// Language selection dropdown with flag icons
|
||||
|
||||
import { Languages } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import { useLocale } from '@/hooks/useLocale';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface LanguageSwitcherProps {
|
||||
/** Compact variant for header (smaller, icon-only trigger) */
|
||||
compact?: boolean;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Language options with flag emojis and labels
|
||||
const LANGUAGE_OPTIONS = [
|
||||
{ value: 'en' as const, label: 'English', flag: '🇺🇸' },
|
||||
{ value: 'zh' as const, label: '中文', flag: '🇨🇳' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Language switcher component
|
||||
* Allows users to switch between English and Chinese
|
||||
*/
|
||||
export function LanguageSwitcher({ compact = false, className }: LanguageSwitcherProps) {
|
||||
const { locale, setLocale } = useLocale();
|
||||
|
||||
return (
|
||||
<Select value={locale} onValueChange={setLocale}>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
compact ? 'w-[110px]' : 'w-[160px]',
|
||||
'gap-2',
|
||||
className
|
||||
)}
|
||||
aria-label="Select language"
|
||||
>
|
||||
{compact ? (
|
||||
<>
|
||||
<Languages className="w-4 h-4" />
|
||||
<SelectValue />
|
||||
</>
|
||||
) : (
|
||||
<SelectValue />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LANGUAGE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-base">{option.flag}</span>
|
||||
<span>{option.label}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export default LanguageSwitcher;
|
||||
@@ -3,8 +3,9 @@
|
||||
// ========================================
|
||||
// Collapsible navigation sidebar with route links
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Home,
|
||||
FolderKanban,
|
||||
@@ -18,6 +19,9 @@ import {
|
||||
HelpCircle,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
LayoutDashboard,
|
||||
Clock,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -41,17 +45,21 @@ interface NavItem {
|
||||
badgeVariant?: 'default' | 'success' | 'warning' | 'info';
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ path: '/', label: 'Home', icon: Home },
|
||||
{ path: '/sessions', label: 'Sessions', icon: FolderKanban },
|
||||
{ path: '/orchestrator', label: 'Orchestrator', icon: Workflow },
|
||||
{ path: '/loops', label: 'Loop Monitor', icon: RefreshCw },
|
||||
{ path: '/issues', label: 'Issues', icon: AlertCircle },
|
||||
{ path: '/skills', label: 'Skills', icon: Sparkles },
|
||||
{ path: '/commands', label: 'Commands', icon: Terminal },
|
||||
{ path: '/memory', label: 'Memory', icon: Brain },
|
||||
{ path: '/settings', label: 'Settings', icon: Settings },
|
||||
{ path: '/help', label: 'Help', icon: HelpCircle },
|
||||
// Navigation item definitions (without labels for i18n)
|
||||
const navItemDefinitions: Omit<NavItem, 'label'>[] = [
|
||||
{ path: '/', icon: Home },
|
||||
{ path: '/sessions', icon: FolderKanban },
|
||||
{ path: '/lite-tasks', icon: Zap },
|
||||
{ path: '/project', icon: LayoutDashboard },
|
||||
{ path: '/history', icon: Clock },
|
||||
{ path: '/orchestrator', icon: Workflow },
|
||||
{ path: '/loops', icon: RefreshCw },
|
||||
{ path: '/issues', icon: AlertCircle },
|
||||
{ path: '/skills', icon: Sparkles },
|
||||
{ path: '/commands', icon: Terminal },
|
||||
{ path: '/memory', icon: Brain },
|
||||
{ path: '/settings', icon: Settings },
|
||||
{ path: '/help', icon: HelpCircle },
|
||||
];
|
||||
|
||||
export function Sidebar({
|
||||
@@ -60,6 +68,7 @@ export function Sidebar({
|
||||
mobileOpen = false,
|
||||
onMobileClose,
|
||||
}: SidebarProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const location = useLocation();
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(collapsed);
|
||||
|
||||
@@ -80,6 +89,29 @@ export function Sidebar({
|
||||
}
|
||||
}, [onMobileClose]);
|
||||
|
||||
// Build nav items with translated labels
|
||||
const navItems = useMemo(() => {
|
||||
const keyMap: Record<string, string> = {
|
||||
'/': 'main.home',
|
||||
'/sessions': 'main.sessions',
|
||||
'/lite-tasks': 'main.liteTasks',
|
||||
'/project': 'main.project',
|
||||
'/history': 'main.history',
|
||||
'/orchestrator': 'main.orchestrator',
|
||||
'/loops': 'main.loops',
|
||||
'/issues': 'main.issues',
|
||||
'/skills': 'main.skills',
|
||||
'/commands': 'main.commands',
|
||||
'/memory': 'main.memory',
|
||||
'/settings': 'main.settings',
|
||||
'/help': 'main.help',
|
||||
};
|
||||
return navItemDefinitions.map((item) => ({
|
||||
...item,
|
||||
label: formatMessage({ id: `navigation.${keyMap[item.path]}` }),
|
||||
}));
|
||||
}, [formatMessage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
@@ -103,7 +135,7 @@ export function Sidebar({
|
||||
mobileOpen && 'fixed left-0 top-14 flex translate-x-0 z-50 h-[calc(100vh-56px)] w-64 shadow-lg'
|
||||
)}
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
aria-label={formatMessage({ id: 'header.brand' })}
|
||||
>
|
||||
<nav className="flex-1 py-3 overflow-y-auto">
|
||||
<ul className="space-y-1 px-2">
|
||||
@@ -164,14 +196,17 @@ export function Sidebar({
|
||||
'w-full flex items-center gap-2 text-muted-foreground hover:text-foreground',
|
||||
isCollapsed && 'justify-center'
|
||||
)}
|
||||
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
aria-label={isCollapsed
|
||||
? formatMessage({ id: 'navigation.sidebar.expand' })
|
||||
: formatMessage({ id: 'navigation.sidebar.collapseAria' })
|
||||
}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpen className="w-4 h-4" />
|
||||
) : (
|
||||
<>
|
||||
<PanelLeftClose className="w-4 h-4" />
|
||||
<span>Collapse</span>
|
||||
<span>{formatMessage({ id: 'navigation.sidebar.collapse' })}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user