From 129cb0241d63b183017ad10464c47bea3d8876b7 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 25 Jun 2026 19:37:08 +0200 Subject: [PATCH 1/2] 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 | 35 ++--------- .../src/routes/manga/[id]/edit/+page.svelte | 55 +++------------- frontend/src/routes/upload/+page.svelte | 55 +++------------- 6 files changed, 156 insertions(+), 153 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 981e4d1..f7c282c 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}

@@ -406,25 +404,4 @@ opacity: 1; } - .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; From 79a1432db89664e4c3d786290ce629d42d6a5e1e Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 25 Jun 2026 19:46:54 +0200 Subject: [PATCH 2/2] refactor(ui): fold the 36px header/search buttons into IconButton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the IconButton extraction by adding `size` and `radius` props and converting the two remaining outliers that were left out of the first pass: the desktop header logout button (+layout) and the home search submit button (+page), both 36px with the medium radius. IconButton now drives every icon button in the app (except profile/history's, which the shared-HistoryList change removes separately). Defaults stay 32px / small radius, so the four already-converted sites are untouched. Rendered size, radius, and variant match the old markup — no behaviour change, no version bump. This also sets up applying the --tap-min touch floor in one place once the touch-target change lands. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/lib/components/IconButton.svelte | 40 ++++++++++++++----- .../lib/components/IconButton.svelte.test.ts | 17 ++++++++ frontend/src/routes/+layout.svelte | 27 +++---------- frontend/src/routes/+page.svelte | 32 +++++---------- 4 files changed, 62 insertions(+), 54 deletions(-) diff --git a/frontend/src/lib/components/IconButton.svelte b/frontend/src/lib/components/IconButton.svelte index 6826f05..08a1852 100644 --- a/frontend/src/lib/components/IconButton.svelte +++ b/frontend/src/lib/components/IconButton.svelte @@ -2,23 +2,36 @@ import type { Snippet } from 'svelte'; import type { HTMLButtonAttributes } from 'svelte/elements'; - // Shared square icon button — extracted from five near-identical - // `.icon-btn` copies (collections, manga edit, upload, the chapter-pages - // editor) so the size, hover, and variant treatment live in one place. - // Callers pass the lucide icon as the child and any button attributes - // (onclick, disabled, aria-label, title, data-testid) straight through. + // Shared square icon button — extracted from the near-identical + // `.icon-btn` copies across the app so the size, hover, and variant + // treatment live in one place. Callers pass the lucide icon as the child + // and any button attributes (onclick, disabled, aria-label, title, + // data-testid) straight through. `size`/`radius` cover the two larger + // header/search buttons without forcing the 32px default everywhere. type Variant = 'plain' | 'primary' | 'danger'; let { variant = 'plain', + size = 32, + radius = 'sm', children, ...rest - }: { variant?: Variant; children: Snippet } & HTMLButtonAttributes = $props(); + }: { + variant?: Variant; + size?: number; + radius?: 'sm' | 'md'; + children: Snippet; + } & HTMLButtonAttributes = $props(); - @@ -27,16 +40,23 @@ display: inline-flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; + width: var(--ib-size, 32px); + height: var(--ib-size, 32px); padding: 0; background: transparent; color: var(--text-muted); border: 1px solid transparent; - border-radius: var(--radius-sm); cursor: pointer; } + .radius-sm { + border-radius: var(--radius-sm); + } + + .radius-md { + border-radius: var(--radius-md); + } + .icon-btn:hover:not(:disabled) { background: var(--surface-elevated); color: var(--text); diff --git a/frontend/src/lib/components/IconButton.svelte.test.ts b/frontend/src/lib/components/IconButton.svelte.test.ts index 0be4512..46e1769 100644 --- a/frontend/src/lib/components/IconButton.svelte.test.ts +++ b/frontend/src/lib/components/IconButton.svelte.test.ts @@ -37,6 +37,23 @@ describe('IconButton', () => { expect(c2.querySelector('button.icon-btn.danger')).toBeTruthy(); }); + it('defaults to a 32px square with the small radius', () => { + const { container } = render(IconButton, { props: { children: icon } }); + const btn = container.querySelector('button.icon-btn') as HTMLButtonElement; + expect(btn.classList.contains('radius-sm')).toBe(true); + expect(btn.style.getPropertyValue('--ib-size')).toBe('32px'); + }); + + it('honours an explicit size and radius (the larger header/search buttons)', () => { + const { container } = render(IconButton, { + props: { size: 36, radius: 'md', children: icon } + }); + const btn = container.querySelector('button.icon-btn') as HTMLButtonElement; + expect(btn.style.getPropertyValue('--ib-size')).toBe('36px'); + expect(btn.classList.contains('radius-md')).toBe(true); + expect(btn.classList.contains('radius-sm')).toBe(false); + }); + it('forwards onclick', async () => { const onclick = vi.fn(); render(IconButton, { props: { onclick, children: icon } }); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index c8d4095..c621b41 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -9,6 +9,7 @@ import { theme } from '$lib/theme.svelte'; import AppBar from '$lib/components/AppBar.svelte'; import BottomNav, { type BottomNavTab } from '$lib/components/BottomNav.svelte'; + import IconButton from '$lib/components/IconButton.svelte'; import Upload from '@lucide/svelte/icons/upload'; import UserCircle from '@lucide/svelte/icons/user-circle'; import Bookmark from '@lucide/svelte/icons/bookmark'; @@ -232,9 +233,9 @@ {:else if session.user} {session.user.username} - + {:else} Login {#if authConfig.self_register_enabled} @@ -348,24 +349,6 @@ padding: 0 var(--space-2); } - .icon-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 36px; - height: 36px; - padding: 0; - background: transparent; - color: var(--text-muted); - border: 1px solid transparent; - border-radius: var(--radius-md); - } - - .icon-btn:hover:not(:disabled) { - background: var(--surface-elevated); - color: var(--text); - } - .logging-out { font-size: var(--font-base); line-height: 1; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index a89a384..88f6661 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -27,6 +27,7 @@ import Pager from '$lib/components/Pager.svelte'; import SegmentedControl from '$lib/components/SegmentedControl.svelte'; import Sheet from '$lib/components/Sheet.svelte'; + import IconButton from '$lib/components/IconButton.svelte'; import Search from '@lucide/svelte/icons/search'; import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal'; import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down'; @@ -467,9 +468,16 @@ placeholder="Search by title or author" data-testid="search-input" /> - +