fix(테이블타입): dropdown key 중복 + hook 순서 + 탭바 outline + 좌측 list 폰트 사이즈
Build & Deploy to K8s / build-and-deploy (push) Successful in 6m43s
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:
@@ -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>
|
||||||
|
|||||||
@@ -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;}
|
||||||
|
|||||||
Reference in New Issue
Block a user