feat(modal-repeater-table): 컬럼 너비 리사이즈 기능 및 엑셀 스타일 UI 개선
- 컬럼 헤더 드래그로 너비 조정 기능 추가 (최소 60px) - 헤더 더블클릭으로 기본 너비 복원 기능 추가 - 엑셀 스타일 테두리 및 색상 적용 (border-b border-r) - 테이블 최대 높이 240px → 400px 확장 - 입력 필드 높이 및 포커스 스타일 개선
This commit is contained in:
parent
9463d8d0b6
commit
190a677067
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue