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:
@@ -10,8 +10,15 @@
|
|||||||
selectable = false,
|
selectable = false,
|
||||||
selectedIds = new Set(),
|
selectedIds = new Set(),
|
||||||
onselectionchange = null,
|
onselectionchange = null,
|
||||||
|
// D.4 키보드 네비게이션 커서
|
||||||
|
kbSelectedId = null,
|
||||||
|
// D.5 행 밀도
|
||||||
|
density = 'comfortable', // 'compact' | 'comfortable'
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
let rowPaddingClass = $derived(density === 'compact' ? 'py-1' : 'py-2.5');
|
||||||
|
let rowTextClass = $derived(density === 'compact' ? 'text-[10px]' : 'text-xs');
|
||||||
|
|
||||||
function toggleSelection(id, e) {
|
function toggleSelection(id, e) {
|
||||||
e?.stopPropagation?.();
|
e?.stopPropagation?.();
|
||||||
const next = new Set(selectedIds);
|
const next = new Set(selectedIds);
|
||||||
@@ -130,10 +137,13 @@
|
|||||||
<!-- 행 -->
|
<!-- 행 -->
|
||||||
{#each sortedItems() as doc}
|
{#each sortedItems() as doc}
|
||||||
{@const isChecked = selectedIds.has(doc.id)}
|
{@const isChecked = selectedIds.has(doc.id)}
|
||||||
|
{@const isKbCursor = doc.id === kbSelectedId}
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-1 px-2 py-1.5 w-full border-b border-default/30 hover:bg-surface transition-colors group
|
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' : ''}
|
{selectedId === doc.id ? 'bg-accent/5 border-l-2 border-l-accent' : ''}
|
||||||
{isChecked ? 'bg-accent/10' : ''}"
|
{isChecked ? 'bg-accent/10' : ''}
|
||||||
|
{isKbCursor ? 'ring-1 ring-accent-ring ring-inset' : ''}"
|
||||||
>
|
>
|
||||||
{#if selectable}
|
{#if selectable}
|
||||||
<span class="w-6 shrink-0 flex items-center justify-center">
|
<span class="w-6 shrink-0 flex items-center justify-center">
|
||||||
@@ -156,7 +166,7 @@
|
|||||||
<div class="flex-1 flex items-center gap-2 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>
|
<span class="w-1 h-4 rounded-full shrink-0" style="background: {getDomainColor(doc.ai_domain)}"></span>
|
||||||
<FormatIcon format={doc.file_format} size={14} />
|
<FormatIcon format={doc.file_format} size={14} />
|
||||||
<span class="text-xs truncate group-hover:text-accent">{doc.title || '제목 없음'}</span>
|
<span class="{rowTextClass} truncate group-hover:text-accent">{doc.title || '제목 없음'}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 분류 -->
|
<!-- 분류 -->
|
||||||
<div class="w-48 text-[10px] text-dim truncate">
|
<div class="w-48 text-[10px] text-dim truncate">
|
||||||
|
|||||||
62
frontend/src/lib/composables/useListKeyboardNav.svelte.ts
Normal file
62
frontend/src/lib/composables/useListKeyboardNav.svelte.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { addToast } from '$lib/stores/toast';
|
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 DocumentCard from '$lib/components/DocumentCard.svelte';
|
||||||
import DocumentTable from '$lib/components/DocumentTable.svelte';
|
import DocumentTable from '$lib/components/DocumentTable.svelte';
|
||||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
import TextInput from '$lib/components/ui/TextInput.svelte';
|
import TextInput from '$lib/components/ui/TextInput.svelte';
|
||||||
import { ui } from '$lib/stores/uiState.svelte';
|
import { ui } from '$lib/stores/uiState.svelte';
|
||||||
import { useIsXl } from '$lib/composables/useMedia.svelte';
|
import { useIsXl } from '$lib/composables/useMedia.svelte';
|
||||||
|
import { useListKeyboardNav } from '$lib/composables/useListKeyboardNav.svelte';
|
||||||
import { pLimit } from '$lib/utils/pLimit';
|
import { pLimit } from '$lib/utils/pLimit';
|
||||||
|
|
||||||
// D.2: 필터 칩에서 사용할 format 화이트리스트.
|
// D.2: 필터 칩에서 사용할 format 화이트리스트.
|
||||||
@@ -43,6 +44,17 @@
|
|||||||
if (typeof localStorage !== 'undefined') localStorage.setItem('viewMode', viewMode);
|
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 documents = $state([]);
|
||||||
let total = $state(0);
|
let total = $state(0);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -110,6 +122,35 @@
|
|||||||
let bulkTagValue = $state('');
|
let bulkTagValue = $state('');
|
||||||
let bulkBusy = $state(false);
|
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(() => {
|
$effect(() => {
|
||||||
const _p = currentPage;
|
const _p = currentPage;
|
||||||
const _d = filterDomain;
|
const _d = filterDomain;
|
||||||
@@ -124,6 +165,7 @@
|
|||||||
searchMode = urlMode;
|
searchMode = urlMode;
|
||||||
selectedDoc = null;
|
selectedDoc = null;
|
||||||
selectedIds = new Set(); // D.3: URL/필터 변경 시 선택 초기화
|
selectedIds = new Set(); // D.3: URL/필터 변경 시 선택 초기화
|
||||||
|
kbIndex = 0; // D.4: 키보드 커서 초기화
|
||||||
if (ui.isDrawerOpen('meta')) ui.closeDrawer();
|
if (ui.isDrawerOpen('meta')) ui.closeDrawer();
|
||||||
|
|
||||||
if (urlQ) {
|
if (urlQ) {
|
||||||
@@ -329,6 +371,17 @@
|
|||||||
items.length > 0 && items.every((d) => selectedIds.has(d.id))
|
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개 태그 (클라이언트 집계, 백엔드 변경 없음).
|
// D.2: 현재 결과 집계 — 상위 20개 태그 (클라이언트 집계, 백엔드 변경 없음).
|
||||||
let topTags = $derived.by(() => {
|
let topTags = $derived.by(() => {
|
||||||
const counts = new Map();
|
const counts = new Map();
|
||||||
@@ -390,7 +443,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
onclick={toggleViewMode}
|
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="뷰 모드 전환"
|
aria-label="뷰 모드 전환"
|
||||||
title={viewMode === 'card' ? '테이블 뷰' : '카드 뷰'}
|
title={viewMode === 'card' ? '테이블 뷰' : '카드 뷰'}
|
||||||
>
|
>
|
||||||
@@ -400,6 +453,21 @@
|
|||||||
<LayoutGrid size={16} />
|
<LayoutGrid size={16} />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</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}
|
{#if selectedDoc}
|
||||||
{@const isPanelActive = isXl.current ? metaRailOpen : ui.isDrawerOpen('meta')}
|
{@const isPanelActive = isXl.current ? metaRailOpen : ui.isDrawerOpen('meta')}
|
||||||
<button
|
<button
|
||||||
@@ -649,19 +717,23 @@
|
|||||||
selectable
|
selectable
|
||||||
{selectedIds}
|
{selectedIds}
|
||||||
onselectionchange={handleSelectionChange}
|
onselectionchange={handleSelectionChange}
|
||||||
|
{kbSelectedId}
|
||||||
|
density={tableDensity}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
{#each items as doc}
|
{#each items as doc}
|
||||||
<DocumentCard
|
<div data-kb-selected={doc.id === kbSelectedId}>
|
||||||
{doc}
|
<DocumentCard
|
||||||
showDomain={!filterDomain}
|
{doc}
|
||||||
selected={selectedDoc?.id === doc.id}
|
showDomain={!filterDomain}
|
||||||
onselect={selectDoc}
|
selected={selectedDoc?.id === doc.id}
|
||||||
selectable
|
onselect={selectDoc}
|
||||||
{selectedIds}
|
selectable
|
||||||
onselectionchange={handleSelectionChange}
|
{selectedIds}
|
||||||
/>
|
onselectionchange={handleSelectionChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user