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 <noreply@anthropic.com>
This commit is contained in:
@@ -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(() => {
|
||||
<input
|
||||
type="text"
|
||||
bind:value={q}
|
||||
oninput={onSearch}
|
||||
oninput={smartMode ? undefined : onSearch}
|
||||
onkeydown={onSearchKeydown}
|
||||
onfocus={onfocus}
|
||||
onblur={onblur}
|
||||
maxlength={smartMode ? 500 : undefined}
|
||||
aria-label={m.docs_search_placeholder()}
|
||||
placeholder={m.docs_search_placeholder()}
|
||||
class="block w-full border-line py-2.5 pr-10 pl-3 placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
class="block w-full border-line py-2.5 pl-10 placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {smartMode
|
||||
? 'pr-28'
|
||||
: 'pr-20'}"
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<!-- Decorative search icon / loading spinner — left slot keeps the right
|
||||
slot free for the always-visible smart-mode toggle pill. -->
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
{#if isLoading}
|
||||
<svg
|
||||
role="status"
|
||||
@@ -110,6 +132,7 @@ $effect(() => {
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<SmartModeToggle bind:smartMode={smartMode} onToggle={onModeToggle} />
|
||||
</div>
|
||||
|
||||
<!-- Sort Dropdown -->
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user