From 7c7f377f06ed0c0411a12d1792e701be0fb2f28a Mon Sep 17 00:00:00 2001 From: codebude Date: Mon, 7 Sep 2026 08:39:08 +0200 Subject: [PATCH 1/3] Implement close on esc keypress for various dialogs --- .../src/lib/components/AddBookModal.svelte | 15 ++++++- .../components/AutoSearchCoverModal.svelte | 13 +++++- .../components/AutoSearchCoverModal.test.ts | 10 ++++- .../src/lib/components/BarcodeScanner.svelte | 20 +++++---- .../lib/components/BookDetailDialog.svelte | 32 ++++++++------ .../lib/components/BookDetailDialog.test.ts | 31 +++++++++++++ frontend/src/lib/components/BookDrawer.svelte | 43 +++++++++++-------- .../src/lib/components/BookDrawer.test.ts | 15 +++++++ .../lib/components/DateConflictDialog.svelte | 13 +++++- frontend/src/routes/data-hygiene/+page.svelte | 19 +++++--- 10 files changed, 161 insertions(+), 50 deletions(-) diff --git a/frontend/src/lib/components/AddBookModal.svelte b/frontend/src/lib/components/AddBookModal.svelte index 9bb46695..581abd39 100644 --- a/frontend/src/lib/components/AddBookModal.svelte +++ b/frontend/src/lib/components/AddBookModal.svelte @@ -43,6 +43,18 @@ let cover_url = $state(null); $effect(() => { status = defaultStatus; }); + // Close on Escape right away — the backdrop only receives key events + // after it has been clicked, so listen at the window level instead. + // Skip while the nested barcode scanner is open. + $effect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !scannerOpen) open = false; + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }); + function reset() { title = ''; subtitle = ''; @@ -268,7 +280,6 @@ isbn = detected; }} /> - - + {/if} diff --git a/frontend/src/lib/components/AutoSearchCoverModal.svelte b/frontend/src/lib/components/AutoSearchCoverModal.svelte index e7722e72..24433aea 100644 --- a/frontend/src/lib/components/AutoSearchCoverModal.svelte +++ b/frontend/src/lib/components/AutoSearchCoverModal.svelte @@ -23,6 +23,17 @@ function close() { onCancel?.(); } + + // Close on Escape right away — the modal-backdrop only receives key events + // after it has been clicked, so listen at the window level instead. + $effect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') close(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }); {#if open} @@ -48,6 +59,6 @@ - + {/if} diff --git a/frontend/src/lib/components/AutoSearchCoverModal.test.ts b/frontend/src/lib/components/AutoSearchCoverModal.test.ts index 7bc84f49..baafb80a 100644 --- a/frontend/src/lib/components/AutoSearchCoverModal.test.ts +++ b/frontend/src/lib/components/AutoSearchCoverModal.test.ts @@ -100,13 +100,21 @@ describe('AutoSearchCoverModal', () => { expect(onCancel).toHaveBeenCalledOnce(); }); - it('calls onCancel when backdrop clicked', async () => { + it('does not call onCancel when backdrop clicked', async () => { render(AutoSearchCoverModal, { props: { open: true, loading: false, candidates: [], error: null, onCancel, onSelect } }); const backdrop = document.querySelector('.modal-backdrop'); expect(backdrop).toBeTruthy(); await fireEvent.click(backdrop as Element); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it('calls onCancel when Escape is pressed', async () => { + render(AutoSearchCoverModal, { + props: { open: true, loading: false, candidates: [], error: null, onCancel, onSelect } + }); + await fireEvent.keyDown(window, { key: 'Escape' }); expect(onCancel).toHaveBeenCalledOnce(); }); diff --git a/frontend/src/lib/components/BarcodeScanner.svelte b/frontend/src/lib/components/BarcodeScanner.svelte index adc55d5f..6ec53386 100644 --- a/frontend/src/lib/components/BarcodeScanner.svelte +++ b/frontend/src/lib/components/BarcodeScanner.svelte @@ -339,6 +339,17 @@ } }); + // Close on Escape right away — the backdrop only receives key events + // after it has been clicked, so listen at the window level instead. + $effect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') void closeScanner(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }); + onDestroy(() => { void stopScanner(); }); @@ -346,14 +357,7 @@ {#if open}
-
e.key === 'Escape' && closeScanner()} - role="button" - tabindex="0" - aria-label={$_('scanner.close')} - >
+
{/if}
- +
{/if} diff --git a/frontend/src/routes/data-hygiene/+page.svelte b/frontend/src/routes/data-hygiene/+page.svelte index c2a0529d..a20d60c5 100644 --- a/frontend/src/routes/data-hygiene/+page.svelte +++ b/frontend/src/routes/data-hygiene/+page.svelte @@ -251,6 +251,17 @@ batchFieldWasAutoSelected = false; } }); + + // Close the cover viewer on Escape right away — the backdrop only + // receives key events after it has been clicked. + $effect(() => { + if (!coverViewer) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') closeCoverViewer(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + });
@@ -493,13 +504,7 @@
{#if coverViewer} -
e.key === 'Escape' && closeCoverViewer()} - >
+
From c9ba979bf35ddeeb49b96e84f18cfa09473f6f8d Mon Sep 17 00:00:00 2001 From: codebude Date: Tue, 8 Sep 2026 00:07:31 +0200 Subject: [PATCH 2/3] Add cancelable book search to the import modal --- docs/guide/using-librislog/library.md | 2 + frontend/src/lib/api.test.ts | 41 +++ frontend/src/lib/api.ts | 5 +- .../src/lib/components/ImportSearch.svelte | 61 +++- .../src/lib/components/ImportSearch.test.ts | 275 ++++++++++++++++++ 5 files changed, 371 insertions(+), 13 deletions(-) create mode 100644 frontend/src/lib/components/ImportSearch.test.ts diff --git a/docs/guide/using-librislog/library.md b/docs/guide/using-librislog/library.md index 48a2084d..d4cc243a 100644 --- a/docs/guide/using-librislog/library.md +++ b/docs/guide/using-librislog/library.md @@ -65,6 +65,8 @@ Search external sources for book metadata: The search automatically tries Open Library first, then falls back to other sources. For ISBN searches, all available sources are queried in parallel. +While a search is running, the **Search** button changes to **Cancel**, so you can stop the request at any time and refine your query. + ### ISBN Barcode Scan Use the camera to scan ISBN barcodes. The app uses the device's camera with real-time barcode detection to quickly look up books. diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts index ff1fb970..7c2eafd6 100644 --- a/frontend/src/lib/api.test.ts +++ b/frontend/src/lib/api.test.ts @@ -285,3 +285,44 @@ describe('api.statistics.gamification', () => { expect(body).toMatchObject({ goal_pages_per_day_enabled: true, goal_pages_per_day: 25 }); }); }); + +describe('api.import.searchStream', () => { + beforeEach(() => { + apiKey.set(null); + csrfToken.set(null); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('passes abort signal to fetch', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + body: new ReadableStream({ start(controller) { controller.close(); } }), + } as unknown as Response); + + const controller = new AbortController(); + const gen = api.import.searchStream('dune', 'title', 'auto', controller.signal); + await gen.next(); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.signal).toBe(controller.signal); + }); + + it('builds the stream URL with query, type and mode', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + body: new ReadableStream({ start(controller) { controller.close(); } }), + } as unknown as Response); + + const gen = api.import.searchStream('dune', 'isbn', 'google_only'); + await gen.next(); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toContain('/import/search/stream'); + expect(url).toContain('q=dune'); + expect(url).toContain('type=isbn'); + expect(url).toContain('mode=google_only'); + }); +}); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3dcdd415..573f457d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -483,11 +483,12 @@ export const api = { async *searchStream( q: string, type: 'title' | 'isbn' = 'title', - mode: ImportSearchMode = 'auto' + mode: ImportSearchMode = 'auto', + signal?: AbortSignal ): AsyncGenerator { const res = await fetch( `${BASE}/import/search/stream?q=${encodeURIComponent(q)}&type=${type}&mode=${mode}`, - { headers: authHeaders() } + { headers: authHeaders(), signal } ); if (!res.ok || !res.body) { const detail = await res.json().catch(() => ({})); diff --git a/frontend/src/lib/components/ImportSearch.svelte b/frontend/src/lib/components/ImportSearch.svelte index 07917ad2..01dcb152 100644 --- a/frontend/src/lib/components/ImportSearch.svelte +++ b/frontend/src/lib/components/ImportSearch.svelte @@ -1,6 +1,6 @@