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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.56.0"
|
||||
version = "0.57.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.56.0"
|
||||
version = "0.57.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
178
frontend/e2e/mobile-list-search.spec.ts
Normal file
178
frontend/e2e/mobile-list-search.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.56.0",
|
||||
"version": "0.57.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user