feat(ui): present page-action editors as sheets on mobile
All checks were successful
deploy / test-backend (pull_request) Successful in 28m26s
deploy / test-frontend (pull_request) Successful in 10m22s
deploy / build-and-push (pull_request) Has been skipped
deploy / deploy (pull_request) Has been skipped

The Add-tags and Add-to-collection editors opened as centered Modals on
every form factor, so a mobile user went action-Sheet → centered Modal — an
inconsistent hop, and AddTagsSheet's docstring promised a sheet it never
got. Introduce AdaptiveDialog, which renders a bottom Sheet under 640px and
a centered Modal above it behind one shared contract (open / title / onClose
/ children / testid). Route both editors through it:

- AddToCollectionModal now wraps AdaptiveDialog (both its call sites — manga
  detail and the reader — adapt automatically).
- The reader hosts AddTagsSheet in an AdaptiveDialog instead of a Modal, so
  its "slots into a Sheet (mobile) or Modal (desktop)" contract is now true.

Modal/Sheet already share an API, so the switch is transparent: the dialog
keeps the same role, title, testid, and close affordance. Desktop is
unchanged.

AdaptiveDialog has its own unit test (Sheet on mobile, Modal on desktop);
an e2e proves the mobile long-press → Add-tag flow now opens a Sheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-25 20:21:10 +02:00
parent dee53fa212
commit 92db3ce324
8 changed files with 130 additions and 9 deletions

2
backend/Cargo.lock generated
View File

@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.88.0"
version = "0.89.0"
dependencies = [
"anyhow",
"argon2",

View File

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

View File

@@ -373,5 +373,12 @@ test.describe('page action sheet (mobile long-press)', () => {
await expect(page.getByTestId('page-action-add-tag')).toBeVisible();
await expect(page.getByTestId('page-action-save-image')).toBeVisible();
await expect(page.getByTestId('page-action-copy-link')).toBeVisible();
// The editor itself opens as a bottom Sheet on mobile (not a centered
// Modal), so the action-sheet → editor flow stays one consistent
// idiom. Sheet renders a `${testid}-scrim`; Modal a `${testid}-backdrop`.
await page.getByTestId('page-action-add-tag').click();
await expect(page.getByTestId('add-tags-modal-scrim')).toBeVisible();
await expect(page.getByTestId('add-tags-modal-backdrop')).toHaveCount(0);
});
});

View File

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

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Modal from './Modal.svelte';
import Sheet from './Sheet.svelte';
// One overlay that adapts to the form factor: a bottom Sheet on phones,
// a centered Modal on desktop. Both share the same chrome contract
// (open / title / onClose / children / testid), so callers stay agnostic
// and the page-action editors present consistently with the rest of the
// mobile UI (long-press → action sheet → editor sheet, no Modal hop).
let {
open,
title,
onClose,
children,
size = 'md',
testid
}: {
open: boolean;
title: string;
onClose: () => void;
children: Snippet;
/** Desktop modal width; ignored by the mobile sheet. */
size?: 'sm' | 'md' | 'lg';
testid?: string;
} = $props();
// Bottom sheet under the 640px breakpoint. Guarded on matchMedia rather
// than $app/environment so it's SSR-safe (no window) and unit-testable
// (stub matchMedia). First paint defaults to the modal; the effect
// corrects to a sheet on phones before the dialog is opened.
let isMobile = $state(false);
$effect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return;
const mql = window.matchMedia('(max-width: 640px)');
isMobile = mql.matches;
const onChange = (e: MediaQueryListEvent) => {
isMobile = e.matches;
};
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
});
</script>
{#if isMobile}
<Sheet {open} {title} {onClose} {testid}>
{@render children()}
</Sheet>
{:else}
<Modal {open} {title} {onClose} {size} {testid}>
{@render children()}
</Modal>
{/if}

View File

@@ -0,0 +1,61 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/svelte';
import { createRawSnippet } from 'svelte';
import AdaptiveDialog from './AdaptiveDialog.svelte';
afterEach(() => {
cleanup();
// @ts-expect-error — restore between tests
delete window.matchMedia;
});
// jsdom has no matchMedia; stub it to drive the breakpoint branch.
function stubMatchMedia(matches: boolean) {
window.matchMedia = vi.fn().mockImplementation((media: string) => ({
matches,
media,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn()
})) as unknown as typeof window.matchMedia;
}
const body = createRawSnippet(() => ({
render: () => `<p data-testid="dlg-body">hello</p>`
}));
describe('AdaptiveDialog', () => {
it('renders a centered Modal on desktop (the scrim is the Modal backdrop)', async () => {
stubMatchMedia(false);
render(AdaptiveDialog, {
props: { open: true, title: 'Tag this page', onClose: () => {}, testid: 'dlg', children: body }
});
// Modal exposes a `${testid}-backdrop`; Sheet exposes `${testid}-scrim`.
expect(await screen.findByTestId('dlg-backdrop')).toBeTruthy();
expect(screen.queryByTestId('dlg-scrim')).toBeNull();
expect(screen.getByRole('dialog', { name: 'Tag this page' })).toBeTruthy();
expect(screen.getByTestId('dlg-body')).toBeTruthy();
});
it('renders a bottom Sheet on mobile', async () => {
stubMatchMedia(true);
render(AdaptiveDialog, {
props: { open: true, title: 'Tag this page', onClose: () => {}, testid: 'dlg', children: body }
});
// The effect flips to mobile after mount; poll for the Sheet scrim.
expect(await screen.findByTestId('dlg-scrim')).toBeTruthy();
expect(screen.queryByTestId('dlg-backdrop')).toBeNull();
expect(screen.getByRole('dialog', { name: 'Tag this page' })).toBeTruthy();
});
it('renders nothing when closed', () => {
stubMatchMedia(false);
render(AdaptiveDialog, {
props: { open: false, title: 'Tag this page', onClose: () => {}, testid: 'dlg', children: body }
});
expect(screen.queryByRole('dialog')).toBeNull();
});
});

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import Modal from './Modal.svelte';
import AdaptiveDialog from './AdaptiveDialog.svelte';
import {
addMangaToCollection,
createCollection,
@@ -166,7 +166,7 @@
}
</script>
<Modal {open} {onClose} title="Add to collection" size="md" testid="add-to-collection-modal">
<AdaptiveDialog {open} {onClose} title="Add to collection" size="md" testid="add-to-collection-modal">
{#if loading}
<p class="status">Loading your collections…</p>
{:else if error}
@@ -235,7 +235,7 @@
<span>{creating ? 'Creating…' : 'Create + add'}</span>
</button>
</form>
</Modal>
</AdaptiveDialog>
<style>
.status {

View File

@@ -11,7 +11,7 @@
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
import { session } from '$lib/session.svelte';
import Sheet from '$lib/components/Sheet.svelte';
import Modal from '$lib/components/Modal.svelte';
import AdaptiveDialog from '$lib/components/AdaptiveDialog.svelte';
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
import TapZone from '$lib/components/TapZone.svelte';
import PageContextMenu from '$lib/components/PageContextMenu.svelte';
@@ -1555,7 +1555,7 @@
if (activePageId) void loadPageSummary(activePageId);
}}
/>
<Modal
<AdaptiveDialog
open={tagsModalOpen}
title="Tag this page"
onClose={() => (tagsModalOpen = false)}
@@ -1568,7 +1568,7 @@
activePageTags = tags;
}}
/>
</Modal>
</AdaptiveDialog>
{/if}
{/if}