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:
@@ -15,6 +15,7 @@ let {
|
||||
tagNames = $bindable<{ name: string; id?: string; color?: string; parentId?: string }[]>([]),
|
||||
tagQ = $bindable(''),
|
||||
tagOperator = $bindable<'AND' | 'OR'>('AND'),
|
||||
undated = $bindable(false),
|
||||
sort = $bindable('DATE'),
|
||||
dir = $bindable('desc'),
|
||||
showAdvanced = $bindable(false),
|
||||
@@ -35,6 +36,7 @@ let {
|
||||
tagNames?: { name: string; id?: string; color?: string; parentId?: string }[];
|
||||
tagQ?: string;
|
||||
tagOperator?: 'AND' | 'OR';
|
||||
undated?: boolean;
|
||||
sort?: string;
|
||||
dir?: string;
|
||||
showAdvanced?: boolean;
|
||||
@@ -248,6 +250,33 @@ $effect(() => {
|
||||
/>
|
||||
</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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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', () => {
|
||||
it('calls onSearch when tag text changes in TagInput', async () => {
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -32,10 +32,16 @@ let sort = $state(untrack(() => data.sort || 'DATE'));
|
||||
let dir = $state(untrack(() => data.dir || 'desc'));
|
||||
let tagQ = $state(untrack(() => data.tagQ || ''));
|
||||
let tagOperator = $state<'AND' | 'OR'>(untrack(() => (data.tagOp as 'AND' | 'OR') || 'AND'));
|
||||
let undated = $state(untrack(() => data.undated ?? false));
|
||||
|
||||
function hasAdvancedFilters() {
|
||||
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;
|
||||
tagQ: string;
|
||||
tagOp: 'AND' | 'OR';
|
||||
undated: boolean;
|
||||
zoomFrom?: 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.tagQ) params.set('tagQ', filters.tagQ);
|
||||
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.zoomTo) params.set('zoomTo', filters.zoomTo);
|
||||
if (targetPage !== undefined && targetPage > 0) params.set('page', String(targetPage));
|
||||
@@ -112,6 +120,7 @@ function navigateWithZoom(zoomFrom: string | null, zoomTo: string | null) {
|
||||
dir,
|
||||
tagQ,
|
||||
tagOp: tagOperator,
|
||||
undated,
|
||||
zoomFrom,
|
||||
zoomTo
|
||||
});
|
||||
@@ -136,7 +145,8 @@ function buildPageHref(targetPage: number): string {
|
||||
sort: data.sort || '',
|
||||
dir: data.dir || '',
|
||||
tagQ: data.tagQ || '',
|
||||
tagOp: (data.tagOp as 'AND' | 'OR') || 'AND'
|
||||
tagOp: (data.tagOp as 'AND' | 'OR') || 'AND',
|
||||
undated: data.undated ?? false
|
||||
},
|
||||
targetPage
|
||||
);
|
||||
@@ -188,7 +198,8 @@ async function editAllMatching() {
|
||||
sort: '',
|
||||
dir: '',
|
||||
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('dir');
|
||||
@@ -226,6 +237,7 @@ $effect(() => {
|
||||
dir = data.dir || 'desc';
|
||||
tagQ = data.tagQ || '';
|
||||
tagOperator = (data.tagOp as 'AND' | 'OR') || 'AND';
|
||||
undated = data.undated ?? false;
|
||||
if (hasAdvancedFilters()) showAdvanced = true;
|
||||
});
|
||||
</script>
|
||||
@@ -255,6 +267,7 @@ $effect(() => {
|
||||
bind:dir={dir}
|
||||
bind:tagQ={tagQ}
|
||||
bind:tagOperator={tagOperator}
|
||||
bind:undated={undated}
|
||||
initialSenderName={initialSenderName}
|
||||
initialReceiverName={initialReceiverName}
|
||||
navKey={navKey}
|
||||
|
||||
Reference in New Issue
Block a user