Files
hyungi_document_server/frontend/src/lib/components/DocumentTable.svelte
Hyungi Ahn d83842ccd8 feat(ui): Phase D.4/D.5 — keyboard nav + density toggle
D.4 useListKeyboardNav:
- 신규: frontend/src/lib/composables/useListKeyboardNav.svelte.ts
  j/k/Arrow/Enter/Esc, isTypingTarget 가드 (input/textarea/select/
  contenteditable 포커스 시 비활성)
- documents/+page.svelte: kbIndex $state, kbSelectedId $derived,
  items 변경 시 clamp, URL 변경 시 0 리셋
- DocumentTable/DocumentCard: kbSelectedId prop → data-kb-selected
  속성 + ring-accent-ring 시각 표시
- scrollSelectedIntoView: queueMicrotask + querySelector로 현재
  커서를 뷰포트 내로 스크롤 (block: nearest)

D.5 Table density:
- DocumentTable: density prop (compact/comfortable), rowPaddingClass
  ($derived: py-1 | py-2.5), rowTextClass (text-[10px] | text-xs)
- documents/+page.svelte: tableDensity $state, toggleDensity 헬퍼,
  localStorage.tableDensity persistent, 테이블 뷰에서만 토글 버튼
  노출 (Rows2/Rows3 아이콘)
- 뷰 모드 버튼도 token 기반으로 리팩토링

검증:
- npm run build 통과
- npm run lint:tokens 231 → 229 (뷰 모드 버튼 token swap으로 -2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:42:03 +09:00

191 lines
6.0 KiB
Svelte

<script>
import { goto } from '$app/navigation';
import FormatIcon from './FormatIcon.svelte';
let {
items = [],
selectedId = null,
onselect = null,
// D.3 다중 선택
selectable = false,
selectedIds = new Set(),
onselectionchange = null,
// D.4 키보드 네비게이션 커서
kbSelectedId = null,
// D.5 행 밀도
density = 'comfortable', // 'compact' | 'comfortable'
} = $props();
let rowPaddingClass = $derived(density === 'compact' ? 'py-1' : 'py-2.5');
let rowTextClass = $derived(density === 'compact' ? 'text-[10px]' : 'text-xs');
function toggleSelection(id, e) {
e?.stopPropagation?.();
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
onselectionchange?.(next);
}
let sortKey = $state('created_at');
let sortOrder = $state('desc');
// localStorage에서 정렬 상태 복원
if (typeof localStorage !== 'undefined') {
const saved = localStorage.getItem('tableSort');
if (saved) {
try {
const { key, order } = JSON.parse(saved);
sortKey = key;
sortOrder = order;
} catch (e) {}
}
}
function toggleSort(key) {
if (sortKey === key) {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
} else {
sortKey = key;
sortOrder = 'asc';
}
if (typeof localStorage !== 'undefined') {
localStorage.setItem('tableSort', JSON.stringify({ key: sortKey, order: sortOrder }));
}
}
// stable sort
let sortedItems = $derived(() => {
const arr = [...items];
arr.sort((a, b) => {
let va = a[sortKey] ?? '';
let vb = b[sortKey] ?? '';
if (typeof va === 'string') va = va.toLowerCase();
if (typeof vb === 'string') vb = vb.toLowerCase();
if (va === vb) return a.id - b.id;
if (sortOrder === 'asc') return va > vb ? 1 : -1;
return va < vb ? 1 : -1;
});
return arr;
});
function formatDate(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
}
function formatSize(bytes) {
if (!bytes) return '-';
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)}KB`;
return `${(bytes / 1048576).toFixed(1)}MB`;
}
function handleClick(doc) {
if (typeof window !== 'undefined' && window.innerWidth < 1024) {
goto(`/documents/${doc.id}`);
return;
}
if (onselect) onselect(doc);
else goto(`/documents/${doc.id}`);
}
const DOMAIN_COLORS = {
'Philosophy': 'var(--domain-philosophy)',
'Language': 'var(--domain-language)',
'Engineering': 'var(--domain-engineering)',
'Industrial_Safety': 'var(--domain-safety)',
'Programming': 'var(--domain-programming)',
'General': 'var(--domain-general)',
'Reference': 'var(--domain-reference)',
};
function getDomainColor(domain) {
if (!domain) return 'var(--border)';
const top = domain.split('/')[0];
return DOMAIN_COLORS[top] || 'var(--border)';
}
const columns = [
{ key: 'title', label: '이름', flex: 'flex-1' },
{ key: 'ai_domain', label: '분류', width: 'w-48' },
{ key: 'document_type', label: '타입', width: 'w-24' },
{ key: 'file_size', label: '크기', width: 'w-20' },
{ key: 'created_at', label: '등록일', width: 'w-20' },
];
</script>
<div class="w-full">
<!-- 헤더 -->
<div class="flex items-center gap-1 px-2 py-1.5 border-b border-default text-[10px] text-dim uppercase tracking-wider">
{#if selectable}
<div class="w-6 shrink-0" aria-hidden="true"></div>
{/if}
{#each columns as col}
<button
onclick={() => toggleSort(col.key)}
class="flex items-center gap-1 {col.flex || col.width || ''} px-1 hover:text-text transition-colors text-left"
>
{col.label}
{#if sortKey === col.key}
<span class="text-accent">{sortOrder === 'asc' ? '↑' : '↓'}</span>
{/if}
</button>
{/each}
</div>
<!-- 행 -->
{#each sortedItems() as doc}
{@const isChecked = selectedIds.has(doc.id)}
{@const isKbCursor = doc.id === kbSelectedId}
<div
data-kb-selected={isKbCursor}
class="flex items-center gap-1 px-2 {rowPaddingClass} w-full border-b border-default/30 hover:bg-surface transition-colors group
{selectedId === doc.id ? 'bg-accent/5 border-l-2 border-l-accent' : ''}
{isChecked ? 'bg-accent/10' : ''}
{isKbCursor ? 'ring-1 ring-accent-ring ring-inset' : ''}"
>
{#if selectable}
<span class="w-6 shrink-0 flex items-center justify-center">
<input
type="checkbox"
checked={isChecked}
onchange={(e) => toggleSelection(doc.id, e)}
onclick={(e) => e.stopPropagation()}
class="h-3.5 w-3.5 accent-accent cursor-pointer"
aria-label="{doc.title || '문서'} 선택"
/>
</span>
{/if}
<button
type="button"
onclick={() => handleClick(doc)}
class="flex-1 flex items-center gap-1 text-left min-w-0"
>
<!-- 이름 -->
<div class="flex-1 flex items-center gap-2 min-w-0">
<span class="w-1 h-4 rounded-full shrink-0" style="background: {getDomainColor(doc.ai_domain)}"></span>
<FormatIcon format={doc.file_format} size={14} />
<span class="{rowTextClass} truncate group-hover:text-accent">{doc.title || '제목 없음'}</span>
</div>
<!-- 분류 -->
<div class="w-48 text-[10px] text-dim truncate">
{doc.ai_domain?.replace('Industrial_Safety/', 'IS/') || '-'}
</div>
<!-- 타입 -->
<div class="w-24 text-[10px] text-dim">
{doc.document_type || doc.file_format?.toUpperCase() || '-'}
</div>
<!-- 크기 -->
<div class="w-20 text-[10px] text-dim text-right">
{formatSize(doc.file_size)}
</div>
<!-- 등록일 -->
<div class="w-20 text-[10px] text-dim text-right">
{formatDate(doc.created_at)}
</div>
</button>
</div>
{/each}
</div>