From 3a36796768ee068142423440afaaf4f033c6c67c Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 25 Jun 2026 19:37:08 +0200 Subject: [PATCH] refactor(ui): extract shared IconButton from duplicated .icon-btn copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../lib/components/ChapterPagesEditor.svelte | 42 +++---------- frontend/src/lib/components/IconButton.svelte | 60 ++++++++++++++++++ .../lib/components/IconButton.svelte.test.ts | 62 +++++++++++++++++++ .../src/routes/collections/[id]/+page.svelte | 36 ++--------- .../src/routes/manga/[id]/edit/+page.svelte | 55 +++------------- frontend/src/routes/upload/+page.svelte | 55 +++------------- 6 files changed, 156 insertions(+), 154 deletions(-) create mode 100644 frontend/src/lib/components/IconButton.svelte create mode 100644 frontend/src/lib/components/IconButton.svelte.test.ts diff --git a/frontend/src/lib/components/ChapterPagesEditor.svelte b/frontend/src/lib/components/ChapterPagesEditor.svelte index 57e0b91..f35292f 100644 --- a/frontend/src/lib/components/ChapterPagesEditor.svelte +++ b/frontend/src/lib/components/ChapterPagesEditor.svelte @@ -17,6 +17,7 @@ import { onDestroy } from 'svelte'; import { formatBytes, validateImageFile } from '$lib/upload-validation'; import Modal from './Modal.svelte'; + import IconButton from '$lib/components/IconButton.svelte'; import ArrowUp from '@lucide/svelte/icons/arrow-up'; import ArrowDown from '@lucide/svelte/icons/arrow-down'; import Trash2 from '@lucide/svelte/icons/trash-2'; @@ -145,36 +146,31 @@ from {p.file.name} ยท {formatBytes(p.file.size)} - - - + {#if p.error} {p.error} {/if} @@ -297,28 +293,6 @@ white-space: nowrap; } - .icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - padding: 0; - background: transparent; - color: var(--text-muted); - border: 1px solid transparent; - border-radius: var(--radius-sm); - } - - .icon-btn:hover:not(:disabled) { - background: var(--surface-elevated); - color: var(--text); - } - - .icon-btn.danger:hover:not(:disabled) { - color: var(--danger); - } - .field-error { grid-column: 1 / -1; color: var(--danger); diff --git a/frontend/src/lib/components/IconButton.svelte b/frontend/src/lib/components/IconButton.svelte new file mode 100644 index 0000000..6826f05 --- /dev/null +++ b/frontend/src/lib/components/IconButton.svelte @@ -0,0 +1,60 @@ + + + + + + diff --git a/frontend/src/lib/components/IconButton.svelte.test.ts b/frontend/src/lib/components/IconButton.svelte.test.ts new file mode 100644 index 0000000..0be4512 --- /dev/null +++ b/frontend/src/lib/components/IconButton.svelte.test.ts @@ -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: () => `` +})); + +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); + }); +}); diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte index 4c8585a..a8f58f7 100644 --- a/frontend/src/routes/collections/[id]/+page.svelte +++ b/frontend/src/routes/collections/[id]/+page.svelte @@ -12,6 +12,7 @@ import type { Manga } from '$lib/api/client'; import { fileUrl } from '$lib/api/client'; import MangaCard from '$lib/components/MangaCard.svelte'; + import IconButton from '$lib/components/IconButton.svelte'; import ArrowLeft from '@lucide/svelte/icons/arrow-left'; import Pencil from '@lucide/svelte/icons/pencil'; import Check from '@lucide/svelte/icons/check'; @@ -148,26 +149,23 @@ {:else}

{collection.name}

- - +
{#if collection.description}

@@ -418,26 +416,4 @@ height: 32px; } } - - .icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - padding: 0; - background: transparent; - color: var(--text-muted); - border: 1px solid transparent; - border-radius: var(--radius-sm); - } - - .icon-btn:hover { - background: var(--surface-elevated); - color: var(--text); - } - - .icon-btn.danger:hover { - color: var(--danger); - } diff --git a/frontend/src/routes/manga/[id]/edit/+page.svelte b/frontend/src/routes/manga/[id]/edit/+page.svelte index b6b801c..19ce1b3 100644 --- a/frontend/src/routes/manga/[id]/edit/+page.svelte +++ b/frontend/src/routes/manga/[id]/edit/+page.svelte @@ -10,6 +10,7 @@ import { session } from '$lib/session.svelte'; import { formatBytes, validateImageFile } from '$lib/upload-validation'; import Chip from '$lib/components/Chip.svelte'; + import IconButton from '$lib/components/IconButton.svelte'; import Plus from '@lucide/svelte/icons/plus'; import Trash2 from '@lucide/svelte/icons/trash-2'; @@ -190,16 +191,15 @@ maxlength="200" data-testid="manga-author-input" /> - + @@ -240,16 +240,15 @@ maxlength="200" data-testid="manga-alt-input" /> - + @@ -270,16 +269,15 @@ src={fileUrl(currentCoverPath)} alt="Current cover" /> - + {:else if pendingCoverRemoval}

@@ -419,39 +417,6 @@ cursor: pointer; } - .icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - padding: 0; - background: transparent; - color: var(--text-muted); - border: 1px solid transparent; - border-radius: var(--radius-sm); - } - - .icon-btn:hover:not(:disabled) { - background: var(--surface-elevated); - color: var(--text); - } - - .icon-btn.primary { - background: var(--primary); - color: var(--primary-contrast); - border-color: var(--primary); - } - - .icon-btn.primary:hover:not(:disabled) { - background: var(--primary-hover); - border-color: var(--primary-hover); - } - - .icon-btn.danger:hover:not(:disabled) { - color: var(--danger); - } - .cover-preview { display: flex; align-items: flex-start; diff --git a/frontend/src/routes/upload/+page.svelte b/frontend/src/routes/upload/+page.svelte index e04ac3e..a5326c0 100644 --- a/frontend/src/routes/upload/+page.svelte +++ b/frontend/src/routes/upload/+page.svelte @@ -9,6 +9,7 @@ import ChapterPagesEditor, { type PendingPage } from '$lib/components/ChapterPagesEditor.svelte'; + import IconButton from '$lib/components/IconButton.svelte'; import Plus from '@lucide/svelte/icons/plus'; import Trash2 from '@lucide/svelte/icons/trash-2'; @@ -236,16 +237,15 @@ maxlength="200" data-testid="manga-author-input" /> - + @@ -286,16 +286,15 @@ maxlength="200" data-testid="manga-alt-input" /> - + @@ -378,16 +377,15 @@ Failed {/if} - + {#if c.error}

@@ -513,39 +511,6 @@ cursor: pointer; } - .icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - padding: 0; - background: transparent; - color: var(--text-muted); - border: 1px solid transparent; - border-radius: var(--radius-sm); - } - - .icon-btn:hover:not(:disabled) { - background: var(--surface-elevated); - color: var(--text); - } - - .icon-btn.primary { - background: var(--primary); - color: var(--primary-contrast); - border-color: var(--primary); - } - - .icon-btn.primary:hover:not(:disabled) { - background: var(--primary-hover); - border-color: var(--primary-hover); - } - - .icon-btn.danger:hover:not(:disabled) { - color: var(--danger); - } - .chapters-header { display: flex; align-items: center;