test(ui): cover Skeleton, SkeletonGroup, SearchInput, LoadingDots (TODO #2)

Adds 13 vitest cases for the Wave 9.D loading primitives; ui suite now 19/19.
Removes the resolved ROADMAP-EXEC-TODO #2 marker from Skeleton.tsx.

Verified: npx vitest run --pool forks (19 passed); npx tsc --noEmit (clean).
This commit is contained in:
saravanakumardb1 2026-05-28 17:19:04 -07:00
parent e5061350a5
commit 6cd60e86e8
2 changed files with 128 additions and 2 deletions

View File

@ -0,0 +1,128 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import * as React from 'react';
import { Skeleton, SkeletonGroup, TableSkeleton } from '../components/Skeleton.js';
import { SearchInput } from '../components/SearchInput.js';
import { LoadingDots } from '../components/LoadingDots.js';
beforeEach(() => {
cleanup();
});
describe('Skeleton', () => {
it('is aria-hidden so screen readers skip the placeholder', () => {
const { container } = render(<Skeleton />);
const node = container.firstChild as HTMLElement;
expect(node.getAttribute('aria-hidden')).toBe('true');
});
it('applies shape-specific classes for each of the 4 variants', () => {
const { container: text } = render(<Skeleton shape="text" />);
expect((text.firstChild as HTMLElement).className).toContain('rounded-full');
const { container: circle } = render(<Skeleton shape="circle" />);
expect((circle.firstChild as HTMLElement).className).toContain('h-10');
const { container: card } = render(<Skeleton shape="card" />);
expect((card.firstChild as HTMLElement).className).toContain('rounded-2xl');
const { container: block } = render(<Skeleton shape="block" />);
expect((block.firstChild as HTMLElement).className).toContain('min-h-20');
});
it('TableSkeleton renders one row per `rows`', () => {
const { container } = render(<TableSkeleton rows={4} />);
expect(container.querySelectorAll('[aria-hidden="true"]').length).toBe(4);
});
});
describe('SkeletonGroup', () => {
it('renders the fallback (not children) while loading', () => {
render(
<SkeletonGroup loading fallback={<span>placeholder</span>}>
<span>real content</span>
</SkeletonGroup>
);
expect(screen.getByText('placeholder')).toBeTruthy();
expect(screen.queryByText('real content')).toBeNull();
});
it('renders children once loading flips false', () => {
render(
<SkeletonGroup loading={false} fallback={<span>placeholder</span>}>
<span>real content</span>
</SkeletonGroup>
);
expect(screen.getByText('real content')).toBeTruthy();
expect(screen.queryByText('placeholder')).toBeNull();
});
it('keeps children mounted (hidden) while loading when keepContent is set', () => {
const { container } = render(
<SkeletonGroup loading keepContent fallback={<span>placeholder</span>}>
<span>real content</span>
</SkeletonGroup>
);
expect(container.querySelector('[data-bl-skeleton-group="loading"]')).toBeTruthy();
expect(screen.getByText('real content')).toBeTruthy();
});
});
describe('SearchInput', () => {
function Harness({ clearable }: { clearable?: boolean } = {}) {
const [v, setV] = React.useState('');
return <SearchInput value={v} onChange={setV} clearable={clearable} ariaLabel="Search items" />;
}
it('exposes a searchbox role with an accessible label', () => {
render(<Harness />);
const input = screen.getByRole('searchbox', { name: 'Search items' });
expect(input).toBeTruthy();
});
it('calls onChange with each keystroke value', () => {
const onChange = vi.fn();
render(<SearchInput value="" onChange={onChange} ariaLabel="Search" />);
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'hello' } });
expect(onChange).toHaveBeenCalledWith('hello');
});
it('shows a clear button only when value is non-empty and clears on click', () => {
render(<Harness />);
const input = screen.getByRole('searchbox');
expect(screen.queryByTestId('bl-search-clear')).toBeNull();
fireEvent.change(input, { target: { value: 'abc' } });
const clear = screen.getByTestId('bl-search-clear');
expect(clear).toBeTruthy();
fireEvent.click(clear);
expect((input as HTMLInputElement).value).toBe('');
});
it('renders a suggestions slot when provided', () => {
render(
<SearchInput value="a" onChange={() => {}} ariaLabel="Search" suggestions={<div>hint</div>} />
);
expect(screen.getByTestId('bl-search-suggestions').textContent).toContain('hint');
});
});
describe('LoadingDots', () => {
it('is a status region with an accessible label', () => {
render(<LoadingDots label="Thinking" />);
const status = screen.getByRole('status', { name: 'Thinking' });
expect(status.getAttribute('aria-busy')).toBe('true');
});
it('renders exactly three animated dots', () => {
const { container } = render(<LoadingDots />);
const dots = container.querySelectorAll('[aria-hidden="true"]');
expect(dots.length).toBe(3);
});
it('honours a custom dot colour', () => {
const { container } = render(<LoadingDots color="rgb(255, 0, 0)" />);
const firstDot = container.querySelector('[aria-hidden="true"]') as HTMLElement;
expect(firstDot.style.background).toBe('rgb(255, 0, 0)');
});
});

View File

@ -1,5 +1,3 @@
// ROADMAP-EXEC-TODO #2 — vitest setup pending in @bytelyst/ui; add Skeleton +
// SkeletonGroup tests once happy-dom + @testing-library/react devDeps land.
import * as React from 'react'; import * as React from 'react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';