diff --git a/frontend/src/routes/SearchFilterBar.svelte b/frontend/src/routes/SearchFilterBar.svelte
index f9441d41..9786bec6 100644
--- a/frontend/src/routes/SearchFilterBar.svelte
+++ b/frontend/src/routes/SearchFilterBar.svelte
@@ -14,6 +14,7 @@ let {
receiverId = $bindable(''),
tagNames = $bindable<{ name: string; id?: string; color?: string; parentId?: string }[]>([]),
tagQ = $bindable(''),
+ tagOperator = $bindable<'AND' | 'OR'>('AND'),
sort = $bindable('DATE'),
dir = $bindable('desc'),
showAdvanced = $bindable(false),
@@ -31,6 +32,7 @@ let {
receiverId?: string;
tagNames?: { name: string; id?: string; color?: string; parentId?: string }[];
tagQ?: string;
+ tagOperator?: 'AND' | 'OR';
sort?: string;
dir?: string;
showAdvanced?: boolean;
@@ -153,6 +155,26 @@ $effect(() => {
onSearch();
}}
/>
+ {#if tagNames.length >= 2}
+
+
+
+
+ {/if}
diff --git a/frontend/src/routes/SearchFilterBar.svelte.spec.ts b/frontend/src/routes/SearchFilterBar.svelte.spec.ts
index c3f8a454..86075e09 100644
--- a/frontend/src/routes/SearchFilterBar.svelte.spec.ts
+++ b/frontend/src/routes/SearchFilterBar.svelte.spec.ts
@@ -1,8 +1,10 @@
-import { describe, expect, it, vi } from 'vitest';
-import { render } from 'vitest-browser-svelte';
+import { describe, expect, it, vi, afterEach } from 'vitest';
+import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import SearchFilterBar from './SearchFilterBar.svelte';
+afterEach(() => cleanup());
+
const defaultProps = {
onSearch: vi.fn()
};
@@ -41,6 +43,68 @@ describe('SearchFilterBar – loading spinner', () => {
});
});
+describe('SearchFilterBar – AND/OR tag operator toggle', () => {
+ async function openAdvanced() {
+ const filterBtn = page.getByRole('button', { name: 'Filter', exact: true });
+ await filterBtn.click();
+ }
+
+ it('hides AND/OR toggle when fewer than 2 tags are selected', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
+ );
+ render(SearchFilterBar, {
+ ...defaultProps,
+ sort: 'DATE',
+ dir: 'desc',
+ tagNames: [{ name: 'Tag1' }]
+ });
+ await openAdvanced();
+ await expect.element(page.getByRole('button', { name: 'AND' })).not.toBeInTheDocument();
+ vi.unstubAllGlobals();
+ });
+
+ it('shows AND/OR toggle when 2+ tags are selected', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
+ );
+ render(SearchFilterBar, {
+ ...defaultProps,
+ sort: 'DATE',
+ dir: 'desc',
+ tagNames: [{ name: 'Tag1' }, { name: 'Tag2' }]
+ });
+ await openAdvanced();
+ const toggle = page.getByTestId('and-or-toggle');
+ await expect.element(toggle).toBeInTheDocument();
+ await expect.element(toggle.getByRole('button', { name: 'AND' })).toBeInTheDocument();
+ await expect.element(toggle.getByRole('button', { name: 'OR' })).toBeInTheDocument();
+ vi.unstubAllGlobals();
+ });
+
+ it('calls onSearch when operator is toggled', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
+ );
+ const onSearch = vi.fn();
+ render(SearchFilterBar, {
+ ...defaultProps,
+ onSearch,
+ sort: 'DATE',
+ dir: 'desc',
+ tagNames: [{ name: 'Tag1' }, { name: 'Tag2' }]
+ });
+ await openAdvanced();
+ const toggle = page.getByTestId('and-or-toggle');
+ await toggle.getByRole('button', { name: 'OR' }).click();
+ await expect.poll(() => onSearch.mock.calls.length).toBeGreaterThan(0);
+ vi.unstubAllGlobals();
+ });
+});
+
describe('SearchFilterBar – tagQ live filter', () => {
it('calls onSearch when tag text changes in TagInput', async () => {
vi.stubGlobal(