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>
This commit is contained in:
Hyungi Ahn
2026-04-08 12:42:03 +09:00
parent 3375a5f1b1
commit d83842ccd8
3 changed files with 158 additions and 14 deletions

View File

@@ -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 @@
</select>
<button
onclick={toggleViewMode}
class="p-1.5 rounded-lg border border-[var(--border)] hover:border-[var(--accent)] text-[var(--text-dim)] hover:text-[var(--accent)] transition-colors"
class="p-1.5 rounded-lg border border-default text-dim hover:text-accent hover:border-accent transition-colors"
aria-label="뷰 모드 전환"
title={viewMode === 'card' ? '테이블 뷰' : '카드 뷰'}
>
@@ -400,6 +453,21 @@
<LayoutGrid size={16} />
{/if}
</button>
{#if viewMode === 'table'}
<button
type="button"
onclick={toggleDensity}
class="p-1.5 rounded-lg border border-default text-dim hover:text-accent hover:border-accent transition-colors"
aria-label="밀도 전환"
title={tableDensity === 'compact' ? '여유 있게' : '촘촘하게'}
>
{#if tableDensity === 'compact'}
<Rows2 size={16} />
{:else}
<Rows3 size={16} />
{/if}
</button>
{/if}
{#if selectedDoc}
{@const isPanelActive = isXl.current ? metaRailOpen : ui.isDrawerOpen('meta')}
<button
@@ -649,19 +717,23 @@
selectable
{selectedIds}
onselectionchange={handleSelectionChange}
{kbSelectedId}
density={tableDensity}
/>
{:else}
<div class="space-y-1">
{#each items as doc}
<DocumentCard
{doc}
showDomain={!filterDomain}
selected={selectedDoc?.id === doc.id}
onselect={selectDoc}
selectable
{selectedIds}
onselectionchange={handleSelectionChange}
/>
<div data-kb-selected={doc.id === kbSelectedId}>
<DocumentCard
{doc}
showDomain={!filterDomain}
selected={selectedDoc?.id === doc.id}
onselect={selectDoc}
selectable
{selectedIds}
onselectionchange={handleSelectionChange}
/>
</div>
{/each}
</div>
{/if}