diff --git a/packages/ui/src/__tests__/loading-primitives.test.tsx b/packages/ui/src/__tests__/loading-primitives.test.tsx
new file mode 100644
index 00000000..64c4d37f
--- /dev/null
+++ b/packages/ui/src/__tests__/loading-primitives.test.tsx
@@ -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();
+ 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();
+ expect((text.firstChild as HTMLElement).className).toContain('rounded-full');
+
+ const { container: circle } = render();
+ expect((circle.firstChild as HTMLElement).className).toContain('h-10');
+
+ const { container: card } = render();
+ expect((card.firstChild as HTMLElement).className).toContain('rounded-2xl');
+
+ const { container: block } = render();
+ expect((block.firstChild as HTMLElement).className).toContain('min-h-20');
+ });
+
+ it('TableSkeleton renders one row per `rows`', () => {
+ const { container } = render();
+ expect(container.querySelectorAll('[aria-hidden="true"]').length).toBe(4);
+ });
+});
+
+describe('SkeletonGroup', () => {
+ it('renders the fallback (not children) while loading', () => {
+ render(
+ placeholder}>
+ real content
+
+ );
+ expect(screen.getByText('placeholder')).toBeTruthy();
+ expect(screen.queryByText('real content')).toBeNull();
+ });
+
+ it('renders children once loading flips false', () => {
+ render(
+ placeholder}>
+ real content
+
+ );
+ 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(
+ placeholder}>
+ real content
+
+ );
+ 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 ;
+ }
+
+ it('exposes a searchbox role with an accessible label', () => {
+ render();
+ const input = screen.getByRole('searchbox', { name: 'Search items' });
+ expect(input).toBeTruthy();
+ });
+
+ it('calls onChange with each keystroke value', () => {
+ const onChange = vi.fn();
+ render();
+ 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();
+ 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(
+ {}} ariaLabel="Search" suggestions={hint
} />
+ );
+ expect(screen.getByTestId('bl-search-suggestions').textContent).toContain('hint');
+ });
+});
+
+describe('LoadingDots', () => {
+ it('is a status region with an accessible label', () => {
+ render();
+ const status = screen.getByRole('status', { name: 'Thinking' });
+ expect(status.getAttribute('aria-busy')).toBe('true');
+ });
+
+ it('renders exactly three animated dots', () => {
+ const { container } = render();
+ const dots = container.querySelectorAll('[aria-hidden="true"]');
+ expect(dots.length).toBe(3);
+ });
+
+ it('honours a custom dot colour', () => {
+ const { container } = render();
+ const firstDot = container.querySelector('[aria-hidden="true"]') as HTMLElement;
+ expect(firstDot.style.background).toBe('rgb(255, 0, 0)');
+ });
+});
diff --git a/packages/ui/src/components/Skeleton.tsx b/packages/ui/src/components/Skeleton.tsx
index 1baca0d3..c21f36c4 100644
--- a/packages/ui/src/components/Skeleton.tsx
+++ b/packages/ui/src/components/Skeleton.tsx
@@ -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 { clsx } from 'clsx';