Files
Mangalord/frontend/src/lib/components/IconButton.svelte.test.ts
MechaCat02 129cb0241d
All checks were successful
deploy / test-frontend (pull_request) Successful in 10m21s
deploy / test-backend (pull_request) Successful in 28m26s
deploy / build-and-push (pull_request) Has been skipped
deploy / deploy (pull_request) Has been skipped
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>
2026-06-25 19:37:08 +02:00

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);
});
});