- Tabs: disabled per-item (aria-disabled, native disabled, tab-underline-disabled,
arrow-key skip); aria-controls wired
- useHashTab: optional validIds fallback to defaultId
- SimulationFormPage: 3-tab refactor; SOC tab gated on review_required|done;
C2 tab disabled in create mode; contextual sticky bar hidden on C2 tab;
safeTab guard against stale hash; SOC-blocked banner removed
- fr.json: simulation.form.tab.{redTeam,soc,c2} keys
- DESIGN.md: disabled tab variant + aria-controls documented
- Tests: 253 passing (5 new Tabs disabled tests, SimulationFormPage rewritten
for tab layout)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
126 lines
5.3 KiB
TypeScript
126 lines
5.3 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { render, screen, fireEvent } from '@testing-library/react';
|
|
import { Tabs } from '@/components/Tabs';
|
|
|
|
const ITEMS = [
|
|
{ id: 'schedule', label: 'Schedule' },
|
|
{ id: 'description', label: 'Description' },
|
|
{ id: 'simulations', label: 'Simulations', count: 5 },
|
|
];
|
|
|
|
describe('Tabs', () => {
|
|
it('renders all tab labels', () => {
|
|
render(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
|
|
expect(screen.getByText('Schedule')).toBeInTheDocument();
|
|
expect(screen.getByText('Description')).toBeInTheDocument();
|
|
expect(screen.getByText('Simulations')).toBeInTheDocument();
|
|
});
|
|
|
|
it('marks the active tab with aria-selected=true', () => {
|
|
render(<Tabs items={ITEMS} activeId="description" onChange={vi.fn()} />);
|
|
const descBtn = screen.getByRole('tab', { name: /Description/i });
|
|
expect(descBtn).toHaveAttribute('aria-selected', 'true');
|
|
const schedBtn = screen.getByRole('tab', { name: /Schedule/i });
|
|
expect(schedBtn).toHaveAttribute('aria-selected', 'false');
|
|
});
|
|
|
|
it('applies tab-underline-active class to the active tab', () => {
|
|
render(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
|
|
const activeBtn = screen.getByRole('tab', { name: /Schedule/i });
|
|
expect(activeBtn).toHaveClass('tab-underline-active');
|
|
});
|
|
|
|
it('renders count pill for the simulations tab', () => {
|
|
render(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
|
|
const pill = screen.getByText('5');
|
|
expect(pill).toHaveClass('tab-count-pill');
|
|
});
|
|
|
|
it('applies tab-count-pill-active when the tab with count is active', () => {
|
|
render(<Tabs items={ITEMS} activeId="simulations" onChange={vi.fn()} />);
|
|
const pill = screen.getByText('5');
|
|
expect(pill).toHaveClass('tab-count-pill-active');
|
|
});
|
|
|
|
it('calls onChange with the tab id when clicked', () => {
|
|
const onChange = vi.fn();
|
|
render(<Tabs items={ITEMS} activeId="schedule" onChange={onChange} />);
|
|
fireEvent.click(screen.getByRole('tab', { name: /Description/i }));
|
|
expect(onChange).toHaveBeenCalledWith('description');
|
|
});
|
|
|
|
it('sets aria-controls and id on each tab button', () => {
|
|
render(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
|
|
const schedBtn = screen.getByRole('tab', { name: /Schedule/i });
|
|
expect(schedBtn).toHaveAttribute('id', 'tab-schedule');
|
|
expect(schedBtn).toHaveAttribute('aria-controls', 'tabpanel-schedule');
|
|
const simsBtn = screen.getByRole('tab', { name: /Simulations/i });
|
|
expect(simsBtn).toHaveAttribute('id', 'tab-simulations');
|
|
expect(simsBtn).toHaveAttribute('aria-controls', 'tabpanel-simulations');
|
|
});
|
|
|
|
it('ArrowRight moves focus to the next tab', () => {
|
|
const onChange = vi.fn();
|
|
render(<Tabs items={ITEMS} activeId="schedule" onChange={onChange} />);
|
|
fireEvent.keyDown(screen.getByRole('tab', { name: /Schedule/i }), { key: 'ArrowRight' });
|
|
expect(onChange).toHaveBeenCalledWith('description');
|
|
});
|
|
|
|
it('ArrowLeft wraps around to the last tab', () => {
|
|
const onChange = vi.fn();
|
|
render(<Tabs items={ITEMS} activeId="schedule" onChange={onChange} />);
|
|
fireEvent.keyDown(screen.getByRole('tab', { name: /Schedule/i }), { key: 'ArrowLeft' });
|
|
expect(onChange).toHaveBeenCalledWith('simulations');
|
|
});
|
|
|
|
it('has no rounded-md, transition-*, or shadow-* on tab buttons (brutalism)', () => {
|
|
render(<Tabs items={ITEMS} activeId="schedule" onChange={vi.fn()} />);
|
|
for (const btn of screen.getAllByRole('tab')) {
|
|
expect(btn).not.toHaveClass('rounded-md');
|
|
expect(btn.className).not.toMatch(/transition-/);
|
|
expect(btn.className).not.toMatch(/shadow-/);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Tabs — disabled state', () => {
|
|
const ITEMS_WITH_DISABLED = [
|
|
{ id: 'a', label: 'Alpha' },
|
|
{ id: 'b', label: 'Beta', disabled: true },
|
|
{ id: 'c', label: 'Gamma' },
|
|
];
|
|
|
|
it('click on disabled tab does not fire onChange', () => {
|
|
const onChange = vi.fn();
|
|
render(<Tabs items={ITEMS_WITH_DISABLED} activeId="a" onChange={onChange} />);
|
|
fireEvent.click(screen.getByRole('tab', { name: /Beta/i }));
|
|
expect(onChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('has aria-disabled="true" on disabled tab', () => {
|
|
render(<Tabs items={ITEMS_WITH_DISABLED} activeId="a" onChange={vi.fn()} />);
|
|
const btn = screen.getByRole('tab', { name: /Beta/i });
|
|
expect(btn).toHaveAttribute('aria-disabled', 'true');
|
|
});
|
|
|
|
it('applies tab-underline-disabled class on disabled tab', () => {
|
|
render(<Tabs items={ITEMS_WITH_DISABLED} activeId="a" onChange={vi.fn()} />);
|
|
const btn = screen.getByRole('tab', { name: /Beta/i });
|
|
expect(btn).toHaveClass('tab-underline-disabled');
|
|
});
|
|
|
|
it('ArrowRight skips disabled tab (a → c, skipping b)', () => {
|
|
const onChange = vi.fn();
|
|
render(<Tabs items={ITEMS_WITH_DISABLED} activeId="a" onChange={onChange} />);
|
|
fireEvent.keyDown(screen.getByRole('tab', { name: /Alpha/i }), { key: 'ArrowRight' });
|
|
expect(onChange).toHaveBeenCalledWith('c');
|
|
});
|
|
|
|
it('ArrowLeft skips disabled tab (c → a, skipping b)', () => {
|
|
const onChange = vi.fn();
|
|
render(<Tabs items={ITEMS_WITH_DISABLED} activeId="c" onChange={onChange} />);
|
|
fireEvent.keyDown(screen.getByRole('tab', { name: /Gamma/i }), { key: 'ArrowLeft' });
|
|
expect(onChange).toHaveBeenCalledWith('a');
|
|
});
|
|
});
|