feat(#221): add AND/OR pill toggle to SearchFilterBar tag filter
Toggle appears when ≥2 tags are selected; defaults to AND.
Exposes tagOperator prop ('AND'|'OR') for parent to read via bind.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
<div data-testid="and-or-toggle" class="mt-2 flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-2 py-0.5 text-xs font-bold tracking-widest uppercase transition-colors {tagOperator === 'AND' ? 'bg-primary text-primary-fg' : 'bg-muted text-ink-2 hover:bg-line'}"
|
||||
onclick={() => {
|
||||
tagOperator = 'AND';
|
||||
onSearch();
|
||||
}}>AND</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-2 py-0.5 text-xs font-bold tracking-widest uppercase transition-colors {tagOperator === 'OR' ? 'bg-primary text-primary-fg' : 'bg-muted text-ink-2 hover:bg-line'}"
|
||||
onclick={() => {
|
||||
tagOperator = 'OR';
|
||||
onSearch();
|
||||
}}>OR</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sender -->
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user