DataGrid 컬럼 너비 마우스 드래그 리사이저 + localStorage 영구 저장

- 헤더 우측 6px 영역 드래그 핸들 (col-resize 커서 + hover 강조)
- mousedown→document mousemove/mouseup으로 너비 계산 (최소 40px)
- columnWidths state로 inline width 적용 (Tailwind w-[Xpx]는 fallback)
- gridId prop이 있으면 localStorage에 영구 저장 → 새로고침 후 유지
- 컬럼 순서 드래그(@dnd-kit)와 충돌 안 하도록 핸들 영역 분리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-08 14:53:08 +09:00
parent b7a6816ef2
commit 10e9996249
+87 -4
View File
@@ -99,6 +99,7 @@ function SortableHeaderCell({
col, sortKey, sortDir, onSort,
headerFilterValues, uniqueValues, onToggleFilter, onClearFilter,
frozenLeftClass = "left-0",
widthPx, onResizeStart,
}: {
col: DataGridColumn;
sortKey: string | null;
@@ -109,6 +110,10 @@ function SortableHeaderCell({
onToggleFilter: (colKey: string, value: string) => void;
onClearFilter: (colKey: string) => void;
frozenLeftClass?: string;
/** 사용자 리사이즈로 결정된 현재 너비(px). 없으면 col.width Tailwind 클래스 사용 */
widthPx?: number;
/** 리사이즈 핸들 mousedown 핸들러 */
onResizeStart?: (e: React.MouseEvent, colKey: string, currentWidthPx: number) => void;
}) {
const [filterSearch, setFilterSearch] = useState("");
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
@@ -119,16 +124,23 @@ function SortableHeaderCell({
opacity: isDragging ? 0.5 : 1,
cursor: "grab",
};
if (widthPx != null) {
style.width = widthPx;
style.minWidth = widthPx;
style.maxWidth = widthPx;
}
const isSorted = sortKey === col.key;
const hasFilter = headerFilterValues.size > 0;
const effectiveWidthPx = widthPx ?? parseWidthClass(col.width) ?? 100;
return (
<TableHead
ref={setNodeRef}
style={style}
className={cn(
col.width, col.minWidth, "select-none relative",
widthPx == null && col.width, widthPx == null && col.minWidth,
"select-none relative",
col.frozen && cn("sticky z-20 bg-background", frozenLeftClass),
)}
>
@@ -218,10 +230,28 @@ function SortableHeaderCell({
</Popover>
)}
</div>
{/* 리사이저 핸들 — 우측 가장자리 6px 영역에서 드래그하여 컬럼 너비 조정 */}
{onResizeStart && (
<div
onMouseDown={(e) => onResizeStart(e, col.key, effectiveWidthPx)}
onClick={(e) => e.stopPropagation()}
className="absolute right-0 top-0 h-full w-1.5 cursor-col-resize hover:bg-primary/40 active:bg-primary/60 transition-colors z-10"
aria-label="컬럼 너비 조정"
title="드래그하여 컬럼 너비 조정"
/>
)}
</TableHead>
);
}
// w-[XXXpx] Tailwind 클래스에서 px 정수 추출. 없으면 undefined.
function parseWidthClass(cls?: string): number | undefined {
if (!cls) return undefined;
const m = cls.match(/w-\[(\d+)px\]/);
return m ? Number(m[1]) : undefined;
}
// --- DataGrid ---
export function DataGrid({
@@ -286,6 +316,52 @@ export function DataGrid({
}
}, [gridId]); // eslint-disable-line react-hooks/exhaustive-deps
// 컬럼별 너비(px) — 사용자가 핸들로 드래그하면 갱신. localStorage에 영구 저장(gridId 있을 때).
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
useEffect(() => {
if (!gridId) return;
const saved = localStorage.getItem(`datagrid_col_widths_${gridId}`);
if (saved) {
try { setColumnWidths(JSON.parse(saved)); } catch { /* skip */ }
}
}, [gridId]);
const persistColumnWidths = useCallback((next: Record<string, number>) => {
setColumnWidths(next);
if (gridId) {
try { localStorage.setItem(`datagrid_col_widths_${gridId}`, JSON.stringify(next)); } catch { /* skip */ }
}
}, [gridId]);
// 리사이즈 드래그 핸들러 — 헤더 우측 핸들에서 mousedown 발생 시 호출.
const startResize = useCallback((e: React.MouseEvent, colKey: string, currentWidthPx: number) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startWidth = currentWidthPx;
const onMove = (ev: MouseEvent) => {
const delta = ev.clientX - startX;
const next = Math.max(40, Math.round(startWidth + delta));
setColumnWidths((prev) => ({ ...prev, [colKey]: next }));
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
// 최종 값으로 영구 저장 (state 최신값 직접 읽기 위해 setter 형태로)
setColumnWidths((latest) => {
if (gridId) {
try { localStorage.setItem(`datagrid_col_widths_${gridId}`, JSON.stringify(latest)); } catch { /* skip */ }
}
return latest;
});
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}, [gridId]);
// 컬럼별 고유값 계산 (필터 팝오버용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
@@ -600,6 +676,8 @@ export function DataGrid({
onToggleFilter={toggleHeaderFilter}
onClearFilter={clearHeaderFilter}
frozenLeftClass={frozenLeftClass}
widthPx={columnWidths[col.key]}
onResizeStart={startResize}
/>
))}
</TableRow>
@@ -671,11 +749,15 @@ export function DataGrid({
{pageOffset + rowIdx + 1}
</TableCell>
)}
{columns.map((col) => (
{columns.map((col) => {
const w = columnWidths[col.key];
const inlineStyle = w != null ? { width: w, minWidth: w, maxWidth: w } : undefined;
return (
<TableCell
key={col.key}
style={inlineStyle}
className={cn(
col.width, col.minWidth, "py-2.5",
w == null && col.width, w == null && col.minWidth, "py-2.5",
col.editable && "cursor-text",
isSelected && "bg-accent",
col.frozen && cn("sticky z-[5]", frozenLeftClass, stickyBgClass),
@@ -689,7 +771,8 @@ export function DataGrid({
>
{renderCell(row, col, rowIdx)}
</TableCell>
))}
);
})}
</TableRow>
);})}
</TableBody>