feat(documents): add a "Nur undatierte" filter toggle wired to the URL

SearchFilterBar gains an aria-pressed "Nur undatierte" toggle in the
advanced row (min-h-[44px] touch target, labels the state not the colour).
The documents page threads `undated` through the filter snapshot so it is a
shareable URL param picked up by both filter-change nav and pagination, and
flows into the bulk-edit "select all" /ids request. Toggling resets to page
0 via the existing implicit page-drop.

Refs #668
This commit is contained in:
Marcel
2026-05-27 18:53:44 +02:00
parent 5d8bb70255
commit 098c2c9def
3 changed files with 81 additions and 3 deletions

View File

@@ -15,6 +15,7 @@ let {
tagNames = $bindable<{ name: string; id?: string; color?: string; parentId?: string }[]>([]), tagNames = $bindable<{ name: string; id?: string; color?: string; parentId?: string }[]>([]),
tagQ = $bindable(''), tagQ = $bindable(''),
tagOperator = $bindable<'AND' | 'OR'>('AND'), tagOperator = $bindable<'AND' | 'OR'>('AND'),
undated = $bindable(false),
sort = $bindable('DATE'), sort = $bindable('DATE'),
dir = $bindable('desc'), dir = $bindable('desc'),
showAdvanced = $bindable(false), showAdvanced = $bindable(false),
@@ -35,6 +36,7 @@ let {
tagNames?: { name: string; id?: string; color?: string; parentId?: string }[]; tagNames?: { name: string; id?: string; color?: string; parentId?: string }[];
tagQ?: string; tagQ?: string;
tagOperator?: 'AND' | 'OR'; tagOperator?: 'AND' | 'OR';
undated?: boolean;
sort?: string; sort?: string;
dir?: string; dir?: string;
showAdvanced?: boolean; showAdvanced?: boolean;
@@ -248,6 +250,33 @@ $effect(() => {
/> />
</div> </div>
</div> </div>
<!-- Undated-only triage toggle (#668). aria-pressed states the toggle, not a
colour; min-h-[44px] meets the senior-audience touch target (WCAG 2.5.5). -->
<div class="md:col-span-12">
<button
type="button"
data-testid="undated-only-toggle"
aria-pressed={undated}
onclick={() => {
undated = !undated;
(onSearchImmediate ?? onSearch)();
}}
class="inline-flex min-h-[44px] items-center gap-2 rounded border px-3 text-xs font-bold tracking-widest uppercase transition-colors {undated
? 'border-primary bg-primary text-primary-fg'
: 'border-line bg-muted text-ink-2 hover:bg-line'}"
>
<span
aria-hidden="true"
class="inline-flex h-4 w-4 items-center justify-center rounded-sm border {undated
? 'border-primary-fg bg-primary-fg/20'
: 'border-ink-3'}"
>
{#if undated}{/if}
</span>
{m.docs_filter_undated_only()}
</button>
</div>
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -128,6 +128,42 @@ describe('SearchFilterBar AND/OR tag operator toggle', () => {
}); });
}); });
describe('SearchFilterBar undated-only toggle (#668)', () => {
async function openAdvanced() {
const filterBtn = page.getByRole('button', { name: 'Filter', exact: true });
await filterBtn.click();
}
it('renders the "Nur undatierte" toggle in the advanced row', async () => {
render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc' });
await openAdvanced();
await expect.element(page.getByTestId('undated-only-toggle')).toBeInTheDocument();
});
it('reflects the active undated state via aria-pressed', async () => {
render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc', undated: true });
await openAdvanced();
await expect
.element(page.getByTestId('undated-only-toggle'))
.toHaveAttribute('aria-pressed', 'true');
});
it('calls onSearchImmediate when the undated toggle is clicked', async () => {
const onSearch = vi.fn();
const onSearchImmediate = vi.fn();
render(SearchFilterBar, {
...defaultProps,
onSearch,
onSearchImmediate,
sort: 'DATE',
dir: 'desc'
});
await openAdvanced();
await page.getByTestId('undated-only-toggle').click();
await expect.poll(() => onSearchImmediate.mock.calls.length).toBeGreaterThan(0);
});
});
describe('SearchFilterBar tagQ live filter', () => { describe('SearchFilterBar tagQ live filter', () => {
it('calls onSearch when tag text changes in TagInput', async () => { it('calls onSearch when tag text changes in TagInput', async () => {
vi.stubGlobal( vi.stubGlobal(

View File

@@ -32,10 +32,16 @@ let sort = $state(untrack(() => data.sort || 'DATE'));
let dir = $state(untrack(() => data.dir || 'desc')); let dir = $state(untrack(() => data.dir || 'desc'));
let tagQ = $state(untrack(() => data.tagQ || '')); let tagQ = $state(untrack(() => data.tagQ || ''));
let tagOperator = $state<'AND' | 'OR'>(untrack(() => (data.tagOp as 'AND' | 'OR') || 'AND')); let tagOperator = $state<'AND' | 'OR'>(untrack(() => (data.tagOp as 'AND' | 'OR') || 'AND'));
let undated = $state(untrack(() => data.undated ?? false));
function hasAdvancedFilters() { function hasAdvancedFilters() {
return ( return (
(data.tags?.length ?? 0) > 0 || !!data.senderId || !!data.receiverId || !!data.from || !!data.to (data.tags?.length ?? 0) > 0 ||
!!data.senderId ||
!!data.receiverId ||
!!data.from ||
!!data.to ||
!!data.undated
); );
} }
@@ -54,6 +60,7 @@ type FilterSnapshot = {
dir: string; dir: string;
tagQ: string; tagQ: string;
tagOp: 'AND' | 'OR'; tagOp: 'AND' | 'OR';
undated: boolean;
zoomFrom?: string | null; zoomFrom?: string | null;
zoomTo?: string | null; zoomTo?: string | null;
}; };
@@ -77,6 +84,7 @@ function buildSearchParams(filters: FilterSnapshot, targetPage?: number): Svelte
if (filters.dir) params.set('dir', filters.dir); if (filters.dir) params.set('dir', filters.dir);
if (filters.tagQ) params.set('tagQ', filters.tagQ); if (filters.tagQ) params.set('tagQ', filters.tagQ);
if (filters.tagOp === 'OR') params.set('tagOp', 'OR'); if (filters.tagOp === 'OR') params.set('tagOp', 'OR');
if (filters.undated) params.set('undated', 'true');
if (filters.zoomFrom) params.set('zoomFrom', filters.zoomFrom); if (filters.zoomFrom) params.set('zoomFrom', filters.zoomFrom);
if (filters.zoomTo) params.set('zoomTo', filters.zoomTo); if (filters.zoomTo) params.set('zoomTo', filters.zoomTo);
if (targetPage !== undefined && targetPage > 0) params.set('page', String(targetPage)); if (targetPage !== undefined && targetPage > 0) params.set('page', String(targetPage));
@@ -112,6 +120,7 @@ function navigateWithZoom(zoomFrom: string | null, zoomTo: string | null) {
dir, dir,
tagQ, tagQ,
tagOp: tagOperator, tagOp: tagOperator,
undated,
zoomFrom, zoomFrom,
zoomTo zoomTo
}); });
@@ -136,7 +145,8 @@ function buildPageHref(targetPage: number): string {
sort: data.sort || '', sort: data.sort || '',
dir: data.dir || '', dir: data.dir || '',
tagQ: data.tagQ || '', tagQ: data.tagQ || '',
tagOp: (data.tagOp as 'AND' | 'OR') || 'AND' tagOp: (data.tagOp as 'AND' | 'OR') || 'AND',
undated: data.undated ?? false
}, },
targetPage targetPage
); );
@@ -188,7 +198,8 @@ async function editAllMatching() {
sort: '', sort: '',
dir: '', dir: '',
tagQ: data.tagQ || '', tagQ: data.tagQ || '',
tagOp: (data.tagOp as 'AND' | 'OR') || 'AND' tagOp: (data.tagOp as 'AND' | 'OR') || 'AND',
undated: data.undated ?? false
}); });
params.delete('sort'); params.delete('sort');
params.delete('dir'); params.delete('dir');
@@ -226,6 +237,7 @@ $effect(() => {
dir = data.dir || 'desc'; dir = data.dir || 'desc';
tagQ = data.tagQ || ''; tagQ = data.tagQ || '';
tagOperator = (data.tagOp as 'AND' | 'OR') || 'AND'; tagOperator = (data.tagOp as 'AND' | 'OR') || 'AND';
undated = data.undated ?? false;
if (hasAdvancedFilters()) showAdvanced = true; if (hasAdvancedFilters()) showAdvanced = true;
}); });
</script> </script>
@@ -255,6 +267,7 @@ $effect(() => {
bind:dir={dir} bind:dir={dir}
bind:tagQ={tagQ} bind:tagQ={tagQ}
bind:tagOperator={tagOperator} bind:tagOperator={tagOperator}
bind:undated={undated}
initialSenderName={initialSenderName} initialSenderName={initialSenderName}
initialReceiverName={initialReceiverName} initialReceiverName={initialReceiverName}
navKey={navKey} navKey={navKey}