feat(modal-repeater-table): 컬럼 너비 리사이즈 기능 및 엑셀 스타일 UI 개선

- 컬럼 헤더 드래그로 너비 조정 기능 추가 (최소 60px)
- 헤더 더블클릭으로 기본 너비 복원 기능 추가
- 엑셀 스타일 테두리 및 색상 적용 (border-b border-r)
- 테이블 최대 높이 240px → 400px 확장
- 입력 필드 높이 및 포커스 스타일 개선
This commit is contained in:
SeongHyun Kim 2025-12-11 14:05:34 +09:00
parent 9463d8d0b6
commit 190a677067
1 changed files with 152 additions and 75 deletions

View File

@ -36,6 +36,71 @@ export function RepeaterTable({
// 동적 데이터 소스 Popover 열림 상태
const [openPopover, setOpenPopover] = useState<string | null>(null);
// 컬럼 너비 상태 관리
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
const widths: Record<string, number> = {};
columns.forEach((col) => {
widths[col.field] = col.width ? parseInt(col.width) : 120;
});
return widths;
});
// 기본 너비 저장 (리셋용)
const defaultWidths = React.useMemo(() => {
const widths: Record<string, number> = {};
columns.forEach((col) => {
widths[col.field] = col.width ? parseInt(col.width) : 120;
});
return widths;
}, [columns]);
// 리사이즈 상태
const [resizing, setResizing] = useState<{ field: string; startX: number; startWidth: number } | null>(null);
// 리사이즈 핸들러
const handleMouseDown = (e: React.MouseEvent, field: string) => {
e.preventDefault();
setResizing({
field,
startX: e.clientX,
startWidth: columnWidths[field] || 120,
});
};
// 더블클릭으로 기본 너비로 리셋
const handleDoubleClick = (field: string) => {
setColumnWidths((prev) => ({
...prev,
[field]: defaultWidths[field] || 120,
}));
};
useEffect(() => {
if (!resizing) return;
const handleMouseMove = (e: MouseEvent) => {
if (!resizing) return;
const diff = e.clientX - resizing.startX;
const newWidth = Math.max(60, resizing.startWidth + diff);
setColumnWidths((prev) => ({
...prev,
[resizing.field]: newWidth,
}));
};
const handleMouseUp = () => {
setResizing(null);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [resizing, columns, data]);
// 데이터 변경 감지 (필요시 활성화)
// useEffect(() => {
@ -79,7 +144,7 @@ export function RepeaterTable({
onChange={(e) =>
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
}
className="h-7 text-xs"
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
/>
);
@ -107,7 +172,7 @@ export function RepeaterTable({
type="date"
value={formatDateValue(value)}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-7 text-xs"
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
/>
);
@ -119,7 +184,7 @@ export function RepeaterTable({
handleCellEdit(rowIndex, column.field, newValue)
}
>
<SelectTrigger className="h-7 text-xs">
<SelectTrigger className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -138,19 +203,19 @@ export function RepeaterTable({
type="text"
value={value || ""}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-7 text-xs"
className="h-8 text-xs border-gray-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 rounded-none"
/>
);
}
};
return (
<div className="border rounded-md overflow-hidden bg-background">
<div className="overflow-x-auto max-h-[240px] overflow-y-auto">
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted sticky top-0 z-10">
<div className="border border-gray-200 bg-white">
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
<table className="w-full text-xs border-collapse">
<thead className="bg-gray-50 sticky top-0 z-10">
<tr>
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
<th className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 w-12">
#
</th>
{columns.map((col) => {
@ -163,101 +228,113 @@ export function RepeaterTable({
return (
<th
key={col.field}
className="px-4 py-2 text-left font-medium text-muted-foreground"
style={{ width: col.width }}
className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 relative group cursor-pointer select-none"
style={{ width: `${columnWidths[col.field]}px` }}
onDoubleClick={() => handleDoubleClick(col.field)}
title="더블클릭하여 기본 너비로 되돌리기"
>
{hasDynamicSource ? (
<Popover
open={openPopover === col.field}
onOpenChange={(open) => setOpenPopover(open ? col.field : null)}
>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center gap-1 hover:text-primary transition-colors",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded px-1 -mx-1"
)}
<div className="flex items-center justify-between pointer-events-none">
<div className="flex items-center gap-1 pointer-events-auto">
{hasDynamicSource ? (
<Popover
open={openPopover === col.field}
onOpenChange={(open) => setOpenPopover(open ? col.field : null)}
>
<span>{col.label}</span>
<ChevronDown className="h-3 w-3 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-auto min-w-[160px] p-1"
align="start"
sideOffset={4}
>
<div className="text-[10px] text-muted-foreground px-2 py-1 border-b mb-1">
</div>
{col.dynamicDataSource!.options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
onDataSourceChange?.(col.field, option.id);
setOpenPopover(null);
}}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm",
"hover:bg-accent hover:text-accent-foreground transition-colors",
"focus:outline-none focus-visible:bg-accent",
activeOption?.id === option.id && "bg-accent/50"
)}
>
<Check
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"h-3 w-3",
activeOption?.id === option.id ? "opacity-100" : "opacity-0"
)}
/>
<span>{option.label}</span>
</button>
))}
</PopoverContent>
</Popover>
) : (
<>
{col.label}
{col.required && <span className="text-destructive ml-1">*</span>}
</>
)}
</th>
"inline-flex items-center gap-1 hover:text-blue-600 transition-colors",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 rounded px-1 -mx-1"
)}
>
<span>{col.label}</span>
<ChevronDown className="h-3 w-3 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-auto min-w-[160px] p-1"
align="start"
sideOffset={4}
>
<div className="text-[10px] text-muted-foreground px-2 py-1 border-b mb-1">
</div>
{col.dynamicDataSource!.options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
onDataSourceChange?.(col.field, option.id);
setOpenPopover(null);
}}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm",
"hover:bg-accent hover:text-accent-foreground transition-colors",
"focus:outline-none focus-visible:bg-accent",
activeOption?.id === option.id && "bg-accent/50"
)}
>
<Check
className={cn(
"h-3 w-3",
activeOption?.id === option.id ? "opacity-100" : "opacity-0"
)}
/>
<span>{option.label}</span>
</button>
))}
</PopoverContent>
</Popover>
) : (
<>
{col.label}
{col.required && <span className="text-red-500 ml-1">*</span>}
</>
)}
</div>
{/* 리사이즈 핸들 */}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-500 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-auto"
onMouseDown={(e) => handleMouseDown(e, col.field)}
title="드래그하여 너비 조정"
/>
</div>
</th>
);
})}
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
<th className="px-3 py-2 text-left font-medium text-gray-700 border-b border-r border-gray-200 w-20">
</th>
</tr>
</thead>
<tbody className="bg-background">
<tbody className="bg-white">
{data.length === 0 ? (
<tr>
<td
colSpan={columns.length + 2}
className="px-4 py-8 text-center text-muted-foreground"
className="px-4 py-8 text-center text-gray-500 border-b border-gray-200"
>
</td>
</tr>
) : (
data.map((row, rowIndex) => (
<tr key={rowIndex} className="border-t hover:bg-accent/50">
<td className="px-4 py-2 text-center text-muted-foreground">
<tr key={rowIndex} className="hover:bg-blue-50/50 transition-colors">
<td className="px-3 py-1 text-center text-gray-600 border-b border-r border-gray-200">
{rowIndex + 1}
</td>
{columns.map((col) => (
<td key={col.field} className="px-2 py-1">
<td key={col.field} className="px-1 py-1 border-b border-r border-gray-200">
{renderCell(row, col, rowIndex)}
</td>
))}
<td className="px-4 py-2 text-center">
<td className="px-3 py-1 text-center border-b border-r border-gray-200">
<Button
variant="ghost"
size="sm"
onClick={() => onRowDelete(rowIndex)}
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
className="h-7 w-7 p-0 text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>