@conscience-technology/ctds 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/components/ChatInput/ChatInput.module.css +19 -14
- package/assets/components/ChatInput/ChatInput.tsx +9 -9
- package/assets/components/Drawer/Drawer.module.css +1 -3
- package/assets/components/Onboarding/Onboarding.module.css +31 -1
- package/assets/components/Onboarding/OnboardingPage.tsx +124 -46
- package/assets/components/Onboarding/wizardData.ts +47 -12
- package/assets/components/TableHeader/TableHeader.module.css +34 -8
- package/assets/components/TableHeader/TableHeader.tsx +26 -4
- package/assets/components/TableRow/TableRow.module.css +83 -10
- package/assets/components/TableRow/TableRow.tsx +35 -6
- package/assets/components/TableRowEdit/TableRowEdit.module.css +12 -3
- package/assets/components/_data.ts +1 -1
- package/assets/prompts/components/chat-input.md +1 -0
- package/assets/prompts/primitives/scroll.md +69 -0
- package/package.json +1 -1
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
display: grid;
|
|
40
40
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
41
41
|
gap: var(--space-1);
|
|
42
|
-
padding: var(--space-3) var(--space-
|
|
42
|
+
padding: var(--space-3) var(--space-4) var(--space-1);
|
|
43
43
|
}
|
|
44
44
|
/* Thumb은 셀 안에서 자체 폭 유지(좌측 정렬), chip은 셀 폭 채움. */
|
|
45
45
|
.attachmentCell {
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
/* Quotation */
|
|
58
58
|
.quotation {
|
|
59
59
|
display: block;
|
|
60
|
-
padding: var(--space-3) var(--space-
|
|
60
|
+
padding: var(--space-3) var(--space-4) var(--space-1);
|
|
61
61
|
}
|
|
62
62
|
/* attachments 아래에 quotation이 오면 상단 패딩 제거 (attachments bottom이 책임) */
|
|
63
63
|
.attachments + .quotation {
|
|
@@ -88,8 +88,8 @@
|
|
|
88
88
|
- 긴 글: wrapper scroll 발생 → 글이 상·하 padding 영역을 통과해 root border에서 자연스레 잘림. */
|
|
89
89
|
.multiScroll {
|
|
90
90
|
overflow-y: auto;
|
|
91
|
-
max-height: calc(var(--text-
|
|
92
|
-
padding: var(--space-3) var(--space-
|
|
91
|
+
max-height: calc(var(--text-sm) * 1.5 * 8); /* ~8 lines */
|
|
92
|
+
padding: var(--space-3) var(--space-4) 0;
|
|
93
93
|
}
|
|
94
94
|
/* attachments/quotation 아래에 오면 상단 패딩 절반 (이미 attachments/quotation에 bottom padding 있음) */
|
|
95
95
|
.attachments + .body .multiScroll,
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
display: block;
|
|
104
104
|
width: 100%;
|
|
105
105
|
min-width: 0;
|
|
106
|
-
min-height: calc(var(--text-
|
|
106
|
+
min-height: calc(var(--text-sm) * 1.5);
|
|
107
107
|
padding: 0 0 var(--space-3);
|
|
108
108
|
background: transparent;
|
|
109
109
|
border: 0;
|
|
@@ -111,7 +111,7 @@
|
|
|
111
111
|
resize: none;
|
|
112
112
|
color: var(--fg-strong);
|
|
113
113
|
font: inherit;
|
|
114
|
-
font-size: var(--text-
|
|
114
|
+
font-size: var(--text-sm);
|
|
115
115
|
line-height: 1.5;
|
|
116
116
|
overflow: hidden;
|
|
117
117
|
}
|
|
@@ -124,15 +124,15 @@
|
|
|
124
124
|
align-items: center;
|
|
125
125
|
justify-content: space-between;
|
|
126
126
|
gap: var(--space-1);
|
|
127
|
-
padding: 0 var(--space-
|
|
127
|
+
padding: 0 var(--space-2) var(--space-2);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
/* SingleLine */
|
|
131
131
|
.shape-single .body {
|
|
132
132
|
flex-direction: row;
|
|
133
133
|
align-items: center;
|
|
134
|
-
gap: var(--space-
|
|
135
|
-
padding: var(--space-2) var(--space-
|
|
134
|
+
gap: var(--space-0_5);
|
|
135
|
+
padding: var(--space-2) var(--space-2);
|
|
136
136
|
}
|
|
137
137
|
.singleField {
|
|
138
138
|
flex: 1 1 auto;
|
|
@@ -141,14 +141,19 @@
|
|
|
141
141
|
background: transparent;
|
|
142
142
|
border: 0;
|
|
143
143
|
outline: none;
|
|
144
|
+
resize: none;
|
|
145
|
+
overflow: hidden;
|
|
144
146
|
color: var(--fg-strong);
|
|
145
147
|
font: inherit;
|
|
146
|
-
font-size: var(--text-
|
|
148
|
+
font-size: var(--text-sm);
|
|
147
149
|
line-height: 1.5;
|
|
148
150
|
}
|
|
149
151
|
.singleField::placeholder {
|
|
150
152
|
color: var(--fg-muted);
|
|
151
153
|
}
|
|
154
|
+
.singleField::placeholder {
|
|
155
|
+
color: var(--fg-muted);
|
|
156
|
+
}
|
|
152
157
|
|
|
153
158
|
/* Attach 버튼 */
|
|
154
159
|
.attachBtn {
|
|
@@ -179,8 +184,8 @@
|
|
|
179
184
|
opacity: 0.4;
|
|
180
185
|
}
|
|
181
186
|
.attachIcon {
|
|
182
|
-
width:
|
|
183
|
-
height:
|
|
187
|
+
width: 50%;
|
|
188
|
+
height: 50%;
|
|
184
189
|
}
|
|
185
190
|
|
|
186
191
|
/* model + send */
|
|
@@ -265,8 +270,8 @@
|
|
|
265
270
|
cursor: not-allowed;
|
|
266
271
|
}
|
|
267
272
|
.sendIcon {
|
|
268
|
-
width:
|
|
269
|
-
height:
|
|
273
|
+
width: 44%;
|
|
274
|
+
height: 44%;
|
|
270
275
|
}
|
|
271
276
|
|
|
272
277
|
/* 파일 input 숨김 */
|
|
@@ -108,11 +108,10 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
|
|
|
108
108
|
const canSend = !disabled && (value.trim().length > 0 || attachments.length > 0);
|
|
109
109
|
const showStop = isStreaming && !!onStop;
|
|
110
110
|
|
|
111
|
-
/* autoResize
|
|
112
|
-
외부 scroll wrapper가 max-height
|
|
113
|
-
|
|
111
|
+
/* autoResize — multi/single 둘 다. textarea는 content에 맞춰 자람(scroll 아님).
|
|
112
|
+
외부 scroll wrapper(multi)가 max-height 책임. single은 wrapper 없이 textarea가 직접 자람.
|
|
113
|
+
Shift+Enter로 줄바꿈 → textarea가 즉시 자라고, SingleLine도 자연스레 늘어남. */
|
|
114
114
|
useLayoutEffect(() => {
|
|
115
|
-
if (shape !== "multi") return;
|
|
116
115
|
const el = taRef.current;
|
|
117
116
|
if (!el) return;
|
|
118
117
|
el.style.height = "auto";
|
|
@@ -122,8 +121,8 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
|
|
|
122
121
|
const handleKeyDown = useCallback(
|
|
123
122
|
(e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
|
124
123
|
if (e.key !== "Enter") return;
|
|
125
|
-
if (e.shiftKey) return; //
|
|
126
|
-
if (
|
|
124
|
+
if (e.shiftKey) return; // Shift+Enter: multi/single 모두 줄바꿈 허용
|
|
125
|
+
if (e.nativeEvent.isComposing) return; // 한글 조합 중 전송 방지
|
|
127
126
|
e.preventDefault();
|
|
128
127
|
if (showStop) {
|
|
129
128
|
onStop?.();
|
|
@@ -131,7 +130,7 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
|
|
|
131
130
|
onSend();
|
|
132
131
|
}
|
|
133
132
|
},
|
|
134
|
-
[
|
|
133
|
+
[canSend, showStop, onSend, onStop]
|
|
135
134
|
);
|
|
136
135
|
|
|
137
136
|
const handleSendClick = useCallback(() => {
|
|
@@ -277,14 +276,15 @@ export const ChatInput = forwardRef<HTMLDivElement, ChatInputProps>(
|
|
|
277
276
|
{shape === "single" ? (
|
|
278
277
|
<>
|
|
279
278
|
{AttachButton}
|
|
280
|
-
<
|
|
281
|
-
|
|
279
|
+
<textarea
|
|
280
|
+
ref={taRef}
|
|
282
281
|
className={styles.singleField}
|
|
283
282
|
value={value}
|
|
284
283
|
onChange={(e) => onChange(e.target.value)}
|
|
285
284
|
onKeyDown={handleKeyDown}
|
|
286
285
|
placeholder={placeholder}
|
|
287
286
|
disabled={disabled}
|
|
287
|
+
rows={1}
|
|
288
288
|
aria-label={placeholder}
|
|
289
289
|
/>
|
|
290
290
|
{ModelAndSend}
|
|
@@ -33,14 +33,13 @@
|
|
|
33
33
|
will-change: transform, opacity;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/* side별 자리·테두리 —
|
|
36
|
+
/* side별 자리·테두리 — 가장자리에 붙는 패널이라 radius 없음(각진 모서리).
|
|
37
37
|
공통: 세로 full-height. 폭은 size로 결정. */
|
|
38
38
|
.content[data-side="right"] {
|
|
39
39
|
top: 0;
|
|
40
40
|
right: 0;
|
|
41
41
|
bottom: 0;
|
|
42
42
|
border-right: 0;
|
|
43
|
-
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
|
|
44
43
|
animation: drawer-in-right var(--duration-slow) var(--ease-emphasized);
|
|
45
44
|
}
|
|
46
45
|
.content[data-side="right"][data-state="closed"] {
|
|
@@ -52,7 +51,6 @@
|
|
|
52
51
|
left: 0;
|
|
53
52
|
bottom: 0;
|
|
54
53
|
border-left: 0;
|
|
55
|
-
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
|
56
54
|
animation: drawer-in-left var(--duration-slow) var(--ease-emphasized);
|
|
57
55
|
}
|
|
58
56
|
.content[data-side="left"][data-state="closed"] {
|
|
@@ -667,6 +667,21 @@
|
|
|
667
667
|
background: color-mix(in oklab, var(--onb-fg) 8%, transparent);
|
|
668
668
|
border-radius: var(--radius-xs);
|
|
669
669
|
}
|
|
670
|
+
.layoutPreview[data-frame="topbar-first"] {
|
|
671
|
+
flex-direction: column;
|
|
672
|
+
}
|
|
673
|
+
.layoutPreviewBody {
|
|
674
|
+
flex: 1;
|
|
675
|
+
display: flex;
|
|
676
|
+
gap: var(--space-0_5);
|
|
677
|
+
min-height: 0;
|
|
678
|
+
}
|
|
679
|
+
.layoutPreviewTabs {
|
|
680
|
+
height: var(--space-1_5);
|
|
681
|
+
flex-shrink: 0;
|
|
682
|
+
background: color-mix(in oklab, var(--onb-fg) 14%, transparent);
|
|
683
|
+
border-radius: var(--radius-xs);
|
|
684
|
+
}
|
|
670
685
|
|
|
671
686
|
/* 밀도 카드 — row 4개로 간격 비주얼 */
|
|
672
687
|
.densityPreview {
|
|
@@ -1388,7 +1403,22 @@
|
|
|
1388
1403
|
.layoutWireContent {
|
|
1389
1404
|
flex: 1;
|
|
1390
1405
|
background: var(--bg-subtle);
|
|
1391
|
-
border: var(--
|
|
1406
|
+
border-radius: var(--radius-sm);
|
|
1407
|
+
}
|
|
1408
|
+
/* topbar-first: 탑바가 가로 전체, 아래로 사이드바+콘텐츠 */
|
|
1409
|
+
.layoutWire[data-frame="topbar-first"] {
|
|
1410
|
+
flex-direction: column;
|
|
1411
|
+
}
|
|
1412
|
+
.layoutWireBody {
|
|
1413
|
+
flex: 1;
|
|
1414
|
+
display: flex;
|
|
1415
|
+
gap: var(--space-2);
|
|
1416
|
+
min-height: 0;
|
|
1417
|
+
}
|
|
1418
|
+
.layoutWireTabs {
|
|
1419
|
+
height: var(--space-4);
|
|
1420
|
+
flex-shrink: 0;
|
|
1421
|
+
background: color-mix(in oklab, var(--primary) 25%, var(--bg) 75%);
|
|
1392
1422
|
border-radius: var(--radius-sm);
|
|
1393
1423
|
}
|
|
1394
1424
|
|
|
@@ -205,31 +205,44 @@ export function OnboardingPage() {
|
|
|
205
205
|
lang: "ko",
|
|
206
206
|
});
|
|
207
207
|
// 2) 파일 쓰기 (server action)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
208
|
+
// server action이 reject되면(네트워크 끊김·배포환경·내부 throw 등) applying에 멈춰서
|
|
209
|
+
// 무한로딩처럼 보이므로 반드시 try/catch로 감싸서 error phase로 떨어뜨린다.
|
|
210
|
+
try {
|
|
211
|
+
const result = await applyOnboarding(
|
|
212
|
+
{
|
|
213
|
+
theme: answers.theme,
|
|
214
|
+
mode: "light",
|
|
215
|
+
palette: paletteChoice.id,
|
|
216
|
+
hue,
|
|
217
|
+
accentHue: null,
|
|
218
|
+
shape: vibe.values.shape,
|
|
219
|
+
density: density.id,
|
|
220
|
+
motion: vibe.values.motion,
|
|
221
|
+
iconFamily: "phosphor",
|
|
222
|
+
voice,
|
|
223
|
+
lang: "ko",
|
|
224
|
+
focusRing,
|
|
225
|
+
appliedAt: new Date().toISOString(),
|
|
226
|
+
},
|
|
227
|
+
{ overwrite }
|
|
228
|
+
);
|
|
229
|
+
if (result.ok) {
|
|
230
|
+
setApplyState({ phase: "done", files: result.files });
|
|
231
|
+
} else {
|
|
232
|
+
setApplyState({
|
|
233
|
+
phase: "error",
|
|
234
|
+
message: result.error,
|
|
235
|
+
files: result.files,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
} catch (e) {
|
|
229
239
|
setApplyState({
|
|
230
240
|
phase: "error",
|
|
231
|
-
message:
|
|
232
|
-
|
|
241
|
+
message:
|
|
242
|
+
e instanceof Error
|
|
243
|
+
? `적용 중 예외: ${e.message}`
|
|
244
|
+
: `적용 중 알 수 없는 오류: ${String(e)}`,
|
|
245
|
+
files: [],
|
|
233
246
|
});
|
|
234
247
|
}
|
|
235
248
|
};
|
|
@@ -890,6 +903,10 @@ function LayoutStep({
|
|
|
890
903
|
};
|
|
891
904
|
|
|
892
905
|
const hoveredChoice = hovered ? LAYOUT_CHOICES.find((c) => c.id === hovered) : null;
|
|
906
|
+
const hoveredSidebar = hoveredChoice?.sidebar ?? "none";
|
|
907
|
+
const hoveredTopbar = hoveredChoice?.topbar ?? false;
|
|
908
|
+
const hoveredFrameLayout = hoveredChoice?.appFrameLayout ?? "sidebar-first";
|
|
909
|
+
const hoveredTabs = hoveredChoice?.tabs ?? false;
|
|
893
910
|
|
|
894
911
|
// ThemeStep과 동일한 커서 추적 로직.
|
|
895
912
|
const OFFSET_X = 20;
|
|
@@ -932,7 +949,14 @@ function LayoutStep({
|
|
|
932
949
|
style={{ transform: `translate(${left}px, ${top}px)` }}
|
|
933
950
|
aria-hidden
|
|
934
951
|
>
|
|
935
|
-
{hoveredChoice &&
|
|
952
|
+
{hoveredChoice && (
|
|
953
|
+
<LayoutWireframe
|
|
954
|
+
sidebar={hoveredSidebar}
|
|
955
|
+
topbar={hoveredTopbar}
|
|
956
|
+
frameLayout={hoveredFrameLayout}
|
|
957
|
+
tabs={hoveredTabs}
|
|
958
|
+
/>
|
|
959
|
+
)}
|
|
936
960
|
</div>
|
|
937
961
|
</>
|
|
938
962
|
);
|
|
@@ -940,24 +964,48 @@ function LayoutStep({
|
|
|
940
964
|
|
|
941
965
|
/**
|
|
942
966
|
* 레이아웃 호버 미리보기 와이어프레임.
|
|
943
|
-
*
|
|
944
|
-
*
|
|
967
|
+
* frameLayout에 따라 두 가지 구조:
|
|
968
|
+
* - sidebar-first: [좌사이드바(세로 전체) | (탑바 + 콘텐츠 세로)] — Figma/Notion
|
|
969
|
+
* - topbar-first: [탑바(가로 전체); (좌사이드바 | 콘텐츠)] — GitHub/Vercel
|
|
970
|
+
* tabs=true면 탑바 아래 탭 바 한 줄 추가.
|
|
945
971
|
*/
|
|
946
972
|
function LayoutWireframe({
|
|
947
973
|
sidebar,
|
|
948
974
|
topbar,
|
|
975
|
+
frameLayout,
|
|
976
|
+
tabs,
|
|
949
977
|
}: {
|
|
950
978
|
sidebar: "left" | "right" | "dual" | "none";
|
|
951
979
|
topbar: boolean;
|
|
980
|
+
frameLayout: "sidebar-first" | "topbar-first";
|
|
981
|
+
tabs: boolean;
|
|
952
982
|
}) {
|
|
983
|
+
const hasLeft = sidebar === "left" || sidebar === "dual";
|
|
984
|
+
const hasRight = sidebar === "right" || sidebar === "dual";
|
|
985
|
+
|
|
986
|
+
if (topbar && frameLayout === "topbar-first") {
|
|
987
|
+
return (
|
|
988
|
+
<div className={styles.layoutWire} data-frame="topbar-first">
|
|
989
|
+
<div className={styles.layoutWireTopbar} />
|
|
990
|
+
{tabs && <div className={styles.layoutWireTabs} />}
|
|
991
|
+
<div className={styles.layoutWireBody}>
|
|
992
|
+
{hasLeft && <div className={styles.layoutWireSidebar} />}
|
|
993
|
+
<div className={styles.layoutWireContent} />
|
|
994
|
+
{hasRight && <div className={styles.layoutWireSidebar} />}
|
|
995
|
+
</div>
|
|
996
|
+
</div>
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
|
|
953
1000
|
return (
|
|
954
|
-
<div className={styles.layoutWire}>
|
|
955
|
-
{
|
|
1001
|
+
<div className={styles.layoutWire} data-frame="sidebar-first">
|
|
1002
|
+
{hasLeft && <div className={styles.layoutWireSidebar} />}
|
|
956
1003
|
<div className={styles.layoutWireMain}>
|
|
957
1004
|
{topbar && <div className={styles.layoutWireTopbar} />}
|
|
1005
|
+
{tabs && <div className={styles.layoutWireTabs} />}
|
|
958
1006
|
<div className={styles.layoutWireContent} />
|
|
959
1007
|
</div>
|
|
960
|
-
{
|
|
1008
|
+
{hasRight && <div className={styles.layoutWireSidebar} />}
|
|
961
1009
|
</div>
|
|
962
1010
|
);
|
|
963
1011
|
}
|
|
@@ -1064,23 +1112,12 @@ function SummaryStep({ answers }: { answers: Answers }) {
|
|
|
1064
1112
|
value={layout?.title ?? "—"}
|
|
1065
1113
|
preview={
|
|
1066
1114
|
layout && (
|
|
1067
|
-
<
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
{(layout.sidebar === "left" || layout.sidebar === "dual") && (
|
|
1074
|
-
<span className={styles.layoutPreviewRail} />
|
|
1075
|
-
)}
|
|
1076
|
-
<span className={styles.layoutPreviewMain}>
|
|
1077
|
-
{layout.topbar && <span className={styles.layoutPreviewBar} />}
|
|
1078
|
-
<span className={styles.layoutPreviewContent} />
|
|
1079
|
-
</span>
|
|
1080
|
-
{(layout.sidebar === "right" || layout.sidebar === "dual") && (
|
|
1081
|
-
<span className={styles.layoutPreviewRail} />
|
|
1082
|
-
)}
|
|
1083
|
-
</div>
|
|
1115
|
+
<LayoutPreviewMini
|
|
1116
|
+
sidebar={layout.sidebar}
|
|
1117
|
+
topbar={layout.topbar}
|
|
1118
|
+
frameLayout={layout.appFrameLayout ?? "sidebar-first"}
|
|
1119
|
+
tabs={layout.tabs ?? false}
|
|
1120
|
+
/>
|
|
1084
1121
|
)
|
|
1085
1122
|
}
|
|
1086
1123
|
/>
|
|
@@ -1112,6 +1149,47 @@ function SummaryStep({ answers }: { answers: Answers }) {
|
|
|
1112
1149
|
);
|
|
1113
1150
|
}
|
|
1114
1151
|
|
|
1152
|
+
function LayoutPreviewMini({
|
|
1153
|
+
sidebar,
|
|
1154
|
+
topbar,
|
|
1155
|
+
frameLayout,
|
|
1156
|
+
tabs,
|
|
1157
|
+
}: {
|
|
1158
|
+
sidebar: "left" | "right" | "dual" | "none";
|
|
1159
|
+
topbar: boolean;
|
|
1160
|
+
frameLayout: "sidebar-first" | "topbar-first";
|
|
1161
|
+
tabs: boolean;
|
|
1162
|
+
}) {
|
|
1163
|
+
const hasLeft = sidebar === "left" || sidebar === "dual";
|
|
1164
|
+
const hasRight = sidebar === "right" || sidebar === "dual";
|
|
1165
|
+
|
|
1166
|
+
if (topbar && frameLayout === "topbar-first") {
|
|
1167
|
+
return (
|
|
1168
|
+
<div className={styles.layoutPreview} data-frame="topbar-first" aria-hidden>
|
|
1169
|
+
<span className={styles.layoutPreviewBar} />
|
|
1170
|
+
{tabs && <span className={styles.layoutPreviewTabs} />}
|
|
1171
|
+
<span className={styles.layoutPreviewBody}>
|
|
1172
|
+
{hasLeft && <span className={styles.layoutPreviewRail} />}
|
|
1173
|
+
<span className={styles.layoutPreviewContent} />
|
|
1174
|
+
{hasRight && <span className={styles.layoutPreviewRail} />}
|
|
1175
|
+
</span>
|
|
1176
|
+
</div>
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
return (
|
|
1181
|
+
<div className={styles.layoutPreview} data-frame="sidebar-first" aria-hidden>
|
|
1182
|
+
{hasLeft && <span className={styles.layoutPreviewRail} />}
|
|
1183
|
+
<span className={styles.layoutPreviewMain}>
|
|
1184
|
+
{topbar && <span className={styles.layoutPreviewBar} />}
|
|
1185
|
+
{tabs && <span className={styles.layoutPreviewTabs} />}
|
|
1186
|
+
<span className={styles.layoutPreviewContent} />
|
|
1187
|
+
</span>
|
|
1188
|
+
{hasRight && <span className={styles.layoutPreviewRail} />}
|
|
1189
|
+
</div>
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1115
1193
|
function SummaryCard({
|
|
1116
1194
|
label,
|
|
1117
1195
|
value,
|
|
@@ -10,7 +10,12 @@ import type {
|
|
|
10
10
|
export type ThemeId = "none" | "pure" | "spare" | "expressive";
|
|
11
11
|
export type VibeId = "balanced" | "playful" | "serious";
|
|
12
12
|
export type DensityChoiceId = DensityValue;
|
|
13
|
-
export type LayoutId =
|
|
13
|
+
export type LayoutId =
|
|
14
|
+
| "sidebar-only"
|
|
15
|
+
| "topbar-sidebar"
|
|
16
|
+
| "dual-sidebar"
|
|
17
|
+
| "topbar-over-sidebar"
|
|
18
|
+
| "topbar-tabs";
|
|
14
19
|
|
|
15
20
|
// ── 디자인 스타일(테마) ─────────────────────────────────
|
|
16
21
|
// 테마를 고르면 뒤의 축(vibe/density 등) 디폴트가 묶여서 결정됩니다.
|
|
@@ -187,33 +192,63 @@ export type LayoutChoice = {
|
|
|
187
192
|
examples: string;
|
|
188
193
|
sidebar: "left" | "right" | "dual" | "none";
|
|
189
194
|
topbar: boolean;
|
|
195
|
+
/**
|
|
196
|
+
* 탑바가 사이드바와 어떻게 걸치는지. AppFrame의 layout prop과 1:1.
|
|
197
|
+
* - "sidebar-first": 사이드바가 세로 전체, 탑바는 콘텐츠 영역 위에만. Linear/Slack/Figma.
|
|
198
|
+
* - "topbar-first": 탑바가 가로 전체, 사이드바는 그 아래로. GitHub/Vercel/Jira.
|
|
199
|
+
* topbar=false일 때는 의미 없음.
|
|
200
|
+
*/
|
|
201
|
+
appFrameLayout?: "sidebar-first" | "topbar-first";
|
|
202
|
+
/** 탑바 아래에 탭 바가 있는지. GitHub repo 네비처럼 섹션 전환이 탭으로. */
|
|
203
|
+
tabs?: boolean;
|
|
190
204
|
};
|
|
191
205
|
|
|
192
206
|
export const LAYOUT_CHOICES: LayoutChoice[] = [
|
|
193
207
|
{
|
|
194
|
-
id: "
|
|
195
|
-
title: "
|
|
196
|
-
hint: "
|
|
197
|
-
examples: "
|
|
198
|
-
sidebar: "
|
|
208
|
+
id: "topbar-sidebar",
|
|
209
|
+
title: "사이드바가 세로 전체, 탑바는 오른쪽에만",
|
|
210
|
+
hint: "사이드바가 항상 보이는 작업 공간. 섹션 트리가 깊고 자주 오간다.",
|
|
211
|
+
examples: "Notion, Linear, Slack, Figma",
|
|
212
|
+
sidebar: "left",
|
|
199
213
|
topbar: true,
|
|
214
|
+
appFrameLayout: "sidebar-first",
|
|
200
215
|
},
|
|
201
216
|
{
|
|
202
|
-
id: "topbar-sidebar",
|
|
203
|
-
title: "
|
|
204
|
-
hint: "
|
|
205
|
-
examples: "
|
|
217
|
+
id: "topbar-over-sidebar",
|
|
218
|
+
title: "탑바가 가로 전체, 그 아래 사이드바",
|
|
219
|
+
hint: "로고·검색·계정이 전역에 자리. 사이드바는 현재 섹션 안쪽 네비.",
|
|
220
|
+
examples: "GitHub, Vercel, Jira, Jenkins",
|
|
206
221
|
sidebar: "left",
|
|
207
222
|
topbar: true,
|
|
223
|
+
appFrameLayout: "topbar-first",
|
|
208
224
|
},
|
|
209
225
|
{
|
|
210
226
|
id: "sidebar-only",
|
|
211
|
-
title: "
|
|
212
|
-
hint: "
|
|
227
|
+
title: "사이드바만, 탑바 없음",
|
|
228
|
+
hint: "왼쪽에 최소 네비. 상단 바 없이 콘텐츠에 몰입.",
|
|
213
229
|
examples: "Linear (이슈 뷰), Vercel Dashboard, Slack",
|
|
214
230
|
sidebar: "left",
|
|
215
231
|
topbar: false,
|
|
216
232
|
},
|
|
233
|
+
{
|
|
234
|
+
id: "dual-sidebar",
|
|
235
|
+
title: "양쪽 사이드바 + 탑바",
|
|
236
|
+
hint: "왼쪽 트리, 오른쪽 속성 패널. 도구 자체가 UI의 주인공인 편집기.",
|
|
237
|
+
examples: "Figma, Framer, VS Code, Blender",
|
|
238
|
+
sidebar: "dual",
|
|
239
|
+
topbar: true,
|
|
240
|
+
appFrameLayout: "sidebar-first",
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
id: "topbar-tabs",
|
|
244
|
+
title: "탑바 아래 탭으로 섹션 전환",
|
|
245
|
+
hint: "사이드바 없이 상단 탑바 + 탭 한 줄. 섹션 수가 적고 깊이가 얕을 때.",
|
|
246
|
+
examples: "GitHub repo, Stripe Dashboard, Vercel 프로젝트",
|
|
247
|
+
sidebar: "none",
|
|
248
|
+
topbar: true,
|
|
249
|
+
appFrameLayout: "topbar-first",
|
|
250
|
+
tabs: true,
|
|
251
|
+
},
|
|
217
252
|
];
|
|
218
253
|
|
|
219
254
|
/**
|
|
@@ -48,18 +48,21 @@
|
|
|
48
48
|
min-height: var(--control-h-md);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/* line variant
|
|
52
|
-
콘텐츠가 테이블 좌/우 경계에 바짝 붙어 "선 사이의 데이터" 톤 유지.
|
|
53
|
-
plate에선 .cell 기본 padding-inline(--space-2)을 유지. */
|
|
51
|
+
/* line variant 셀 좌우 패딩 — TableRow.line과 짝. 콘텐츠가 셀 경계에서 떨어짐. */
|
|
54
52
|
.line .cell {
|
|
55
|
-
padding-inline:
|
|
53
|
+
padding-inline: var(--space-2);
|
|
56
54
|
}
|
|
57
55
|
|
|
58
|
-
/*
|
|
59
|
-
.line {
|
|
60
|
-
|
|
56
|
+
/* 첫/마지막 셀의 가장자리 padding만 0 — 테이블 좌/우 끝과 콘텐츠 바짝 붙게. */
|
|
57
|
+
.line .cellSlot:first-child .cell {
|
|
58
|
+
padding-inline-start: 0;
|
|
59
|
+
}
|
|
60
|
+
.line .cellSlot:last-child .cell {
|
|
61
|
+
padding-inline-end: 0;
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
/* 셀 간 column-gap 없음 — 셀 자체 padding이 간격 담당. */
|
|
65
|
+
|
|
63
66
|
/* variant: plate ─────────────────────────────────────────────
|
|
64
67
|
pill 형태. 컨테이너 자체에 radius + padding, 셀 hover 배경이 라운드 안쪽에서 발생.
|
|
65
68
|
min-height으로 시각 무게 유지. */
|
|
@@ -89,6 +92,18 @@
|
|
|
89
92
|
background: var(--bg);
|
|
90
93
|
}
|
|
91
94
|
|
|
95
|
+
/* pinned 경계 — hairline으로 "고정 영역" 분리. TableRow와 짝. */
|
|
96
|
+
.cellSlot[data-pinned="left"] {
|
|
97
|
+
border-right: var(--hairline) solid var(--border);
|
|
98
|
+
}
|
|
99
|
+
.cellSlot[data-pinned="right"] {
|
|
100
|
+
border-left: var(--hairline) solid var(--border);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.selectCell {
|
|
104
|
+
border-right: var(--hairline) solid var(--border);
|
|
105
|
+
}
|
|
106
|
+
|
|
92
107
|
/* Cell (columnheader) ─────────────────────────────────────── */
|
|
93
108
|
/* width:100%로 슬롯 폭을 그대로 채워 라벨/tick/handle 정렬이 슬롯 경계에 맞춰 계산됨.
|
|
94
109
|
padding-inline은 hover/drag hit 영역이 콘텐츠에 바짝 붙지 않도록 필수.
|
|
@@ -260,13 +275,24 @@
|
|
|
260
275
|
}
|
|
261
276
|
|
|
262
277
|
/* Select-all cell (leading checkbox) ─────────────────────
|
|
263
|
-
|
|
278
|
+
line variant: 좌측 정렬. plate variant: 가운데 정렬. TableRow와 짝 맞춤.
|
|
279
|
+
position: sticky — TableRow.selectCell과 짝. 좌측 고정으로 가로스크롤에도 유지. */
|
|
264
280
|
.selectCell {
|
|
281
|
+
position: sticky;
|
|
282
|
+
left: 0;
|
|
283
|
+
z-index: var(--z-raise);
|
|
265
284
|
display: inline-flex;
|
|
266
285
|
align-items: center;
|
|
267
286
|
justify-content: flex-start;
|
|
268
287
|
flex-shrink: 0;
|
|
288
|
+
width: var(--iconbtn-h-lg);
|
|
289
|
+
background: var(--bg);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.plate .selectCell {
|
|
293
|
+
justify-content: center;
|
|
269
294
|
width: var(--control-h-sm);
|
|
295
|
+
background: var(--bg-subtle);
|
|
270
296
|
}
|
|
271
297
|
|
|
272
298
|
/* Column manager cell (trailing IconButton) ────────────── */
|
|
@@ -142,6 +142,9 @@ export function pinnedStyle(
|
|
|
142
142
|
orderedKeys: string[],
|
|
143
143
|
columnMap: Map<string, TableHeaderColumn>,
|
|
144
144
|
widths: Record<string, number>,
|
|
145
|
+
/* selectable일 때 selectCell이 좌측에 먼저 sticky로 깔려 있음 — 그 폭만큼
|
|
146
|
+
좌측 pinned column들의 left offset을 밀어줘야 겹치지 않음. */
|
|
147
|
+
leftOffsetBase = 0,
|
|
145
148
|
): CSSProperties {
|
|
146
149
|
if (!column.pinned) return {};
|
|
147
150
|
const resolveWidth = (key: string): number => {
|
|
@@ -153,7 +156,7 @@ export function pinnedStyle(
|
|
|
153
156
|
};
|
|
154
157
|
const idx = orderedKeys.indexOf(column.key);
|
|
155
158
|
if (column.pinned === "left") {
|
|
156
|
-
let offset =
|
|
159
|
+
let offset = leftOffsetBase;
|
|
157
160
|
for (let i = 0; i < idx; i++) {
|
|
158
161
|
const c = columnMap.get(orderedKeys[i]);
|
|
159
162
|
if (c?.pinned === "left") offset += resolveWidth(orderedKeys[i]);
|
|
@@ -391,6 +394,25 @@ const TableHeader = forwardRef<HTMLDivElement, TableHeaderProps>(function TableH
|
|
|
391
394
|
.map((k) => columnMap.get(k))
|
|
392
395
|
.filter((c): c is TableHeaderColumn => !!c);
|
|
393
396
|
|
|
397
|
+
/* selectCell이 렌더되면 그 실제 폭을 측정해 좌측 pinned columns의 left offset에
|
|
398
|
+
더해준다. CSS 토큰(--iconbtn-h-lg / --control-h-sm) + space-scale 반영이 자동으로
|
|
399
|
+
흡수되므로 하드코딩이나 CSS 변수 파싱 없이 처리 가능. */
|
|
400
|
+
const selectCellRef = useRef<HTMLDivElement>(null);
|
|
401
|
+
const [selectCellWidth, setSelectCellWidth] = useState(0);
|
|
402
|
+
useEffect(() => {
|
|
403
|
+
if (!selectable) {
|
|
404
|
+
setSelectCellWidth(0);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const el = selectCellRef.current;
|
|
408
|
+
if (!el) return;
|
|
409
|
+
const update = () => setSelectCellWidth(el.getBoundingClientRect().width);
|
|
410
|
+
update();
|
|
411
|
+
const ro = new ResizeObserver(update);
|
|
412
|
+
ro.observe(el);
|
|
413
|
+
return () => ro.disconnect();
|
|
414
|
+
}, [selectable]);
|
|
415
|
+
|
|
394
416
|
/* Sort ─────────────────────────────────────────────────── */
|
|
395
417
|
const toggleSort = useCallback(
|
|
396
418
|
(key: string) => {
|
|
@@ -494,7 +516,7 @@ const TableHeader = forwardRef<HTMLDivElement, TableHeaderProps>(function TableH
|
|
|
494
516
|
const cells = (
|
|
495
517
|
<>
|
|
496
518
|
{selectable && (
|
|
497
|
-
<div className={styles.selectCell} role="columnheader">
|
|
519
|
+
<div ref={selectCellRef} className={styles.selectCell} role="columnheader">
|
|
498
520
|
<Checkbox
|
|
499
521
|
checked={selectAllChecked}
|
|
500
522
|
onChange={(e) => onSelectAllChange?.(e.currentTarget.checked)}
|
|
@@ -521,7 +543,7 @@ const TableHeader = forwardRef<HTMLDivElement, TableHeaderProps>(function TableH
|
|
|
521
543
|
className={styles.cellSlot}
|
|
522
544
|
style={{
|
|
523
545
|
...cellSlotStyle(col, widths[col.key]),
|
|
524
|
-
...pinnedStyle(col, order, columnMap, widths),
|
|
546
|
+
...pinnedStyle(col, order, columnMap, widths, selectCellWidth),
|
|
525
547
|
}}
|
|
526
548
|
>
|
|
527
549
|
<Cell
|
|
@@ -548,7 +570,7 @@ const TableHeader = forwardRef<HTMLDivElement, TableHeaderProps>(function TableH
|
|
|
548
570
|
className={styles.cellSlot}
|
|
549
571
|
style={{
|
|
550
572
|
...cellSlotStyle(col, widths[col.key]),
|
|
551
|
-
...pinnedStyle(col, order, columnMap, widths),
|
|
573
|
+
...pinnedStyle(col, order, columnMap, widths, selectCellWidth),
|
|
552
574
|
}}
|
|
553
575
|
>
|
|
554
576
|
<Cell
|
|
@@ -50,18 +50,22 @@
|
|
|
50
50
|
border-bottom: none;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/* line variant
|
|
54
|
-
|
|
55
|
-
plate에서는 .cell 기본 padding-inline을 유지해 셀 간 간격·hover 영역 확보. */
|
|
53
|
+
/* line variant 내부 셀 좌우 패딩. 콘텐츠가 셀 경계(세로선 위치)에서 떨어져
|
|
54
|
+
있도록 안쪽에 숨구멍을 둔다. */
|
|
56
55
|
.line .cell {
|
|
57
|
-
padding-inline:
|
|
56
|
+
padding-inline: var(--space-2);
|
|
58
57
|
}
|
|
59
58
|
|
|
60
|
-
/*
|
|
61
|
-
콘텐츠가
|
|
62
|
-
.line {
|
|
63
|
-
|
|
59
|
+
/* 첫 셀의 왼쪽 / 마지막 셀의 오른쪽만 테이블 가장자리에 바짝 붙게 padding 0.
|
|
60
|
+
가장자리 콘텐츠가 shell 경계에서 띄워 보이지 않도록. */
|
|
61
|
+
.line .cellSlot:first-child .cell {
|
|
62
|
+
padding-inline-start: 0;
|
|
64
63
|
}
|
|
64
|
+
.line .cellSlot:last-child .cell {
|
|
65
|
+
padding-inline-end: 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* 셀 간 추가 column-gap 없음 — 셀 자체 좌우 padding이 셀 사이 간격을 모두 담당. */
|
|
65
69
|
|
|
66
70
|
/* ── Variant: plate ────────────────────────────────────────
|
|
67
71
|
라운드 라인. 셀 간격감이 느껴지되 행 자체는 pill처럼 보임.
|
|
@@ -141,6 +145,21 @@ a.root:not(.disabled):focus-visible,
|
|
|
141
145
|
contain: none;
|
|
142
146
|
}
|
|
143
147
|
|
|
148
|
+
/* pinned 경계 — scroll shadow 대신 상시 hairline으로 "여기까지 고정 영역"을 분명히.
|
|
149
|
+
좌측 pinned → 우측 경계에 세로 hairline. 우측 pinned → 좌측 경계. */
|
|
150
|
+
.cellSlot[data-pinned="left"] {
|
|
151
|
+
border-right: var(--hairline) solid var(--border);
|
|
152
|
+
}
|
|
153
|
+
.cellSlot[data-pinned="right"] {
|
|
154
|
+
border-left: var(--hairline) solid var(--border);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/* selectable + pinned 조합일 때 selectCell이 "실질적 좌측 pinned의 선두" 역할이라
|
|
158
|
+
selectCell에도 우측 hairline을 그어 경계 톤 일치. */
|
|
159
|
+
.selectCell {
|
|
160
|
+
border-right: var(--hairline) solid var(--border);
|
|
161
|
+
}
|
|
162
|
+
|
|
144
163
|
/* ── Cell ───────────────────────────────────────────────
|
|
145
164
|
Header의 .cell과 1:1 동일. slot 안쪽 width:100%로 가득 채우는 콘텐츠 그릇.
|
|
146
165
|
display:inline-flex 는 Header 그대로 따라감 — flex vs inline-flex 차이가
|
|
@@ -279,12 +298,66 @@ a.root:not(.disabled):focus-visible,
|
|
|
279
298
|
}
|
|
280
299
|
|
|
281
300
|
/* ── Select cell (leading checkbox) ────────────────────
|
|
282
|
-
|
|
301
|
+
line variant: 좌측 정렬 — 테이블이 경계에 바짝 붙는 톤이라 체크박스도 좌측에 붙임.
|
|
302
|
+
plate variant: 가운데 정렬 — pill 안쪽 좌 padding(space-1)과 체크박스 사이가 너무
|
|
303
|
+
붙어 보이는 걸 방지.
|
|
304
|
+
|
|
305
|
+
position: sticky — 좌측 pinned 컬럼과 짝. 가로스크롤 시에도 체크박스가 좌측에
|
|
306
|
+
유지되도록. 가로스크롤 없으면 sticky는 아무 효과도 안 내므로 모든 테이블에
|
|
307
|
+
안전하게 적용 가능.
|
|
308
|
+
배경(--bg)과 z-index — sticky 상태에서 뒤 셀 콘텐츠가 비치지 않도록. */
|
|
283
309
|
.selectCell {
|
|
284
|
-
position:
|
|
310
|
+
position: sticky;
|
|
311
|
+
left: 0;
|
|
312
|
+
z-index: var(--z-raise);
|
|
285
313
|
display: inline-flex;
|
|
286
314
|
align-items: center;
|
|
287
315
|
justify-content: flex-start;
|
|
288
316
|
flex-shrink: 0;
|
|
317
|
+
width: var(--iconbtn-h-lg);
|
|
318
|
+
background: var(--bg);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/* row hover/active 톤을 selectCell에도 — ::before 오버레이로 깔아 opacity transition
|
|
322
|
+
가능하게. background 직접 변경은 gradient 때문에 CSS가 보간을 못 해서 즉시 교체돼
|
|
323
|
+
row 트랜지션과 안 맞춰짐. opacity transition은 row의 background transition과 동일한
|
|
324
|
+
duration/ease라 자연스레 함께 움직임. */
|
|
325
|
+
.selectCell::before,
|
|
326
|
+
.cellSlot[data-pinned]::before {
|
|
327
|
+
content: "";
|
|
328
|
+
position: absolute;
|
|
329
|
+
inset: 0;
|
|
330
|
+
background: var(--hover);
|
|
331
|
+
opacity: 0;
|
|
332
|
+
pointer-events: none;
|
|
333
|
+
transition: opacity var(--duration-slow) var(--ease-out);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
a.root:not(.disabled):hover .selectCell::before,
|
|
337
|
+
.root[tabindex]:not(.disabled):hover .selectCell::before,
|
|
338
|
+
.active .selectCell::before,
|
|
339
|
+
a.root:not(.disabled):hover .cellSlot[data-pinned]::before,
|
|
340
|
+
.root[tabindex]:not(.disabled):hover .cellSlot[data-pinned]::before,
|
|
341
|
+
.active .cellSlot[data-pinned]::before {
|
|
342
|
+
opacity: 1;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* selectCell 안쪽 콘텐츠(Checkbox)는 ::before 위에 올라가야 함. */
|
|
346
|
+
.selectCell > * {
|
|
347
|
+
position: relative;
|
|
348
|
+
z-index: var(--z-raise);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/* plate: 체크박스 영역엔 bg 깔지 않음 — plate pill 자체가 bg를 가져 selectCell이
|
|
352
|
+
별도 bg면 pill 안에 네모가 뜸. sticky는 유지하되 투명으로. */
|
|
353
|
+
.plate .selectCell {
|
|
354
|
+
justify-content: center;
|
|
289
355
|
width: var(--control-h-sm);
|
|
356
|
+
background: transparent;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
a.plate.root:not(.disabled):hover .selectCell,
|
|
360
|
+
.plate.root[tabindex]:not(.disabled):hover .selectCell,
|
|
361
|
+
.plate.active .selectCell {
|
|
362
|
+
background: transparent;
|
|
290
363
|
}
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
forwardRef,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
5
8
|
type AnchorHTMLAttributes,
|
|
6
9
|
type CSSProperties,
|
|
7
10
|
type HTMLAttributes,
|
|
@@ -11,10 +14,11 @@ import {
|
|
|
11
14
|
} from "react";
|
|
12
15
|
|
|
13
16
|
import { Checkbox } from "../Checkbox";
|
|
14
|
-
import
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
import {
|
|
18
|
+
pinnedStyle,
|
|
19
|
+
type TableHeaderAlign,
|
|
20
|
+
type TableHeaderColumn,
|
|
21
|
+
type TableHeaderVariant,
|
|
18
22
|
} from "../TableHeader";
|
|
19
23
|
import styles from "./TableRow.module.css";
|
|
20
24
|
|
|
@@ -142,6 +146,25 @@ function TableRowInner<T>(
|
|
|
142
146
|
const orderedKeys = resolveOrder(columns, order);
|
|
143
147
|
const columnMap = new Map(columns.map((c) => [c.key, c]));
|
|
144
148
|
|
|
149
|
+
/* selectCell 폭을 측정해 좌측 pinned column의 left offset에 반영.
|
|
150
|
+
Header와 동일한 로직 — selectCell이 sticky left:0이라 pinned column들이 그 뒤에
|
|
151
|
+
위치하려면 폭만큼 밀어야 함. */
|
|
152
|
+
const selectCellRef = useRef<HTMLDivElement>(null);
|
|
153
|
+
const [selectCellWidth, setSelectCellWidth] = useState(0);
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (!selectable) {
|
|
156
|
+
setSelectCellWidth(0);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const el = selectCellRef.current;
|
|
160
|
+
if (!el) return;
|
|
161
|
+
const update = () => setSelectCellWidth(el.getBoundingClientRect().width);
|
|
162
|
+
update();
|
|
163
|
+
const ro = new ResizeObserver(update);
|
|
164
|
+
ro.observe(el);
|
|
165
|
+
return () => ro.disconnect();
|
|
166
|
+
}, [selectable]);
|
|
167
|
+
|
|
145
168
|
const rootClass = [
|
|
146
169
|
styles.root,
|
|
147
170
|
variant === "plate" ? styles.plate : styles.line,
|
|
@@ -162,7 +185,7 @@ function TableRowInner<T>(
|
|
|
162
185
|
const cells = (
|
|
163
186
|
<>
|
|
164
187
|
{selectable && (
|
|
165
|
-
<div className={styles.selectCell} onClick={stop}>
|
|
188
|
+
<div ref={selectCellRef} className={styles.selectCell} onClick={stop}>
|
|
166
189
|
<Checkbox
|
|
167
190
|
checked={selected}
|
|
168
191
|
disabled={disabled}
|
|
@@ -183,10 +206,16 @@ function TableRowInner<T>(
|
|
|
183
206
|
minWidth: toCssSize(col.minWidth) ?? cssWidth,
|
|
184
207
|
flex: col.flex ? "1 1 0" : undefined,
|
|
185
208
|
justifyContent: alignToJustify(col.align),
|
|
209
|
+
...pinnedStyle(col, orderedKeys, columnMap, widths ?? {}, selectCellWidth),
|
|
186
210
|
};
|
|
187
211
|
const content = col.render ? col.render(data) : (data as Record<string, ReactNode>)?.[col.key];
|
|
188
212
|
return (
|
|
189
|
-
<div
|
|
213
|
+
<div
|
|
214
|
+
key={col.key}
|
|
215
|
+
data-pinned={col.pinned || undefined}
|
|
216
|
+
className={styles.cellSlot}
|
|
217
|
+
style={slotStyle}
|
|
218
|
+
>
|
|
190
219
|
<div role="cell" className={styles.cell} data-align={col.align ?? "start"}>
|
|
191
220
|
<span className={styles.cellInner}>{content}</span>
|
|
192
221
|
</div>
|
|
@@ -16,9 +16,8 @@
|
|
|
16
16
|
.edit {
|
|
17
17
|
cursor: default;
|
|
18
18
|
border-bottom: var(--hairline) solid var(--border);
|
|
19
|
-
/*
|
|
20
|
-
|
|
21
|
-
column-gap: var(--space-2);
|
|
19
|
+
/* column-gap 없음 — 셀 자체 padding-inline이 간격 담당. TableHeader/TableRow
|
|
20
|
+
line과 동일 공식으로 통일(gap 쓰면 셀 폭이 오른쪽으로 갈수록 어긋남). */
|
|
22
21
|
}
|
|
23
22
|
|
|
24
23
|
/* ── Size ─────────────────────────────────────────────────
|
|
@@ -42,6 +41,16 @@
|
|
|
42
41
|
-webkit-mask-image: none;
|
|
43
42
|
}
|
|
44
43
|
|
|
44
|
+
/* 첫/마지막 셀 가장자리 padding만 0 — 테이블 좌/우 끝과 콘텐츠 바짝 붙게. */
|
|
45
|
+
.edit > :first-child .editCell,
|
|
46
|
+
.edit > :first-child .readonlyCell {
|
|
47
|
+
padding-inline-start: 0;
|
|
48
|
+
}
|
|
49
|
+
.edit > :last-child .editCell,
|
|
50
|
+
.edit > :last-child .readonlyCell {
|
|
51
|
+
padding-inline-end: 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
45
54
|
/* ── 세로 구분선 ────────────────────────────────────────── */
|
|
46
55
|
.editCell[data-divider]::before,
|
|
47
56
|
.readonlyCell[data-divider]::before {
|
|
@@ -25,6 +25,7 @@ shape 애매하면 `multi`, radius 애매하면 `rounded`. maxRows 애매하면
|
|
|
25
25
|
- [[components/file-icon]] / [[components/attachment-thumb]] / [[components/attachment-chip]] / [[components/quotation-bar]] — 조립 요소
|
|
26
26
|
- [[components/message-bubble]] — 짝 컴포넌트 (후속)
|
|
27
27
|
- [[components/context-menu]] — 모델 선택 메뉴를 소비처에서 조립할 때
|
|
28
|
+
- [[primitives/scroll]] — "Textarea / 단일 콘텐츠 스크롤" 섹션. 긴 글 스크롤 시 글이 root border에서 clip되도록 wrapper padding-top + textarea padding-bottom 패턴 사용. 레이아웃 변경 시 반드시 그 규약 유지.
|
|
28
29
|
|
|
29
30
|
### Props 시그니처
|
|
30
31
|
```tsx
|
|
@@ -73,3 +73,72 @@
|
|
|
73
73
|
padding: var(--space-2);
|
|
74
74
|
}
|
|
75
75
|
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Textarea / 단일 콘텐츠 스크롤
|
|
80
|
+
|
|
81
|
+
리스트가 아니라 **텍스트 하나**가 자라서 스크롤되는 경우(채팅 입력창, 코드 에디터, Markdown preview 등). 리스트 규약과 다른 **특수 패턴** 필요 — 브라우저가 scroll 컨테이너의 `padding-bottom`을 scroll 영역에 포함시키지 않는 알려진 이슈(Chrome/Safari/Firefox 공통) 때문.
|
|
82
|
+
|
|
83
|
+
### 왜 특수한가
|
|
84
|
+
- 리스트는 "items wrapper에 padding"이면 끝 — 각 item 자체가 콘텐츠라 scroll content의 일부.
|
|
85
|
+
- textarea는 자신이 scroll 컨테이너면 **padding-top은 scroll track에 포함**(글이 거기까지 흘러옴)되지만, **padding-bottom은 scroll content 바깥**이 되어 "글이 bottom padding 앞에서 미리 잘리는" 현상이 생김.
|
|
86
|
+
|
|
87
|
+
### 금지 패턴 (실제 ChatInput 작업 중 겪은 삽질)
|
|
88
|
+
|
|
89
|
+
❌ **"root 전체에 padding"** — 글이 root padding 안쪽에서만 보이고 scroll clip이 root 안쪽 border에서 생겨 어색:
|
|
90
|
+
```css
|
|
91
|
+
.chatInput { padding: var(--space-2); }
|
|
92
|
+
.textarea { overflow-y: auto; }
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
❌ **"textarea 자체가 scroll + 상하 padding"** — padding-bottom이 scroll 밖이라 작동 안 함:
|
|
96
|
+
```css
|
|
97
|
+
.textarea { overflow-y: auto; padding: var(--space-1); }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### 정답 패턴 (ChatInput이 쓰는 구조)
|
|
101
|
+
|
|
102
|
+
1. **root는 `padding: 0; overflow: hidden`** — scroll clip이 root border에서 생기도록.
|
|
103
|
+
2. **별도 wrapper div(`.scroll`)에 overflow-y: auto + padding-top만**.
|
|
104
|
+
3. **textarea는 flat, `padding-bottom`만** — 이게 scroll content의 일부가 됨.
|
|
105
|
+
|
|
106
|
+
```css
|
|
107
|
+
.root {
|
|
108
|
+
padding: 0;
|
|
109
|
+
overflow: hidden; /* radius 안쪽에서 scroll clip */
|
|
110
|
+
border-radius: var(--radius-sm);
|
|
111
|
+
border: var(--hairline) solid var(--border);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.scroll {
|
|
115
|
+
overflow-y: auto;
|
|
116
|
+
max-height: calc(var(--text-base) * 1.5 * 8);
|
|
117
|
+
padding: var(--space-3) var(--space-3) 0; /* top만 */
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.textarea {
|
|
121
|
+
display: block;
|
|
122
|
+
width: 100%;
|
|
123
|
+
padding: 0 0 var(--space-3); /* bottom만 */
|
|
124
|
+
background: transparent;
|
|
125
|
+
border: 0;
|
|
126
|
+
outline: none;
|
|
127
|
+
resize: none;
|
|
128
|
+
overflow: hidden; /* 자체 스크롤 금지 */
|
|
129
|
+
font: inherit;
|
|
130
|
+
line-height: 1.5;
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
이 구조에서 짧은 글은 wrapper 안에 스크롤 없음, padding이 "숨 공간"처럼 보임.
|
|
135
|
+
긴 글은:
|
|
136
|
+
- wrapper max-height 초과 → wrapper 스크롤
|
|
137
|
+
- 아래로 스크롤 시 글이 wrapper padding-top을 통과해 **root 상단 border에서 자연스레 clip**
|
|
138
|
+
- 끝까지 스크롤 시 글이 textarea padding-bottom을 통과해 **root 하단 border에서 자연스레 clip**
|
|
139
|
+
|
|
140
|
+
### 함께 적용되는 레이아웃 룰
|
|
141
|
+
|
|
142
|
+
- `root`에 다른 섹션(attachment bar, footer 등)이 함께 있으면 각 섹션이 **자기 상하 padding을 책임**. `root`는 어떤 상·하 패딩도 걸지 않음.
|
|
143
|
+
- textarea JS autoResize는 `textarea.style.height = textarea.scrollHeight` 패턴. `scrollHeight`가 padding 포함이라 그대로 됨.
|
|
144
|
+
|