Files
familienarchiv/frontend/src/lib/document/FileSwitcherStrip.svelte
Marcel e7f8aa5894 refactor: move document domain core to lib/document/
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>
2026-05-05 13:56:36 +02:00

155 lines
5.2 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>