feat(ui): Phase D.2 — filter chips + URL sync

- 검색바 아래 새 필터 칩 row: domain/tag/format/source 활성 필터를
  인라인 칩으로 렌더, 각 칩에 X 버튼으로 제거.
- `+ 태그` popover: 현재 결과의 상위 20개 태그 클라이언트 집계
  (items.flatMap(d => d.ai_tags).counts + sort) → 선택 시 ?tag=...
- `+ 형식` popover: FORMATS 화이트리스트 (pdf/hwp/hwpx/md/docx/xlsx/png/jpg)
  → 선택 시 ?format=...
- 바깥 클릭으로 popover 자동 close ($effect + document listener)
- filterFormat $derived + loadDocuments params 확장 + hasActiveFilters 확장
- 결과 헤더는 카운트만 남기고 필터 표시/초기화는 칩 row로 이전 (중복 제거)
- addFilter/removeFilter 헬퍼로 URL 라운드트립 관리 (domain 제거 시 sub_group 함께)
- 백엔드 변경 없음 (GET /documents/가 이미 tag/format 지원)

검증:
- npm run build 통과
- npm run lint:tokens 236 → 231 (신규 코드 0 위반, 결과 헤더 리팩토링으로
  5건 organically 감소)
- popover 키보드 a11y (role=listbox/option, aria-expanded, aria-selected)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-08 12:27:13 +09:00
parent ffac4975b9
commit 8f312f50a7

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 } from 'lucide-svelte';
import { Info, List, LayoutGrid, ChevronLeft, X, Plus } from 'lucide-svelte';
import DocumentCard from '$lib/components/DocumentCard.svelte';
import DocumentTable from '$lib/components/DocumentTable.svelte';
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
@@ -13,6 +13,10 @@
import { ui } from '$lib/stores/uiState.svelte';
import { useIsXl } from '$lib/composables/useMedia.svelte';
// D.2: 필터 칩에서 사용할 format 화이트리스트.
// 백엔드 `GET /documents/?format=...` 파라미터가 이미 받음.
const FORMATS = ['pdf', 'hwp', 'hwpx', 'md', 'docx', 'xlsx', 'png', 'jpg'];
// 뷰 모드 (localStorage 기억)
let viewMode = $state(typeof localStorage !== 'undefined' ? (localStorage.getItem('viewMode') || 'card') : 'card');
function toggleViewMode() {
@@ -75,6 +79,11 @@
let filterSubGroup = $derived($page.url.searchParams.get('sub_group') || '');
let filterTag = $derived($page.url.searchParams.get('tag') || '');
let filterSource = $derived($page.url.searchParams.get('source') || '');
let filterFormat = $derived($page.url.searchParams.get('format') || '');
// D.2: 필터 칩 popover 상태
let tagPopoverOpen = $state(false);
let formatPopoverOpen = $state(false);
$effect(() => {
const _p = currentPage;
@@ -82,6 +91,7 @@
const _s = filterSubGroup;
const _t = filterTag;
const _src = filterSource;
const _f = filterFormat;
const urlQ = $page.url.searchParams.get('q') || '';
const urlMode = $page.url.searchParams.get('mode') || 'hybrid';
@@ -108,6 +118,7 @@
if (filterSubGroup) params.set('sub_group', filterSubGroup);
if (filterTag) params.set('tag', filterTag);
if (filterSource) params.set('source', filterSource);
if (filterFormat) params.set('format', filterFormat);
const data = await api(`/documents/?${params}`);
documents = data.items;
@@ -176,6 +187,26 @@
searchQuery = '';
}
// D.2: 필터 add/remove 헬퍼 — URL 라운드트립으로 필터 상태 관리.
function addFilter(key, value) {
const params = new URLSearchParams($page.url.searchParams);
params.set(key, value);
params.delete('page');
const qs = params.toString();
goto(`/documents${qs ? '?' + qs : ''}`, { noScroll: true });
tagPopoverOpen = false;
formatPopoverOpen = false;
}
function removeFilter(key) {
const params = new URLSearchParams($page.url.searchParams);
params.delete(key);
// domain 제거 시 sub_group도 함께 제거 (의존성)
if (key === 'domain') params.delete('sub_group');
const qs = params.toString();
goto(`/documents${qs ? '?' + qs : ''}`, { noScroll: true });
}
function selectDoc(doc) {
selectedDoc = selectedDoc?.id === doc.id ? null : doc;
}
@@ -188,7 +219,37 @@
let totalPages = $derived(Math.ceil(total / 20));
let items = $derived(searchResults || documents);
let hasActiveFilters = $derived(!!filterDomain || !!filterSubGroup || !!filterTag || !!filterSource || !!searchQuery);
let hasActiveFilters = $derived(
!!filterDomain || !!filterSubGroup || !!filterTag || !!filterSource || !!filterFormat || !!searchQuery
);
// D.2: 현재 결과 집계 — 상위 20개 태그 (클라이언트 집계, 백엔드 변경 없음).
let topTags = $derived.by(() => {
const counts = new Map();
for (const d of items) {
for (const t of d.ai_tags || []) {
counts.set(t, (counts.get(t) || 0) + 1);
}
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 20);
});
// 바깥 클릭으로 popover 닫기
$effect(() => {
if (!tagPopoverOpen && !formatPopoverOpen) return;
function onDocClick(e) {
const target = e.target;
if (!(target instanceof Element)) return;
if (!target.closest('[data-popover-root]')) {
tagPopoverOpen = false;
formatPopoverOpen = false;
}
}
document.addEventListener('click', onDocClick);
return () => document.removeEventListener('click', onDocClick);
});
</script>
<svelte:window on:keydown={handleKeydown} />
@@ -250,27 +311,150 @@
{/if}
</div>
<!-- D.2: 필터 칩 row -->
<div class="flex flex-wrap items-center gap-1.5 px-4 pb-2 shrink-0">
{#if filterDomain}
<span class="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded bg-accent/15 text-accent border border-accent/30">
{filterDomain.replace('Knowledge/', '')}{filterSubGroup ? ` / ${filterSubGroup}` : ''}
<button
type="button"
onclick={() => removeFilter('domain')}
class="hover:text-error -mr-0.5 ml-0.5"
aria-label="도메인 필터 제거"
>
<X size={10} />
</button>
</span>
{/if}
{#if filterTag}
<span class="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded bg-surface text-text border border-default">
#{filterTag}
<button
type="button"
onclick={() => removeFilter('tag')}
class="hover:text-error -mr-0.5 ml-0.5"
aria-label="태그 필터 제거"
>
<X size={10} />
</button>
</span>
{/if}
{#if filterFormat}
<span class="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded bg-surface text-text border border-default uppercase">
{filterFormat}
<button
type="button"
onclick={() => removeFilter('format')}
class="hover:text-error -mr-0.5 ml-0.5"
aria-label="형식 필터 제거"
>
<X size={10} />
</button>
</span>
{/if}
{#if filterSource}
<span class="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded bg-surface text-text border border-default">
{filterSource}
<button
type="button"
onclick={() => removeFilter('source')}
class="hover:text-error -mr-0.5 ml-0.5"
aria-label="소스 필터 제거"
>
<X size={10} />
</button>
</span>
{/if}
<!-- + 태그 popover -->
<div class="relative" data-popover-root>
<button
type="button"
onclick={() => { tagPopoverOpen = !tagPopoverOpen; formatPopoverOpen = false; }}
class="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded bg-surface text-dim border border-default border-dashed hover:text-text hover:border-accent transition-colors"
aria-haspopup="listbox"
aria-expanded={tagPopoverOpen}
>
<Plus size={10} /> 태그
</button>
{#if tagPopoverOpen}
<div
class="absolute top-full left-0 mt-1 z-dropdown bg-surface border border-default rounded-md shadow-lg min-w-[180px] max-h-[280px] overflow-y-auto py-1"
role="listbox"
aria-label="태그 선택"
>
{#if topTags.length === 0}
<p class="text-[10px] text-dim px-3 py-2">현재 결과에 태그 없음</p>
{:else}
{#each topTags as [tag, count]}
<button
type="button"
role="option"
aria-selected={filterTag === tag}
onclick={() => addFilter('tag', tag)}
class="flex items-center justify-between w-full text-left text-[11px] px-3 py-1 text-text hover:bg-surface-hover"
>
<span>#{tag}</span>
<span class="text-dim ml-2 tabular-nums">{count}</span>
</button>
{/each}
{/if}
</div>
{/if}
</div>
<!-- + 형식 popover -->
<div class="relative" data-popover-root>
<button
type="button"
onclick={() => { formatPopoverOpen = !formatPopoverOpen; tagPopoverOpen = false; }}
class="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded bg-surface text-dim border border-default border-dashed hover:text-text hover:border-accent transition-colors"
aria-haspopup="listbox"
aria-expanded={formatPopoverOpen}
>
<Plus size={10} /> 형식
</button>
{#if formatPopoverOpen}
<div
class="absolute top-full left-0 mt-1 z-dropdown bg-surface border border-default rounded-md shadow-lg min-w-[140px] py-1"
role="listbox"
aria-label="형식 선택"
>
{#each FORMATS as fmt}
<button
type="button"
role="option"
aria-selected={filterFormat === fmt}
onclick={() => addFilter('format', fmt)}
class="flex items-center w-full text-left text-[11px] px-3 py-1 text-text hover:bg-surface-hover uppercase"
>
{fmt}
</button>
{/each}
</div>
{/if}
</div>
{#if hasActiveFilters}
<button
type="button"
onclick={clearAllFilters}
class="text-[10px] text-dim hover:text-text px-2 py-0.5 ml-auto"
>
초기화
</button>
{/if}
</div>
<!-- 스크롤 영역 (목록) -->
<div class="flex-1 overflow-y-auto px-4">
<!-- 결과 헤더 -->
<!-- 결과 헤더 — 카운트만. 필터는 위의 칩 row가 표시. -->
{#if !loading}
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="text-[10px] text-[var(--text-dim)]">{total}</span>
{#if filterDomain}
<span class="text-[10px] text-[var(--accent)] bg-[var(--accent)]/10 px-1.5 py-0.5 rounded">
{filterDomain.replace('Knowledge/', '')}{filterSubGroup ? ` / ${filterSubGroup}` : ''}
</span>
{/if}
</div>
{#if hasActiveFilters}
<button
onclick={clearAllFilters}
class="text-[10px] text-[var(--text-dim)] hover:text-[var(--text)] px-1.5 py-0.5 rounded border border-[var(--border)]"
>
초기화
</button>
{/if}
<div class="flex items-center mb-2">
<span class="text-[10px] text-dim">{total}</span>
</div>
{/if}