Compare commits
7 Commits
9aed929b67
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50621f9a15 | ||
|
|
1fca1f80a2 | ||
|
|
46dae8a826 | ||
|
|
e5fe2fc5c6 | ||
|
|
0ab85d888b | ||
|
|
48c82aa07b | ||
|
|
1299f191e2 |
@@ -134,6 +134,7 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void validateBatch(int fileCount, DocumentBatchMetadataDTO metadata) {
|
public void validateBatch(int fileCount, DocumentBatchMetadataDTO metadata) {
|
||||||
|
// 50-file hard cap keeps FormData requests at a manageable size and protects against runaway bulk uploads.
|
||||||
if (fileCount > 50) {
|
if (fileCount > 50) {
|
||||||
throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request");
|
throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -858,6 +858,7 @@
|
|||||||
"bulk_save_cta_one": "Speichern →",
|
"bulk_save_cta_one": "Speichern →",
|
||||||
"bulk_save_cta": "{count} speichern →",
|
"bulk_save_cta": "{count} speichern →",
|
||||||
"bulk_discard_all": "Alle verwerfen",
|
"bulk_discard_all": "Alle verwerfen",
|
||||||
|
"bulk_discard_confirm": "Alle Dateien und eingegebenen Daten verwerfen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
"bulk_add_more": "Weitere hinzufügen",
|
"bulk_add_more": "Weitere hinzufügen",
|
||||||
"bulk_scope_per_file_label": "Nur diese Datei",
|
"bulk_scope_per_file_label": "Nur diese Datei",
|
||||||
"bulk_scope_shared_label": "Gilt für alle {count}",
|
"bulk_scope_shared_label": "Gilt für alle {count}",
|
||||||
|
|||||||
@@ -858,6 +858,7 @@
|
|||||||
"bulk_save_cta_one": "Save →",
|
"bulk_save_cta_one": "Save →",
|
||||||
"bulk_save_cta": "Save {count} →",
|
"bulk_save_cta": "Save {count} →",
|
||||||
"bulk_discard_all": "Discard all",
|
"bulk_discard_all": "Discard all",
|
||||||
|
"bulk_discard_confirm": "Discard all files and entered data? This action cannot be undone.",
|
||||||
"bulk_add_more": "Add more",
|
"bulk_add_more": "Add more",
|
||||||
"bulk_scope_per_file_label": "This file only",
|
"bulk_scope_per_file_label": "This file only",
|
||||||
"bulk_scope_shared_label": "Applies to all {count}",
|
"bulk_scope_shared_label": "Applies to all {count}",
|
||||||
|
|||||||
@@ -858,6 +858,7 @@
|
|||||||
"bulk_save_cta_one": "Guardar →",
|
"bulk_save_cta_one": "Guardar →",
|
||||||
"bulk_save_cta": "Guardar {count} →",
|
"bulk_save_cta": "Guardar {count} →",
|
||||||
"bulk_discard_all": "Descartar todo",
|
"bulk_discard_all": "Descartar todo",
|
||||||
|
"bulk_discard_confirm": "¿Descartar todos los archivos y datos introducidos? Esta acción no se puede deshacer.",
|
||||||
"bulk_add_more": "Añadir más",
|
"bulk_add_more": "Añadir más",
|
||||||
"bulk_scope_per_file_label": "Solo este archivo",
|
"bulk_scope_per_file_label": "Solo este archivo",
|
||||||
"bulk_scope_shared_label": "Para todos los {count}",
|
"bulk_scope_shared_label": "Para todos los {count}",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { SvelteMap } from 'svelte/reactivity';
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onDestroy, untrack } from 'svelte';
|
import { onDestroy, untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||||
|
import type { ConfirmService } from '$lib/services/confirm.svelte.js';
|
||||||
import BulkDropZone from './BulkDropZone.svelte';
|
import BulkDropZone from './BulkDropZone.svelte';
|
||||||
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
|
import FileSwitcherStrip from './FileSwitcherStrip.svelte';
|
||||||
import type { FileEntry } from './FileSwitcherStrip.svelte';
|
import type { FileEntry } from './FileSwitcherStrip.svelte';
|
||||||
@@ -17,6 +19,14 @@ import type { components } from '$lib/generated/api';
|
|||||||
|
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
|
|
||||||
|
// Optional — not available in unit tests that don't provide CONFIRM_KEY context.
|
||||||
|
let _confirmService: ConfirmService | null;
|
||||||
|
try {
|
||||||
|
_confirmService = getConfirmService();
|
||||||
|
} catch {
|
||||||
|
_confirmService = null;
|
||||||
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
initialSenderId = '',
|
initialSenderId = '',
|
||||||
initialSenderName = '',
|
initialSenderName = '',
|
||||||
@@ -31,6 +41,7 @@ let {
|
|||||||
let files = new SvelteMap<string, FileEntry>();
|
let files = new SvelteMap<string, FileEntry>();
|
||||||
let activeId = $state<string | null>(null);
|
let activeId = $state<string | null>(null);
|
||||||
let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined);
|
let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
// --- Shared metadata ---
|
// --- Shared metadata ---
|
||||||
let senderId = $state(untrack(() => initialSenderId));
|
let senderId = $state(untrack(() => initialSenderId));
|
||||||
@@ -76,6 +87,18 @@ function discardAll() {
|
|||||||
chunkProgress = undefined;
|
chunkProgress = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDiscard() {
|
||||||
|
if (_confirmService) {
|
||||||
|
const ok = await _confirmService.confirm({
|
||||||
|
title: m.bulk_discard_all(),
|
||||||
|
body: m.bulk_discard_confirm(),
|
||||||
|
destructive: true
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
discardAll();
|
||||||
|
}
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
for (const entry of files.values()) {
|
for (const entry of files.values()) {
|
||||||
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
if (entry.previewUrl) URL.revokeObjectURL(entry.previewUrl);
|
||||||
@@ -84,7 +107,10 @@ onDestroy(() => {
|
|||||||
|
|
||||||
// --- Save ---
|
// --- Save ---
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (saving) return;
|
||||||
|
saving = true;
|
||||||
const entries = Array.from(files.values());
|
const entries = Array.from(files.values());
|
||||||
|
// 10 files per request keeps multipart bodies well under typical reverse-proxy limits (e.g. nginx default 1 MB client_max_body_size per PDF).
|
||||||
const chunkSize = 10;
|
const chunkSize = 10;
|
||||||
const chunks: FileEntry[][] = [];
|
const chunks: FileEntry[][] = [];
|
||||||
for (let i = 0; i < entries.length; i += chunkSize) {
|
for (let i = 0; i < entries.length; i += chunkSize) {
|
||||||
@@ -108,22 +134,33 @@ async function save() {
|
|||||||
// Raw fetch is intentional: SvelteKit form actions can't stream chunked
|
// Raw fetch is intentional: SvelteKit form actions can't stream chunked
|
||||||
// FormData with per-chunk progress. Session cookie is sent automatically
|
// FormData with per-chunk progress. Session cookie is sent automatically
|
||||||
// by the browser for same-origin requests.
|
// by the browser for same-origin requests.
|
||||||
const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
|
try {
|
||||||
if (!res.ok) {
|
const res = await fetch('/api/documents/quick-upload', { method: 'POST', body: formData });
|
||||||
hadErrors = true;
|
|
||||||
const body = await res.json().catch(() => ({ errors: [] }));
|
const body = await res.json().catch(() => ({ errors: [] }));
|
||||||
const errorFilenames = new Set<string>(
|
const errorFilenames = new Set<string>(
|
||||||
(body.errors ?? []).map((err: { filename: string }) => err.filename)
|
(body.errors ?? []).map((err: { filename: string }) => err.filename)
|
||||||
);
|
);
|
||||||
for (const entry of chunk) {
|
if (!res.ok || errorFilenames.size > 0) {
|
||||||
if (errorFilenames.has(entry.file.name)) {
|
hadErrors = true;
|
||||||
const e = files.get(entry.id);
|
for (const entry of chunk) {
|
||||||
if (e) files.set(entry.id, { ...e, status: 'error' });
|
// When backend names specific files, mark only those; otherwise mark all.
|
||||||
|
const isError = errorFilenames.size > 0 ? errorFilenames.has(entry.file.name) : true;
|
||||||
|
if (isError) {
|
||||||
|
const e = files.get(entry.id);
|
||||||
|
if (e) files.set(entry.id, { ...e, status: 'error' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
hadErrors = true;
|
||||||
|
for (const entry of chunk) {
|
||||||
|
const e = files.get(entry.id);
|
||||||
|
if (e) files.set(entry.id, { ...e, status: 'error' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
chunkProgress = { done: i + 1, total: chunks.length };
|
chunkProgress = { done: i + 1, total: chunks.length };
|
||||||
}
|
}
|
||||||
|
saving = false;
|
||||||
if (!hadErrors) goto('/documents');
|
if (!hadErrors) goto('/documents');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -163,7 +200,7 @@ async function save() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="discard-all-btn"
|
data-testid="discard-all-btn"
|
||||||
onclick={discardAll}
|
onclick={handleDiscard}
|
||||||
class="text-xs font-medium text-red-600/70 hover:text-red-700"
|
class="text-xs font-medium text-red-600/70 hover:text-red-700"
|
||||||
>
|
>
|
||||||
{m.bulk_discard_all()}
|
{m.bulk_discard_all()}
|
||||||
@@ -275,7 +312,8 @@ async function save() {
|
|||||||
fileCount={files.size}
|
fileCount={files.size}
|
||||||
chunkProgress={chunkProgress}
|
chunkProgress={chunkProgress}
|
||||||
onSave={save}
|
onSave={save}
|
||||||
onDiscard={discardAll}
|
onDiscard={handleDiscard}
|
||||||
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { goto } from '$app/navigation';
|
|||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page, userEvent } from 'vitest/browser';
|
import { page, userEvent } from 'vitest/browser';
|
||||||
import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte';
|
import BulkDocumentEditLayout from './BulkDocumentEditLayout.svelte';
|
||||||
|
import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
|
||||||
|
|
||||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
@@ -206,6 +207,112 @@ describe('BulkDocumentEditLayout', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('save() marks only the failed file when server returns HTTP 200 with a partial errors array', async () => {
|
||||||
|
// Backend can return 200 OK while reporting individual file failures
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
created: [{ id: '1' }],
|
||||||
|
updated: [],
|
||||||
|
errors: [{ filename: 'b.pdf', code: 'FILE_UPLOAD_FAILED' }]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf'), makeFile('c.pdf')]);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||||
|
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
|
||||||
|
expect(errorChips.length).toBe(1);
|
||||||
|
expect(errorChips[0].textContent).toContain('b');
|
||||||
|
},
|
||||||
|
{ timeout: 1000 }
|
||||||
|
);
|
||||||
|
// Navigation should be suppressed because hadErrors is true
|
||||||
|
expect(goto).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save() marks all chunk files as errored when fetch throws a network error', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')));
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click();
|
||||||
|
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
const errorChips = container.querySelectorAll('[data-chip-id][data-status="error"]');
|
||||||
|
expect(errorChips.length).toBe(2);
|
||||||
|
},
|
||||||
|
{ timeout: 3000 }
|
||||||
|
);
|
||||||
|
expect(goto).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save() does not call fetch a second time when already saving', async () => {
|
||||||
|
let resolveFirst: (() => void) | undefined;
|
||||||
|
const mockFetch = vi.fn().mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<Response>((resolve) => {
|
||||||
|
resolveFirst = () =>
|
||||||
|
resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ created: [], updated: [], errors: [] })
|
||||||
|
} as Response);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
|
await addFilesViaInput(container, [makeFile('a.pdf')]);
|
||||||
|
|
||||||
|
const saveBtn = container.querySelector(
|
||||||
|
'button[data-testid="bulk-save-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
saveBtn.click(); // first click — fetch is in-flight
|
||||||
|
saveBtn.click(); // second click — should be a no-op
|
||||||
|
|
||||||
|
resolveFirst?.();
|
||||||
|
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discard-all does not clear files when the user cancels the confirm dialog', async () => {
|
||||||
|
const service = createConfirmService();
|
||||||
|
const { container } = render(BulkDocumentEditLayout, {
|
||||||
|
context: new Map([[CONFIRM_KEY, service]])
|
||||||
|
});
|
||||||
|
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||||
|
|
||||||
|
const discardBtn = container.querySelector(
|
||||||
|
'button[data-testid="discard-all-btn"]'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
discardBtn.click();
|
||||||
|
|
||||||
|
// The confirm dialog should open (service.options not null)
|
||||||
|
await vi.waitFor(() => expect(service.options).not.toBeNull(), { timeout: 1000 });
|
||||||
|
|
||||||
|
// Cancel — files should remain
|
||||||
|
service.settle(false);
|
||||||
|
await vi.waitFor(
|
||||||
|
() => expect(container.querySelector('[data-testid="file-switcher-strip"]')).not.toBeNull(),
|
||||||
|
{ timeout: 1000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('discard-all resets to N=0 state and shows drop zone', async () => {
|
it('discard-all resets to N=0 state and shows drop zone', async () => {
|
||||||
const { container } = render(BulkDocumentEditLayout, {});
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
export interface FileEntry {
|
export interface FileEntry {
|
||||||
@@ -33,6 +34,15 @@ function scrollNext() {
|
|||||||
trackEl?.scrollBy({ left: 120, behavior: 'smooth' });
|
trackEl?.scrollBy({ left: 120, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRemove(entry: FileEntry, index: number) {
|
||||||
|
const targetId = index > 0 ? files[index - 1].id : (files[index + 1]?.id ?? null);
|
||||||
|
onRemove(entry.id);
|
||||||
|
if (targetId) {
|
||||||
|
await tick();
|
||||||
|
(listEl?.querySelector<HTMLElement>(`[data-chip-id="${targetId}"]`) ?? null)?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
const node = listEl;
|
const node = listEl;
|
||||||
@@ -73,19 +83,27 @@ $effect(() => {
|
|||||||
>‹</button
|
>‹</button
|
||||||
>
|
>
|
||||||
|
|
||||||
<div bind:this={trackEl} class="flex flex-1 gap-1 overflow-x-auto" style="scrollbar-width:none">
|
<!-- Gradient fade overlays signal hidden overflow to pointer-only users -->
|
||||||
<ul bind:this={listEl} role="list" class="flex flex-row gap-1 py-1">
|
<div class="relative flex flex-1 overflow-hidden">
|
||||||
{#each files as entry, i (entry.id)}
|
<div
|
||||||
<li role="listitem" class="inline-flex shrink-0 items-center">
|
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-gradient-to-r from-pdf-ctrl to-transparent"
|
||||||
<button
|
></div>
|
||||||
type="button"
|
<div
|
||||||
tabindex="0"
|
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-gradient-to-l from-pdf-ctrl to-transparent"
|
||||||
aria-current={entry.id === activeId ? 'true' : undefined}
|
></div>
|
||||||
data-status={entry.status}
|
<div bind:this={trackEl} class="flex flex-1 gap-1 overflow-x-auto" style="scrollbar-width:none">
|
||||||
data-chip-id={entry.id}
|
<ul bind:this={listEl} role="list" class="flex flex-row gap-1 py-1">
|
||||||
onclick={() => onSelect(entry.id)}
|
{#each files as entry, i (entry.id)}
|
||||||
class={[
|
<li role="listitem" class="inline-flex shrink-0 items-center">
|
||||||
'inline-flex cursor-pointer items-center gap-1 rounded-[2px] px-1.5 py-0.5 text-[11px] font-bold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent',
|
<button
|
||||||
|
type="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-current={entry.id === activeId ? 'true' : undefined}
|
||||||
|
data-status={entry.status}
|
||||||
|
data-chip-id={entry.id}
|
||||||
|
onclick={() => onSelect(entry.id)}
|
||||||
|
class={[
|
||||||
|
'inline-flex cursor-pointer items-center gap-1 rounded-[2px] px-1.5 py-0.5 text-xs font-bold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent',
|
||||||
entry.id === activeId
|
entry.id === activeId
|
||||||
? 'bg-accent text-primary'
|
? 'bg-accent text-primary'
|
||||||
: 'bg-black/[0.06] text-ink-2 hover:bg-black/10',
|
: 'bg-black/[0.06] text-ink-2 hover:bg-black/10',
|
||||||
@@ -93,32 +111,33 @@ $effect(() => {
|
|||||||
? '!border !border-dashed !border-red-400 !bg-red-50/80 !text-red-700'
|
? '!border !border-dashed !border-red-400 !bg-red-50/80 !text-red-700'
|
||||||
: ''
|
: ''
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class={[
|
class={[
|
||||||
'rounded-[2px] px-0.5 text-[9px] font-extrabold opacity-85',
|
'rounded-[2px] px-0.5 text-[11px] font-extrabold opacity-85',
|
||||||
entry.id === activeId ? 'bg-black/20' : 'bg-black/10'
|
entry.id === activeId ? 'bg-black/20' : 'bg-black/10'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>{i + 1}</span
|
>{i + 1}</span
|
||||||
|
>
|
||||||
|
<span class="max-w-[8rem] truncate" title={entry.title}>{entry.title}</span>
|
||||||
|
{#if entry.status === 'error'}
|
||||||
|
<span class="sr-only">{m.bulk_file_error_chip_label()}</span>
|
||||||
|
<span aria-hidden="true" class="ml-0.5 font-extrabold text-red-600">!</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={m.bulk_remove_file()}
|
||||||
|
data-remove-id={entry.id}
|
||||||
|
onclick={() => handleRemove(entry, i)}
|
||||||
|
class="ml-0.5 flex h-[44px] w-[44px] items-center justify-center text-base text-ink-3 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
>
|
>
|
||||||
<span class="max-w-[8rem] truncate" title={entry.title}>{entry.title}</span>
|
×
|
||||||
{#if entry.status === 'error'}
|
</button>
|
||||||
<span class="sr-only">{m.bulk_file_error_chip_label()}</span>
|
</li>
|
||||||
<span aria-hidden="true" class="ml-0.5 font-extrabold text-red-600">!</span>
|
{/each}
|
||||||
{/if}
|
</ul>
|
||||||
</button>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={m.bulk_remove_file()}
|
|
||||||
data-remove-id={entry.id}
|
|
||||||
onclick={() => onRemove(entry.id)}
|
|
||||||
class="ml-0.5 flex h-[44px] w-[44px] items-center justify-center text-base text-ink-3 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -99,6 +99,32 @@ describe('FileSwitcherStrip', () => {
|
|||||||
expect(srOnly).not.toBeNull();
|
expect(srOnly).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('focus moves to the previous chip after the middle chip is removed', async () => {
|
||||||
|
const files = makeFiles(3); // id-0, id-1, id-2
|
||||||
|
const onRemove = vi.fn();
|
||||||
|
const { container } = render(FileSwitcherStrip, {
|
||||||
|
files,
|
||||||
|
activeId: files[1].id,
|
||||||
|
onSelect: vi.fn(),
|
||||||
|
onRemove
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeBtn = container.querySelector('[data-remove-id="id-1"]') as HTMLButtonElement;
|
||||||
|
expect(removeBtn).not.toBeNull();
|
||||||
|
removeBtn.click();
|
||||||
|
expect(onRemove).toHaveBeenCalledWith('id-1');
|
||||||
|
|
||||||
|
// After removal, focus should be on the chip for id-0 (the previous chip)
|
||||||
|
await vi.waitFor(
|
||||||
|
() => {
|
||||||
|
const prevChip = container.querySelector('[data-chip-id="id-0"]') as HTMLElement | null;
|
||||||
|
expect(prevChip).not.toBeNull();
|
||||||
|
expect(document.activeElement).toBe(prevChip);
|
||||||
|
},
|
||||||
|
{ timeout: 1000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('ArrowRight moves focus to next chip without leaving strip', async () => {
|
it('ArrowRight moves focus to next chip without leaving strip', async () => {
|
||||||
const files = makeFiles(3);
|
const files = makeFiles(3);
|
||||||
const { container } = render(FileSwitcherStrip, {
|
const { container } = render(FileSwitcherStrip, {
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ let {
|
|||||||
fileCount,
|
fileCount,
|
||||||
chunkProgress,
|
chunkProgress,
|
||||||
onSave,
|
onSave,
|
||||||
onDiscard
|
onDiscard,
|
||||||
|
disabled = false
|
||||||
}: {
|
}: {
|
||||||
fileCount: number;
|
fileCount: number;
|
||||||
chunkProgress?: { done: number; total: number };
|
chunkProgress?: { done: number; total: number };
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onDiscard: () => void;
|
onDiscard: () => void | Promise<void>;
|
||||||
|
disabled?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ let {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="bulk-save-btn"
|
data-testid="bulk-save-btn"
|
||||||
disabled={fileCount === 0}
|
disabled={fileCount === 0 || disabled}
|
||||||
onclick={onSave}
|
onclick={onSave}
|
||||||
class="min-h-[44px] rounded-sm bg-primary px-6 text-sm font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90 disabled:opacity-40"
|
class="min-h-[44px] rounded-sm bg-primary px-6 text-sm font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user