diff --git a/desktop/src/components/chat/ImageGalleryModal.test.tsx b/desktop/src/components/chat/ImageGalleryModal.test.tsx index a6ba9b60..91ea24ac 100644 --- a/desktop/src/components/chat/ImageGalleryModal.test.tsx +++ b/desktop/src/components/chat/ImageGalleryModal.test.tsx @@ -1,14 +1,22 @@ import '@testing-library/jest-dom' -import { render } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ImageGalleryModal } from './ImageGalleryModal' import { useOverlayStore } from '../../stores/overlayStore' +import { useSettingsStore } from '../../stores/settingsStore' const images = [{ src: 'data:image/png;base64,AAAA', name: 'a.png' }] +const gallery = [ + { src: 'data:image/png;base64,AAAA', name: 'a.png' }, + { src: 'data:image/png;base64,BBBB', name: 'b.png' }, + { src: 'data:image/png;base64,CCCC', name: 'c.png' }, +] + const reset = () => { useOverlayStore.setState(useOverlayStore.getInitialState(), true) + useSettingsStore.setState({ locale: 'en' }) } beforeEach(reset) @@ -86,3 +94,73 @@ describe('ImageGalleryModal · overlay suppression', () => { expect(useOverlayStore.getState().count).toBe(0) }) }) + +describe('ImageGalleryModal · navigation', () => { + it('names both arrows, which an icon-only control otherwise lacks', () => { + render( {}} onSelect={() => {}} />) + + expect(screen.getByRole('button', { name: 'Previous image' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Next image' })).toBeInTheDocument() + }) + + it('hides the arrows for a single image', () => { + render( {}} onSelect={() => {}} />) + + expect(screen.queryByRole('button', { name: 'Previous image' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Next image' })).not.toBeInTheDocument() + }) + + it('advances and wraps past the last image', () => { + const onSelect = vi.fn() + const { rerender } = render( + {}} onSelect={onSelect} />, + ) + + fireEvent.click(screen.getByRole('button', { name: 'Next image' })) + expect(onSelect).toHaveBeenLastCalledWith(1) + + rerender( {}} onSelect={onSelect} />) + fireEvent.click(screen.getByRole('button', { name: 'Next image' })) + expect(onSelect).toHaveBeenLastCalledWith(0) + }) + + it('steps back and wraps before the first image', () => { + const onSelect = vi.fn() + render( {}} onSelect={onSelect} />) + + fireEvent.click(screen.getByRole('button', { name: 'Previous image' })) + expect(onSelect).toHaveBeenLastCalledWith(2) + }) + + it('navigates with the arrow keys, not only the buttons', () => { + const onSelect = vi.fn() + render( {}} onSelect={onSelect} />) + + fireEvent.keyDown(document, { key: 'ArrowRight' }) + expect(onSelect).toHaveBeenLastCalledWith(2) + + fireEvent.keyDown(document, { key: 'ArrowLeft' }) + expect(onSelect).toHaveBeenLastCalledWith(0) + }) + + it('ignores arrow keys once closed', () => { + const onSelect = vi.fn() + const { rerender } = render( + {}} onSelect={onSelect} />, + ) + rerender( {}} onSelect={onSelect} />) + + fireEvent.keyDown(document, { key: 'ArrowRight' }) + expect(onSelect).not.toHaveBeenCalled() + }) + + it('shows the position and closes on Escape', () => { + const onClose = vi.fn() + render( {}} />) + + expect(screen.getByText('2 / 3')).toBeInTheDocument() + + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/desktop/src/components/search/FindInPageModal.test.tsx b/desktop/src/components/search/FindInPageModal.test.tsx index e637e352..d9ef75bd 100644 --- a/desktop/src/components/search/FindInPageModal.test.tsx +++ b/desktop/src/components/search/FindInPageModal.test.tsx @@ -1,3 +1,4 @@ +import '@testing-library/jest-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { FindInPageModal } from './FindInPageModal' @@ -209,3 +210,78 @@ describe('FindInPageModal', () => { } }) }) + +describe('FindInPageModal · match stepping', () => { + function setupHighlights() { + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }) + const highlights = new Map() + class TestHighlight { + ranges: Range[] = [] + priority?: number + add(range: Range) { this.ranges.push(range) } + } + vi.stubGlobal('CSS', { highlights }) + vi.stubGlobal('Highlight', TestHighlight) + } + + beforeEach(() => { + vi.unstubAllGlobals() + useSettingsStore.setState({ locale: 'en' }) + }) + + it('names every control, none of which shows visible text', async () => { + setupHighlights() + render(<>
alpha
{}} />) + + // These three were icon-only with hardcoded English before; the file had no + // `useTranslation` at all. + expect(screen.getByRole('button', { name: 'Previous match' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Next match' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Close find bar' })).toBeInTheDocument() + }) + + it('disables stepping until there is something to step through', async () => { + setupHighlights() + render(<>
alpha beta alpha
{}} />) + + expect(screen.getByRole('button', { name: 'Next match' })).toBeDisabled() + + fireEvent.change(screen.getByPlaceholderText('Find'), { target: { value: 'alpha' } }) + await waitFor(() => expect(screen.getByRole('button', { name: 'Next match' })).toBeEnabled()) + }) + + it('steps forward and wraps at the last match', async () => { + setupHighlights() + render(<>
alpha beta alpha
{}} />) + fireEvent.change(screen.getByPlaceholderText('Find'), { target: { value: 'alpha' } }) + await waitFor(() => expect(screen.getByText('1 / 2')).toBeTruthy()) + + fireEvent.click(screen.getByRole('button', { name: 'Next match' })) + await waitFor(() => expect(screen.getByText('2 / 2')).toBeTruthy()) + + fireEvent.click(screen.getByRole('button', { name: 'Next match' })) + await waitFor(() => expect(screen.getByText('1 / 2')).toBeTruthy()) + }) + + it('steps backward and wraps at the first match', async () => { + setupHighlights() + render(<>
alpha beta alpha
{}} />) + fireEvent.change(screen.getByPlaceholderText('Find'), { target: { value: 'alpha' } }) + await waitFor(() => expect(screen.getByText('1 / 2')).toBeTruthy()) + + fireEvent.click(screen.getByRole('button', { name: 'Previous match' })) + await waitFor(() => expect(screen.getByText('2 / 2')).toBeTruthy()) + }) + + it('closes from the close button', async () => { + setupHighlights() + const onClose = vi.fn() + render(<>
alpha
) + + fireEvent.click(screen.getByRole('button', { name: 'Close find bar' })) + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/desktop/src/components/ui/keyboard.test.tsx b/desktop/src/components/ui/keyboard.test.tsx new file mode 100644 index 00000000..860dcc44 --- /dev/null +++ b/desktop/src/components/ui/keyboard.test.tsx @@ -0,0 +1,159 @@ +import type { ReactElement } from 'react' +import { render } from '@testing-library/react' +import '@testing-library/jest-dom' +import { describe, expect, it } from 'vitest' + +import { Badge, StatusDot } from './Badge' +import { Button } from './Button' +import { Card } from './Card' +import { Checkbox } from './Checkbox' +import { EmptyState } from './EmptyState' +import { ErrorState } from './ErrorState' +import { IconButton } from './IconButton' +import { Input } from './Input' +import { LoadingState } from './LoadingState' +import { Progress } from './Progress' +import { SearchField } from './SearchField' +import { SegmentedControl } from './SegmentedControl' +import { SelectField } from './SelectField' +import { Skeleton } from './Skeleton' +import { Spinner } from './Spinner' +import { Switch } from './Switch' +import { TextArea } from './TextArea' + +/** + * Keyboard reachability across the library. + * + * Independent QA left `A11Y-01` (a full keyboard tab-order audit) unrun, and + * these components now back roughly 500 controls — a decoration that picks up a + * tab stop, or a control that loses one, would spread everywhere before anyone + * noticed. Per-component suites assert their own semantics; this asserts the + * one property they must all share. + */ + +const NATIVELY_FOCUSABLE = 'a[href], button, input, select, textarea, [tabindex]' + +/** Elements a keyboard user can reach with Tab, in DOM order. */ +function tabStops(container: HTMLElement): HTMLElement[] { + return [...container.querySelectorAll(NATIVELY_FOCUSABLE)].filter((el) => { + if (el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true') return false + const tabIndex = el.getAttribute('tabindex') + if (tabIndex !== null && Number(tabIndex) < 0) return false + // `sr-only` inputs (the Switch's hidden checkbox) are still reachable — + // they are visually hidden, not removed from the sequence. + return true + }) +} + +describe('keyboard reachability', () => { + const interactive: Array<[string, ReactElement, number]> = [ + ['Button', , 1], + ['IconButton', } label="Close" />, 1], + ['Checkbox', , 1], + ['Switch', {}} />, 1], + ['Input', , 1], + ['TextArea',