380 lines
12 KiB
TypeScript
380 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useMemo, useCallback } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { ChevronRight, ChevronLeft, ChevronsRight, ChevronsLeft, Search } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/**
|
|
* DualListBox 아이템 인터페이스
|
|
*/
|
|
export interface DualListBoxItem {
|
|
id: string;
|
|
label: string;
|
|
description?: string;
|
|
disabled?: boolean;
|
|
[key: string]: any;
|
|
}
|
|
|
|
/**
|
|
* DualListBox 컴포넌트 Props
|
|
*/
|
|
interface DualListBoxProps {
|
|
// 데이터
|
|
availableItems: DualListBoxItem[];
|
|
selectedItems: DualListBoxItem[];
|
|
|
|
// 이벤트
|
|
onSelectionChange: (selectedItems: DualListBoxItem[]) => void;
|
|
|
|
// 라벨
|
|
availableLabel?: string;
|
|
selectedLabel?: string;
|
|
|
|
// 검색 활성화
|
|
enableSearch?: boolean;
|
|
|
|
// 높이 설정
|
|
height?: string;
|
|
|
|
// 비활성화
|
|
disabled?: boolean;
|
|
|
|
// 커스텀 렌더링
|
|
renderItem?: (item: DualListBoxItem) => React.ReactNode;
|
|
|
|
// 스타일
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* Dual List Box 컴포넌트 (좌우 이동 방식)
|
|
*
|
|
* 사용 예시:
|
|
* ```tsx
|
|
* <DualListBox
|
|
* availableItems={allUsers}
|
|
* selectedItems={groupMembers}
|
|
* onSelectionChange={setGroupMembers}
|
|
* availableLabel="전체 사용자"
|
|
* selectedLabel="그룹 멤버"
|
|
* enableSearch
|
|
* />
|
|
* ```
|
|
*/
|
|
export function DualListBox({
|
|
availableItems = [],
|
|
selectedItems = [],
|
|
onSelectionChange,
|
|
availableLabel = "사용 가능한 항목",
|
|
selectedLabel = "선택된 항목",
|
|
enableSearch = true,
|
|
height = "400px",
|
|
disabled = false,
|
|
renderItem,
|
|
className,
|
|
}: DualListBoxProps) {
|
|
// 선택된 아이템 ID 목록 (안전하게 처리)
|
|
const selectedItemIds = useMemo(() => new Set((selectedItems || []).map((item) => item.id)), [selectedItems]);
|
|
|
|
// 사용 가능한 아이템 (선택되지 않은 것들만)
|
|
const available = useMemo(
|
|
() => (availableItems || []).filter((item) => !selectedItemIds.has(item.id)),
|
|
[availableItems, selectedItemIds],
|
|
);
|
|
|
|
// 좌측 체크된 아이템
|
|
const [leftChecked, setLeftChecked] = useState<Set<string>>(new Set());
|
|
|
|
// 우측 체크된 아이템
|
|
const [rightChecked, setRightChecked] = useState<Set<string>>(new Set());
|
|
|
|
// 좌측 검색어
|
|
const [leftSearch, setLeftSearch] = useState("");
|
|
|
|
// 우측 검색어
|
|
const [rightSearch, setRightSearch] = useState("");
|
|
|
|
// 검색 필터링
|
|
const filterItems = useCallback((items: DualListBoxItem[], searchTerm: string) => {
|
|
if (!searchTerm.trim()) return items;
|
|
const term = searchTerm.toLowerCase();
|
|
return items.filter(
|
|
(item) => item.label.toLowerCase().includes(term) || item.description?.toLowerCase().includes(term),
|
|
);
|
|
}, []);
|
|
|
|
const filteredAvailable = useMemo(() => filterItems(available, leftSearch), [available, leftSearch, filterItems]);
|
|
|
|
const filteredSelected = useMemo(
|
|
() => filterItems(selectedItems, rightSearch),
|
|
[selectedItems, rightSearch, filterItems],
|
|
);
|
|
|
|
// 좌측 체크박스 토글
|
|
const handleLeftCheck = useCallback((itemId: string, checked: boolean) => {
|
|
setLeftChecked((prev) => {
|
|
const newSet = new Set(prev);
|
|
if (checked) {
|
|
newSet.add(itemId);
|
|
} else {
|
|
newSet.delete(itemId);
|
|
}
|
|
return newSet;
|
|
});
|
|
}, []);
|
|
|
|
// 우측 체크박스 토글
|
|
const handleRightCheck = useCallback((itemId: string, checked: boolean) => {
|
|
setRightChecked((prev) => {
|
|
const newSet = new Set(prev);
|
|
if (checked) {
|
|
newSet.add(itemId);
|
|
} else {
|
|
newSet.delete(itemId);
|
|
}
|
|
return newSet;
|
|
});
|
|
}, []);
|
|
|
|
// 좌측 전체 선택/해제
|
|
const handleLeftSelectAll = useCallback(() => {
|
|
if (leftChecked.size === filteredAvailable.length) {
|
|
setLeftChecked(new Set());
|
|
} else {
|
|
setLeftChecked(new Set(filteredAvailable.map((item) => item.id)));
|
|
}
|
|
}, [leftChecked.size, filteredAvailable]);
|
|
|
|
// 우측 전체 선택/해제
|
|
const handleRightSelectAll = useCallback(() => {
|
|
if (rightChecked.size === filteredSelected.length) {
|
|
setRightChecked(new Set());
|
|
} else {
|
|
setRightChecked(new Set(filteredSelected.map((item) => item.id)));
|
|
}
|
|
}, [rightChecked.size, filteredSelected]);
|
|
|
|
// 선택된 항목 오른쪽으로 이동
|
|
const moveToRight = useCallback(() => {
|
|
const itemsToMove = available.filter((item) => leftChecked.has(item.id));
|
|
onSelectionChange([...selectedItems, ...itemsToMove]);
|
|
setLeftChecked(new Set());
|
|
}, [available, leftChecked, selectedItems, onSelectionChange]);
|
|
|
|
// 선택된 항목 왼쪽으로 이동
|
|
const moveToLeft = useCallback(() => {
|
|
const newSelected = selectedItems.filter((item) => !rightChecked.has(item.id));
|
|
onSelectionChange(newSelected);
|
|
setRightChecked(new Set());
|
|
}, [selectedItems, rightChecked, onSelectionChange]);
|
|
|
|
// 모든 항목 오른쪽으로 이동
|
|
const moveAllToRight = useCallback(() => {
|
|
onSelectionChange([...selectedItems, ...available]);
|
|
setLeftChecked(new Set());
|
|
}, [available, selectedItems, onSelectionChange]);
|
|
|
|
// 모든 항목 왼쪽으로 이동
|
|
const moveAllToLeft = useCallback(() => {
|
|
onSelectionChange([]);
|
|
setRightChecked(new Set());
|
|
}, [onSelectionChange]);
|
|
|
|
// 기본 아이템 렌더링
|
|
const defaultRenderItem = useCallback((item: DualListBoxItem) => {
|
|
return (
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-medium">{item.label}</span>
|
|
{item.description && <span className="text-muted-foreground text-xs">{item.description}</span>}
|
|
</div>
|
|
);
|
|
}, []);
|
|
|
|
const itemRenderer = renderItem || defaultRenderItem;
|
|
|
|
return (
|
|
<div className={cn("flex gap-4", className)}>
|
|
{/* 좌측 리스트 (사용 가능한 항목) */}
|
|
<div className="flex flex-1 flex-col gap-2">
|
|
<Label className="text-sm font-semibold">{availableLabel}</Label>
|
|
|
|
{/* 검색 */}
|
|
{enableSearch && (
|
|
<div className="relative">
|
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
|
<Input
|
|
placeholder="검색..."
|
|
value={leftSearch}
|
|
onChange={(e) => setLeftSearch(e.target.value)}
|
|
className="h-9 pl-9 text-sm"
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 전체 선택 */}
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="left-select-all"
|
|
checked={filteredAvailable.length > 0 && leftChecked.size === filteredAvailable.length}
|
|
onCheckedChange={handleLeftSelectAll}
|
|
disabled={disabled || filteredAvailable.length === 0}
|
|
/>
|
|
<Label htmlFor="left-select-all" className="text-muted-foreground cursor-pointer text-xs">
|
|
전체 선택 ({filteredAvailable.length}개)
|
|
</Label>
|
|
</div>
|
|
|
|
{/* 아이템 리스트 */}
|
|
<div className="bg-background overflow-y-auto rounded-lg border" style={{ height }}>
|
|
{filteredAvailable.length === 0 ? (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-muted-foreground text-sm">
|
|
{leftSearch ? "검색 결과가 없습니다" : "사용 가능한 항목이 없습니다"}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1 p-2">
|
|
{filteredAvailable.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className={cn(
|
|
"hover:bg-muted/50 flex cursor-pointer items-start gap-2 rounded-md p-2 transition-colors",
|
|
leftChecked.has(item.id) && "bg-muted",
|
|
item.disabled && "cursor-not-allowed opacity-50",
|
|
)}
|
|
onClick={() => !item.disabled && !disabled && handleLeftCheck(item.id, !leftChecked.has(item.id))}
|
|
>
|
|
<Checkbox
|
|
checked={leftChecked.has(item.id)}
|
|
onCheckedChange={(checked) => handleLeftCheck(item.id, checked as boolean)}
|
|
disabled={disabled || item.disabled}
|
|
className="mt-0.5"
|
|
/>
|
|
{itemRenderer(item)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 중앙 버튼 (이동) */}
|
|
<div
|
|
className="flex flex-col items-center justify-center gap-2"
|
|
style={{ marginTop: enableSearch ? "80px" : "48px" }}
|
|
>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={moveAllToRight}
|
|
disabled={disabled || available.length === 0}
|
|
title="모두 오른쪽으로 이동"
|
|
className="h-9 w-9"
|
|
>
|
|
<ChevronsRight className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={moveToRight}
|
|
disabled={disabled || leftChecked.size === 0}
|
|
title="선택된 항목 오른쪽으로 이동"
|
|
className="h-9 w-9"
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={moveToLeft}
|
|
disabled={disabled || rightChecked.size === 0}
|
|
title="선택된 항목 왼쪽으로 이동"
|
|
className="h-9 w-9"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={moveAllToLeft}
|
|
disabled={disabled || selectedItems.length === 0}
|
|
title="모두 왼쪽으로 이동"
|
|
className="h-9 w-9"
|
|
>
|
|
<ChevronsLeft className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 우측 리스트 (선택된 항목) */}
|
|
<div className="flex flex-1 flex-col gap-2">
|
|
<Label className="text-sm font-semibold">{selectedLabel}</Label>
|
|
|
|
{/* 검색 */}
|
|
{enableSearch && (
|
|
<div className="relative">
|
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
|
<Input
|
|
placeholder="검색..."
|
|
value={rightSearch}
|
|
onChange={(e) => setRightSearch(e.target.value)}
|
|
className="h-9 pl-9 text-sm"
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 전체 선택 */}
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="right-select-all"
|
|
checked={filteredSelected.length > 0 && rightChecked.size === filteredSelected.length}
|
|
onCheckedChange={handleRightSelectAll}
|
|
disabled={disabled || filteredSelected.length === 0}
|
|
/>
|
|
<Label htmlFor="right-select-all" className="text-muted-foreground cursor-pointer text-xs">
|
|
전체 선택 ({filteredSelected.length}개)
|
|
</Label>
|
|
</div>
|
|
|
|
{/* 아이템 리스트 */}
|
|
<div className="bg-background overflow-y-auto rounded-lg border" style={{ height }}>
|
|
{filteredSelected.length === 0 ? (
|
|
<div className="flex h-full items-center justify-center">
|
|
<p className="text-muted-foreground text-sm">
|
|
{rightSearch ? "검색 결과가 없습니다" : "선택된 항목이 없습니다"}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1 p-2">
|
|
{filteredSelected.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className={cn(
|
|
"hover:bg-muted/50 flex cursor-pointer items-start gap-2 rounded-md p-2 transition-colors",
|
|
rightChecked.has(item.id) && "bg-muted",
|
|
item.disabled && "cursor-not-allowed opacity-50",
|
|
)}
|
|
onClick={() => !item.disabled && !disabled && handleRightCheck(item.id, !rightChecked.has(item.id))}
|
|
>
|
|
<Checkbox
|
|
checked={rightChecked.has(item.id)}
|
|
onCheckedChange={(checked) => handleRightCheck(item.id, checked as boolean)}
|
|
disabled={disabled || item.disabled}
|
|
className="mt-0.5"
|
|
/>
|
|
{itemRenderer(item)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|