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

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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-25 19:46:54 +02:00
parent 3a36796768
commit 198a1d46b0
4 changed files with 62 additions and 54 deletions

View File

@@ -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);

View File

@@ -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 } });