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)} - movePage(p.id, -1)} disabled={i === 0} aria-label="Move {pageLabel(i)} up" title="Move up" > - - + movePage(p.id, 1)} disabled={i === pages.length - 1} aria-label="Move {pageLabel(i)} down" title="Move down" > - - + removePage(p.id)} aria-label="Remove {pageLabel(i)}" title="Remove page" data-testid="{testidPrefix}-remove" > - + {#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..08a1852 --- /dev/null +++ b/frontend/src/lib/components/IconButton.svelte @@ -0,0 +1,80 @@ + + + + + {@render children()} + + + 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..46e1769 --- /dev/null +++ b/frontend/src/lib/components/IconButton.svelte.test.ts @@ -0,0 +1,79 @@ +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('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 } }); + 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/+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} - {/if} - + {: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 683dea9..b6b8209 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" /> - + - + {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} - removeChapter(c.id)} aria-label="Remove chapter" title="Remove chapter" data-testid="staged-chapter-remove" > - + {#if c.error} {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;
@@ -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} - removeChapter(c.id)} aria-label="Remove chapter" title="Remove chapter" data-testid="staged-chapter-remove" > - + {#if c.error}
{c.error}