Merge branch 'feat/adaptive-overlay-sheets' into chore/reconcile-ui-review
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
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">
|
||||
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 {
|
||||
|
||||
@@ -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';
|
||||
@@ -1577,7 +1577,7 @@
|
||||
if (activePageId) void loadPageSummary(activePageId);
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
<AdaptiveDialog
|
||||
open={tagsModalOpen}
|
||||
title="Tag this page"
|
||||
onClose={() => (tagsModalOpen = false)}
|
||||
@@ -1590,7 +1590,7 @@
|
||||
activePageTags = tags;
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</AdaptiveDialog>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user