@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.
@@ -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
- const isSameIdList = (previousIds: string[], nextIds: string[]) =>
16
- previousIds.length === nextIds.length &&
17
- previousIds.every((selectedId, index) => selectedId === nextIds[index]);
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
- * - selected id -> option 조회를 반복하므로 id 기반 맵을 생성한다.
83
+ * 3) option 조회/선택 계산 맵 생성
84
+ * - value 기반 선택 비교를 위해 value key -> option 맵을 생성한다.
84
85
  * - label 계산, tag 파생, payload selectedOptions 계산에 공통 사용한다.
85
86
  */
86
87
  const optionMap = useMemo(
87
- () => new Map(items.map(option => [option.id, option])),
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
- // 변경: 전체 선택 토글 계산을 위해 선택 가능 옵션 id 배열을 별도로 보관한다.
96
- const selectableOptionIds = useMemo(
97
- () => selectableOptions.map(option => option.id),
102
+ // 변경: 전체 선택 토글 계산을 위해 선택 가능 옵션 value 배열을 별도로 보관한다.
103
+ const selectableOptionValues = useMemo(
104
+ () => selectableOptions.map(option => option.value),
98
105
  [selectableOptions],
99
106
  );
100
- // 변경: 전체 해제 시 빠른 membership 체크를 위해 Set 형태도 함께 보관한다.
101
- const selectableOptionIdSet = useMemo(
102
- () => new Set(selectableOptionIds),
103
- [selectableOptionIds],
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 selectedIdsFromOptions = useMemo(
129
- () => items.filter(option => option.selected).map(option => option.id),
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 [uncontrolledSelectedOptionIds, setUncontrolledSelectedOptionIds] =
139
- useState<string[]>(() => selectedIdsFromOptions);
146
+ const [uncontrolledSelectedValues, setUncontrolledSelectedValues] = useState<
147
+ Array<string | number>
148
+ >(() => selectedValuesFromOptions);
140
149
 
141
150
  /**
142
151
  * 6) items 변경 시 내부 state 정합성 보정
143
- * - 기존 선택 id 현재 items에 남아있는 id만 유지한다.
144
- * - 남은 id가 없으면 selectedIdsFromOptions(초기 선택 규칙)로 재동기화한다.
152
+ * - source of truth를 items[].selected(value)로 고정해 외부 선택 상태를 즉시 반영한다.
145
153
  */
146
154
  useEffect(() => {
147
- setUncontrolledSelectedOptionIds(previousSelectedIds => {
148
- const optionIdSet = new Set(items.map(option => option.id));
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 (isSameIdList(previousSelectedIds, nextSelectedIds)) {
157
- return previousSelectedIds;
159
+ if (isSameSelectedValueList(previousSelectedValues, nextSelectedValues)) {
160
+ return previousSelectedValues;
158
161
  }
159
162
 
160
- return nextSelectedIds;
163
+ return nextSelectedValues;
161
164
  });
162
- }, [items, selectedIdsFromOptions]);
165
+ }, [selectedValuesFromOptions]);
163
166
 
164
167
  /**
165
- * 7) 최종 선택 id 계산
168
+ * 7) 최종 선택 value 계산
166
169
  * - 내부 state 사용
167
170
  */
168
- const resolvedSelectedIds = useMemo(() => {
169
- // 변경: 실제 option id만 유지해 내부 synthetic all-option id가 상태에 저장되지 않도록 차단한다.
170
- // 이 필터 덕분에 최종 선택 상태는 "실 데이터 옵션"만 source of truth로 유지된다.
171
- return uncontrolledSelectedOptionIds.filter(selectedId =>
172
- optionMap.has(selectedId),
173
- );
174
- }, [optionMap, uncontrolledSelectedOptionIds]);
175
- const selectedIdSet = useMemo(
176
- () => new Set(resolvedSelectedIds),
177
- [resolvedSelectedIds],
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개 이상이고, 그 id가 전부 selectedIdSet에 포함될 때만 true다.
184
+ // 선택 가능 옵션이 1개 이상이고, 그 value key가 전부 선택되었을 때만 true다.
181
185
  () =>
182
- selectableOptionIds.length > 0 &&
183
- selectableOptionIds.every(selectableId =>
184
- selectedIdSet.has(selectableId),
186
+ selectableOptionValues.length > 0 &&
187
+ selectableOptionValues.every(selectableValue =>
188
+ selectedValueKeySet.has(toSelectedValueKey(selectableValue)),
185
189
  ),
186
- [selectableOptionIds, selectedIdSet],
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: selectableOptionIds.length === 0,
201
+ disabled: selectableOptionValues.length === 0,
198
202
  }),
199
- [allOptionId, selectAllLabel, selectableOptionIds.length],
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
- (resolvedSelectedIds.length > 0
210
- ? optionMap.get(resolvedSelectedIds[0])?.label
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 ids 기반으로 option label을 자동 tag로 변환한다.
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 || resolvedSelectedIds.length === 0) {
227
+ if (optionMap.size === 0 || resolvedSelectedValues.length === 0) {
224
228
  return [];
225
229
  }
226
230
 
227
- return resolvedSelectedIds
228
- .map(selectedId => optionMap.get(selectedId))
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, resolvedSelectedIds, optionMap]);
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
- * - 이미 선택된 id면 제거, 아니면 추가한다.
293
+ * - 이미 선택된 value면 제거, 아니면 추가한다.
299
294
  * - onSelectOption/onSelectChange 콜백으로 상호작용과 값 변경을 분리해 전달한다.
300
295
  */
301
- const handleOptionSelect = (optionId: string, event: Event) => {
296
+ const handleOptionSelect = (
297
+ option: SelectDropdownOption<OptionData>,
298
+ event: Event,
299
+ ) => {
302
300
  // 현재 클릭된 옵션이 synthetic all-option인지 먼저 판별한다.
303
301
  const isSelectAllOption =
304
- shouldRenderSelectAllOption && optionId === allOptionId;
302
+ shouldRenderSelectAllOption && option.id === allOptionId;
305
303
  if (isSelectAllOption) {
306
304
  // all-option 클릭 분기
307
305
  // - 이미 전체 선택 상태면: selectable option들만 제거(전체 해제)
308
306
  // - 일부 선택 상태면: selectable option들을 전부 합집합으로 추가(전체 선택)
309
- const nextSelectedOptionIds = isAllSelectableOptionsSelected
310
- ? resolvedSelectedIds.filter(
311
- selectedId => !selectableOptionIdSet.has(selectedId),
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
- : Array.from(new Set([...resolvedSelectedIds, ...selectableOptionIds]));
314
- const didChange = !isSameIdList(
315
- resolvedSelectedIds,
316
- nextSelectedOptionIds,
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
- setUncontrolledSelectedOptionIds(nextSelectedOptionIds);
330
+ setUncontrolledSelectedValues(nextSelectedValues);
322
331
  onSelectChange?.(selectAllOption, undefined, event);
323
332
  }
324
333
  return;
325
334
  }
326
335
 
327
336
  // 일반 옵션 클릭 분기
328
- // 이미 선택된 id면 제거, 미선택 id면 추가하는 기본 multiple toggle 로직이다.
329
- const wasSelected = selectedIdSet.has(optionId);
330
- const nextSelectedOptionIds = wasSelected
331
- ? resolvedSelectedIds.filter(selectedId => selectedId !== optionId)
332
- : [...resolvedSelectedIds, optionId];
333
- const currentOption = optionMap.get(optionId);
334
-
335
- if (!currentOption) {
336
- return;
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?.(currentOption, undefined, event);
340
- setUncontrolledSelectedOptionIds(nextSelectedOptionIds);
341
- onSelectChange?.(currentOption, undefined, event);
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={!hasLabel}
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
- {renderedOptions.map(option => (
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은 기존 selectedIdSet membership으로 계산한다.
444
+ // 일반 option은 기존 selectedValueKeySet membership으로 계산한다.
429
445
  option.id === allOptionId
430
446
  ? isAllSelectableOptionsSelected
431
- : selectedIdSet.has(option.id)
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.id, event);
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
- ({ key, dataKey, alignX, alignY, cellContents }) => (
94
- <TableTh key={`${key}/head`} data-key={dataKey}>
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
- border-bottom: 1px solid var(--table-border-color);
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
- border-bottom: 1px solid var(--table-border-color);
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
- border-right: 1px solid var(--table-border-color);
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-row) {
257
- > :where(.table-native-cell:first-child):where(.table-th) {
258
- position: sticky;
259
- left: 0;
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:first-child):where(.table-th) {
267
- z-index: 150;
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:first-child):where(.table-th) {
275
- z-index: 100;
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:first-child):where(.table-th) {
283
- z-index: 50;
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
- :where(.table-row:first-child) {
291
- > :where(.table-native-cell:first-child):where(.table-th) {
292
- z-index: 200;
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
- border-radius: var(--table-grid-border-radius);
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
  /**
@@ -1,4 +1,4 @@
1
1
  /**
2
2
  * shared utils placeholder: 토큰 매핑 및 className 헬퍼를 정의한다.
3
3
  */
4
- export {};
4
+ export * from "./selected-values";