Skip to content

Commit 8c644cf

Browse files
authored
Merge pull request #376 from Ved-viraj/feature/implement-frontend-tabs
feat: implement frontend Tabs component with accessibility, animation…
2 parents 46b45a3 + 9bb624a commit 8c644cf

3 files changed

Lines changed: 977 additions & 0 deletions

File tree

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import '@testing-library/jest-dom';
5+
import { axe, toHaveNoViolations } from 'jest-axe';
6+
import Tabs, { TabItem, TabsProps } from './Tabs';
7+
8+
expect.extend(toHaveNoViolations);
9+
10+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
11+
12+
const basicTabs: TabItem[] = [
13+
{ id: 'tab1', label: 'Tab One', content: <p>Content One</p> },
14+
{ id: 'tab2', label: 'Tab Two', content: <p>Content Two</p> },
15+
{ id: 'tab3', label: 'Tab Three', content: <p>Content Three</p> },
16+
];
17+
18+
const tabsWithDisabled: TabItem[] = [
19+
{ id: 'tab1', label: 'Tab One', content: <p>Content One</p> },
20+
{ id: 'tab2', label: 'Tab Two', content: <p>Content Two</p>, disabled: true },
21+
{ id: 'tab3', label: 'Tab Three', content: <p>Content Three</p> },
22+
];
23+
24+
const renderTabs = (props: Partial<TabsProps> = {}) =>
25+
render(<Tabs tabs={basicTabs} ariaLabel="Test tabs" {...props} />);
26+
27+
// ─── Rendering ────────────────────────────────────────────────────────────────
28+
29+
describe('Tabs – Rendering', () => {
30+
test('renders all tab triggers', () => {
31+
renderTabs();
32+
expect(screen.getByRole('tab', { name: 'Tab One' })).toBeInTheDocument();
33+
expect(screen.getByRole('tab', { name: 'Tab Two' })).toBeInTheDocument();
34+
expect(screen.getByRole('tab', { name: 'Tab Three' })).toBeInTheDocument();
35+
});
36+
37+
test('renders tablist with correct aria-label', () => {
38+
renderTabs({ ariaLabel: 'My tabs' });
39+
expect(screen.getByRole('tablist', { name: 'My tabs' })).toBeInTheDocument();
40+
});
41+
42+
test('first enabled tab is active by default', () => {
43+
renderTabs();
44+
expect(screen.getByRole('tab', { name: 'Tab One' })).toHaveAttribute(
45+
'aria-selected',
46+
'true',
47+
);
48+
});
49+
50+
test('defaultActiveTab sets initial active tab', () => {
51+
renderTabs({ defaultActiveTab: 'tab2' });
52+
expect(screen.getByRole('tab', { name: 'Tab Two' })).toHaveAttribute(
53+
'aria-selected',
54+
'true',
55+
);
56+
});
57+
58+
test('active panel content is visible', () => {
59+
renderTabs();
60+
expect(screen.getByText('Content One')).toBeVisible();
61+
});
62+
63+
test('inactive panel content is hidden', () => {
64+
renderTabs();
65+
const tab2 = screen.getByRole('tab', { name: 'Tab Two' });
66+
const panelId = tab2.getAttribute('aria-controls')!;
67+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
68+
const panel2 = document.getElementById(panelId)!;
69+
expect(panel2).toHaveAttribute('hidden');
70+
});
71+
72+
test('applies data-variant attribute', () => {
73+
const { container } = renderTabs({ variant: 'pills' });
74+
expect(container.querySelector('.tabs')).toHaveAttribute('data-variant', 'pills');
75+
});
76+
77+
test('applies data-size attribute', () => {
78+
const { container } = renderTabs({ size: 'lg' });
79+
expect(container.querySelector('.tabs')).toHaveAttribute('data-size', 'lg');
80+
});
81+
82+
test('applies data-orientation attribute', () => {
83+
const { container } = renderTabs({ orientation: 'vertical' });
84+
expect(container.querySelector('.tabs')).toHaveAttribute(
85+
'data-orientation',
86+
'vertical',
87+
);
88+
});
89+
90+
test('applies custom className', () => {
91+
const { container } = renderTabs({ className: 'my-custom-class' });
92+
expect(container.querySelector('.tabs')).toHaveClass('my-custom-class');
93+
});
94+
});
95+
96+
// ─── Interaction ──────────────────────────────────────────────────────────────
97+
98+
describe('Tabs – Interaction', () => {
99+
test('clicking a tab makes it active', async () => {
100+
const user = userEvent.setup();
101+
renderTabs();
102+
await user.click(screen.getByRole('tab', { name: 'Tab Two' }));
103+
expect(screen.getByRole('tab', { name: 'Tab Two' })).toHaveAttribute(
104+
'aria-selected',
105+
'true',
106+
);
107+
});
108+
109+
test('clicking a tab shows its panel content', async () => {
110+
const user = userEvent.setup();
111+
renderTabs();
112+
await user.click(screen.getByRole('tab', { name: 'Tab Two' }));
113+
expect(screen.getByText('Content Two')).toBeVisible();
114+
});
115+
116+
test('clicking a tab hides the previously active panel', async () => {
117+
const user = userEvent.setup();
118+
renderTabs();
119+
await user.click(screen.getByRole('tab', { name: 'Tab Two' }));
120+
const tab1 = screen.getByRole('tab', { name: 'Tab One' });
121+
const panelId = tab1.getAttribute('aria-controls')!;
122+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
123+
const panel1 = document.getElementById(panelId)!;
124+
expect(panel1).toHaveAttribute('hidden');
125+
});
126+
127+
test('calls onTabChange with the correct id', async () => {
128+
const user = userEvent.setup();
129+
const onTabChange = jest.fn();
130+
renderTabs({ onTabChange });
131+
await user.click(screen.getByRole('tab', { name: 'Tab Three' }));
132+
expect(onTabChange).toHaveBeenCalledWith('tab3');
133+
});
134+
135+
test('disabled tab cannot be clicked', async () => {
136+
const user = userEvent.setup();
137+
const onTabChange = jest.fn();
138+
render(
139+
<Tabs tabs={tabsWithDisabled} ariaLabel="Test tabs" onTabChange={onTabChange} />,
140+
);
141+
await user.click(screen.getByRole('tab', { name: 'Tab Two' }));
142+
expect(onTabChange).not.toHaveBeenCalled();
143+
});
144+
145+
test('disabled tab has aria-disabled', () => {
146+
render(<Tabs tabs={tabsWithDisabled} ariaLabel="Test tabs" />);
147+
expect(screen.getByRole('tab', { name: 'Tab Two' })).toHaveAttribute(
148+
'aria-disabled',
149+
'true',
150+
);
151+
});
152+
});
153+
154+
// ─── Controlled mode ──────────────────────────────────────────────────────────
155+
156+
describe('Tabs – Controlled mode', () => {
157+
test('renders with controlled activeTab', () => {
158+
renderTabs({ activeTab: 'tab3' });
159+
expect(screen.getByRole('tab', { name: 'Tab Three' })).toHaveAttribute(
160+
'aria-selected',
161+
'true',
162+
);
163+
});
164+
165+
test('does not change active tab without external state update', async () => {
166+
const user = userEvent.setup();
167+
renderTabs({ activeTab: 'tab1' });
168+
await user.click(screen.getByRole('tab', { name: 'Tab Two' }));
169+
// Still tab1 because no state update
170+
expect(screen.getByRole('tab', { name: 'Tab One' })).toHaveAttribute(
171+
'aria-selected',
172+
'true',
173+
);
174+
});
175+
});
176+
177+
// ─── Keyboard Navigation ──────────────────────────────────────────────────────
178+
179+
describe('Tabs – Keyboard navigation', () => {
180+
test('ArrowRight moves focus to next tab', () => {
181+
renderTabs();
182+
const tab1 = screen.getByRole('tab', { name: 'Tab One' });
183+
tab1.focus();
184+
fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowRight' });
185+
expect(screen.getByRole('tab', { name: 'Tab Two' })).toHaveFocus();
186+
});
187+
188+
test('ArrowLeft moves focus to previous tab', () => {
189+
renderTabs({ defaultActiveTab: 'tab2' });
190+
const tab2 = screen.getByRole('tab', { name: 'Tab Two' });
191+
tab2.focus();
192+
fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowLeft' });
193+
expect(screen.getByRole('tab', { name: 'Tab One' })).toHaveFocus();
194+
});
195+
196+
test('ArrowRight wraps from last to first tab', () => {
197+
renderTabs({ defaultActiveTab: 'tab3' });
198+
const tab3 = screen.getByRole('tab', { name: 'Tab Three' });
199+
tab3.focus();
200+
fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowRight' });
201+
expect(screen.getByRole('tab', { name: 'Tab One' })).toHaveFocus();
202+
});
203+
204+
test('Home key moves focus to first tab', () => {
205+
renderTabs({ defaultActiveTab: 'tab3' });
206+
const tab3 = screen.getByRole('tab', { name: 'Tab Three' });
207+
tab3.focus();
208+
fireEvent.keyDown(screen.getByRole('tablist'), { key: 'Home' });
209+
expect(screen.getByRole('tab', { name: 'Tab One' })).toHaveFocus();
210+
});
211+
212+
test('End key moves focus to last tab', () => {
213+
renderTabs();
214+
const tab1 = screen.getByRole('tab', { name: 'Tab One' });
215+
tab1.focus();
216+
fireEvent.keyDown(screen.getByRole('tablist'), { key: 'End' });
217+
expect(screen.getByRole('tab', { name: 'Tab Three' })).toHaveFocus();
218+
});
219+
220+
test('ArrowDown moves focus in vertical orientation', () => {
221+
renderTabs({ orientation: 'vertical' });
222+
const tab1 = screen.getByRole('tab', { name: 'Tab One' });
223+
tab1.focus();
224+
fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowDown' });
225+
expect(screen.getByRole('tab', { name: 'Tab Two' })).toHaveFocus();
226+
});
227+
228+
test('ArrowUp moves focus in vertical orientation', () => {
229+
renderTabs({ orientation: 'vertical', defaultActiveTab: 'tab2' });
230+
const tab2 = screen.getByRole('tab', { name: 'Tab Two' });
231+
tab2.focus();
232+
fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowUp' });
233+
expect(screen.getByRole('tab', { name: 'Tab One' })).toHaveFocus();
234+
});
235+
236+
test('skips disabled tabs during keyboard navigation', () => {
237+
render(<Tabs tabs={tabsWithDisabled} ariaLabel="Test tabs" />);
238+
const tab1 = screen.getByRole('tab', { name: 'Tab One' });
239+
tab1.focus();
240+
fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowRight' });
241+
// tab2 is disabled, so focus should go to tab3
242+
expect(screen.getByRole('tab', { name: 'Tab Three' })).toHaveFocus();
243+
});
244+
});
245+
246+
// ─── Lazy rendering ───────────────────────────────────────────────────────────
247+
248+
describe('Tabs – Lazy rendering', () => {
249+
test('does not render inactive panel content when lazy=true', () => {
250+
renderTabs({ lazy: true });
251+
expect(screen.queryByText('Content Two')).not.toBeInTheDocument();
252+
expect(screen.queryByText('Content Three')).not.toBeInTheDocument();
253+
});
254+
255+
test('renders panel content after it has been activated', async () => {
256+
const user = userEvent.setup();
257+
renderTabs({ lazy: true });
258+
await user.click(screen.getByRole('tab', { name: 'Tab Two' }));
259+
expect(screen.getByText('Content Two')).toBeInTheDocument();
260+
});
261+
262+
test('keeps previously activated panel in DOM', async () => {
263+
const user = userEvent.setup();
264+
renderTabs({ lazy: true });
265+
await user.click(screen.getByRole('tab', { name: 'Tab Two' }));
266+
await user.click(screen.getByRole('tab', { name: 'Tab One' }));
267+
// Tab Two was activated, so its content should still be in DOM (just hidden)
268+
expect(screen.getByText('Content Two')).toBeInTheDocument();
269+
});
270+
});
271+
272+
// ─── Variants ─────────────────────────────────────────────────────────────────
273+
274+
describe('Tabs – Variants', () => {
275+
const variants: TabsProps['variant'][] = ['default', 'bordered', 'pills', 'underline'];
276+
277+
variants.forEach((variant) => {
278+
test(`renders ${variant} variant without errors`, () => {
279+
const { container } = renderTabs({ variant });
280+
expect(container.querySelector('.tabs')).toBeInTheDocument();
281+
});
282+
});
283+
});
284+
285+
// ─── Icon & Badge ─────────────────────────────────────────────────────────────
286+
287+
describe('Tabs – Icon and Badge', () => {
288+
const tabsWithExtras: TabItem[] = [
289+
{
290+
id: 'tab1',
291+
label: 'Notifications',
292+
content: <p>Notifications content</p>,
293+
icon: <span data-testid="icon-bell">🔔</span>,
294+
badge: 5,
295+
},
296+
{ id: 'tab2', label: 'Settings', content: <p>Settings content</p> },
297+
];
298+
299+
test('renders icon inside trigger', () => {
300+
render(<Tabs tabs={tabsWithExtras} ariaLabel="Test tabs" />);
301+
expect(screen.getByTestId('icon-bell')).toBeInTheDocument();
302+
});
303+
304+
test('renders badge inside trigger', () => {
305+
render(<Tabs tabs={tabsWithExtras} ariaLabel="Test tabs" />);
306+
expect(screen.getByText('5')).toBeInTheDocument();
307+
});
308+
});
309+
310+
// ─── Accessibility ────────────────────────────────────────────────────────────
311+
312+
describe('Tabs – Accessibility', () => {
313+
test('active tab has tabIndex 0, inactive tabs have tabIndex -1', () => {
314+
renderTabs();
315+
expect(screen.getByRole('tab', { name: 'Tab One' })).toHaveAttribute('tabindex', '0');
316+
expect(screen.getByRole('tab', { name: 'Tab Two' })).toHaveAttribute('tabindex', '-1');
317+
});
318+
319+
test('each panel is labelled by its trigger', () => {
320+
renderTabs();
321+
const tab1 = screen.getByRole('tab', { name: 'Tab One' });
322+
const panel1 = screen.getByRole('tabpanel', { name: 'Tab One' });
323+
expect(panel1).toHaveAttribute('aria-labelledby', tab1.id);
324+
});
325+
326+
test('tablist has aria-orientation for horizontal', () => {
327+
renderTabs({ orientation: 'horizontal' });
328+
expect(screen.getByRole('tablist')).toHaveAttribute(
329+
'aria-orientation',
330+
'horizontal',
331+
);
332+
});
333+
334+
test('tablist has aria-orientation for vertical', () => {
335+
renderTabs({ orientation: 'vertical' });
336+
expect(screen.getByRole('tablist')).toHaveAttribute('aria-orientation', 'vertical');
337+
});
338+
339+
test('passes axe accessibility audit', async () => {
340+
const { container } = renderTabs();
341+
const results = await axe(container);
342+
expect(results).toHaveNoViolations();
343+
});
344+
345+
test('passes axe audit for vertical orientation', async () => {
346+
const { container } = renderTabs({ orientation: 'vertical' });
347+
const results = await axe(container);
348+
expect(results).toHaveNoViolations();
349+
});
350+
351+
test('passes axe audit for pills variant', async () => {
352+
const { container } = renderTabs({ variant: 'pills' });
353+
const results = await axe(container);
354+
expect(results).toHaveNoViolations();
355+
});
356+
});

0 commit comments

Comments
 (0)