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')}
{:else}
{#each items as doc}
-
+
+
+
{/each}
{/if}