Moves ~25 components, utils (search, filename, groupDocuments, documentStatusLabel, validateFile), bulkSelection store, and TranscriptionSection sub-component. Fixes broken relative imports. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
155 lines
5.2 KiB
Svelte
155 lines
5.2 KiB
Svelte
<script lang="ts">
|
||
import { tick } from 'svelte';
|
||
import { m } from '$lib/paraglide/messages.js';
|
||
|
||
export interface FileEntry {
|
||
id: string;
|
||
/** Present in upload mode only. Edit mode entries reference an existing
|
||
* document by `documentId` and have no local file blob. */
|
||
file?: File;
|
||
/** Present in edit mode only — the server-side document UUID being edited. */
|
||
documentId?: string;
|
||
title: string;
|
||
status: 'idle' | 'error';
|
||
previewUrl: string;
|
||
}
|
||
|
||
let {
|
||
files,
|
||
activeId,
|
||
onSelect,
|
||
onRemove
|
||
}: {
|
||
files: FileEntry[];
|
||
activeId: string;
|
||
onSelect: (id: string) => void;
|
||
onRemove: (id: string) => void;
|
||
} = $props();
|
||
|
||
let trackEl = $state<HTMLDivElement | null>(null);
|
||
let listEl = $state<HTMLUListElement | null>(null);
|
||
|
||
const activeAnnouncement = $derived(files.find((f) => f.id === activeId)?.title ?? '');
|
||
|
||
function scrollPrev() {
|
||
trackEl?.scrollBy({ left: -120, behavior: 'smooth' });
|
||
}
|
||
function scrollNext() {
|
||
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(() => {
|
||
if (!listEl) return;
|
||
const node = listEl;
|
||
|
||
function handleKeyDown(event: KeyboardEvent) {
|
||
const buttons = Array.from(node.querySelectorAll<HTMLElement>('[data-chip-id]'));
|
||
if (buttons.length === 0) return;
|
||
|
||
const focusedIndex = buttons.indexOf(document.activeElement as HTMLElement);
|
||
if (focusedIndex === -1) return;
|
||
|
||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
||
event.preventDefault();
|
||
const nextIndex = (focusedIndex + 1) % buttons.length;
|
||
buttons[nextIndex].focus();
|
||
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
||
event.preventDefault();
|
||
const prevIndex = (focusedIndex - 1 + buttons.length) % buttons.length;
|
||
buttons[prevIndex].focus();
|
||
}
|
||
}
|
||
|
||
node.addEventListener('keydown', handleKeyDown);
|
||
return () => node.removeEventListener('keydown', handleKeyDown);
|
||
});
|
||
</script>
|
||
|
||
<div aria-live="polite" aria-atomic="true" class="sr-only">{activeAnnouncement}</div>
|
||
<div
|
||
data-testid="file-switcher-strip"
|
||
class="flex h-11 shrink-0 items-center gap-1 border-t border-line bg-pdf-ctrl px-2"
|
||
>
|
||
<button
|
||
type="button"
|
||
aria-label={m.bulk_switcher_prev()}
|
||
onclick={scrollPrev}
|
||
class="flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-sm text-sm text-ink-3 hover:bg-black/10 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||
>‹</button
|
||
>
|
||
|
||
<!-- Gradient fade overlays signal hidden overflow to pointer-only users -->
|
||
<div class="relative flex flex-1 overflow-hidden">
|
||
<div
|
||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-gradient-to-r from-pdf-ctrl to-transparent"
|
||
></div>
|
||
<div
|
||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-gradient-to-l from-pdf-ctrl to-transparent"
|
||
></div>
|
||
<div bind:this={trackEl} class="flex flex-1 gap-1 overflow-x-auto" style="scrollbar-width:none">
|
||
<ul bind:this={listEl} role="list" class="flex flex-row gap-1 py-1">
|
||
{#each files as entry, i (entry.id)}
|
||
<li role="listitem" class="inline-flex shrink-0 items-center">
|
||
<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
|
||
? 'bg-accent text-primary'
|
||
: 'bg-black/[0.06] text-ink-2 hover:bg-black/10',
|
||
entry.status === 'error'
|
||
? '!border !border-dashed !border-red-400 !bg-red-50/80 !text-red-700'
|
||
: ''
|
||
].join(' ')}
|
||
>
|
||
<span
|
||
class={[
|
||
'rounded-[2px] px-0.5 text-[11px] font-extrabold opacity-85',
|
||
entry.id === activeId ? 'bg-black/20' : 'bg-black/10'
|
||
].join(' ')}
|
||
>{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"
|
||
>
|
||
×
|
||
</button>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
aria-label={m.bulk_switcher_next()}
|
||
onclick={scrollNext}
|
||
class="flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-sm text-sm text-ink-3 hover:bg-black/10 hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||
>›</button
|
||
>
|
||
</div>
|