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 관리하는 컴포넌트용)

View File

@@ -37,6 +37,14 @@ export type {
UseDetailPermissionsReturn,
} from './useDetailPermissions';
// ===== 리스트 검색 상태 보존 =====
export { useListSearchState } from './useListSearchState';
export type {
SearchStateField,
UseListSearchStateOptions,
UseListSearchStateReturn,
} from './useListSearchState';
// ===== 날짜 범위 / 목록 페이지 =====
export { useDateRange } from './useDateRange';
export type { DateRangePreset, UseDateRangeReturn } from './useDateRange';

View File

@@ -0,0 +1,297 @@
'use client';
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
/**
* 검색 상태 필드 설정
*/
export interface SearchStateField {
/** URL 쿼리 파라미터 키 */
key: string;
/** 기본값 */
defaultValue: string;
}
export interface UseListSearchStateOptions {
/** 관리할 검색 상태 필드들 (기본: search, tab) */
fields?: SearchStateField[];
/** URL 업데이트 디바운스 ms (기본: 300) */
debounceMs?: number;
}
export interface UseListSearchStateReturn {
/** 검색 상태 값 조회 */
getValue: (key: string) => string;
/** 검색 상태 값 변경 (URL 자동 동기화) */
setValue: (key: string, value: string) => void;
/** 현재 페이지 번호 */
currentPage: number;
/** 페이지 변경 */
setPage: (page: number) => void;
/** 전체 상태 초기화 */
resetAll: () => void;
/** 현재 목록 URL (쿼리 포함) — 상세페이지에서 돌아올 때 사용 */
listUrl: string;
}
const DEFAULT_FIELDS: SearchStateField[] = [
{ key: 'search', defaultValue: '' },
{ key: 'tab', defaultValue: 'all' },
];
// ===== sessionStorage 헬퍼 =====
function getStorageKey(pathname: string): string {
return `listSearch:${pathname}`;
}
function saveToStorage(pathname: string, state: Record<string, string>) {
if (typeof window === 'undefined') return;
try {
sessionStorage.setItem(getStorageKey(pathname), JSON.stringify(state));
} catch {
// sessionStorage 용량 초과 등 무시
}
}
function loadFromStorage(pathname: string): Record<string, string> | null {
if (typeof window === 'undefined') return null;
try {
const raw = sessionStorage.getItem(getStorageKey(pathname));
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
/**
* 리스트 페이지 검색 상태 보존 훅
*
* URL 쿼리파라미터 + sessionStorage 이중 저장으로
* 상세 페이지 이동 후 돌아왔을 때 검색 상태가 유지됩니다.
*
* 우선순위: URL 파라미터 > sessionStorage > 기본값
*/
export function useListSearchState(
options: UseListSearchStateOptions = {}
): UseListSearchStateReturn {
const { fields = DEFAULT_FIELDS, debounceMs = 300 } = options;
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// fields를 ref로 안정화
const fieldsRef = useRef(fields);
fieldsRef.current = fields;
// 초기값: URL 파라미터 > sessionStorage > 기본값
const getInitialValues = (): Record<string, string> => {
const values: Record<string, string> = {};
let hasUrlParams = false;
for (const field of fieldsRef.current) {
const urlValue = searchParams?.get(field.key);
if (urlValue !== null) {
values[field.key] = urlValue;
hasUrlParams = true;
}
}
const urlPage = searchParams?.get('page');
if (urlPage) hasUrlParams = true;
// URL에 파라미터가 있으면 URL 우선
if (hasUrlParams) {
for (const field of fieldsRef.current) {
if (values[field.key] === undefined) {
values[field.key] = field.defaultValue;
}
}
values._page = urlPage ?? '1';
return values;
}
// URL에 없으면 sessionStorage 복원 시도
const stored = loadFromStorage(pathname);
if (stored) {
return stored;
}
// 둘 다 없으면 기본값
for (const field of fieldsRef.current) {
values[field.key] = field.defaultValue;
}
values._page = '1';
return values;
};
// 로컬 상태
const [localState, setLocalState] = useState<Record<string, string>>(getInitialValues);
// ref로 현재 상태 추적
const stateRef = useRef(localState);
stateRef.current = localState;
// URL 업데이트 타이머 + 내부 업데이트 추적
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isInternalUpdate = useRef(false);
// URL → 로컬 상태 동기화 (브라우저 뒤로가기 대응)
useEffect(() => {
if (isInternalUpdate.current) {
isInternalUpdate.current = false;
return;
}
// URL에 파라미터가 없으면 sessionStorage에서 복원
let hasUrlParams = false;
for (const field of fieldsRef.current) {
if (searchParams?.get(field.key) !== null) {
hasUrlParams = true;
break;
}
}
if (searchParams?.get('page') !== null) hasUrlParams = true;
let newValues: Record<string, string>;
if (hasUrlParams) {
newValues = {} as Record<string, string>;
for (const field of fieldsRef.current) {
newValues[field.key] = searchParams?.get(field.key) ?? field.defaultValue;
}
newValues._page = searchParams?.get('page') ?? '1';
} else {
const stored = loadFromStorage(pathname);
if (stored) {
newValues = stored;
// sessionStorage에서 복원한 상태를 URL에도 반영
queueMicrotask(() => syncToUrlImmediate(stored));
} else {
return; // 기본값이면 변경 불필요
}
}
const current = stateRef.current;
const isDifferent = Object.keys(newValues).some(
(key) => newValues[key] !== current[key]
);
if (isDifferent) {
setLocalState(newValues);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
// URL 즉시 업데이트 (디바운스 없이)
const syncToUrlImmediate = useCallback(
(state: Record<string, string>) => {
isInternalUpdate.current = true;
const params = new URLSearchParams();
for (const field of fieldsRef.current) {
const value = state[field.key];
if (value && value !== field.defaultValue) {
params.set(field.key, value);
}
}
const page = state._page;
if (page && page !== '1') {
params.set('page', page);
}
const query = params.toString();
const url = query ? `${pathname}?${query}` : pathname;
router.replace(url, { scroll: false });
},
[pathname, router]
);
// 로컬 상태 → URL + sessionStorage 동기화 (디바운스)
const syncToUrl = useCallback(
(newState: Record<string, string>) => {
// sessionStorage는 즉시 저장 (디바운스 없이)
saveToStorage(pathname, newState);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
syncToUrlImmediate(newState);
}, debounceMs);
},
[pathname, debounceMs, syncToUrlImmediate]
);
// 값 조회
const getValue = useCallback(
(key: string): string => {
return localState[key] ?? fieldsRef.current.find((f) => f.key === key)?.defaultValue ?? '';
},
[localState]
);
// 값 변경
const setValue = useCallback(
(key: string, value: string) => {
setLocalState((prev) => {
if (prev[key] === value) return prev;
const next = { ...prev, [key]: value };
if (key !== '_page') {
next._page = '1';
}
queueMicrotask(() => syncToUrl(next));
return next;
});
},
[syncToUrl]
);
// 페이지
const currentPage = parseInt(localState._page || '1', 10);
const setPage = useCallback(
(page: number) => {
setValue('_page', String(page));
},
[setValue]
);
// 초기화
const resetAll = useCallback(() => {
const defaults: Record<string, string> = {};
for (const field of fieldsRef.current) {
defaults[field.key] = field.defaultValue;
}
defaults._page = '1';
setLocalState(defaults);
saveToStorage(pathname, defaults);
isInternalUpdate.current = true;
router.replace(pathname, { scroll: false });
}, [pathname, router]);
// 현재 목록 URL (쿼리 포함)
const listUrl = useMemo(() => {
const params = new URLSearchParams();
for (const field of fieldsRef.current) {
const value = localState[field.key];
if (value && value !== field.defaultValue) {
params.set(field.key, value);
}
}
const page = localState._page;
if (page && page !== '1') {
params.set('page', page);
}
const query = params.toString();
return query ? `${pathname}?${query}` : pathname;
}, [localState, pathname]);
// cleanup
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
return {
getValue,
setValue,
currentPage,
setPage,
resetAll,
listUrl,
};
}