feat(frontend): mobile catalog with sheet filters + sort (0.57.0)
Phase 2 of the mobile redesign: the catalog at `/` adapts to phone viewports without disrupting the desktop layout. The same filter form renders inline on desktop and inside a bottom sheet on mobile, and a dedicated sort sheet replaces the inline `<select>` below 640px. - MangaCard gains optional `unreadCount` and `progress` props that render a top-right pill badge and a bottom progress overlay on the cover. Both are no-ops when omitted, so existing callers don't change. Counts past 99 cap at "99+". - The filter form body is extracted into a Svelte 5 snippet and rendered conditionally — inline desktop panel OR mobile sheet, never both — so no testid is duplicated and the focus trap can't double- fire. A matchMedia listener tracks the 640px breakpoint and drives the snippet target. - Mobile catalog adds: Filter / Sort chip buttons, an always-visible active-filter chip row with per-facet remove buttons, full-width search, and a 2-column grid. - Auto-expanding the filter panel from URL params is now desktop-only — on mobile the active-filter chips signal applied facets without hiding the catalog under a scrim on first paint. - Vitest suite covers MangaCard badge / overlay behavior including clamp / hide edge cases. Playwright spec covers the mobile filter + sort sheet flow at 390px viewport, with a hydration gate to keep click dispatch from racing the SSR'd-but-not-yet-reactive button. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,13 +8,39 @@
|
||||
manga,
|
||||
authors = [],
|
||||
genres = [],
|
||||
unreadCount,
|
||||
progress,
|
||||
testid
|
||||
}: {
|
||||
manga: Manga;
|
||||
authors?: AuthorRef[];
|
||||
genres?: GenreRef[];
|
||||
/**
|
||||
* Number of unread chapters since the user's last read position.
|
||||
* Shown as a small badge in the cover's top-right corner; 0 or
|
||||
* undefined hides the badge. Counts past 99 cap at "99+".
|
||||
*/
|
||||
unreadCount?: number;
|
||||
/**
|
||||
* Fraction read [0..1]. Rendered as a thin bar overlaid on the
|
||||
* bottom edge of the cover. Out-of-range values clamp; non-
|
||||
* positive values hide the bar entirely.
|
||||
*/
|
||||
progress?: number;
|
||||
testid?: string;
|
||||
} = $props();
|
||||
|
||||
const unreadLabel = $derived(
|
||||
unreadCount && unreadCount > 0
|
||||
? unreadCount > 99
|
||||
? '99+'
|
||||
: String(unreadCount)
|
||||
: null
|
||||
);
|
||||
|
||||
const progressPct = $derived(
|
||||
progress != null && progress > 0 ? Math.min(progress, 1) * 100 : null
|
||||
);
|
||||
</script>
|
||||
|
||||
<li class="manga-card" data-testid={testid}>
|
||||
@@ -31,6 +57,24 @@
|
||||
<BookImage size={36} aria-hidden="true" />
|
||||
</div>
|
||||
{/if}
|
||||
{#if unreadLabel}
|
||||
<span
|
||||
class="unread-badge"
|
||||
data-testid="unread-badge"
|
||||
aria-label="{unreadLabel} unread chapter{unreadLabel === '1' ? '' : 's'}"
|
||||
>
|
||||
{unreadLabel}
|
||||
</span>
|
||||
{/if}
|
||||
{#if progressPct != null}
|
||||
<span class="cover-progress" data-testid="cover-progress" aria-hidden="true">
|
||||
<span
|
||||
class="cover-progress-fill"
|
||||
data-testid="cover-progress-fill"
|
||||
style="width: {progressPct}%;"
|
||||
></span>
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
<div class="meta">
|
||||
<a href="/manga/{manga.id}" class="title">{manga.title}</a>
|
||||
@@ -54,6 +98,45 @@
|
||||
.cover-link {
|
||||
display: block;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
position: absolute;
|
||||
top: var(--space-1);
|
||||
right: var(--space-1);
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--primary);
|
||||
color: var(--primary-contrast);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
line-height: 1;
|
||||
border-radius: var(--radius-pill);
|
||||
box-shadow: var(--shadow-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.cover-progress {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-progress-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.cover {
|
||||
|
||||
65
frontend/src/lib/components/MangaCard.svelte.test.ts
Normal file
65
frontend/src/lib/components/MangaCard.svelte.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||
import MangaCard from './MangaCard.svelte';
|
||||
import type { Manga } from '$lib/api/client';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const baseManga: Manga = {
|
||||
id: 'm1',
|
||||
title: 'Berserk',
|
||||
status: 'ongoing',
|
||||
alt_titles: [],
|
||||
description: null,
|
||||
cover_image_path: null,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z'
|
||||
};
|
||||
|
||||
describe('MangaCard badges & overlays', () => {
|
||||
it('does not render an unread badge when unreadCount is omitted', () => {
|
||||
render(MangaCard, { props: { manga: baseManga } });
|
||||
expect(screen.queryByTestId('unread-badge')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not render an unread badge when unreadCount is 0', () => {
|
||||
render(MangaCard, { props: { manga: baseManga, unreadCount: 0 } });
|
||||
expect(screen.queryByTestId('unread-badge')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the unread badge with the count when unreadCount > 0', () => {
|
||||
render(MangaCard, { props: { manga: baseManga, unreadCount: 7 } });
|
||||
const badge = screen.getByTestId('unread-badge');
|
||||
expect(badge.textContent?.trim()).toBe('7');
|
||||
});
|
||||
|
||||
it('caps the unread badge at 99+ for very large counts', () => {
|
||||
render(MangaCard, { props: { manga: baseManga, unreadCount: 250 } });
|
||||
const badge = screen.getByTestId('unread-badge');
|
||||
expect(badge.textContent?.trim()).toBe('99+');
|
||||
});
|
||||
|
||||
it('does not render a progress bar when progress is omitted', () => {
|
||||
render(MangaCard, { props: { manga: baseManga } });
|
||||
expect(screen.queryByTestId('cover-progress')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not render a progress bar at exactly 0 (no reads yet)', () => {
|
||||
render(MangaCard, { props: { manga: baseManga, progress: 0 } });
|
||||
expect(screen.queryByTestId('cover-progress')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the progress overlay clamped to 0..1 and reflects the value as a width style', () => {
|
||||
const { rerender } = render(MangaCard, {
|
||||
props: { manga: baseManga, progress: 0.4 }
|
||||
});
|
||||
const bar = screen.getByTestId('cover-progress-fill');
|
||||
expect((bar as HTMLElement).style.width).toBe('40%');
|
||||
|
||||
rerender({ manga: baseManga, progress: 1.5 });
|
||||
expect((screen.getByTestId('cover-progress-fill') as HTMLElement).style.width).toBe('100%');
|
||||
|
||||
rerender({ manga: baseManga, progress: -0.2 });
|
||||
expect(screen.queryByTestId('cover-progress')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user