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:
MechaCat02
2026-06-07 10:54:58 +02:00
parent 6dcb720a0f
commit be6b974150
7 changed files with 651 additions and 125 deletions

2
backend/Cargo.lock generated
View File

@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "mangalord" name = "mangalord"
version = "0.56.0" version = "0.57.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "mangalord" name = "mangalord"
version = "0.56.0" version = "0.57.0"
edition = "2021" edition = "2021"
default-run = "mangalord" default-run = "mangalord"

View File

@@ -0,0 +1,178 @@
import { test, expect, type Page } from '@playwright/test';
// Phase 2: the catalog (/) gets a mobile chrome — search input full
// width, Filter and Sort as chip buttons that open bottom sheets, and
// the active-filter row exposes selected facets as removable chips. The
// inline desktop filter panel is hidden on mobile.
const MOBILE = { width: 390, height: 844 } as const;
const DESKTOP = { width: 1280, height: 720 } as const;
async function mockAnonymous(page: Page) {
await page.route('**/api/v1/auth/config', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
});
});
await page.route('**/api/v1/auth/me', async (route) => {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } })
});
});
}
async function mockCatalog(
page: Page,
opts: { genres?: { id: string; name: string }[]; capture?: (url: URL) => void } = {}
) {
const genres = opts.genres ?? [
{ id: 'g-action', name: 'Action' },
{ id: 'g-romance', name: 'Romance' }
];
await page.route('**/api/v1/genres*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(genres)
});
});
await page.route('**/api/v1/mangas*', async (route) => {
const url = new URL(route.request().url());
opts.capture?.(url);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [],
page: { limit: 50, offset: 0, total: 0 }
})
});
});
}
test.describe('mobile catalog chrome', () => {
test('phone viewport: Sort chip is visible, inline desktop select is hidden', async ({
page
}) => {
await mockAnonymous(page);
await mockCatalog(page);
await page.setViewportSize(MOBILE);
await page.goto('/');
await expect(page.getByTestId('sort-chip')).toBeVisible();
await expect(page.getByTestId('sort-select')).toBeHidden();
});
test('desktop viewport: inline Sort select is visible, mobile chip is hidden', async ({
page
}) => {
await mockAnonymous(page);
await mockCatalog(page);
await page.setViewportSize(DESKTOP);
await page.goto('/');
await expect(page.getByTestId('sort-select')).toBeVisible();
await expect(page.getByTestId('sort-chip')).toBeHidden();
});
// `empty` only renders after onMount's listMangas() resolves, which
// means hydration is complete and click handlers are attached. Without
// this gate, Playwright dispatches the click against the SSR'd static
// button before Svelte takes over and the state mutation is dropped.
async function waitForHydration(page: Page) {
await expect(page.getByTestId('empty')).toBeVisible();
}
test('phone viewport: Filter chip opens the bottom sheet, not the inline panel', async ({
page
}) => {
await mockAnonymous(page);
await mockCatalog(page);
await page.setViewportSize(MOBILE);
await page.goto('/');
await waitForHydration(page);
await expect(page.getByTestId('filter-sheet')).toBeHidden();
await page.getByTestId('filters-toggle').click();
await expect(page.getByTestId('filter-sheet')).toBeVisible();
// Inline panel must not render on mobile — the snippet gated by
// !isMobileViewport means the same form is never on the page twice.
await expect(page.getByTestId('filters-panel')).toHaveCount(0);
});
test('phone viewport: picking a genre updates URL + shows an active-filter chip', async ({
page
}) => {
// The API uses `genre_id` (singular, comma-joined) while the URL
// we surface to the user uses `genres` — verify both paths.
let lastGenreIdParam: string | null = null;
await mockAnonymous(page);
await mockCatalog(page, {
capture: (url) => {
lastGenreIdParam = url.searchParams.get('genre_id');
}
});
await page.setViewportSize(MOBILE);
await page.goto('/');
await waitForHydration(page);
await page.getByTestId('filters-toggle').click();
await page.getByTestId('genre-filter-Action').click();
await expect(page).toHaveURL(/genres=g-action/);
await expect(page.getByTestId('active-filter-genre-Action')).toBeVisible();
expect(lastGenreIdParam).toBe('g-action');
});
test('phone viewport: removing an active-filter chip clears that facet', async ({
page
}) => {
await mockAnonymous(page);
await mockCatalog(page);
await page.setViewportSize(MOBILE);
await page.goto('/?genres=g-action');
await waitForHydration(page);
// The chip appears after hydrateFromUrl resolves the genre id.
const chip = page.getByTestId('active-filter-genre-Action');
await expect(chip).toBeVisible();
// Chip's remove button has an aria-label "Remove genre Action".
await chip.getByRole('button', { name: 'Remove genre Action' }).click();
await expect(chip).toHaveCount(0);
await expect(page).toHaveURL((url) => !url.search.includes('genres='));
});
test('phone viewport: Sort sheet swaps the sort and dismisses on pick', async ({
page
}) => {
let lastSortParam: string | null = null;
await mockAnonymous(page);
await mockCatalog(page, {
capture: (url) => {
lastSortParam = url.searchParams.get('sort');
}
});
await page.setViewportSize(MOBILE);
await page.goto('/');
await waitForHydration(page);
await page.getByTestId('sort-chip').click();
await expect(page.getByTestId('sort-sheet')).toBeVisible();
// Use click(), not check(): the onchange handler closes the sheet
// synchronously so the radio is gone before check() can verify the
// `checked` state.
await page.getByTestId('sort-sheet').getByRole('radio', { name: /Title/ }).click();
await expect(page.getByTestId('sort-sheet')).toBeHidden();
await expect(page).toHaveURL(/sort=title/);
expect(lastSortParam).toBe('title');
});
});

View File

@@ -1,6 +1,6 @@
{ {
"name": "mangalord-frontend", "name": "mangalord-frontend",
"version": "0.56.0", "version": "0.57.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -8,13 +8,39 @@
manga, manga,
authors = [], authors = [],
genres = [], genres = [],
unreadCount,
progress,
testid testid
}: { }: {
manga: Manga; manga: Manga;
authors?: AuthorRef[]; authors?: AuthorRef[];
genres?: GenreRef[]; 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; testid?: string;
} = $props(); } = $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> </script>
<li class="manga-card" data-testid={testid}> <li class="manga-card" data-testid={testid}>
@@ -31,6 +57,24 @@
<BookImage size={36} aria-hidden="true" /> <BookImage size={36} aria-hidden="true" />
</div> </div>
{/if} {/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> </a>
<div class="meta"> <div class="meta">
<a href="/manga/{manga.id}" class="title">{manga.title}</a> <a href="/manga/{manga.id}" class="title">{manga.title}</a>
@@ -54,6 +98,45 @@
.cover-link { .cover-link {
display: block; display: block;
line-height: 0; 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 { .cover {

View 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();
});
});

View File

@@ -14,15 +14,17 @@
import Chip from '$lib/components/Chip.svelte'; import Chip from '$lib/components/Chip.svelte';
import MangaCard from '$lib/components/MangaCard.svelte'; import MangaCard from '$lib/components/MangaCard.svelte';
import Pager from '$lib/components/Pager.svelte'; import Pager from '$lib/components/Pager.svelte';
import Sheet from '$lib/components/Sheet.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 Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
let mangas: MangaCardData[] = $state([]); let mangas: MangaCardData[] = $state([]);
let search = $state(''); let search = $state('');
let sort: MangaSort = $state('recent'); let sort = $state<MangaSort>('recent');
let statusFilter = $state<'' | MangaStatus>(''); let statusFilter = $state<'' | MangaStatus>('');
let selectedGenres = $state<Genre[]>([]); let selectedGenres = $state<Genre[]>([]);
let selectedTags = $state<Tag[]>([]); let selectedTags = $state<Tag[]>([]);
@@ -36,15 +38,24 @@
let suggestSeq = 0; let suggestSeq = 0;
const tagSuggestListId = 'tag-filter-suggest-list'; const tagSuggestListId = 'tag-filter-suggest-list';
let filtersOpen = $state(false); let filtersOpen = $state(false);
let sortSheetOpen = $state(false);
let total: number | null = $state(null); let total: number | null = $state(null);
let loading = $state(true); let loading = $state(true);
let error: string | null = $state(null); let error: string | null = $state(null);
let currentPage = $state(1); let currentPage = $state(1);
// Tracks whether the viewport sits below the project's single 640px
// breakpoint. Drives the inline-panel-vs-bottom-sheet split for both
// filters and sort, so the same form body never renders twice and
// duplicate testids / focus-trap interference don't occur.
let isMobileViewport = $state(false);
const activeFilterCount = $derived( const activeFilterCount = $derived(
(statusFilter ? 1 : 0) + selectedGenres.length + selectedTags.length (statusFilter ? 1 : 0) + selectedGenres.length + selectedTags.length
); );
const sortLabel = $derived(sort === 'title' ? 'Title (A→Z)' : 'Recent');
const totalPages = $derived( const totalPages = $derived(
total != null && total > 0 ? Math.ceil(total / PAGE_SIZE) : 1 total != null && total > 0 ? Math.ceil(total / PAGE_SIZE) : 1
); );
@@ -135,8 +146,15 @@
} }
const pageParam = Number(url.searchParams.get('page') ?? '1'); const pageParam = Number(url.searchParams.get('page') ?? '1');
currentPage = Number.isFinite(pageParam) && pageParam >= 1 ? Math.floor(pageParam) : 1; currentPage = Number.isFinite(pageParam) && pageParam >= 1 ? Math.floor(pageParam) : 1;
// Open the filters panel if anything is active so the user can see why. // Auto-expand the inline filters panel on desktop so the user can
if (statusFilter || selectedGenres.length || selectedTags.length) { // see why results are filtered. On phones the active-filter chip
// row carries that signal already, and auto-opening the sheet would
// hide the catalog behind a scrim on first paint.
if (
(statusFilter || selectedGenres.length || selectedTags.length) &&
browser &&
!window.matchMedia('(max-width: 640px)').matches
) {
filtersOpen = true; filtersOpen = true;
} }
} }
@@ -224,6 +242,21 @@
resetAndReload(); resetAndReload();
} }
function clearStatus() {
statusFilter = '';
resetAndReload();
}
function pickSort(next: MangaSort) {
if (next === sort) {
sortSheetOpen = false;
return;
}
sort = next;
sortSheetOpen = false;
resetAndReload();
}
onMount(async () => { onMount(async () => {
try { try {
allGenres = await listGenres(); allGenres = await listGenres();
@@ -233,8 +266,142 @@
await hydrateFromUrl(); await hydrateFromUrl();
await load(); await load();
}); });
// Track viewport in a separate $effect so the listener cleans up on
// unmount without entangling with the data-load lifecycle above.
$effect(() => {
if (!browser) return;
const mql = window.matchMedia('(max-width: 640px)');
isMobileViewport = mql.matches;
const onChange = (e: MediaQueryListEvent) => {
isMobileViewport = e.matches;
};
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
});
</script> </script>
{#snippet filterFormContent()}
<div class="filter-group">
<span class="filter-label">Status</span>
<div class="status-row">
<label>
<input
type="radio"
name="status"
value=""
bind:group={statusFilter}
onchange={onStatusChange}
/>
<span>Any</span>
</label>
<label>
<input
type="radio"
name="status"
value="ongoing"
bind:group={statusFilter}
onchange={onStatusChange}
/>
<span>Ongoing</span>
</label>
<label>
<input
type="radio"
name="status"
value="completed"
bind:group={statusFilter}
onchange={onStatusChange}
/>
<span>Completed</span>
</label>
</div>
</div>
<div class="filter-group">
<span class="filter-label">Genres (all must match)</span>
<div class="filter-chip-row">
{#each allGenres as g (g.id)}
{@const on = selectedGenres.some((x) => x.id === g.id)}
<button
type="button"
class="genre-pill"
class:active={on}
onclick={() => toggleGenre(g)}
data-testid={`genre-filter-${g.name}`}
>
{g.name}
</button>
{/each}
</div>
</div>
<div class="filter-group">
<span class="filter-label">Tags (all must match)</span>
{#if selectedTags.length > 0}
<div class="filter-chip-row">
{#each selectedTags as t (t.id)}
<Chip
label={t.name}
variant="primary"
onRemove={() => removeTag(t)}
removeLabel={`Remove tag ${t.name}`}
testid={`tag-filter-chip-${t.name}`}
/>
{/each}
</div>
{/if}
<div class="tag-search">
<input
type="text"
role="combobox"
bind:value={tagDraft}
oninput={onTagDraftInput}
onkeydown={onTagFilterKeydown}
placeholder="Type to find a tag"
maxlength="64"
aria-label="Find a tag"
aria-controls={tagSuggestListId}
aria-expanded={tagSuggestions.length > 0}
aria-autocomplete="list"
aria-activedescendant={tagSuggestHighlight >= 0
? `${tagSuggestListId}-opt-${tagSuggestHighlight}`
: undefined}
data-testid="tag-filter-input"
/>
{#if tagSuggestions.length > 0}
<ul class="tag-suggestions" role="listbox" id={tagSuggestListId}>
{#each tagSuggestions as s, i (s.id)}
<li
id={`${tagSuggestListId}-opt-${i}`}
role="option"
aria-selected={i === tagSuggestHighlight}
class:active={i === tagSuggestHighlight}
>
<button
type="button"
tabindex="-1"
onmouseenter={() => (tagSuggestHighlight = i)}
onclick={() => pickTag(s)}
data-testid={`tag-filter-suggestion-${s.name}`}
>
<Plus size={12} aria-hidden="true" />
{s.name}
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
{#if activeFilterCount > 0}
<button type="button" class="clear" onclick={clearFilters}>
Clear filters
</button>
{/if}
{/snippet}
<h1>Mangas</h1> <h1>Mangas</h1>
<form <form
@@ -268,130 +435,56 @@
<span class="filter-count">{activeFilterCount}</span> <span class="filter-count">{activeFilterCount}</span>
{/if} {/if}
</button> </button>
<button
type="button"
class="sort-chip mobile-only"
onclick={() => (sortSheetOpen = true)}
data-testid="sort-chip"
>
<ArrowUpDown size={16} aria-hidden="true" />
<span>{sortLabel}</span>
</button>
</div> </div>
{#if filtersOpen}
<div class="filters-panel" id="filters-panel" data-testid="filters-panel">
<div class="filter-group">
<span class="filter-label">Status</span>
<div class="status-row">
<label>
<input
type="radio"
name="status"
value=""
bind:group={statusFilter}
onchange={onStatusChange}
/>
<span>Any</span>
</label>
<label>
<input
type="radio"
name="status"
value="ongoing"
bind:group={statusFilter}
onchange={onStatusChange}
/>
<span>Ongoing</span>
</label>
<label>
<input
type="radio"
name="status"
value="completed"
bind:group={statusFilter}
onchange={onStatusChange}
/>
<span>Completed</span>
</label>
</div>
</div>
<div class="filter-group"> {#if activeFilterCount > 0}
<span class="filter-label">Genres (all must match)</span> <div class="active-filters" data-testid="active-filters">
<div class="filter-chip-row"> {#if statusFilter}
{#each allGenres as g (g.id)} <Chip
{@const on = selectedGenres.some((x) => x.id === g.id)} label={statusFilter === 'ongoing' ? 'Ongoing' : 'Completed'}
<button variant="primary"
type="button" onRemove={clearStatus}
class="genre-pill" removeLabel="Clear status filter"
class:active={on} testid="active-filter-status"
onclick={() => toggleGenre(g)} />
data-testid={`genre-filter-${g.name}`}
>
{g.name}
</button>
{/each}
</div>
</div>
<div class="filter-group">
<span class="filter-label">Tags (all must match)</span>
{#if selectedTags.length > 0}
<div class="filter-chip-row">
{#each selectedTags as t (t.id)}
<Chip
label={t.name}
variant="primary"
onRemove={() => removeTag(t)}
removeLabel={`Remove tag ${t.name}`}
testid={`tag-filter-chip-${t.name}`}
/>
{/each}
</div>
{/if}
<div class="tag-search">
<input
type="text"
role="combobox"
bind:value={tagDraft}
oninput={onTagDraftInput}
onkeydown={onTagFilterKeydown}
placeholder="Type to find a tag"
maxlength="64"
aria-label="Find a tag"
aria-controls={tagSuggestListId}
aria-expanded={tagSuggestions.length > 0}
aria-autocomplete="list"
aria-activedescendant={tagSuggestHighlight >= 0
? `${tagSuggestListId}-opt-${tagSuggestHighlight}`
: undefined}
data-testid="tag-filter-input"
/>
{#if tagSuggestions.length > 0}
<ul class="tag-suggestions" role="listbox" id={tagSuggestListId}>
{#each tagSuggestions as s, i (s.id)}
<li
id={`${tagSuggestListId}-opt-${i}`}
role="option"
aria-selected={i === tagSuggestHighlight}
class:active={i === tagSuggestHighlight}
>
<button
type="button"
tabindex="-1"
onmouseenter={() => (tagSuggestHighlight = i)}
onclick={() => pickTag(s)}
data-testid={`tag-filter-suggestion-${s.name}`}
>
<Plus size={12} aria-hidden="true" />
{s.name}
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
{#if activeFilterCount > 0}
<button type="button" class="clear" onclick={clearFilters}>
Clear filters
</button>
{/if} {/if}
{#each selectedGenres as g (g.id)}
<Chip
label={g.name}
variant="primary"
onRemove={() => toggleGenre(g)}
removeLabel={`Remove genre ${g.name}`}
testid={`active-filter-genre-${g.name}`}
/>
{/each}
{#each selectedTags as t (t.id)}
<Chip
label={t.name}
variant="primary"
onRemove={() => removeTag(t)}
removeLabel={`Remove tag ${t.name}`}
testid={`active-filter-tag-${t.name}`}
/>
{/each}
</div> </div>
{/if} {/if}
<div class="config-row">
{#if filtersOpen && !isMobileViewport}
<div class="filters-panel" id="filters-panel" data-testid="filters-panel">
{@render filterFormContent()}
</div>
{/if}
<div class="config-row desktop-only">
<label class="sort"> <label class="sort">
<span>Sort</span> <span>Sort</span>
<select bind:value={sort} onchange={onSortChange} data-testid="sort-select"> <select bind:value={sort} onchange={onSortChange} data-testid="sort-select">
@@ -402,6 +495,47 @@
</div> </div>
</form> </form>
<Sheet
open={filtersOpen && isMobileViewport}
title="Filter"
onClose={() => (filtersOpen = false)}
testid="filter-sheet"
>
{@render filterFormContent()}
</Sheet>
<Sheet
open={sortSheetOpen && isMobileViewport}
title="Sort"
onClose={() => (sortSheetOpen = false)}
testid="sort-sheet"
>
<div class="sort-options">
<label>
<input
type="radio"
name="sort-mobile"
value="recent"
aria-label="Recent"
checked={sort === 'recent'}
onchange={() => pickSort('recent')}
/>
<span>Recent</span>
</label>
<label>
<input
type="radio"
name="sort-mobile"
value="title"
aria-label="Title (A→Z)"
checked={sort === 'title'}
onchange={() => pickSort('title')}
/>
<span>Title (A→Z)</span>
</label>
</div>
</Sheet>
{#if loading} {#if loading}
<p class="status" data-testid="loading">Loading…</p> <p class="status" data-testid="loading">Loading…</p>
{:else if error} {:else if error}
@@ -476,6 +610,29 @@
text-align: center; text-align: center;
} }
.sort-chip {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-3);
height: 36px;
background: var(--surface);
border: 1px solid var(--border-strong);
color: var(--text);
cursor: pointer;
}
.sort-chip:hover {
background: var(--surface-elevated);
border-color: var(--primary);
}
.active-filters {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.filters-panel { .filters-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -611,6 +768,20 @@
width: auto; width: auto;
} }
.sort-options {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.sort-options label {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) 0;
cursor: pointer;
}
.icon-btn { .icon-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -653,4 +824,33 @@
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-4); gap: var(--space-4);
} }
/* Mobile-only helpers. Defaults flip below the 640px breakpoint
so the same chrome doesn't render twice. */
.mobile-only {
display: none;
}
@media (max-width: 640px) {
.desktop-only {
display: none;
}
.mobile-only {
display: inline-flex;
}
.search {
/* Catalog search should fill the available width on phones —
the 28rem cap is desktop-only. */
max-width: none;
}
.manga-grid {
/* Lock to two columns on phones; the auto-fill default
packs 2-3 unpredictably at narrow widths. */
grid-template-columns: repeat(2, 1fr);
gap: var(--space-3);
}
}
</style> </style>