diff --git a/.drone.yml b/.drone.yml
index 3dd34c1..d05de72 100644
--- a/.drone.yml
+++ b/.drone.yml
@@ -27,6 +27,14 @@ steps:
commands:
- yarn build
+ - name: unit-tests
+ image: node:25
+ environment:
+ NODE_OPTIONS: --no-webstorage
+ commands:
+ - yarn test:coverage
+ - test -f coverage/lcov.info
+
- name: code-analysis
when:
event:
diff --git a/eslint.config.mjs b/eslint.config.mjs
index e428a80..d46578e 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -45,4 +45,14 @@ export default tseslint.config(
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
+ {
+ files: ['tests/**/*.{ts,tsx}'],
+ languageOptions: {
+ globals: {
+ ...globals.browser,
+ ...globals.node,
+ ...globals.vitest,
+ },
+ },
+ },
);
diff --git a/package.json b/package.json
index 96ddb95..0b81a79 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,9 @@
"scripts": {
"clean": "rm -rf dist",
"build": "yarn clean && vite build && tsc -p tsconfig.build.json && mkdir -p dist/styles && cp src/styles/base.css dist/styles/base.css && tailwindcss -c tailwind.build.config.cjs -i src/styles/components.css -o dist/styles/components.css --minify && tailwindcss -c tailwind.build.config.cjs -i src/styles/utilities.css -o dist/styles/utilities.css --minify && cp tailwind-preset.cjs dist/tailwind-preset.cjs",
+ "test": "vitest run",
+ "test:coverage": "vitest run --coverage --coverage.reporter=lcov --coverage.reporter=text-summary",
+ "test:watch": "vitest",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier . --write",
@@ -64,13 +67,17 @@
"@storybook/react": "^10.2.10",
"@storybook/react-vite": "^10.2.10",
"@testing-library/dom": "^10.4.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.0",
+ "@vitest/coverage-v8": "^4.0.18",
"eslint": "^10",
"eslint-plugin-react-hooks": "^7.1.0-canary-ab18f33d-20260220",
"eslint-plugin-react-refresh": "^0.5.1",
"globals": "^17.3.0",
+ "jsdom": "^28.1.0",
"postcss": "^8.4.49",
"prettier": "^3.8.1",
"react": "^19.0.0",
@@ -81,6 +88,7 @@
"typescript": "^5.6.2",
"typescript-eslint": "^8.56.0",
"vite": "^7.0.0",
+ "vitest": "^4.0.18",
"yjs": "^13.6.24"
}
}
diff --git a/tests/components/Button.test.tsx b/tests/components/Button.test.tsx
new file mode 100644
index 0000000..e68f847
--- /dev/null
+++ b/tests/components/Button.test.tsx
@@ -0,0 +1,71 @@
+import { HomeIcon } from '@heroicons/react/24/solid';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { Button } from '../../src/components/Button';
+import { renderWithRouter } from '../helpers/renderWithRouter';
+
+describe('Button', () => {
+ it('renders native button with expected type and disabled state', () => {
+ render();
+
+ const button = screen.getByRole('button', { name: 'Save' });
+ expect(button.tagName).toBe('BUTTON');
+ expect(button).toHaveAttribute('type', 'submit');
+ expect(button).toBeDisabled();
+ expect(button).toHaveClass('btn-solid');
+ expect(button).toHaveClass('btn-primary');
+ });
+
+ it('defaults non-solid button variants to secondary', () => {
+ render();
+ expect(screen.getByRole('button', { name: 'Details' })).toHaveClass('btn-secondary');
+ });
+
+ it('renders icon-only button and custom aria label', () => {
+ render();
+
+ const button = screen.getByRole('button', { name: 'Open home' });
+ expect(button).toBeInTheDocument();
+ expect(button).toHaveClass('!p-0');
+ expect(button.textContent).toBe('');
+ });
+
+ it('renders link button and prevents click when disabled', () => {
+ const onClick = vi.fn();
+ renderWithRouter(
+ ,
+ );
+
+ const link = screen.getByRole('link', { name: 'Go home' });
+ fireEvent.click(link);
+
+ expect(link).toHaveAttribute('aria-disabled', 'true');
+ expect(link).toHaveAttribute('tabindex', '-1');
+ expect(onClick).not.toHaveBeenCalled();
+ });
+
+ it('calls onClick for enabled link buttons', () => {
+ const onClick = vi.fn();
+ renderWithRouter();
+
+ fireEvent.click(screen.getByRole('link', { name: 'Profile' }));
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders icon+label spacing and custom class names', () => {
+ render(
+ ,
+ );
+
+ const button = screen.getByRole('button', { name: 'Home' });
+ expect(button).toHaveClass('gap-1.5');
+ expect(button).toHaveClass('custom-button');
+ expect(button).toHaveClass('w-full');
+ });
+});
diff --git a/tests/components/Chip.test.tsx b/tests/components/Chip.test.tsx
new file mode 100644
index 0000000..8b666dd
--- /dev/null
+++ b/tests/components/Chip.test.tsx
@@ -0,0 +1,54 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { Chip } from '../../src/components/Chip';
+
+describe('Chip', () => {
+ it('renders default span with solid classes', () => {
+ render(Default);
+
+ const chip = screen.getByText('Default');
+ expect(chip.tagName).toBe('SPAN');
+ expect(chip.className).toContain('chip-solid');
+ });
+
+ it('supports outlined variant with valid tone', () => {
+ render(
+
+ Custom
+ ,
+ );
+
+ const chip = screen.getByText('Custom');
+ expect(chip.tagName).toBe('DIV');
+ expect(chip.className).toContain('chip-outlined');
+ expect(chip).toHaveStyle({
+ borderColor: 'rgb(67, 56, 202)',
+ color: 'rgb(67, 56, 202)',
+ });
+ });
+
+ it('supports direct tone tokens without shades for solid variant', () => {
+ render(Solid);
+
+ expect(screen.getByText('Solid')).toHaveStyle({
+ borderColor: 'rgb(255, 255, 255)',
+ backgroundColor: 'rgb(255, 255, 255)',
+ color: 'rgb(255, 255, 255)',
+ });
+ });
+
+ it('ignores invalid/empty tones and keeps className', () => {
+ const { rerender } = render(
+
+ Invalid
+ ,
+ );
+
+ const invalid = screen.getByText('Invalid');
+ expect(invalid).toHaveClass('chip-custom');
+ expect(invalid.getAttribute('style')).toBeNull();
+
+ rerender(Blank);
+ expect(screen.getByText('Blank').getAttribute('style')).toBeNull();
+ });
+});
diff --git a/tests/components/DatePicker.test.tsx b/tests/components/DatePicker.test.tsx
new file mode 100644
index 0000000..00a1e3f
--- /dev/null
+++ b/tests/components/DatePicker.test.tsx
@@ -0,0 +1,62 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { DatePicker } from '../../src/components/DatePicker';
+
+describe('DatePicker', () => {
+ it('supports datetime-local type and change callback', () => {
+ const onChange = vi.fn();
+ render();
+
+ const input = screen.getByLabelText('Schedule') as HTMLInputElement;
+ expect(input.type).toBe('datetime-local');
+
+ fireEvent.change(input, { target: { value: '2031-05-20T14:30' } });
+ expect(onChange).toHaveBeenCalledTimes(1);
+ });
+
+ it('supports date type and disabled state', () => {
+ render(
+ {}}
+ disabled
+ />,
+ );
+
+ const input = screen.getByLabelText('Publish date') as HTMLInputElement;
+ expect(input.type).toBe('date');
+ expect(input).toBeDisabled();
+ });
+
+ it('renders right icon and error message', () => {
+ const { container } = render(
+ {}}
+ rightIcon={R}
+ error="Invalid date"
+ inputClassName="custom-input"
+ />,
+ );
+
+ const input = container.querySelector('input');
+ expect(input).toBeInstanceOf(HTMLInputElement);
+ expect(screen.getByTestId('right-icon')).toBeInTheDocument();
+ expect(input).toHaveClass('pr-10');
+ expect(input).toHaveClass('custom-input');
+ expect(screen.getByText('Invalid date')).toBeInTheDocument();
+ });
+
+ it('supports inline layout', () => {
+ const { container } = render(
+ {}} layout="inline" />,
+ );
+
+ expect(container.querySelector('label')).toHaveClass('inline-flex');
+ expect(container.querySelector('label > div')).not.toHaveClass('mt-1');
+ });
+});
diff --git a/tests/components/Dropdown.test.tsx b/tests/components/Dropdown.test.tsx
new file mode 100644
index 0000000..6ca63c6
--- /dev/null
+++ b/tests/components/Dropdown.test.tsx
@@ -0,0 +1,55 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { Dropdown } from '../../src/components/Dropdown';
+
+const choices = [
+ { id: 'USER', label: 'User' },
+ { id: 'ADMIN', label: 'Admin' },
+];
+
+describe('Dropdown', () => {
+ it('calls onChange with selected value', () => {
+ const onChange = vi.fn();
+ render();
+
+ fireEvent.change(screen.getByLabelText('Role'), { target: { value: 'ADMIN' } });
+ expect(onChange).toHaveBeenCalledWith('ADMIN');
+ });
+
+ it('supports inline layout and disabled/required state', () => {
+ const { container } = render(
+ ,
+ );
+
+ const select = screen.getByLabelText('Rows');
+ expect(select).toBeDisabled();
+ expect(select).toBeRequired();
+ expect(container.querySelector('label')).toHaveClass('inline-flex');
+ });
+
+ it('renders error and custom class names', () => {
+ const { container } = render(
+ ,
+ );
+
+ const select = container.querySelector('select');
+ expect(select).toBeInstanceOf(HTMLSelectElement);
+ expect(screen.getByText('Role is invalid')).toBeInTheDocument();
+ expect(select).toHaveClass('custom-select');
+ expect(screen.getByText('Role').closest('label')).toHaveClass('custom-wrapper');
+ });
+});
diff --git a/tests/components/Form.test.tsx b/tests/components/Form.test.tsx
new file mode 100644
index 0000000..4a31692
--- /dev/null
+++ b/tests/components/Form.test.tsx
@@ -0,0 +1,28 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { Form } from '../../src/components/Form';
+
+describe('Form', () => {
+ it('renders title, title actions and children', () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText('User Details')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
+ expect(screen.getByText('Form child')).toBeInTheDocument();
+ });
+
+ it('supports custom class names and optional title actions', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.firstElementChild).toHaveClass('form-custom');
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
+});
diff --git a/tests/components/InputField.test.tsx b/tests/components/InputField.test.tsx
new file mode 100644
index 0000000..43f3dc4
--- /dev/null
+++ b/tests/components/InputField.test.tsx
@@ -0,0 +1,92 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { createRef } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { InputField } from '../../src/components/InputField';
+
+describe('InputField', () => {
+ it('supports email type and emits change/blur callbacks', () => {
+ const onChange = vi.fn();
+ const onBlur = vi.fn();
+ render(
+ ,
+ );
+
+ const input = screen.getByLabelText('Email') as HTMLInputElement;
+ expect(input.type).toBe('email');
+ expect(input).toBeRequired();
+
+ fireEvent.change(input, { target: { value: 'new@example.com' } });
+ fireEvent.blur(input);
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(onBlur).toHaveBeenCalledTimes(1);
+ });
+
+ it('passes refs to input element', () => {
+ const inputRef = createRef();
+ render( {}} inputRef={inputRef} />);
+
+ expect(inputRef.current).toBeInstanceOf(HTMLInputElement);
+ expect(inputRef.current?.value).toBe('john');
+ });
+
+ it('toggles password visibility', () => {
+ render( {}} />);
+
+ expect((screen.getByLabelText('Password') as HTMLInputElement).type).toBe('password');
+ fireEvent.click(screen.getByRole('button', { name: 'Show password' }));
+ expect((screen.getByLabelText('Password') as HTMLInputElement).type).toBe('text');
+ fireEvent.click(screen.getByRole('button', { name: 'Hide password' }));
+ expect((screen.getByLabelText('Password') as HTMLInputElement).type).toBe('password');
+ });
+
+ it('renders rightIcon for non-password input and displays errors', () => {
+ const { container } = render(
+ {}}
+ rightIcon={R}
+ error="Invalid username"
+ inputClassName="custom-input"
+ />,
+ );
+
+ const input = container.querySelector('input');
+ expect(input).toBeInstanceOf(HTMLInputElement);
+ expect(input).toHaveClass('pr-10');
+ expect(input).toHaveClass('custom-input');
+ expect(screen.getByTestId('right-icon')).toBeInTheDocument();
+ expect(screen.getByText('Invalid username')).toBeInTheDocument();
+ });
+
+ it('disables password toggle when input is disabled', () => {
+ render(
+ {}}
+ disabled
+ />,
+ );
+
+ expect(screen.getByRole('button', { name: 'Show password' })).toBeDisabled();
+ });
+
+ it('supports inline layout classes', () => {
+ const { container } = render(
+ {}} layout="inline" />,
+ );
+
+ expect(container.querySelector('label')).toHaveClass('inline-flex');
+ expect(container.querySelector('label > div')).not.toHaveClass('mt-1');
+ });
+});
diff --git a/tests/components/Label.test.tsx b/tests/components/Label.test.tsx
new file mode 100644
index 0000000..039bd33
--- /dev/null
+++ b/tests/components/Label.test.tsx
@@ -0,0 +1,25 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { Label } from '../../src/components/Label';
+
+describe('Label', () => {
+ it('uses default and variant-specific tags/classes', () => {
+ const { rerender } = render();
+ expect(screen.getByText('Body').tagName).toBe('P');
+ expect(screen.getByText('Body')).toHaveClass('ui-body-primary');
+
+ rerender();
+ expect(screen.getByText('Title').tagName).toBe('H1');
+ expect(screen.getByText('Title')).toHaveClass('ui-title');
+
+ rerender();
+ expect(screen.getByText('Section').tagName).toBe('H3');
+
+ rerender(
+ ,
+ );
+ expect(screen.getByText('Custom').tagName).toBe('SPAN');
+ });
+});
diff --git a/tests/components/MDXEditorField.test.tsx b/tests/components/MDXEditorField.test.tsx
new file mode 100644
index 0000000..e24d8b7
--- /dev/null
+++ b/tests/components/MDXEditorField.test.tsx
@@ -0,0 +1,82 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { MDXEditorField } from '../../src/components/MDXEditorField';
+
+describe('MDXEditorField', () => {
+ it('renders label and change handler in editable mode', () => {
+ const onChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Content')).toHaveClass('ui-label');
+ fireEvent.change(screen.getByLabelText('Markdown Editor'), {
+ target: { value: '# Hello' },
+ });
+ expect(onChange).toHaveBeenCalledWith('# Hello');
+ expect(screen.getByLabelText('Markdown Editor')).toHaveAttribute(
+ 'data-class-name',
+ 'light-theme extra-editor',
+ );
+ });
+
+ it('renders preview and disabled classes when disabled', () => {
+ render(
+ ,
+ );
+
+ const label = screen.getByText('Content');
+ expect(label).toHaveClass('ui-label-disabled');
+ expect(screen.getByTestId('md-preview')).toHaveTextContent('Disabled content');
+ expect(screen.getByTestId('md-preview')).toHaveAttribute('data-class-name', 'dark-theme');
+ expect(screen.queryByLabelText('Markdown Editor')).not.toBeInTheDocument();
+ expect(document.querySelector('.post-mdx-editor--disabled')).toBeTruthy();
+ });
+
+ it('renders read-only preview without label when label is omitted', () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByText('Content')).not.toBeInTheDocument();
+ expect(screen.getByTestId('md-preview')).toHaveTextContent('Read only content');
+ });
+
+ it('supports wrapper style/class overrides and error rendering', () => {
+ render(
+ ,
+ );
+
+ const wrapper = document.querySelector('.editor-wrapper');
+ expect(wrapper).toHaveClass('post-mdx-editor--enabled');
+ expect(wrapper).toHaveStyle({ borderWidth: '2px' });
+ expect(screen.getByText('Content is required')).toHaveClass('ui-error');
+ });
+});
diff --git a/tests/components/SidebarNavItem.test.tsx b/tests/components/SidebarNavItem.test.tsx
new file mode 100644
index 0000000..7d266be
--- /dev/null
+++ b/tests/components/SidebarNavItem.test.tsx
@@ -0,0 +1,55 @@
+import { UserCircleIcon } from '@heroicons/react/24/outline';
+import { fireEvent, screen } from '@testing-library/react';
+import { Route, Routes } from 'react-router-dom';
+import { describe, expect, it, vi } from 'vitest';
+import { SidebarNavItem } from '../../src/components/SidebarNavItem';
+import { renderWithRouter } from '../helpers/renderWithRouter';
+
+describe('SidebarNavItem', () => {
+ it('renders active style and collapsed label behavior', () => {
+ renderWithRouter(
+
+ }
+ />
+ ,
+ { route: '/users' },
+ );
+
+ const link = screen.getByRole('link', { name: 'Users' });
+ expect(link.className).toContain('ui-accent-active');
+ expect(link.className).toContain('mx-auto');
+ expect(screen.getByText('Users').className).toContain('lg:hidden');
+ });
+
+ it('renders inactive style and triggers onClick', () => {
+ const onClick = vi.fn();
+ renderWithRouter(
+
+ Users page} />
+
+ }
+ />
+ ,
+ { route: '/profile' },
+ );
+
+ const link = screen.getByRole('link', { name: 'Users' });
+ expect(link.className).toContain('hover:bg-zinc-500/15');
+ expect(link.className).toContain('lg:w-full');
+ expect(screen.getByText('Users').className).toContain('truncate');
+
+ fireEvent.click(link);
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/tests/components/Table.test.tsx b/tests/components/Table.test.tsx
new file mode 100644
index 0000000..313d0a2
--- /dev/null
+++ b/tests/components/Table.test.tsx
@@ -0,0 +1,199 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { Table, type TableHeader } from '../../src/components/Table';
+import type { SortState } from '../../src/types/sort';
+
+type Row = {
+ id: string;
+ name: string;
+ email: string;
+};
+
+const headers: TableHeader[] = [
+ { label: 'Name', id: 'name', value: (row) => row.name, headerClassName: 'head-name' },
+ { label: 'Email', id: 'email', value: (row) => row.email, cellClassName: 'cell-email' },
+ { label: 'Static', id: 'static', value: 'Always', sortable: false },
+];
+
+const data: Row[] = [{ id: '1', name: 'Jane', email: 'jane@example.com' }];
+
+describe('Table', () => {
+ it('renders loading state', () => {
+ render( row.id} />);
+
+ expect(document.querySelector('.animate-spin')).toBeTruthy();
+ });
+
+ it('renders empty state message when no rows are available', () => {
+ render(
+ row.id}
+ />,
+ );
+
+ expect(screen.getByText('Nothing here')).toBeInTheDocument();
+ });
+
+ it('renders row values from function and static header values', () => {
+ const rowKey = vi.fn((row: Row) => row.id);
+ render();
+
+ expect(screen.getByText('Jane')).toBeInTheDocument();
+ expect(screen.getByText('jane@example.com')).toBeInTheDocument();
+ expect(screen.getByText('Always')).toBeInTheDocument();
+ expect(screen.getByText('Name').closest('th')).toHaveClass('head-name');
+ expect(screen.getByText('jane@example.com').closest('td')).toHaveClass('cell-email');
+ expect(rowKey).toHaveBeenCalledWith(data[0], 0);
+ });
+
+ it('shows sortable buttons only when sort config is complete', () => {
+ const onSortChange = vi.fn();
+ const sortableHeaders: TableHeader[] = [
+ { label: 'No field', id: 'a', sortable: true, value: (row) => row.name },
+ { label: 'Empty field', id: 'b', sortable: true, sortField: '', value: (row) => row.name },
+ { label: 'Name', id: 'c', sortable: true, sortField: 'name', value: (row) => row.name },
+ ];
+
+ const { rerender } = render(
+ row.id} onSortChange={onSortChange} />,
+ );
+
+ expect(screen.queryByRole('button', { name: 'Sort by No field' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Sort by Empty field' })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Sort by Name' })).toBeInTheDocument();
+
+ rerender( row.id} />);
+ expect(screen.queryByRole('button', { name: 'Sort by Name' })).not.toBeInTheDocument();
+ });
+
+ it('renders sort states and notifies callback on header click', () => {
+ const onSortChange = vi.fn();
+ const sortableHeaders: TableHeader[] = [
+ { label: 'Name', id: 'name', sortable: true, sortField: 'name', value: (row) => row.name },
+ { label: 'Email', id: 'email', sortable: true, sortField: 'email', value: (row) => row.email },
+ ];
+ const sorting: SortState = { field: 'name', direction: 'asc' };
+
+ const { rerender } = render(
+ row.id}
+ sorting={sorting}
+ onSortChange={onSortChange}
+ />,
+ );
+
+ const nameSort = screen.getByRole('button', { name: 'Sort by Name' });
+ const emailSort = screen.getByRole('button', { name: 'Sort by Email' });
+ expect(nameSort.querySelector('[data-sort-state]')).toHaveAttribute('data-sort-state', 'asc');
+ expect(emailSort.querySelector('[data-sort-state]')).toHaveAttribute('data-sort-state', 'none');
+
+ fireEvent.click(nameSort);
+ expect(onSortChange).toHaveBeenCalledWith('name');
+
+ rerender(
+ row.id}
+ sorting={{ field: 'name', direction: 'desc' }}
+ onSortChange={onSortChange}
+ />,
+ );
+ expect(nameSort.querySelector('[data-sort-state]')).toHaveAttribute('data-sort-state', 'desc');
+ });
+
+ it('supports pagination controls and page-size changes', () => {
+ const onPageChange = vi.fn();
+ const onPageSizeChange = vi.fn();
+
+ render(
+ row.id}
+ pagination={{
+ page: 2,
+ pageSize: 10,
+ total: 21,
+ totalPages: 3,
+ onPageChange,
+ onPageSizeChange,
+ }}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Previous page' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Next page' }));
+ fireEvent.change(screen.getByLabelText('Rows'), { target: { value: '20' } });
+
+ expect(onPageChange).toHaveBeenNthCalledWith(1, 1);
+ expect(onPageChange).toHaveBeenNthCalledWith(2, 3);
+ expect(onPageSizeChange).toHaveBeenCalledWith(20);
+ });
+
+ it('hides rows selector when onPageSizeChange is absent and clamps page count display', () => {
+ render(
+ row.id}
+ pagination={{
+ page: 1,
+ pageSize: 10,
+ total: 1,
+ totalPages: 0,
+ onPageChange: vi.fn(),
+ }}
+ />,
+ );
+
+ expect(screen.queryByLabelText('Rows')).not.toBeInTheDocument();
+ expect(screen.getByText('Page 1 of 1')).toBeInTheDocument();
+ });
+
+ it('disables prev/next at bounds or while loading', () => {
+ const { rerender } = render(
+ row.id}
+ pagination={{
+ page: 1,
+ pageSize: 10,
+ total: 10,
+ totalPages: 1,
+ onPageChange: vi.fn(),
+ }}
+ />,
+ );
+
+ expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
+
+ rerender(
+ row.id}
+ isLoading
+ pagination={{
+ page: 2,
+ pageSize: 10,
+ total: 100,
+ totalPages: 10,
+ onPageChange: vi.fn(),
+ onPageSizeChange: vi.fn(),
+ }}
+ />,
+ );
+
+ expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled();
+ expect(screen.getByLabelText('Rows')).toBeDisabled();
+ });
+});
diff --git a/tests/helpers/renderWithRouter.tsx b/tests/helpers/renderWithRouter.tsx
new file mode 100644
index 0000000..c9ae14b
--- /dev/null
+++ b/tests/helpers/renderWithRouter.tsx
@@ -0,0 +1,12 @@
+import type { ReactElement } from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import { render } from '@testing-library/react';
+
+type RenderWithRouterOptions = {
+ route?: string;
+};
+
+export function renderWithRouter(ui: ReactElement, options: RenderWithRouterOptions = {}) {
+ const { route = '/' } = options;
+ return render({ui});
+}
diff --git a/tests/index.test.ts b/tests/index.test.ts
new file mode 100644
index 0000000..7b03f49
--- /dev/null
+++ b/tests/index.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest';
+import * as webUi from '../src/index';
+
+describe('index exports', () => {
+ it('exposes runtime component exports', () => {
+ expect(typeof webUi.Button).toBe('function');
+ expect(typeof webUi.Chip).toBe('function');
+ expect(typeof webUi.DatePicker).toBe('function');
+ expect(typeof webUi.Dropdown).toBe('function');
+ expect(typeof webUi.Form).toBe('function');
+ expect(typeof webUi.InputField).toBe('function');
+ expect(typeof webUi.Label).toBe('function');
+ expect(typeof webUi.SidebarNavItem).toBe('function');
+ expect(typeof webUi.Table).toBe('function');
+ });
+});
diff --git a/tests/mocks/mdxeditor.tsx b/tests/mocks/mdxeditor.tsx
new file mode 100644
index 0000000..206d735
--- /dev/null
+++ b/tests/mocks/mdxeditor.tsx
@@ -0,0 +1,74 @@
+import { forwardRef, useImperativeHandle, type ReactNode } from 'react';
+
+export type MDXEditorMethods = {
+ setMarkdown: (value: string) => void;
+};
+
+export type MDXEditorProps = {
+ markdown: string;
+ onChange?: (value: string) => void;
+ readOnly?: boolean;
+ className?: string;
+ contentEditableClassName?: string;
+ plugins?: unknown[];
+};
+
+export const MDXEditor = forwardRef(function MDXEditor(
+ { markdown, onChange, readOnly, className },
+ ref,
+) {
+ useImperativeHandle(
+ ref,
+ () => ({
+ setMarkdown: () => undefined,
+ }),
+ [],
+ );
+
+ if (readOnly) {
+ return (
+
+ {markdown}
+
+ );
+ }
+
+ return (
+