diff --git a/frontend/src/lib/components/DocumentTable.svelte b/frontend/src/lib/components/DocumentTable.svelte index 5921beb..a052010 100644 --- a/frontend/src/lib/components/DocumentTable.svelte +++ b/frontend/src/lib/components/DocumentTable.svelte @@ -10,8 +10,15 @@ 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); @@ -130,10 +137,13 @@ {#each sortedItems() as doc} {@const isChecked = selectedIds.has(doc.id)} + {@const isKbCursor = doc.id === kbSelectedId}
{#if selectable} @@ -156,7 +166,7 @@
- {doc.title || '제목 없음'} + {doc.title || '제목 없음'}
diff --git a/frontend/src/lib/composables/useListKeyboardNav.svelte.ts b/frontend/src/lib/composables/useListKeyboardNav.svelte.ts new file mode 100644 index 0000000..dfcd2f0 --- /dev/null +++ b/frontend/src/lib/composables/useListKeyboardNav.svelte.ts @@ -0,0 +1,62 @@ +// 리스트 키보드 네비게이션 runes 컴포저블 — Phase D.4 신규. +// - j / ArrowDown → next, k / ArrowUp → prev +// - Enter → onenter(items[index]) +// - Escape → onescape?.() +// - input/textarea/select/contenteditable 포커스 중이면 완전히 비활성 +// → '/' 키 검색 포커스(+layout.svelte)와 충돌 없음 +// +// 사용 (documents/+page.svelte): +// let selectedIndex = $state(0); +// useListKeyboardNav({ +// get items() { return items; }, +// get index() { return selectedIndex; }, +// setIndex: (i) => { selectedIndex = i; scrollSelectedIntoView(); }, +// onenter: (doc) => selectDoc(doc), +// }); + +interface Options { + readonly items: unknown[]; + readonly index: number; + setIndex(i: number): void; + onenter?: (item: unknown) => void; + onescape?: () => void; +} + +function isTypingTarget(el: Element | null): boolean { + if (!el) return false; + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; + return (el as HTMLElement).isContentEditable === true; +} + +export function useListKeyboardNav(opts: Options): void { + $effect(() => { + function handler(e: KeyboardEvent) { + if (isTypingTarget(document.activeElement)) return; + const len = opts.items.length; + if (len === 0) return; + + switch (e.key) { + case 'j': + case 'ArrowDown': + e.preventDefault(); + opts.setIndex(Math.min(opts.index + 1, len - 1)); + break; + case 'k': + case 'ArrowUp': + e.preventDefault(); + opts.setIndex(Math.max(opts.index - 1, 0)); + break; + case 'Enter': + e.preventDefault(); + opts.onenter?.(opts.items[opts.index]); + break; + case 'Escape': + opts.onescape?.(); + break; + } + } + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }); +} diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index 146885e..5076069 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -3,7 +3,7 @@ import { goto } from '$app/navigation'; import { api } from '$lib/api'; import { addToast } from '$lib/stores/toast'; - import { Info, List, LayoutGrid, ChevronLeft, X, Plus, Trash2, Tag, FolderTree } from 'lucide-svelte'; + import { Info, List, LayoutGrid, ChevronLeft, X, Plus, Trash2, Tag, FolderTree, Rows3, Rows2 } from 'lucide-svelte'; import DocumentCard from '$lib/components/DocumentCard.svelte'; import DocumentTable from '$lib/components/DocumentTable.svelte'; import DocumentViewer from '$lib/components/DocumentViewer.svelte'; @@ -17,6 +17,7 @@ import TextInput from '$lib/components/ui/TextInput.svelte'; import { ui } from '$lib/stores/uiState.svelte'; import { useIsXl } from '$lib/composables/useMedia.svelte'; + import { useListKeyboardNav } from '$lib/composables/useListKeyboardNav.svelte'; import { pLimit } from '$lib/utils/pLimit'; // D.2: 필터 칩에서 사용할 format 화이트리스트. @@ -43,6 +44,17 @@ if (typeof localStorage !== 'undefined') localStorage.setItem('viewMode', viewMode); } + // D.5: 테이블 밀도 (localStorage 기억) + let tableDensity = $state( + typeof localStorage !== 'undefined' + ? (localStorage.getItem('tableDensity') || 'comfortable') + : 'comfortable' + ); + function toggleDensity() { + tableDensity = tableDensity === 'compact' ? 'comfortable' : 'compact'; + if (typeof localStorage !== 'undefined') localStorage.setItem('tableDensity', tableDensity); + } + let documents = $state([]); let total = $state(0); let loading = $state(true); @@ -110,6 +122,35 @@ let bulkTagValue = $state(''); let bulkBusy = $state(false); + // D.4: 키보드 네비게이션 + let kbIndex = $state(0); + + function scrollSelectedIntoView() { + // 다음 tick에 DOM 업데이트 후 선택 행으로 스크롤 + queueMicrotask(() => { + const el = document.querySelector('[data-kb-selected="true"]'); + if (el instanceof HTMLElement) { + el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + }); + } + + useListKeyboardNav({ + get items() { + return items; + }, + get index() { + return kbIndex; + }, + setIndex(i) { + kbIndex = i; + scrollSelectedIntoView(); + }, + onenter(doc) { + if (doc) selectDoc(doc); + }, + }); + $effect(() => { const _p = currentPage; const _d = filterDomain; @@ -124,6 +165,7 @@ searchMode = urlMode; selectedDoc = null; selectedIds = new Set(); // D.3: URL/필터 변경 시 선택 초기화 + kbIndex = 0; // D.4: 키보드 커서 초기화 if (ui.isDrawerOpen('meta')) ui.closeDrawer(); if (urlQ) { @@ -329,6 +371,17 @@ items.length > 0 && items.every((d) => selectedIds.has(d.id)) ); + // D.4: 키보드 nav 커서 ID (scrollIntoView 대상) + // items가 변경되면 kbIndex를 범위 내로 clamp + $effect(() => { + if (kbIndex >= items.length && items.length > 0) { + kbIndex = items.length - 1; + } else if (items.length === 0) { + kbIndex = 0; + } + }); + let kbSelectedId = $derived(items[kbIndex]?.id ?? null); + // D.2: 현재 결과 집계 — 상위 20개 태그 (클라이언트 집계, 백엔드 변경 없음). let topTags = $derived.by(() => { const counts = new Map(); @@ -390,7 +443,7 @@ + {#if viewMode === 'table'} + + {/if} {#if selectedDoc} {@const isPanelActive = isXl.current ? metaRailOpen : ui.isDrawerOpen('meta')}