feat: [common] 검색 X 클리어 버튼 + 검색 상태 sessionStorage 보존
- SearchFilter, IntegratedListTemplateV2: 검색 입력 시 X(클리어) 버튼 표시 - useListSearchState 훅 신규: URL + sessionStorage 이중 저장으로 상세→목록 복귀 시 검색 유지 - UniversalListPage: useListSearchState 연동
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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 관리하는 컴포넌트용)
|
||||
|
||||
@@ -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';
|
||||
|
||||
297
src/hooks/useListSearchState.ts
Normal file
297
src/hooks/useListSearchState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user