fix(테이블타입): dropdown key 중복 + hook 순서 + 탭바 outline + 좌측 list 폰트 사이즈
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m43s

오늘 시리즈 후속 UX 다듬기 + 회귀 fix:

1) ColumnDetailPanel: dropdown key 중복 방어
   - codeInfoOptions 에 placeholder "none" + 데이터 "none" 중복 시 React 가 'two children
     with the same key, none' 으로 거부 → filter 로 사전 제거.
   - refTableOpts 도 referenceTableOptions/tables 어디서든 중복 들어오면 같은 증상 →
     Set 기반 dedupe.

2) ColumnDetailPanel: hook 순서 위반 수정
   - 기존 'if (!column) return null' 이 useMemo(refTableOpts) 앞에 있어서
     column null/존재 케이스마다 hook 호출 수가 달라짐 (Rules of Hooks 위반).
     overlay 패턴 도입 후 column null 케이스가 자주 들어오면서 드러남.
   - early return 을 모든 hook 뒤로 이동.

3) v5-layout.css 탭바: Chrome 식 outline 스타일
   - 비활성 탭도 각자 outline 보이게 (border:1px solid var(--v5-border))로 카드처럼 분리.
   - 활성 탭은 border + surface-hover 배경 + 위쪽 primary 1px inset 강조선.
   - 위 모서리 rounded, margin-bottom:-1px 로 탭바 하단 border 와 seamless 연결.

4) 좌측 테이블 list 폰트 사이즈 축소
   - 한글명 16px → 13px, 영문명 12px → 10.5px, 행 padding 7px → 6px.
   - 280px 좁은 패널에 맞는 컴팩트 비율로 v5 컨벤션 정렬.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 22:42:45 +09:00
parent 78c5e3e358
commit d306ac2865
3 changed files with 27 additions and 10 deletions
@@ -1458,7 +1458,7 @@ export default function TableManagementPage() {
)} )}
<div <div
className={cn( className={cn(
"group relative flex items-center gap-2 rounded-md px-2.5 py-[7px] transition-colors", "group relative flex items-center gap-2 rounded-md px-2.5 py-1.5 transition-colors",
isActive isActive
? "bg-accent text-foreground" ? "bg-accent text-foreground"
: "text-foreground/80 hover:bg-accent/50", : "text-foreground/80 hover:bg-accent/50",
@@ -1488,13 +1488,13 @@ export default function TableManagementPage() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<span className={cn( <span className={cn(
"truncate text-[16px] leading-tight", "truncate text-[13px] leading-tight",
isActive ? "font-bold" : "font-medium", isActive ? "font-bold" : "font-medium",
)}> )}>
{table.display_name || table.table_name} {table.display_name || table.table_name}
</span> </span>
</div> </div>
<div className="text-muted-foreground truncate font-mono text-[12px] leading-tight tracking-tight"> <div className="text-muted-foreground truncate font-mono text-[10.5px] leading-tight tracking-tight">
{table.table_name} {table.table_name}
</div> </div>
</div> </div>
@@ -75,11 +75,9 @@ export function ColumnDetailPanel({
return n; return n;
}, [column]); }, [column]);
if (!column) return null;
const refTableOpts = useMemo(() => { const refTableOpts = useMemo(() => {
const hasKorean = (s: string) => /[가-힣]/.test(s); const hasKorean = (s: string) => /[가-힣]/.test(s);
const raw = referenceTableOptions.length const rawSource = referenceTableOptions.length
? [...referenceTableOptions] ? [...referenceTableOptions]
: [ : [
{ value: "none", label: "없음" }, { value: "none", label: "없음" },
@@ -92,6 +90,14 @@ export function ColumnDetailPanel({
})), })),
]; ];
// value 기준 dedupe — referenceTableOptions/tables 어디서든 중복 들어오면 React key 충돌
const seen = new Set<string>();
const raw = rawSource.filter((o) => {
if (seen.has(o.value)) return false;
seen.add(o.value);
return true;
});
const noneOpt = raw.find((o) => o.value === "none"); const noneOpt = raw.find((o) => o.value === "none");
const rest = raw.filter((o) => o.value !== "none"); const rest = raw.filter((o) => o.value !== "none");
@@ -106,6 +112,10 @@ export function ColumnDetailPanel({
return noneOpt ? [noneOpt, ...rest] : rest; return noneOpt ? [noneOpt, ...rest] : rest;
}, [referenceTableOptions, tables]); }, [referenceTableOptions, tables]);
// early return 은 반드시 모든 hook 호출 뒤에 (Rules of Hooks).
// overlay 패턴으로 항상 마운트되므로 column null 케이스가 정상적으로 들어옴.
if (!column) return null;
return ( return (
<div className="flex h-full w-full flex-col border-l bg-card"> <div className="flex h-full w-full flex-col border-l bg-card">
{/* 헤더 */} {/* 헤더 */}
@@ -372,7 +382,10 @@ export function ColumnDetailPanel({
<SelectValue placeholder="코드 선택" /> <SelectValue placeholder="코드 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{[{ value: "none", label: "선택 안함" }, ...codeInfoOptions].map((opt) => ( {[
{ value: "none", label: "선택 안함" },
...codeInfoOptions.filter((opt) => opt.value !== "none"),
].map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
{opt.label} {opt.label}
</SelectItem> </SelectItem>
+7 -3
View File
@@ -413,15 +413,19 @@ html:not(.dark) .v5-hdr{
@keyframes v5-bdPulse{0%,100%{box-shadow:0 0 4px var(--v5-primary-glow)}50%{box-shadow:0 0 12px var(--v5-primary-glow)}} @keyframes v5-bdPulse{0%,100%{box-shadow:0 0 4px var(--v5-primary-glow)}50%{box-shadow:0 0 12px var(--v5-primary-glow)}}
/* ===== SOLID TABS ===== */ /* ===== SOLID TABS ===== */
.v5-tabs{height:36px;display:flex;align-items:stretch;padding:0 .5rem;gap:1px;overflow-x:auto; .v5-tabs{height:36px;display:flex;align-items:stretch;padding:4px .5rem 0;gap:2px;overflow-x:auto;
background:var(--v5-surface-solid); background:var(--v5-surface-solid);
border-bottom:1px solid var(--v5-border);position:relative;z-index:15;flex-shrink:0; border-bottom:1px solid var(--v5-border);position:relative;z-index:15;flex-shrink:0;
scrollbar-width:none;-ms-overflow-style:none;} scrollbar-width:none;-ms-overflow-style:none;}
.v5-tabs::-webkit-scrollbar{display:none;} .v5-tabs::-webkit-scrollbar{display:none;}
/* Chrome 식 outline 탭: 비활성도 카드처럼 각각 outline. 활성 탭은 본문과 seamless + primary 강조선 */
.v5-tab{display:flex;align-items:center;gap:.4rem;padding:0 .85rem;font-size:.7rem;font-weight:500; .v5-tab{display:flex;align-items:center;gap:.4rem;padding:0 .85rem;font-size:.7rem;font-weight:500;
color:var(--v5-text-muted);cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap;transition:all .25s;} color:var(--v5-text-muted);cursor:pointer;white-space:nowrap;transition:color .15s,border-color .15s,background .15s;
border:1px solid var(--v5-border);border-radius:8px 8px 0 0;margin-bottom:-1px;}
.v5-tab:hover{color:var(--v5-text-sec);background:var(--v5-surface-hover);} .v5-tab:hover{color:var(--v5-text-sec);background:var(--v5-surface-hover);}
.v5-tab.on{color:var(--v5-primary);font-weight:600;border-bottom-color:var(--v5-primary);background:var(--v5-surface);} .v5-tab.on{color:var(--v5-primary);font-weight:600;
border-color:var(--v5-border);border-bottom-color:var(--v5-surface-hover);
background:var(--v5-surface-hover);box-shadow:0 -1px 0 var(--v5-primary) inset;}
.v5-tab-x{width:14px;height:14px;border-radius:3px;border:none;background:transparent;color:var(--v5-text-muted); .v5-tab-x{width:14px;height:14px;border-radius:3px;border:none;background:transparent;color:var(--v5-text-muted);
font-size:.6rem;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:0;transition:all .15s;} font-size:.6rem;cursor:pointer;display:flex;align-items:center;justify-content:center;opacity:0;transition:all .15s;}
.v5-tab:hover .v5-tab-x{opacity:1;} .v5-tab:hover .v5-tab-x{opacity:1;}