diff --git a/apps/web/components/ui/__tests__/tabs.spec.tsx b/apps/web/components/ui/__tests__/tabs.spec.tsx
index 54c1e17..ccb4a53 100644
--- a/apps/web/components/ui/__tests__/tabs.spec.tsx
+++ b/apps/web/components/ui/__tests__/tabs.spec.tsx
@@ -3,88 +3,55 @@ import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../tabs';
-describe('Tabs', () => {
- it('renders the active tab content', () => {
- render(
-
+function renderTabs(value = 'tab1', onValueChange = vi.fn()) {
+ return {
+ onValueChange,
+ ...render(
+
Tab 1
Tab 2
+ Tab 3
Content 1
Content 2
+ Content 3
,
- );
+ ),
+ };
+}
+describe('Tabs', () => {
+ it('renders the active tab content', () => {
+ renderTabs('tab1');
expect(screen.getByText('Content 1')).toBeInTheDocument();
expect(screen.queryByText('Content 2')).not.toBeInTheDocument();
});
it('hides inactive tab content', () => {
- render(
-
-
- Tab 1
- Tab 2
-
- Content 1
- Content 2
- ,
- );
-
+ renderTabs('tab2');
expect(screen.queryByText('Content 1')).not.toBeInTheDocument();
expect(screen.getByText('Content 2')).toBeInTheDocument();
});
it('calls onValueChange when a trigger is clicked', async () => {
const onValueChange = vi.fn();
- render(
-
-
- Tab 1
- Tab 2
-
- Content 1
- Content 2
- ,
- );
-
+ renderTabs('tab1', onValueChange);
await userEvent.click(screen.getByText('Tab 2'));
expect(onValueChange).toHaveBeenCalledWith('tab2');
});
it('renders all trigger buttons', () => {
- render(
-
-
- First
- Second
- Third
-
- C1
- C2
- C3
- ,
- );
-
- expect(screen.getByText('First')).toBeInTheDocument();
- expect(screen.getByText('Second')).toBeInTheDocument();
- expect(screen.getByText('Third')).toBeInTheDocument();
+ renderTabs();
+ expect(screen.getByText('Tab 1')).toBeInTheDocument();
+ expect(screen.getByText('Tab 2')).toBeInTheDocument();
+ expect(screen.getByText('Tab 3')).toBeInTheDocument();
});
it('applies active styles to selected trigger', () => {
- render(
-
-
- Tab 1
- Tab 2
-
- Content
- ,
- );
-
- expect(screen.getByTestId('trigger-1')).toHaveClass('bg-background');
- expect(screen.getByTestId('trigger-2')).not.toHaveClass('bg-background');
+ renderTabs('tab1');
+ expect(screen.getByRole('tab', { name: 'Tab 1' })).toHaveClass('bg-background');
+ expect(screen.getByRole('tab', { name: 'Tab 2' })).not.toHaveClass('bg-background');
});
it('applies custom className to TabsList', () => {
@@ -96,7 +63,6 @@ describe('Tabs', () => {
Content
,
);
-
expect(screen.getByTestId('list')).toHaveClass('custom-list');
});
@@ -111,7 +77,92 @@ describe('Tabs', () => {
,
);
-
expect(screen.getByTestId('content')).toHaveClass('custom-content');
});
+
+ describe('ARIA attributes', () => {
+ it('sets role="tablist" on TabsList', () => {
+ renderTabs();
+ expect(screen.getByRole('tablist')).toBeInTheDocument();
+ });
+
+ it('sets role="tab" with aria-selected on triggers', () => {
+ renderTabs('tab1');
+ const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
+ const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
+ expect(tab1).toHaveAttribute('aria-selected', 'true');
+ expect(tab2).toHaveAttribute('aria-selected', 'false');
+ });
+
+ it('sets aria-controls on triggers matching tabpanel ids', () => {
+ renderTabs('tab1');
+ const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
+ const panel = screen.getByRole('tabpanel');
+ expect(tab1).toHaveAttribute('aria-controls', panel.id);
+ });
+
+ it('sets role="tabpanel" with aria-labelledby on content', () => {
+ renderTabs('tab1');
+ const panel = screen.getByRole('tabpanel');
+ const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
+ expect(panel).toHaveAttribute('aria-labelledby', tab1.id);
+ });
+
+ it('sets tabIndex correctly (0 for selected, -1 for others)', () => {
+ renderTabs('tab1');
+ expect(screen.getByRole('tab', { name: 'Tab 1' })).toHaveAttribute('tabindex', '0');
+ expect(screen.getByRole('tab', { name: 'Tab 2' })).toHaveAttribute('tabindex', '-1');
+ expect(screen.getByRole('tab', { name: 'Tab 3' })).toHaveAttribute('tabindex', '-1');
+ });
+ });
+
+ describe('Arrow-key navigation', () => {
+ it('moves to next tab on ArrowRight', async () => {
+ const onValueChange = vi.fn();
+ renderTabs('tab1', onValueChange);
+ screen.getByRole('tab', { name: 'Tab 1' }).focus();
+ await userEvent.keyboard('{ArrowRight}');
+ expect(onValueChange).toHaveBeenCalledWith('tab2');
+ });
+
+ it('moves to previous tab on ArrowLeft', async () => {
+ const onValueChange = vi.fn();
+ renderTabs('tab2', onValueChange);
+ screen.getByRole('tab', { name: 'Tab 2' }).focus();
+ await userEvent.keyboard('{ArrowLeft}');
+ expect(onValueChange).toHaveBeenCalledWith('tab1');
+ });
+
+ it('wraps around from last to first on ArrowRight', async () => {
+ const onValueChange = vi.fn();
+ renderTabs('tab3', onValueChange);
+ screen.getByRole('tab', { name: 'Tab 3' }).focus();
+ await userEvent.keyboard('{ArrowRight}');
+ expect(onValueChange).toHaveBeenCalledWith('tab1');
+ });
+
+ it('wraps around from first to last on ArrowLeft', async () => {
+ const onValueChange = vi.fn();
+ renderTabs('tab1', onValueChange);
+ screen.getByRole('tab', { name: 'Tab 1' }).focus();
+ await userEvent.keyboard('{ArrowLeft}');
+ expect(onValueChange).toHaveBeenCalledWith('tab3');
+ });
+
+ it('moves to first tab on Home', async () => {
+ const onValueChange = vi.fn();
+ renderTabs('tab3', onValueChange);
+ screen.getByRole('tab', { name: 'Tab 3' }).focus();
+ await userEvent.keyboard('{Home}');
+ expect(onValueChange).toHaveBeenCalledWith('tab1');
+ });
+
+ it('moves to last tab on End', async () => {
+ const onValueChange = vi.fn();
+ renderTabs('tab1', onValueChange);
+ screen.getByRole('tab', { name: 'Tab 1' }).focus();
+ await userEvent.keyboard('{End}');
+ expect(onValueChange).toHaveBeenCalledWith('tab3');
+ });
+ });
});
diff --git a/apps/web/components/ui/tabs.tsx b/apps/web/components/ui/tabs.tsx
index c7424bd..4c3deaa 100644
--- a/apps/web/components/ui/tabs.tsx
+++ b/apps/web/components/ui/tabs.tsx
@@ -6,6 +6,10 @@ import { cn } from '@/lib/utils';
interface TabsContextValue {
value: string;
onValueChange: (value: string) => void;
+ baseId: string;
+ registerTab: (value: string) => void;
+ unregisterTab: (value: string) => void;
+ tabs: string[];
}
const TabsContext = React.createContext(null);
@@ -21,25 +25,71 @@ interface TabsProps extends React.HTMLAttributes {
onValueChange: (value: string) => void;
}
+let tabsCounter = 0;
+
function Tabs({ value, onValueChange, className, ...props }: TabsProps) {
+ const [baseId] = React.useState(() => `tabs-${++tabsCounter}`);
+ const [tabs, setTabs] = React.useState([]);
+
+ const registerTab = React.useCallback((tabValue: string) => {
+ setTabs((prev) => (prev.includes(tabValue) ? prev : [...prev, tabValue]));
+ }, []);
+
+ const unregisterTab = React.useCallback((tabValue: string) => {
+ setTabs((prev) => prev.filter((t) => t !== tabValue));
+ }, []);
+
return (
-
+
);
}
const TabsList = React.forwardRef>(
- ({ className, ...props }, ref) => (
-
- ),
+ ({ className, ...props }, ref) => {
+ const { tabs, value, onValueChange } = useTabs();
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ const currentIndex = tabs.indexOf(value);
+ if (currentIndex === -1) return;
+
+ let nextIndex: number | null = null;
+
+ switch (e.key) {
+ case 'ArrowRight':
+ nextIndex = (currentIndex + 1) % tabs.length;
+ break;
+ case 'ArrowLeft':
+ nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
+ break;
+ case 'Home':
+ nextIndex = 0;
+ break;
+ case 'End':
+ nextIndex = tabs.length - 1;
+ break;
+ default:
+ return;
+ }
+
+ e.preventDefault();
+ onValueChange(tabs[nextIndex]);
+ };
+
+ return (
+
+ );
+ },
);
TabsList.displayName = 'TabsList';
@@ -49,13 +99,37 @@ interface TabsTriggerProps extends React.ButtonHTMLAttributes
const TabsTrigger = React.forwardRef(
({ className, value, ...props }, ref) => {
- const { value: selectedValue, onValueChange } = useTabs();
+ const { value: selectedValue, onValueChange, baseId, registerTab, unregisterTab } = useTabs();
+ const isSelected = selectedValue === value;
+ const internalRef = React.useRef(null);
+
+ React.useEffect(() => {
+ registerTab(value);
+ return () => unregisterTab(value);
+ }, [value, registerTab, unregisterTab]);
+
+ // Focus the newly selected tab
+ React.useEffect(() => {
+ if (isSelected && internalRef.current) {
+ internalRef.current.focus();
+ }
+ }, [isSelected]);
+
return (