refactor(ui): fold the 36px header/search buttons into IconButton #16
@@ -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();
|
||||
</script>
|
||||
|
||||
<!-- `type="button"` precedes the spread so a caller can still override it
|
||||
(e.g. a submit button) while the default never submits a form. -->
|
||||
<button type="button" class="icon-btn {variant}" {...rest}>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn {variant} radius-{radius}"
|
||||
style:--ib-size="{size}px"
|
||||
{...rest}
|
||||
>
|
||||
{@render children()}
|
||||
</button>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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 @@
|
||||
<span data-testid="session-loading" aria-busy="true">…</span>
|
||||
{:else if session.user}
|
||||
<span class="username" data-testid="session-user">{session.user.username}</span>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
<IconButton
|
||||
size={36}
|
||||
radius="md"
|
||||
onclick={handleLogout}
|
||||
disabled={loggingOut}
|
||||
aria-label="Logout"
|
||||
@@ -245,7 +246,7 @@
|
||||
{:else}
|
||||
<LogOut size={18} aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
</IconButton>
|
||||
{:else}
|
||||
<a class="text-link" href="/login" data-testid="nav-login">Login</a>
|
||||
{#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;
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<button class="icon-btn primary" type="submit" aria-label="Search" title="Search">
|
||||
<IconButton
|
||||
variant="primary"
|
||||
size={36}
|
||||
radius="md"
|
||||
type="submit"
|
||||
aria-label="Search"
|
||||
title="Search"
|
||||
>
|
||||
<Search size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</IconButton>
|
||||
<button
|
||||
type="button"
|
||||
class="filters-toggle"
|
||||
@@ -922,26 +930,6 @@
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.icon-btn.primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-contrast);
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
|
||||
.icon-btn.primary:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
border-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.status {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user