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

View File

@@ -14,15 +14,17 @@
import Chip from '$lib/components/Chip.svelte';
import MangaCard from '$lib/components/MangaCard.svelte';
import Pager from '$lib/components/Pager.svelte';
import Sheet from '$lib/components/Sheet.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';
import Plus from '@lucide/svelte/icons/plus';
const PAGE_SIZE = 50;
let mangas: MangaCardData[] = $state([]);
let search = $state('');
let sort: MangaSort = $state('recent');
let sort = $state<MangaSort>('recent');
let statusFilter = $state<'' | MangaStatus>('');
let selectedGenres = $state<Genre[]>([]);
let selectedTags = $state<Tag[]>([]);
@@ -36,15 +38,24 @@
let suggestSeq = 0;
const tagSuggestListId = 'tag-filter-suggest-list';
let filtersOpen = $state(false);
let sortSheetOpen = $state(false);
let total: number | null = $state(null);
let loading = $state(true);
let error: string | null = $state(null);
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(
(statusFilter ? 1 : 0) + selectedGenres.length + selectedTags.length
);
const sortLabel = $derived(sort === 'title' ? 'Title (A→Z)' : 'Recent');
const totalPages = $derived(
total != null && total > 0 ? Math.ceil(total / PAGE_SIZE) : 1
);
@@ -135,8 +146,15 @@
}
const pageParam = Number(url.searchParams.get('page') ?? '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.
if (statusFilter || selectedGenres.length || selectedTags.length) {
// Auto-expand the inline filters panel on desktop so the user can
// 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;
}
}
@@ -224,6 +242,21 @@
resetAndReload();
}
function clearStatus() {
statusFilter = '';
resetAndReload();
}
function pickSort(next: MangaSort) {
if (next === sort) {
sortSheetOpen = false;
return;
}
sort = next;
sortSheetOpen = false;
resetAndReload();
}
onMount(async () => {
try {
allGenres = await listGenres();
@@ -233,8 +266,142 @@
await hydrateFromUrl();
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>
{#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>
<form
@@ -268,130 +435,56 @@
<span class="filter-count">{activeFilterCount}</span>
{/if}
</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>
{#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">
<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 activeFilterCount > 0}
<div class="active-filters" data-testid="active-filters">
{#if statusFilter}
<Chip
label={statusFilter === 'ongoing' ? 'Ongoing' : 'Completed'}
variant="primary"
onRemove={clearStatus}
removeLabel="Clear status filter"
testid="active-filter-status"
/>
{/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>
{/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">
<span>Sort</span>
<select bind:value={sort} onchange={onSortChange} data-testid="sort-select">
@@ -402,6 +495,47 @@
</div>
</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}
<p class="status" data-testid="loading">Loading…</p>
{:else if error}
@@ -476,6 +610,29 @@
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 {
display: flex;
flex-direction: column;
@@ -611,6 +768,20 @@
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 {
display: inline-flex;
align-items: center;
@@ -653,4 +824,33 @@
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
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>