feat(ui): present page-action editors as sheets on mobile
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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1517,7 +1517,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.88.0"
|
version = "0.89.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.88.0"
|
version = "0.89.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -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-add-tag')).toBeVisible();
|
||||||
await expect(page.getByTestId('page-action-save-image')).toBeVisible();
|
await expect(page.getByTestId('page-action-save-image')).toBeVisible();
|
||||||
await expect(page.getByTestId('page-action-copy-link')).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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.88.0",
|
"version": "0.89.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
53
frontend/src/lib/components/AdaptiveDialog.svelte
Normal file
53
frontend/src/lib/components/AdaptiveDialog.svelte
Normal 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}
|
||||||
61
frontend/src/lib/components/AdaptiveDialog.svelte.test.ts
Normal file
61
frontend/src/lib/components/AdaptiveDialog.svelte.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Modal from './Modal.svelte';
|
import AdaptiveDialog from './AdaptiveDialog.svelte';
|
||||||
import {
|
import {
|
||||||
addMangaToCollection,
|
addMangaToCollection,
|
||||||
createCollection,
|
createCollection,
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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}
|
{#if loading}
|
||||||
<p class="status">Loading your collections…</p>
|
<p class="status">Loading your collections…</p>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
@@ -235,7 +235,7 @@
|
|||||||
<span>{creating ? 'Creating…' : 'Create + add'}</span>
|
<span>{creating ? 'Creating…' : 'Create + add'}</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</AdaptiveDialog>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.status {
|
.status {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
|
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
|
||||||
import { session } from '$lib/session.svelte';
|
import { session } from '$lib/session.svelte';
|
||||||
import Sheet from '$lib/components/Sheet.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 SegmentedControl from '$lib/components/SegmentedControl.svelte';
|
||||||
import TapZone from '$lib/components/TapZone.svelte';
|
import TapZone from '$lib/components/TapZone.svelte';
|
||||||
import PageContextMenu from '$lib/components/PageContextMenu.svelte';
|
import PageContextMenu from '$lib/components/PageContextMenu.svelte';
|
||||||
@@ -1555,7 +1555,7 @@
|
|||||||
if (activePageId) void loadPageSummary(activePageId);
|
if (activePageId) void loadPageSummary(activePageId);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<AdaptiveDialog
|
||||||
open={tagsModalOpen}
|
open={tagsModalOpen}
|
||||||
title="Tag this page"
|
title="Tag this page"
|
||||||
onClose={() => (tagsModalOpen = false)}
|
onClose={() => (tagsModalOpen = false)}
|
||||||
@@ -1568,7 +1568,7 @@
|
|||||||
activePageTags = tags;
|
activePageTags = tags;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</AdaptiveDialog>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user