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;