refactor(ui): fold the 36px header/search buttons into IconButton #16

Closed
fabi wants to merge 1 commits from refactor/icon-button-outliers into refactor/shared-icon-button
4 changed files with 62 additions and 54 deletions
Showing only changes of commit 79a1432db8 - Show all commits

View File

@@ -2,23 +2,36 @@
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements'; import type { HTMLButtonAttributes } from 'svelte/elements';
// Shared square icon button — extracted from five near-identical // Shared square icon button — extracted from the near-identical
// `.icon-btn` copies (collections, manga edit, upload, the chapter-pages // `.icon-btn` copies across the app so the size, hover, and variant
// editor) so the size, hover, and variant treatment live in one place. // treatment live in one place. Callers pass the lucide icon as the child
// Callers pass the lucide icon as the child and any button attributes // and any button attributes (onclick, disabled, aria-label, title,
// (onclick, disabled, aria-label, title, data-testid) straight through. // data-testid) straight through. `size`/`radius` cover the two larger
// header/search buttons without forcing the 32px default everywhere.
type Variant = 'plain' | 'primary' | 'danger'; type Variant = 'plain' | 'primary' | 'danger';
let { let {
variant = 'plain', variant = 'plain',
size = 32,
radius = 'sm',
children, children,
...rest ...rest
}: { variant?: Variant; children: Snippet } & HTMLButtonAttributes = $props(); }: {
variant?: Variant;
size?: number;
radius?: 'sm' | 'md';
children: Snippet;
} & HTMLButtonAttributes = $props();
</script> </script>
<!-- `type="button"` precedes the spread so a caller can still override it <!-- `type="button"` precedes the spread so a caller can still override it
(e.g. a submit button) while the default never submits a form. --> (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()} {@render children()}
</button> </button>
@@ -27,16 +40,23 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 32px; width: var(--ib-size, 32px);
height: 32px; height: var(--ib-size, 32px);
padding: 0; padding: 0;
background: transparent; background: transparent;
color: var(--text-muted); color: var(--text-muted);
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
} }
.radius-sm {
border-radius: var(--radius-sm);
}
.radius-md {
border-radius: var(--radius-md);
}
.icon-btn:hover:not(:disabled) { .icon-btn:hover:not(:disabled) {
background: var(--surface-elevated); background: var(--surface-elevated);
color: var(--text); color: var(--text);

View File

@@ -37,6 +37,23 @@ describe('IconButton', () => {
expect(c2.querySelector('button.icon-btn.danger')).toBeTruthy(); 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 () => { it('forwards onclick', async () => {
const onclick = vi.fn(); const onclick = vi.fn();
render(IconButton, { props: { onclick, children: icon } }); render(IconButton, { props: { onclick, children: icon } });

View File

@@ -9,6 +9,7 @@
import { theme } from '$lib/theme.svelte'; import { theme } from '$lib/theme.svelte';
import AppBar from '$lib/components/AppBar.svelte'; import AppBar from '$lib/components/AppBar.svelte';
import BottomNav, { type BottomNavTab } from '$lib/components/BottomNav.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 Upload from '@lucide/svelte/icons/upload';
import UserCircle from '@lucide/svelte/icons/user-circle'; import UserCircle from '@lucide/svelte/icons/user-circle';
import Bookmark from '@lucide/svelte/icons/bookmark'; import Bookmark from '@lucide/svelte/icons/bookmark';
@@ -232,9 +233,9 @@
<span data-testid="session-loading" aria-busy="true"></span> <span data-testid="session-loading" aria-busy="true"></span>
{:else if session.user} {:else if session.user}
<span class="username" data-testid="session-user">{session.user.username}</span> <span class="username" data-testid="session-user">{session.user.username}</span>
<button <IconButton
class="icon-btn" size={36}
type="button" radius="md"
onclick={handleLogout} onclick={handleLogout}
disabled={loggingOut} disabled={loggingOut}
aria-label="Logout" aria-label="Logout"
@@ -245,7 +246,7 @@
{:else} {:else}
<LogOut size={18} aria-hidden="true" /> <LogOut size={18} aria-hidden="true" />
{/if} {/if}
</button> </IconButton>
{:else} {:else}
<a class="text-link" href="/login" data-testid="nav-login">Login</a> <a class="text-link" href="/login" data-testid="nav-login">Login</a>
{#if authConfig.self_register_enabled} {#if authConfig.self_register_enabled}
@@ -348,24 +349,6 @@
padding: 0 var(--space-2); 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 { .logging-out {
font-size: var(--font-base); font-size: var(--font-base);
line-height: 1; line-height: 1;

View File

@@ -27,6 +27,7 @@
import Pager from '$lib/components/Pager.svelte'; import Pager from '$lib/components/Pager.svelte';
import SegmentedControl from '$lib/components/SegmentedControl.svelte'; import SegmentedControl from '$lib/components/SegmentedControl.svelte';
import Sheet from '$lib/components/Sheet.svelte'; import Sheet from '$lib/components/Sheet.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import Search from '@lucide/svelte/icons/search'; import Search from '@lucide/svelte/icons/search';
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal'; import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down'; import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
@@ -467,9 +468,16 @@
placeholder="Search by title or author" placeholder="Search by title or author"
data-testid="search-input" 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" /> <Search size={18} aria-hidden="true" />
</button> </IconButton>
<button <button
type="button" type="button"
class="filters-toggle" class="filters-toggle"
@@ -922,26 +930,6 @@
border-color: var(--primary-hover); 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 { .status {
color: var(--text-muted); color: var(--text-muted);
} }