import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, cleanup, fireEvent } from '@testing-library/svelte'; import SegmentedControl from './SegmentedControl.svelte'; afterEach(() => cleanup()); const opts = [ { label: 'Bookmarks', value: 'bookmarks' as const }, { label: 'Collections', value: 'collections' as const }, { label: 'History', value: 'history' as const } ]; describe('SegmentedControl', () => { it('marks the active option with aria-checked=true and a single .active class', () => { render(SegmentedControl, { props: { options: opts, value: 'collections', onchange: () => {}, ariaLabel: 'Library tab' } }); const active = screen.getByRole('radio', { name: 'Collections' }); expect(active.getAttribute('aria-checked')).toBe('true'); const inactive = screen.getByRole('radio', { name: 'Bookmarks' }); expect(inactive.getAttribute('aria-checked')).toBe('false'); }); it('fires onchange with the new value when an inactive option is clicked', () => { const onchange = vi.fn(); render(SegmentedControl, { props: { options: opts, value: 'bookmarks', onchange, ariaLabel: 'Library tab' } }); screen.getByRole('radio', { name: 'History' }).click(); expect(onchange).toHaveBeenCalledWith('history'); }); it('does not fire onchange when the currently active option is clicked again', () => { const onchange = vi.fn(); render(SegmentedControl, { props: { options: opts, value: 'bookmarks', onchange, ariaLabel: 'Library tab' } }); screen.getByRole('radio', { name: 'Bookmarks' }).click(); expect(onchange).not.toHaveBeenCalled(); }); it('exposes the group with the provided aria-label', () => { render(SegmentedControl, { props: { options: opts, value: 'bookmarks', onchange: () => {}, ariaLabel: 'Library tab' } }); expect(screen.getByRole('radiogroup', { name: 'Library tab' })).toBeTruthy(); }); it('gives the selected option a roving tabindex of 0 and the rest -1', () => { render(SegmentedControl, { props: { options: opts, value: 'collections', onchange: () => {}, ariaLabel: 'Library tab' } }); expect(screen.getByRole('radio', { name: 'Collections' }).getAttribute('tabindex')).toBe('0'); expect(screen.getByRole('radio', { name: 'Bookmarks' }).getAttribute('tabindex')).toBe('-1'); expect(screen.getByRole('radio', { name: 'History' }).getAttribute('tabindex')).toBe('-1'); }); it('selects the next option on ArrowRight/ArrowDown (selection follows focus)', async () => { const onchange = vi.fn(); render(SegmentedControl, { props: { options: opts, value: 'bookmarks', onchange, ariaLabel: 'Library tab' } }); const group = screen.getByRole('radiogroup', { name: 'Library tab' }); const first = screen.getByRole('radio', { name: 'Bookmarks' }); first.focus(); await fireEvent.keyDown(group, { key: 'ArrowRight' }); expect(onchange).toHaveBeenCalledWith('collections'); onchange.mockClear(); // Re-anchor on the first option to assert ArrowDown behaves the same. first.focus(); await fireEvent.keyDown(group, { key: 'ArrowDown' }); expect(onchange).toHaveBeenCalledWith('collections'); }); it('wraps from the first option to the last on ArrowLeft', async () => { const onchange = vi.fn(); render(SegmentedControl, { props: { options: opts, value: 'bookmarks', onchange, ariaLabel: 'Library tab' } }); await fireEvent.keyDown(screen.getByRole('radiogroup', { name: 'Library tab' }), { key: 'ArrowLeft' }); expect(onchange).toHaveBeenCalledWith('history'); }); it('jumps to the first/last option with Home/End', async () => { const onchange = vi.fn(); render(SegmentedControl, { props: { options: opts, value: 'collections', onchange, ariaLabel: 'Library tab' } }); const group = screen.getByRole('radiogroup', { name: 'Library tab' }); await fireEvent.keyDown(group, { key: 'Home' }); expect(onchange).toHaveBeenCalledWith('bookmarks'); onchange.mockClear(); await fireEvent.keyDown(group, { key: 'End' }); expect(onchange).toHaveBeenCalledWith('history'); }); // Navigation anchors on the focused button, not the (parent-controlled, // lagging) `value` prop: even when `value` never updates, consecutive // ArrowRights fired on the focused child advance step by step. This pins // both the keydown-bubbles-from-child path and the no-stick behavior. it('advances step by step on consecutive ArrowRights, following focus', async () => { const onchange = vi.fn(); render(SegmentedControl, { props: { options: opts, value: 'bookmarks', onchange, ariaLabel: 'Library tab' } }); screen.getByRole('radio', { name: 'Bookmarks' }).focus(); await fireEvent.keyDown(document.activeElement as Element, { key: 'ArrowRight' }); expect(onchange).toHaveBeenLastCalledWith('collections'); expect((document.activeElement as HTMLElement).textContent?.trim()).toBe('Collections'); // `value` is still 'bookmarks' (vi.fn never feeds it back), yet the next // press lands on the third option because it anchors on focus. await fireEvent.keyDown(document.activeElement as Element, { key: 'ArrowRight' }); expect(onchange).toHaveBeenLastCalledWith('history'); expect((document.activeElement as HTMLElement).textContent?.trim()).toBe('History'); }); });