feat(bulk-edit): add canWrite-gated row checkboxes on /documents and /enrich
Each row in the document search list and the enrichment queue gets a WCAG-compliant (44px touch target) checkbox bound to bulkSelectionStore. Checkbox click does not trigger the row's stretched-link navigation — it sits inside the z-10 content sibling, the link is in the z-0 sibling, so click events do not bubble between them. Refs #225 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,13 +4,14 @@ import type { components } from '$lib/generated/api';
|
|||||||
import { applyOffsets } from '$lib/search';
|
import { applyOffsets } from '$lib/search';
|
||||||
import { formatDate } from '$lib/utils/date';
|
import { formatDate } from '$lib/utils/date';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||||
import ProgressRing from './ProgressRing.svelte';
|
import ProgressRing from './ProgressRing.svelte';
|
||||||
import ContributorStack from './ContributorStack.svelte';
|
import ContributorStack from './ContributorStack.svelte';
|
||||||
import DocumentThumbnail from './DocumentThumbnail.svelte';
|
import DocumentThumbnail from './DocumentThumbnail.svelte';
|
||||||
|
|
||||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||||
|
|
||||||
let { item }: { item: DocumentSearchItem } = $props();
|
let { item, canWrite = false }: { item: DocumentSearchItem; canWrite?: boolean } = $props();
|
||||||
|
|
||||||
const doc = $derived(item.document);
|
const doc = $derived(item.document);
|
||||||
const titleText = $derived(doc.title || doc.originalFilename);
|
const titleText = $derived(doc.title || doc.originalFilename);
|
||||||
@@ -55,6 +56,21 @@ function safeTagColor(color: string | null | undefined): string {
|
|||||||
<a href="/documents/{doc.id}" aria-label={titleText} class="absolute inset-0 z-0 block"></a>
|
<a href="/documents/{doc.id}" aria-label={titleText} class="absolute inset-0 z-0 block"></a>
|
||||||
<div class="pointer-events-none relative z-10 px-4 py-4 sm:py-5">
|
<div class="pointer-events-none relative z-10 px-4 py-4 sm:py-5">
|
||||||
<div class="flex gap-3 sm:gap-5">
|
<div class="flex gap-3 sm:gap-5">
|
||||||
|
<!-- Bulk-selection checkbox -->
|
||||||
|
{#if canWrite}
|
||||||
|
<label
|
||||||
|
class="pointer-events-auto flex min-h-[44px] min-w-[44px] flex-shrink-0 cursor-pointer items-start pt-1"
|
||||||
|
data-testid="bulk-select-checkbox"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="h-5 w-5 cursor-pointer accent-brand-navy"
|
||||||
|
checked={bulkSelectionStore.has(doc.id)}
|
||||||
|
onchange={() => bulkSelectionStore.toggle(doc.id)}
|
||||||
|
aria-label={m.bulk_edit_select_document({ title: titleText })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
<!-- Thumbnail tile -->
|
<!-- Thumbnail tile -->
|
||||||
<DocumentThumbnail doc={doc} size="lg" />
|
<DocumentThumbnail doc={doc} size="lg" />
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
|||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import DocumentRow from './DocumentRow.svelte';
|
import DocumentRow from './DocumentRow.svelte';
|
||||||
|
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
@@ -10,6 +11,7 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.mocked(goto).mockClear();
|
vi.mocked(goto).mockClear();
|
||||||
|
bulkSelectionStore.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||||
@@ -265,6 +267,45 @@ describe('DocumentRow – tags', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Bulk-selection checkbox ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('DocumentRow – bulk selection checkbox', () => {
|
||||||
|
it('does not render the checkbox when canWrite is false', async () => {
|
||||||
|
render(DocumentRow, { item: makeItem(), canWrite: false });
|
||||||
|
await expect.element(page.getByTestId('bulk-select-checkbox')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the checkbox when canWrite is true', async () => {
|
||||||
|
render(DocumentRow, { item: makeItem(), canWrite: true });
|
||||||
|
await expect.element(page.getByTestId('bulk-select-checkbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checkbox aria-label includes the document title', async () => {
|
||||||
|
const item = makeItem({ document: { ...makeItem().document, title: 'Brief an Anna' } });
|
||||||
|
render(DocumentRow, { item, canWrite: true });
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('checkbox', { name: /Brief an Anna/i }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggling the checkbox calls bulkSelectionStore.toggle', async () => {
|
||||||
|
const item = makeItem({ document: { ...makeItem().document, id: 'doc-42' } });
|
||||||
|
render(DocumentRow, { item, canWrite: true });
|
||||||
|
expect(bulkSelectionStore.has('doc-42')).toBe(false);
|
||||||
|
|
||||||
|
document.querySelector<HTMLInputElement>('input[type="checkbox"]')?.click();
|
||||||
|
|
||||||
|
await expect.poll(() => bulkSelectionStore.has('doc-42')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checked state mirrors the store', async () => {
|
||||||
|
bulkSelectionStore.add('doc-99');
|
||||||
|
const item = makeItem({ document: { ...makeItem().document, id: 'doc-99' } });
|
||||||
|
render(DocumentRow, { item, canWrite: true });
|
||||||
|
await expect.element(page.getByRole('checkbox')).toBeChecked();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── ProgressRing & ContributorStack ─────────────────────────────────────────
|
// ─── ProgressRing & ContributorStack ─────────────────────────────────────────
|
||||||
|
|
||||||
describe('DocumentRow – progress ring and contributors', () => {
|
describe('DocumentRow – progress ring and contributors', () => {
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ function groupByReceiver(docItems: DocumentSearchItem[]) {
|
|||||||
</div>
|
</div>
|
||||||
<ul class="divide-y divide-line">
|
<ul class="divide-y divide-line">
|
||||||
{#each group.items as item (group.label + '-' + item.document.id)}
|
{#each group.items as item (group.label + '-' + item.document.id)}
|
||||||
<DocumentRow item={item} />
|
<DocumentRow item={item} canWrite={canWrite} />
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,5 +19,5 @@ export async function load({
|
|||||||
|
|
||||||
const documents = result.response.ok ? (result.data ?? []) : [];
|
const documents = result.response.ok ? (result.data ?? []) : [];
|
||||||
|
|
||||||
return { documents };
|
return { documents, canWrite };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import BackButton from '$lib/components/BackButton.svelte';
|
import BackButton from '$lib/components/BackButton.svelte';
|
||||||
|
import { bulkSelectionStore } from '$lib/stores/bulkSelection.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
const documents = $derived(data.documents);
|
const documents = $derived(data.documents);
|
||||||
const count = $derived(documents.length);
|
const count = $derived(documents.length);
|
||||||
|
const canWrite = $derived(data.canWrite);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-4xl px-4 py-10">
|
<div class="mx-auto max-w-4xl px-4 py-10">
|
||||||
@@ -61,8 +63,24 @@ const count = $derived(documents.length);
|
|||||||
<div class="border border-line bg-surface shadow-sm">
|
<div class="border border-line bg-surface shadow-sm">
|
||||||
<ul class="divide-y divide-line-2">
|
<ul class="divide-y divide-line-2">
|
||||||
{#each documents as doc (doc.id)}
|
{#each documents as doc (doc.id)}
|
||||||
<li class="group transition-colors duration-200 hover:bg-muted">
|
<li class="group relative transition-colors duration-200 hover:bg-muted">
|
||||||
<a href="/enrich/{doc.id}" class="flex items-center justify-between p-6">
|
<a href="/enrich/{doc.id}" class="absolute inset-0 z-0 block" aria-label={doc.title}
|
||||||
|
></a>
|
||||||
|
<div class="pointer-events-none relative z-10 flex items-center justify-between p-6">
|
||||||
|
{#if canWrite}
|
||||||
|
<label
|
||||||
|
class="pointer-events-auto mr-4 flex min-h-[44px] min-w-[44px] flex-shrink-0 cursor-pointer items-center"
|
||||||
|
data-testid="bulk-select-checkbox"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="h-5 w-5 cursor-pointer accent-brand-navy"
|
||||||
|
checked={bulkSelectionStore.has(doc.id)}
|
||||||
|
onchange={() => bulkSelectionStore.toggle(doc.id)}
|
||||||
|
aria-label={m.bulk_edit_select_document({ title: doc.title })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="font-serif text-lg font-medium text-ink group-hover:underline">
|
<p class="font-serif text-lg font-medium text-ink group-hover:underline">
|
||||||
{doc.title}
|
{doc.title}
|
||||||
@@ -74,7 +92,7 @@ const count = $derived(documents.length);
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="ml-4 h-5 w-5 shrink-0 opacity-30 transition-opacity group-hover:opacity-70"
|
class="ml-4 h-5 w-5 shrink-0 opacity-30 transition-opacity group-hover:opacity-70"
|
||||||
/>
|
/>
|
||||||
</a>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
Reference in New Issue
Block a user