Five files hand-rolled a near-identical 32px `.icon-btn` (same size, hover, and primary/danger variants). Extract a single IconButton.svelte component so the treatment lives in one place. Converts the four sites with the standard 32px form: collections detail, manga edit, upload, and the chapter-pages editor. The component takes a `variant` (plain/primary/danger) and spreads any button attributes (onclick, disabled, aria-label, title, data-testid) straight through; `type="button"` defaults but a caller can override. The rendered button keeps the same class, styles, and DOM position, so layout and behaviour are unchanged — no version bump. Three icon-button sites are intentionally left out: - The header (+layout) and home search button are 36px / different radius — size outliers that need a size/radius prop before folding in. - profile/history's copy is being removed in the shared-HistoryList change; touching it here would just conflict. A component (not a global class) avoids colliding with those remaining local `.icon-btn` definitions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
63 lines
2.5 KiB
TypeScript
63 lines
2.5 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import { render, screen, cleanup, fireEvent } from '@testing-library/svelte';
|
|
import { createRawSnippet } from 'svelte';
|
|
import IconButton from './IconButton.svelte';
|
|
|
|
afterEach(() => cleanup());
|
|
|
|
// A stand-in for the lucide icon callers pass as the child.
|
|
const icon = createRawSnippet(() => ({
|
|
render: () => `<svg data-testid="glyph"></svg>`
|
|
}));
|
|
|
|
describe('IconButton', () => {
|
|
it('renders a button containing the icon child', () => {
|
|
render(IconButton, { props: { children: icon } });
|
|
const btn = screen.getByRole('button');
|
|
expect(btn.querySelector('[data-testid="glyph"]')).toBeTruthy();
|
|
});
|
|
|
|
it('defaults to type=button so it never submits a surrounding form by accident', () => {
|
|
render(IconButton, { props: { children: icon } });
|
|
expect(screen.getByRole('button').getAttribute('type')).toBe('button');
|
|
});
|
|
|
|
it('lets a caller override the type (e.g. submit)', () => {
|
|
render(IconButton, { props: { type: 'submit', children: icon } });
|
|
expect(screen.getByRole('button').getAttribute('type')).toBe('submit');
|
|
});
|
|
|
|
it('applies the variant as a class (plain by default)', () => {
|
|
const { container } = render(IconButton, { props: { children: icon } });
|
|
expect(container.querySelector('button.icon-btn.plain')).toBeTruthy();
|
|
cleanup();
|
|
const { container: c2 } = render(IconButton, {
|
|
props: { variant: 'danger', children: icon }
|
|
});
|
|
expect(c2.querySelector('button.icon-btn.danger')).toBeTruthy();
|
|
});
|
|
|
|
it('forwards onclick', async () => {
|
|
const onclick = vi.fn();
|
|
render(IconButton, { props: { onclick, children: icon } });
|
|
await fireEvent.click(screen.getByRole('button'));
|
|
expect(onclick).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('forwards arbitrary button attributes (aria-label, title, disabled, data-testid)', () => {
|
|
render(IconButton, {
|
|
props: {
|
|
'aria-label': 'Delete collection',
|
|
title: 'Delete',
|
|
disabled: true,
|
|
'data-testid': 'collection-delete',
|
|
children: icon
|
|
}
|
|
});
|
|
const btn = screen.getByTestId('collection-delete');
|
|
expect(btn.getAttribute('aria-label')).toBe('Delete collection');
|
|
expect(btn.getAttribute('title')).toBe('Delete');
|
|
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
|
});
|
|
});
|