feat: [common] 검색 X 클리어 버튼 + 검색 상태 sessionStorage 보존

- SearchFilter, IntegratedListTemplateV2: 검색 입력 시 X(클리어) 버튼 표시
- useListSearchState 훅 신규: URL + sessionStorage 이중 저장으로 상세→목록 복귀 시 검색 유지
- UniversalListPage: useListSearchState 연동
This commit is contained in:
유병철
2026-03-19 17:48:53 +09:00
parent 30e61301b5
commit 30f4150dfa
5 changed files with 348 additions and 8 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import { Input } from "@/components/ui/input";
import { Search } from "lucide-react";
import { Search, XCircle } from "lucide-react";
import { ReactNode, useState, useEffect } from "react";
interface SearchFilterProps {
@@ -45,8 +45,17 @@ export function SearchFilter({
placeholder={isMobile ? "내용을 검색해주세요." : searchPlaceholder}
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 text-[14px]"
className="pl-10 pr-8 text-[14px]"
/>
{searchValue && (
<button
type="button"
onClick={() => onSearchChange('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<XCircle className="w-4 h-4" />
</button>
)}
</div>
{extraActions && (
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:flex-wrap">

View File

@@ -1,7 +1,7 @@
"use client";
import { ReactNode, Fragment, useState, useEffect, useRef, useCallback, Children, isValidElement, cloneElement } from "react";
import { LucideIcon, Trash2, Plus, Loader2, ArrowUpDown, ArrowUp, ArrowDown, Search } from "lucide-react";
import { LucideIcon, Trash2, Plus, Loader2, ArrowUpDown, ArrowUp, ArrowDown, Search, XCircle } from "lucide-react";
import { DateRangeSelector } from "@/components/molecules/DateRangeSelector";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent } from "@/components/ui/tabs";
@@ -691,8 +691,17 @@ export function IntegratedListTemplateV2<T = any>({
placeholder={searchPlaceholder}
value={searchValue || ''}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 w-full bg-gray-50 border-gray-200"
className="pl-9 pr-8 w-full bg-gray-50 border-gray-200"
/>
{searchValue && (
<button
type="button"
onClick={() => onSearchChange('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<XCircle className="w-4 h-4" />
</button>
)}
</div>
)}
{/* 기존 extraActions (추가 버튼 등) */}
@@ -710,8 +719,17 @@ export function IntegratedListTemplateV2<T = any>({
placeholder={searchPlaceholder}
value={searchValue || ''}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 w-full bg-gray-50 border-gray-200"
className="pl-9 pr-8 w-full bg-gray-50 border-gray-200"
/>
{searchValue && (
<button
type="button"
onClick={() => onSearchChange('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<XCircle className="w-4 h-4" />
</button>
)}
</div>
)
)}

View File

@@ -15,6 +15,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useRouter, useParams } from 'next/navigation';
import { usePermission } from '@/hooks/usePermission';
import { useColumnSettings } from '@/hooks/useColumnSettings';
import { useListSearchState } from '@/hooks/useListSearchState';
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
import { toast } from 'sonner';
import { Download, Loader2 } from 'lucide-react';
@@ -48,14 +49,21 @@ export function UniversalListPage<T>({
const locale = (params.locale as string) || 'ko';
const { canCreate: permCanCreate, canDelete: permCanDelete, canExport } = usePermission();
// ===== 검색 상태 보존 (sessionStorage + URL) =====
const {
getValue: getSearchStateValue,
setValue: setSearchStateValue,
} = useListSearchState({ debounceMs: 500 });
const restoredSearch = getSearchStateValue('search');
// ===== 상태 관리 =====
// 원본 데이터 (클라이언트 사이드 필터링용)
const [rawData, setRawData] = useState<T[]>(initialData || []);
// UI 상태
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(!initialData);
const [searchValue, setSearchValue] = useState(''); // UI 입력용 (즉시 반영)
const [debouncedSearchValue, setDebouncedSearchValue] = useState(''); // API 호출용 (debounced)
const [searchValue, setSearchValue] = useState(restoredSearch); // UI 입력용 (즉시 반영, sessionStorage에서 복원)
const [debouncedSearchValue, setDebouncedSearchValue] = useState(restoredSearch); // API 호출용 (debounced)
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
// 탭이 없으면 IntegratedListTemplateV2의 기본값 'default'와 일치해야 함
const [activeTab, setActiveTab] = useState(
@@ -536,7 +544,7 @@ export function UniversalListPage<T>({
// ===== 검색 핸들러 =====
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value); // UI 즉시 반영
// 페이지 초기화와 외부 콜백은 debounce 후 실행됨 (아래 useEffect에서 처리)
setSearchStateValue('search', value); // sessionStorage + URL 동기화
}, []);
// 외부 콜백에 debounced 값 전달 (자체 loadData 관리하는 컴포넌트용)