From 5945824b54262624789507f8b6c375483a2adc0a Mon Sep 17 00:00:00 2001 From: Marcel Date: Sat, 6 Jun 2026 17:47:05 +0200 Subject: [PATCH] feat(search): wire SmartModeToggle into SearchFilterBar (#739) Add smartMode $bindable plus onSmartSearch/onModeToggle callbacks. The toggle pill sits in the input's right slot (decorative icon moved to the left); smart mode disables the live oninput keyword search, adds maxlength=500, and submits the NL query on Enter. 4 integration specs. Co-Authored-By: Claude Opus 4.8 --- frontend/src/routes/SearchFilterBar.svelte | 29 ++++++++++-- .../search/SmartModeToggle.svelte.spec.ts | 47 ++++++++++++++++++- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/frontend/src/routes/SearchFilterBar.svelte b/frontend/src/routes/SearchFilterBar.svelte index 2b980974..779ab8f3 100644 --- a/frontend/src/routes/SearchFilterBar.svelte +++ b/frontend/src/routes/SearchFilterBar.svelte @@ -3,6 +3,7 @@ import PersonTypeahead from '$lib/person/PersonTypeahead.svelte'; import TagInput from '$lib/tag/TagInput.svelte'; import DateInput from '$lib/shared/primitives/DateInput.svelte'; import SortDropdown from '$lib/shared/primitives/SortDropdown.svelte'; +import SmartModeToggle from './search/SmartModeToggle.svelte'; import { slide } from 'svelte/transition'; import { m } from '$lib/paraglide/messages.js'; @@ -20,12 +21,15 @@ let { sort = $bindable('DATE'), dir = $bindable('desc'), showAdvanced = $bindable(false), + smartMode = $bindable(false), initialSenderName = '', initialReceiverName = '', navKey = 0, isLoading = false, onSearch, onSearchImmediate, + onSmartSearch, + onModeToggle, onfocus, onblur }: { @@ -42,16 +46,28 @@ let { sort?: string; dir?: string; showAdvanced?: boolean; + smartMode?: boolean; initialSenderName?: string; initialReceiverName?: string; navKey?: number; isLoading?: boolean; onSearch: () => void; onSearchImmediate?: () => void; + onSmartSearch?: () => void; + onModeToggle?: () => void; onfocus?: () => void; onblur?: () => void; } = $props(); +// In smart mode the keyword search must not fire on every keystroke — the NL +// query is submitted only on Enter (or an explicit button click). +function onSearchKeydown(event: KeyboardEvent) { + if (smartMode && event.key === 'Enter') { + event.preventDefault(); + onSmartSearch?.(); + } +} + // Plain (non-reactive) flag — not $state, so no reactive assignment inside $effect let sortDirMounted = false; @@ -76,14 +92,20 @@ $effect(() => { -
+ +
{#if isLoading} { /> {/if}
+
diff --git a/frontend/src/routes/search/SmartModeToggle.svelte.spec.ts b/frontend/src/routes/search/SmartModeToggle.svelte.spec.ts index 042a7008..01347271 100644 --- a/frontend/src/routes/search/SmartModeToggle.svelte.spec.ts +++ b/frontend/src/routes/search/SmartModeToggle.svelte.spec.ts @@ -1,10 +1,13 @@ -import { describe, expect, it, afterEach } from 'vitest'; +import { describe, expect, it, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import SmartModeToggle from './SmartModeToggle.svelte'; +import SearchFilterBar from '../SearchFilterBar.svelte'; afterEach(() => cleanup()); +const SEARCH_PLACEHOLDER = 'Titel, Personen, Tags durchsuchen…'; + describe('SmartModeToggle', () => { it('renders aria-pressed="false" by default and toggles on click', async () => { render(SmartModeToggle, { smartMode: false }); @@ -34,3 +37,45 @@ describe('SmartModeToggle', () => { await expect.element(btn).toHaveClass(/bg-primary/); }); }); + +describe('SmartModeToggle inside SearchFilterBar', () => { + it('adds maxlength="500" to the search input only in smart mode', async () => { + render(SearchFilterBar, { onSearch: vi.fn(), sort: 'DATE', dir: 'desc', smartMode: true }); + await expect + .element(page.getByPlaceholder(SEARCH_PLACEHOLDER)) + .toHaveAttribute('maxlength', '500'); + }); + + it('omits maxlength from the search input in keyword mode', async () => { + render(SearchFilterBar, { onSearch: vi.fn(), sort: 'DATE', dir: 'desc', smartMode: false }); + await expect + .element(page.getByPlaceholder(SEARCH_PLACEHOLDER)) + .not.toHaveAttribute('maxlength'); + }); + + it('does not fire the keyword search on input while in smart mode', async () => { + const onSearch = vi.fn(); + render(SearchFilterBar, { onSearch, sort: 'DATE', dir: 'desc', smartMode: true }); + await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill('Walter im Krieg'); + expect(onSearch).not.toHaveBeenCalled(); + }); + + it('fires the smart search callback on Enter in smart mode', async () => { + const onSmartSearch = vi.fn(); + render(SearchFilterBar, { + onSearch: vi.fn(), + onSmartSearch, + sort: 'DATE', + dir: 'desc', + smartMode: true + }); + const input = page.getByPlaceholder(SEARCH_PLACEHOLDER); + await input.fill('Walter im Krieg'); + await input.click(); + // Enter submits the NL query in smart mode + (document.activeElement as HTMLElement).dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + ); + await vi.waitFor(() => expect(onSmartSearch).toHaveBeenCalled()); + }); +});