refactor(ui): extract shared IconButton from duplicated .icon-btn copies
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>
This commit is contained in:
62
frontend/src/lib/components/IconButton.svelte.test.ts
Normal file
62
frontend/src/lib/components/IconButton.svelte.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user