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 (