@uniai-fe/uds-primitives 0.3.47 → 0.3.49
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/dist/styles.css +40 -12
- package/package.json +1 -1
- package/src/components/chip/markup/InputStyle.tsx +2 -1
- package/src/components/dropdown/markup/Template.tsx +75 -68
- package/src/components/dropdown/types/props.ts +21 -0
- package/src/components/select/markup/Default.tsx +56 -45
- package/src/components/select/markup/multiple/Multiple.tsx +113 -95
- package/src/components/table/markup/Container.tsx +42 -2
- package/src/components/table/styles/foundation.scss +71 -24
- package/src/components/table/styles/variables.scss +5 -0
- package/src/components/table/types/foundation.ts +5 -0
- package/src/utils/index.ts +1 -1
- package/src/utils/selected-values.ts +70 -0
|
@@ -11,10 +11,11 @@ import type { SelectDropdownOption } from "../../types/option";
|
|
|
11
11
|
import { SelectMultipleSelectedChip } from "./SelectedChip";
|
|
12
12
|
import { SelectTriggerBase, SelectTriggerSelected } from "../foundation";
|
|
13
13
|
import { useSelectDropdownOpenState } from "../../hooks";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
import {
|
|
15
|
+
isSameSelectedValue,
|
|
16
|
+
isSameSelectedValueList,
|
|
17
|
+
toSelectedValueKey,
|
|
18
|
+
} from "../../../../utils/selected-values";
|
|
18
19
|
// 변경: synthetic "전체" 옵션 id의 기본 키다. 실제 items id와 충돌하면 suffix를 붙여 회피한다.
|
|
19
20
|
const SELECT_MULTIPLE_ALL_OPTION_BASE_ID = "__select_multiple_all__";
|
|
20
21
|
|
|
@@ -31,7 +32,7 @@ const SELECT_MULTIPLE_ALL_OPTION_BASE_ID = "__select_multiple_all__";
|
|
|
31
32
|
* @param {React.ReactNode} [props.placeholder] placeholder 텍스트
|
|
32
33
|
* @param {"primary" | "secondary" | "table"} [props.priority="primary"] priority scale
|
|
33
34
|
* @param {"xsmall" | "small" | "medium" | "large"} [props.size="medium"] size scale
|
|
34
|
-
* @param {"default" | "focused" | "disabled"} [props.state="default"] 시각 상태
|
|
35
|
+
* @param {"default" | "focused" | "error" | "disabled"} [props.state="default"] 시각 상태
|
|
35
36
|
* @param {boolean} [props.block] block 여부
|
|
36
37
|
* @param {FormFieldWidth} [props.width] container width preset
|
|
37
38
|
* @param {boolean} [props.disabled] disabled 여부
|
|
@@ -79,28 +80,35 @@ export function SelectMultipleTrigger<OptionData = unknown>({
|
|
|
79
80
|
priority !== "primary" && size === "xsmall" ? "small" : size;
|
|
80
81
|
|
|
81
82
|
/**
|
|
82
|
-
* 3) option
|
|
83
|
-
* -
|
|
83
|
+
* 3) option 조회/선택 계산 맵 생성
|
|
84
|
+
* - value 기반 선택 비교를 위해 value key -> option 맵을 생성한다.
|
|
84
85
|
* - label 계산, tag 파생, payload selectedOptions 계산에 공통 사용한다.
|
|
85
86
|
*/
|
|
86
87
|
const optionMap = useMemo(
|
|
87
|
-
() =>
|
|
88
|
+
() =>
|
|
89
|
+
new Map(items.map(option => [toSelectedValueKey(option.value), option])),
|
|
88
90
|
[items],
|
|
89
91
|
);
|
|
92
|
+
/**
|
|
93
|
+
* value 배열을 value key 배열로 변환한다.
|
|
94
|
+
*/
|
|
95
|
+
const toValueKeys = (values: Array<string | number>): string[] =>
|
|
96
|
+
values.map(value => toSelectedValueKey(value));
|
|
90
97
|
// 변경: 전체 선택 대상은 disabled option을 제외한 "선택 가능 옵션"으로 한정한다.
|
|
91
98
|
const selectableOptions = useMemo(
|
|
92
99
|
() => items.filter(option => !option.disabled),
|
|
93
100
|
[items],
|
|
94
101
|
);
|
|
95
|
-
// 변경: 전체 선택 토글 계산을 위해 선택 가능 옵션
|
|
96
|
-
const
|
|
97
|
-
() => selectableOptions.map(option => option.
|
|
102
|
+
// 변경: 전체 선택 토글 계산을 위해 선택 가능 옵션 value 배열을 별도로 보관한다.
|
|
103
|
+
const selectableOptionValues = useMemo(
|
|
104
|
+
() => selectableOptions.map(option => option.value),
|
|
98
105
|
[selectableOptions],
|
|
99
106
|
);
|
|
100
|
-
// 변경: 전체 해제 시 빠른 membership 체크를 위해 Set
|
|
101
|
-
const
|
|
102
|
-
() =>
|
|
103
|
-
|
|
107
|
+
// 변경: 전체 해제 시 빠른 membership 체크를 위해 value key Set을 함께 보관한다.
|
|
108
|
+
const selectableOptionValueKeySet = useMemo(
|
|
109
|
+
() =>
|
|
110
|
+
new Set(selectableOptionValues.map(value => toSelectedValueKey(value))),
|
|
111
|
+
[selectableOptionValues],
|
|
104
112
|
);
|
|
105
113
|
const allOptionId = useMemo(() => {
|
|
106
114
|
// 현재 전달된 옵션 id 집합을 먼저 만든다.
|
|
@@ -125,8 +133,8 @@ export function SelectMultipleTrigger<OptionData = unknown>({
|
|
|
125
133
|
* 4) uncontrolled 초기 선택값 계산
|
|
126
134
|
* - items[].selected를 다중 선택 초기값으로 그대로 반영한다.
|
|
127
135
|
*/
|
|
128
|
-
const
|
|
129
|
-
() => items.filter(option => option.selected).map(option => option.
|
|
136
|
+
const selectedValuesFromOptions = useMemo(
|
|
137
|
+
() => items.filter(option => option.selected).map(option => option.value),
|
|
130
138
|
[items],
|
|
131
139
|
);
|
|
132
140
|
|
|
@@ -135,55 +143,51 @@ export function SelectMultipleTrigger<OptionData = unknown>({
|
|
|
135
143
|
* - controlled 모드에서는 source로 사용되지 않는다.
|
|
136
144
|
* - uncontrolled 모드에서는 최종 선택 배열을 내부 state가 소유한다.
|
|
137
145
|
*/
|
|
138
|
-
const [
|
|
139
|
-
|
|
146
|
+
const [uncontrolledSelectedValues, setUncontrolledSelectedValues] = useState<
|
|
147
|
+
Array<string | number>
|
|
148
|
+
>(() => selectedValuesFromOptions);
|
|
140
149
|
|
|
141
150
|
/**
|
|
142
151
|
* 6) items 변경 시 내부 state 정합성 보정
|
|
143
|
-
* -
|
|
144
|
-
* - 남은 id가 없으면 selectedIdsFromOptions(초기 선택 규칙)로 재동기화한다.
|
|
152
|
+
* - source of truth를 items[].selected(value)로 고정해 외부 선택 상태를 즉시 반영한다.
|
|
145
153
|
*/
|
|
146
154
|
useEffect(() => {
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
const filteredIds = previousSelectedIds.filter(selectedId =>
|
|
150
|
-
optionIdSet.has(selectedId),
|
|
151
|
-
);
|
|
152
|
-
const nextSelectedIds =
|
|
153
|
-
filteredIds.length > 0 ? filteredIds : selectedIdsFromOptions;
|
|
155
|
+
setUncontrolledSelectedValues(previousSelectedValues => {
|
|
156
|
+
const nextSelectedValues = selectedValuesFromOptions;
|
|
154
157
|
|
|
155
158
|
// 동일한 선택 결과면 기존 배열 참조를 유지해 재렌더 루프를 방지한다.
|
|
156
|
-
if (
|
|
157
|
-
return
|
|
159
|
+
if (isSameSelectedValueList(previousSelectedValues, nextSelectedValues)) {
|
|
160
|
+
return previousSelectedValues;
|
|
158
161
|
}
|
|
159
162
|
|
|
160
|
-
return
|
|
163
|
+
return nextSelectedValues;
|
|
161
164
|
});
|
|
162
|
-
}, [
|
|
165
|
+
}, [selectedValuesFromOptions]);
|
|
163
166
|
|
|
164
167
|
/**
|
|
165
|
-
* 7) 최종 선택
|
|
168
|
+
* 7) 최종 선택 value 계산
|
|
166
169
|
* - 내부 state 사용
|
|
167
170
|
*/
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
() =>
|
|
177
|
-
|
|
171
|
+
const resolvedSelectedValues = useMemo(
|
|
172
|
+
() =>
|
|
173
|
+
uncontrolledSelectedValues.filter(selectedValue =>
|
|
174
|
+
optionMap.has(toSelectedValueKey(selectedValue)),
|
|
175
|
+
),
|
|
176
|
+
[optionMap, uncontrolledSelectedValues],
|
|
177
|
+
);
|
|
178
|
+
const selectedValueKeySet = useMemo(
|
|
179
|
+
() =>
|
|
180
|
+
new Set(resolvedSelectedValues.map(value => toSelectedValueKey(value))),
|
|
181
|
+
[resolvedSelectedValues],
|
|
178
182
|
);
|
|
179
183
|
const isAllSelectableOptionsSelected = useMemo(
|
|
180
|
-
// 선택 가능 옵션이 1개 이상이고, 그
|
|
184
|
+
// 선택 가능 옵션이 1개 이상이고, 그 value key가 전부 선택되었을 때만 true다.
|
|
181
185
|
() =>
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
186
|
+
selectableOptionValues.length > 0 &&
|
|
187
|
+
selectableOptionValues.every(selectableValue =>
|
|
188
|
+
selectedValueKeySet.has(toSelectedValueKey(selectableValue)),
|
|
185
189
|
),
|
|
186
|
-
[
|
|
190
|
+
[selectableOptionValues, selectedValueKeySet],
|
|
187
191
|
);
|
|
188
192
|
const selectAllOption = useMemo<SelectDropdownOption<OptionData>>(
|
|
189
193
|
// 변경: "전체" 옵션은 렌더링을 위한 synthetic option 객체다.
|
|
@@ -194,9 +198,9 @@ export function SelectMultipleTrigger<OptionData = unknown>({
|
|
|
194
198
|
label: selectAllLabel,
|
|
195
199
|
multiple: true,
|
|
196
200
|
// 선택 가능한 옵션이 하나도 없으면 "전체" 항목도 disabled로 처리한다.
|
|
197
|
-
disabled:
|
|
201
|
+
disabled: selectableOptionValues.length === 0,
|
|
198
202
|
}),
|
|
199
|
-
[allOptionId, selectAllLabel,
|
|
203
|
+
[allOptionId, selectAllLabel, selectableOptionValues.length],
|
|
200
204
|
);
|
|
201
205
|
|
|
202
206
|
/**
|
|
@@ -206,42 +210,38 @@ export function SelectMultipleTrigger<OptionData = unknown>({
|
|
|
206
210
|
*/
|
|
207
211
|
const resolvedDisplayLabel =
|
|
208
212
|
displayLabel ??
|
|
209
|
-
(
|
|
210
|
-
? optionMap.get(
|
|
213
|
+
(resolvedSelectedValues.length > 0
|
|
214
|
+
? optionMap.get(toSelectedValueKey(resolvedSelectedValues[0]))?.label
|
|
211
215
|
: undefined);
|
|
212
216
|
|
|
213
217
|
/**
|
|
214
218
|
* 9) tag 파생 계산
|
|
215
219
|
* - 외부 tags가 주어지면 해당 값을 그대로 사용한다(외부 커스텀 우선).
|
|
216
|
-
* - tags 미지정이면 selected
|
|
220
|
+
* - tags 미지정이면 selected values 기반으로 option label을 자동 tag로 변환한다.
|
|
217
221
|
*/
|
|
218
222
|
const derivedTags = useMemo<SelectMultipleTag[]>(() => {
|
|
219
223
|
if (tags && tags.length > 0) {
|
|
220
224
|
return tags;
|
|
221
225
|
}
|
|
222
226
|
|
|
223
|
-
if (optionMap.size === 0 ||
|
|
227
|
+
if (optionMap.size === 0 || resolvedSelectedValues.length === 0) {
|
|
224
228
|
return [];
|
|
225
229
|
}
|
|
226
230
|
|
|
227
|
-
return
|
|
228
|
-
.map(
|
|
231
|
+
return resolvedSelectedValues
|
|
232
|
+
.map(selectedValue => optionMap.get(toSelectedValueKey(selectedValue)))
|
|
229
233
|
.filter((option): option is NonNullable<typeof option> => Boolean(option))
|
|
230
234
|
.map(option => ({
|
|
231
235
|
label: option.label,
|
|
232
236
|
removable: false,
|
|
233
237
|
}));
|
|
234
|
-
}, [tags,
|
|
238
|
+
}, [tags, resolvedSelectedValues, optionMap]);
|
|
235
239
|
|
|
236
240
|
/**
|
|
237
241
|
* 10) placeholder/label 표시 상태 계산
|
|
238
242
|
* - label 값이 비어 있으면 placeholder 표시로 간주한다.
|
|
239
243
|
*/
|
|
240
244
|
const hasTags = derivedTags.length > 0;
|
|
241
|
-
const hasLabel =
|
|
242
|
-
resolvedDisplayLabel !== undefined &&
|
|
243
|
-
resolvedDisplayLabel !== null &&
|
|
244
|
-
resolvedDisplayLabel !== "";
|
|
245
245
|
|
|
246
246
|
/**
|
|
247
247
|
* 11) dropdown open 상태 관리
|
|
@@ -282,11 +282,6 @@ export function SelectMultipleTrigger<OptionData = unknown>({
|
|
|
282
282
|
const shouldRenderSelectAllOption = Boolean(
|
|
283
283
|
showSelectAllOption && hasOptions,
|
|
284
284
|
);
|
|
285
|
-
const renderedOptions = useMemo(
|
|
286
|
-
// 변경: all-option은 dropdown 첫 행 고정 요구사항에 맞춰 항상 배열 맨 앞에 주입한다.
|
|
287
|
-
() => (shouldRenderSelectAllOption ? [selectAllOption, ...items] : items),
|
|
288
|
-
[items, selectAllOption, shouldRenderSelectAllOption],
|
|
289
|
-
);
|
|
290
285
|
const MAX_VISIBLE_TAGS = 3;
|
|
291
286
|
const visibleTags = hasTags ? derivedTags.slice(0, MAX_VISIBLE_TAGS) : [];
|
|
292
287
|
const overflowCount = hasTags
|
|
@@ -295,50 +290,64 @@ export function SelectMultipleTrigger<OptionData = unknown>({
|
|
|
295
290
|
|
|
296
291
|
/**
|
|
297
292
|
* 15) option 선택 처리(multiple toggle)
|
|
298
|
-
* - 이미 선택된
|
|
293
|
+
* - 이미 선택된 value면 제거, 아니면 추가한다.
|
|
299
294
|
* - onSelectOption/onSelectChange 콜백으로 상호작용과 값 변경을 분리해 전달한다.
|
|
300
295
|
*/
|
|
301
|
-
const handleOptionSelect = (
|
|
296
|
+
const handleOptionSelect = (
|
|
297
|
+
option: SelectDropdownOption<OptionData>,
|
|
298
|
+
event: Event,
|
|
299
|
+
) => {
|
|
302
300
|
// 현재 클릭된 옵션이 synthetic all-option인지 먼저 판별한다.
|
|
303
301
|
const isSelectAllOption =
|
|
304
|
-
shouldRenderSelectAllOption &&
|
|
302
|
+
shouldRenderSelectAllOption && option.id === allOptionId;
|
|
305
303
|
if (isSelectAllOption) {
|
|
306
304
|
// all-option 클릭 분기
|
|
307
305
|
// - 이미 전체 선택 상태면: selectable option들만 제거(전체 해제)
|
|
308
306
|
// - 일부 선택 상태면: selectable option들을 전부 합집합으로 추가(전체 선택)
|
|
309
|
-
const
|
|
310
|
-
?
|
|
311
|
-
|
|
307
|
+
const nextSelectedValues = isAllSelectableOptionsSelected
|
|
308
|
+
? resolvedSelectedValues.filter(
|
|
309
|
+
selectedValue =>
|
|
310
|
+
!selectableOptionValueKeySet.has(
|
|
311
|
+
toSelectedValueKey(selectedValue),
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
: Array.from(
|
|
315
|
+
new Set([
|
|
316
|
+
...toValueKeys(resolvedSelectedValues),
|
|
317
|
+
...toValueKeys(selectableOptionValues),
|
|
318
|
+
]),
|
|
312
319
|
)
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
320
|
+
// 변경: toValuesFromKeys 1회성 헬퍼는 제거하고 선택 시점에서 즉시 복원한다.
|
|
321
|
+
.map(valueKey => optionMap.get(valueKey)?.value)
|
|
322
|
+
.filter((value): value is string | number => value !== undefined);
|
|
323
|
+
const didChange = !isSameSelectedValueList(
|
|
324
|
+
resolvedSelectedValues,
|
|
325
|
+
nextSelectedValues,
|
|
317
326
|
);
|
|
318
327
|
|
|
319
328
|
onSelectOption?.(selectAllOption, undefined, event);
|
|
320
329
|
if (didChange) {
|
|
321
|
-
|
|
330
|
+
setUncontrolledSelectedValues(nextSelectedValues);
|
|
322
331
|
onSelectChange?.(selectAllOption, undefined, event);
|
|
323
332
|
}
|
|
324
333
|
return;
|
|
325
334
|
}
|
|
326
335
|
|
|
327
336
|
// 일반 옵션 클릭 분기
|
|
328
|
-
// 이미 선택된
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
337
|
+
// 이미 선택된 value면 제거, 미선택 value면 추가하는 기본 multiple toggle 로직이다.
|
|
338
|
+
// 변경: 1회성 optionValueKey 상수는 제거하고 판정식에 직접 사용한다.
|
|
339
|
+
const wasSelected = selectedValueKeySet.has(
|
|
340
|
+
toSelectedValueKey(option.value),
|
|
341
|
+
);
|
|
342
|
+
const nextSelectedValues = wasSelected
|
|
343
|
+
? resolvedSelectedValues.filter(
|
|
344
|
+
selectedValue => !isSameSelectedValue(selectedValue, option.value),
|
|
345
|
+
)
|
|
346
|
+
: [...resolvedSelectedValues, option.value];
|
|
338
347
|
|
|
339
|
-
onSelectOption?.(
|
|
340
|
-
|
|
341
|
-
onSelectChange?.(
|
|
348
|
+
onSelectOption?.(option, undefined, event);
|
|
349
|
+
setUncontrolledSelectedValues(nextSelectedValues);
|
|
350
|
+
onSelectChange?.(option, undefined, event);
|
|
342
351
|
};
|
|
343
352
|
|
|
344
353
|
/**
|
|
@@ -398,7 +407,11 @@ export function SelectMultipleTrigger<OptionData = unknown>({
|
|
|
398
407
|
<SelectTriggerSelected
|
|
399
408
|
label={resolvedDisplayLabel}
|
|
400
409
|
placeholder={placeholder}
|
|
401
|
-
isPlaceholder={
|
|
410
|
+
isPlaceholder={
|
|
411
|
+
resolvedDisplayLabel === undefined ||
|
|
412
|
+
resolvedDisplayLabel === null ||
|
|
413
|
+
resolvedDisplayLabel === ""
|
|
414
|
+
}
|
|
402
415
|
// 변경: Multiple의 Selected 뷰는 표시 전용이므로 항상 편집을 차단한다.
|
|
403
416
|
readOnly
|
|
404
417
|
/>
|
|
@@ -414,7 +427,10 @@ export function SelectMultipleTrigger<OptionData = unknown>({
|
|
|
414
427
|
{hasOptions ? (
|
|
415
428
|
<>
|
|
416
429
|
{/* multi select 전용 옵션을 Dropdown.Menu.Item으로 노출한다. */}
|
|
417
|
-
{
|
|
430
|
+
{(shouldRenderSelectAllOption
|
|
431
|
+
? [selectAllOption, ...items]
|
|
432
|
+
: items
|
|
433
|
+
).map(option => (
|
|
418
434
|
<Dropdown.Menu.Item
|
|
419
435
|
key={option.id}
|
|
420
436
|
label={option.label}
|
|
@@ -425,17 +441,19 @@ export function SelectMultipleTrigger<OptionData = unknown>({
|
|
|
425
441
|
multiple
|
|
426
442
|
isSelected={
|
|
427
443
|
// synthetic all-option의 checked 상태는 "전체가 선택되었는가"로 계산한다.
|
|
428
|
-
// 일반 option은 기존
|
|
444
|
+
// 일반 option은 기존 selectedValueKeySet membership으로 계산한다.
|
|
429
445
|
option.id === allOptionId
|
|
430
446
|
? isAllSelectableOptionsSelected
|
|
431
|
-
:
|
|
447
|
+
: selectedValueKeySet.has(
|
|
448
|
+
toSelectedValueKey(option.value),
|
|
449
|
+
)
|
|
432
450
|
}
|
|
433
451
|
onSelect={event => {
|
|
434
452
|
if (option.disabled || isInteractionBlocked) {
|
|
435
453
|
event.preventDefault();
|
|
436
454
|
return;
|
|
437
455
|
}
|
|
438
|
-
handleOptionSelect(option
|
|
456
|
+
handleOptionSelect(option, event);
|
|
439
457
|
}}
|
|
440
458
|
/>
|
|
441
459
|
))}
|
|
@@ -59,6 +59,19 @@ const TableContainer = forwardRef<HTMLTableElement, TableContainerProps>(
|
|
|
59
59
|
// 변경: footer prop 주입 여부를 table data-attr로 노출해 grid radius 규칙 분기에 사용한다.
|
|
60
60
|
const hasFooter = typeof footer !== "undefined";
|
|
61
61
|
|
|
62
|
+
const getStickyLeft = (
|
|
63
|
+
index: number,
|
|
64
|
+
unit: string = "px",
|
|
65
|
+
): number | string => {
|
|
66
|
+
// [80, 100, 100, ..., "auto", ...]
|
|
67
|
+
const widths = resolvedColumns.map(({ width }) =>
|
|
68
|
+
typeof width === "number" ? width : 0,
|
|
69
|
+
);
|
|
70
|
+
const res = widths.slice(0, index).reduce((acc, width) => acc + width, 0);
|
|
71
|
+
|
|
72
|
+
return `${res}${unit}`;
|
|
73
|
+
};
|
|
74
|
+
|
|
62
75
|
const tableNode = (
|
|
63
76
|
<TableRoot
|
|
64
77
|
{...tableProps}
|
|
@@ -90,8 +103,33 @@ const TableContainer = forwardRef<HTMLTableElement, TableContainerProps>(
|
|
|
90
103
|
<TableHead>
|
|
91
104
|
<TableRow>
|
|
92
105
|
{resolvedColumns.map(
|
|
93
|
-
(
|
|
94
|
-
|
|
106
|
+
(
|
|
107
|
+
{ key, dataKey, alignX, alignY, sticky, width, cellContents },
|
|
108
|
+
index,
|
|
109
|
+
) => (
|
|
110
|
+
<TableTh
|
|
111
|
+
key={`${key}/head`}
|
|
112
|
+
data-key={dataKey}
|
|
113
|
+
className={sticky ? "table-cell-sticky" : undefined}
|
|
114
|
+
style={
|
|
115
|
+
sticky
|
|
116
|
+
? ({
|
|
117
|
+
"--table-cell-sticky-left": getStickyLeft(
|
|
118
|
+
index,
|
|
119
|
+
width &&
|
|
120
|
+
typeof width === "string" &&
|
|
121
|
+
!isNaN(Number(width))
|
|
122
|
+
? width.endsWith("rem")
|
|
123
|
+
? "rem"
|
|
124
|
+
: width.endsWith("%")
|
|
125
|
+
? "%"
|
|
126
|
+
: "px"
|
|
127
|
+
: "px",
|
|
128
|
+
),
|
|
129
|
+
} as Record<string, number | string>)
|
|
130
|
+
: undefined
|
|
131
|
+
}
|
|
132
|
+
>
|
|
95
133
|
{/* 변경: header cell 정렬은 alignX/alignY로만 제어한다. */}
|
|
96
134
|
<TableCell section="head" alignX={alignX} alignY={alignY}>
|
|
97
135
|
{/* 변경: key는 렌더 식별자이므로 헤더 노출값 fallback으로 사용하지 않는다. */}
|
|
@@ -117,6 +155,8 @@ const TableContainer = forwardRef<HTMLTableElement, TableContainerProps>(
|
|
|
117
155
|
return (
|
|
118
156
|
<div
|
|
119
157
|
className={clsx("table-scroll-wrapper", scrollClassName)}
|
|
158
|
+
data-layout={tableProps?.layout ?? "line"}
|
|
159
|
+
data-role={tableProps?.role ?? "table"}
|
|
120
160
|
data-scroll-axis={scrollAxis}
|
|
121
161
|
>
|
|
122
162
|
{tableNode}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
@function cellHeight($rowspan, $cell-height) {
|
|
2
|
+
@return calc(#{$rowspan} * #{$cell-height});
|
|
3
|
+
}
|
|
4
|
+
|
|
1
5
|
.table {
|
|
2
6
|
width: 100%;
|
|
3
7
|
border-collapse: collapse;
|
|
@@ -28,13 +32,30 @@
|
|
|
28
32
|
// table cell 내부 field full-bleed 계산을 위해 padding 값을 변수로 노출한다.
|
|
29
33
|
--table-cell-padding-inline: var(--table-line-cell-padding-inline);
|
|
30
34
|
--table-cell-padding-block: var(--table-line-cell-padding-block);
|
|
31
|
-
|
|
35
|
+
|
|
36
|
+
// 변경: rowspan은 숫자 배수 계산이므로 typed attr(number)로 높이를 계산한다.
|
|
37
|
+
height: cellHeight(
|
|
38
|
+
attr(rowspan number, 1),
|
|
39
|
+
var(--table-line-cell-height-head)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
:where(.table-cell-content) {
|
|
43
|
+
border-bottom: 1px solid var(--table-border-color);
|
|
44
|
+
}
|
|
32
45
|
}
|
|
33
46
|
|
|
34
47
|
&:where(.table-td) {
|
|
35
48
|
--table-cell-padding-inline: var(--table-line-cell-padding-inline);
|
|
36
49
|
--table-cell-padding-block: var(--table-line-cell-padding-block);
|
|
37
|
-
|
|
50
|
+
|
|
51
|
+
height: cellHeight(
|
|
52
|
+
attr(rowspan number, 1),
|
|
53
|
+
var(--table-line-cell-height-body)
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
:where(.table-cell-content) {
|
|
57
|
+
border-bottom: 1px solid var(--table-border-color);
|
|
58
|
+
}
|
|
38
59
|
}
|
|
39
60
|
}
|
|
40
61
|
|
|
@@ -42,9 +63,11 @@
|
|
|
42
63
|
:where(.table-native-cell) {
|
|
43
64
|
&:where(.table-th) {
|
|
44
65
|
height: var(--table-line-cell-height-head);
|
|
45
|
-
border-top: 1px solid var(--table-border-color);
|
|
46
66
|
background-color: var(--table-line-head-background-color);
|
|
47
67
|
}
|
|
68
|
+
:where(.table-cell-content) {
|
|
69
|
+
border-top: 1px solid var(--table-border-color);
|
|
70
|
+
}
|
|
48
71
|
}
|
|
49
72
|
}
|
|
50
73
|
|
|
@@ -79,7 +102,9 @@
|
|
|
79
102
|
|
|
80
103
|
// 변경: line(list) 타입에서 rowspan 셀은 기본적으로 우측 경계선을 표시한다.
|
|
81
104
|
:where(.table-native-cell[rowspan]) {
|
|
82
|
-
|
|
105
|
+
:where(.table-cell-content) {
|
|
106
|
+
border-right: 1px solid var(--table-border-color);
|
|
107
|
+
}
|
|
83
108
|
}
|
|
84
109
|
}
|
|
85
110
|
|
|
@@ -96,12 +121,24 @@
|
|
|
96
121
|
--table-cell-padding-inline: var(--table-grid-cell-padding-inline);
|
|
97
122
|
--table-cell-padding-block: var(--table-grid-cell-padding-block);
|
|
98
123
|
height: var(--table-grid-cell-height);
|
|
124
|
+
&[rowspan] {
|
|
125
|
+
height: cellHeight(
|
|
126
|
+
attr(rowspan number, 1),
|
|
127
|
+
var(--table-grid-cell-height)
|
|
128
|
+
);
|
|
129
|
+
}
|
|
99
130
|
}
|
|
100
131
|
|
|
101
132
|
&:where(.table-td) {
|
|
102
133
|
--table-cell-padding-inline: var(--table-grid-cell-padding-inline);
|
|
103
134
|
--table-cell-padding-block: var(--table-grid-cell-padding-block);
|
|
104
135
|
height: var(--table-grid-cell-height);
|
|
136
|
+
&[rowspan] {
|
|
137
|
+
height: cellHeight(
|
|
138
|
+
attr(rowspan number, 1),
|
|
139
|
+
var(--table-grid-cell-height)
|
|
140
|
+
);
|
|
141
|
+
}
|
|
105
142
|
}
|
|
106
143
|
}
|
|
107
144
|
|
|
@@ -253,46 +290,54 @@
|
|
|
253
290
|
}
|
|
254
291
|
|
|
255
292
|
// 변경: 인라인 스타일 대신 각 row의 첫 번째 th만 sticky로 고정한다.
|
|
256
|
-
:where(.table-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
293
|
+
:where(.table-native-cell):where(.table-th) {
|
|
294
|
+
position: sticky;
|
|
295
|
+
top: var(--table-cell-sticky-top);
|
|
296
|
+
left: var(--table-cell-sticky-left);
|
|
297
|
+
right: var(--table-cell-sticky-right);
|
|
298
|
+
bottom: var(--table-cell-sticky-bottom);
|
|
299
|
+
z-index: var(--table-cell-z-index);
|
|
261
300
|
}
|
|
262
301
|
|
|
263
302
|
// 변경: sticky된 첫 번째 th가 td 위에서 안정적으로 보이도록 섹션별 z-index를 50단위로 분리한다.
|
|
264
303
|
:where(.table-head) {
|
|
265
304
|
:where(.table-row) {
|
|
266
|
-
> :where(.table-native-cell
|
|
267
|
-
z-index:
|
|
305
|
+
> :where(.table-native-cell):where(.table-th) {
|
|
306
|
+
--table-cell-z-index: 300;
|
|
307
|
+
&:where(.table-cell-sticky) {
|
|
308
|
+
--table-cell-z-index: 400;
|
|
309
|
+
}
|
|
268
310
|
}
|
|
269
311
|
}
|
|
270
312
|
}
|
|
271
313
|
|
|
272
314
|
:where(.table-body) {
|
|
273
315
|
:where(.table-row) {
|
|
274
|
-
> :where(.table-native-cell
|
|
275
|
-
z-index:
|
|
316
|
+
> :where(.table-native-cell):where(.table-th) {
|
|
317
|
+
--table-cell-z-index: 200;
|
|
318
|
+
&:where(.table-cell-sticky) {
|
|
319
|
+
--table-cell-z-index: 300;
|
|
320
|
+
}
|
|
276
321
|
}
|
|
277
322
|
}
|
|
278
323
|
}
|
|
279
324
|
|
|
280
325
|
:where(.table-foot) {
|
|
281
326
|
:where(.table-row) {
|
|
282
|
-
> :where(.table-native-cell
|
|
283
|
-
z-index:
|
|
327
|
+
> :where(.table-native-cell):where(.table-th) {
|
|
328
|
+
--table-cell-z-index: 200;
|
|
284
329
|
}
|
|
285
330
|
}
|
|
286
331
|
}
|
|
287
332
|
|
|
288
|
-
// 변경: scroll 시 index 0 컬럼의 첫 header th가 항상 최상단에 보이도록 z-index를 상향한다.
|
|
289
|
-
:where(.table-head) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
333
|
+
// // 변경: scroll 시 index 0 컬럼의 첫 header th가 항상 최상단에 보이도록 z-index를 상향한다.
|
|
334
|
+
// :where(.table-head) {
|
|
335
|
+
// :where(.table-row:first-child) {
|
|
336
|
+
// > :where(.table-native-cell:first-child):where(.table-th) {
|
|
337
|
+
// --table-cell-z-index: 300;
|
|
338
|
+
// }
|
|
339
|
+
// }
|
|
340
|
+
// }
|
|
296
341
|
|
|
297
342
|
// 변경: footer(table-foot) 내부 셀 콘텐츠 타이포를 body와 동일한 td 스케일로 고정한다.
|
|
298
343
|
:where(.table-foot .table-cell-content) {
|
|
@@ -313,7 +358,9 @@
|
|
|
313
358
|
// 변경: optional scroll 래퍼를 통해 필요한 화면에서만 스크롤 레이어를 분리한다.
|
|
314
359
|
.table-scroll-wrapper {
|
|
315
360
|
width: 100%;
|
|
316
|
-
|
|
361
|
+
&[data-layout="grid"] {
|
|
362
|
+
border-radius: var(--table-grid-border-radius);
|
|
363
|
+
}
|
|
317
364
|
}
|
|
318
365
|
|
|
319
366
|
.table-scroll-wrapper[data-scroll-axis="x"] {
|
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
--table-grid-row-highlight-background-color: rgba(229, 238, 255, 0.52);
|
|
14
14
|
--table-cell-background-color: var(--color-surface-static-white);
|
|
15
15
|
|
|
16
|
+
--table-cell-z-index: 100;
|
|
17
|
+
--table-cell-sticky-top: 0px;
|
|
18
|
+
--table-cell-sticky-left: auto;
|
|
19
|
+
--table-cell-sticky-right: auto;
|
|
20
|
+
--table-cell-sticky-bottom: auto;
|
|
16
21
|
--table-cell-content-gap: var(--spacing-gap-2);
|
|
17
22
|
|
|
18
23
|
--table-line-cell-height-head: 44px;
|
|
@@ -100,6 +100,7 @@ export interface TableColgroupProps extends ComponentPropsWithoutRef<"colgroup">
|
|
|
100
100
|
* @property {"left" | "center" | "right" | "normal" | "start" | "end" | "flex-start" | "flex-end" | "space-between" | "space-around" | "space-evenly" | "stretch"} [alignX] 헤더 가로 정렬(CSS `justify-content` 값과 매핑)
|
|
101
101
|
* @property {"top" | "center" | "bottom" | "normal" | "stretch" | "start" | "end" | "flex-start" | "flex-end" | "self-start" | "self-end" | "baseline"} [alignY] 헤더 세로 정렬(CSS `align-items` 값과 매핑)
|
|
102
102
|
* @property {boolean} [required] 입력 필드 필수여부
|
|
103
|
+
* @property {boolean} [sticky] 셀 고정 여부
|
|
103
104
|
*/
|
|
104
105
|
export interface TableColumnData<
|
|
105
106
|
RowData extends Record<string, unknown> = Record<string, unknown>,
|
|
@@ -140,6 +141,10 @@ export interface TableColumnData<
|
|
|
140
141
|
* 입력 필드 필수여부
|
|
141
142
|
*/
|
|
142
143
|
required?: boolean;
|
|
144
|
+
/**
|
|
145
|
+
* 셀 고정 여부
|
|
146
|
+
*/
|
|
147
|
+
sticky?: boolean;
|
|
143
148
|
}
|
|
144
149
|
|
|
145
150
|
/**
|
package/src/utils/index.ts
CHANGED