@uniai-fe/uds-primitives 0.3.48 → 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/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
package/dist/styles.css
CHANGED
|
@@ -667,6 +667,11 @@
|
|
|
667
667
|
);
|
|
668
668
|
--table-grid-row-highlight-background-color: rgba(229, 238, 255, 0.52);
|
|
669
669
|
--table-cell-background-color: var(--color-surface-static-white);
|
|
670
|
+
--table-cell-z-index: 100;
|
|
671
|
+
--table-cell-sticky-top: 0px;
|
|
672
|
+
--table-cell-sticky-left: auto;
|
|
673
|
+
--table-cell-sticky-right: auto;
|
|
674
|
+
--table-cell-sticky-bottom: auto;
|
|
670
675
|
--table-cell-content-gap: var(--spacing-gap-2);
|
|
671
676
|
--table-line-cell-height-head: 44px;
|
|
672
677
|
--table-line-cell-height-body: 56px;
|
|
@@ -4465,18 +4470,26 @@ figure.chip {
|
|
|
4465
4470
|
.table[data-layout=line] :where(.table-native-cell):where(.table-th) {
|
|
4466
4471
|
--table-cell-padding-inline: var(--table-line-cell-padding-inline);
|
|
4467
4472
|
--table-cell-padding-block: var(--table-line-cell-padding-block);
|
|
4473
|
+
height: calc(attr(rowspan number, 1) * var(--table-line-cell-height-head));
|
|
4474
|
+
}
|
|
4475
|
+
.table[data-layout=line] :where(.table-native-cell):where(.table-th) :where(.table-cell-content) {
|
|
4468
4476
|
border-bottom: 1px solid var(--table-border-color);
|
|
4469
4477
|
}
|
|
4470
4478
|
.table[data-layout=line] :where(.table-native-cell):where(.table-td) {
|
|
4471
4479
|
--table-cell-padding-inline: var(--table-line-cell-padding-inline);
|
|
4472
4480
|
--table-cell-padding-block: var(--table-line-cell-padding-block);
|
|
4481
|
+
height: calc(attr(rowspan number, 1) * var(--table-line-cell-height-body));
|
|
4482
|
+
}
|
|
4483
|
+
.table[data-layout=line] :where(.table-native-cell):where(.table-td) :where(.table-cell-content) {
|
|
4473
4484
|
border-bottom: 1px solid var(--table-border-color);
|
|
4474
4485
|
}
|
|
4475
4486
|
.table[data-layout=line] :where(.table-head) :where(.table-native-cell):where(.table-th) {
|
|
4476
4487
|
height: var(--table-line-cell-height-head);
|
|
4477
|
-
border-top: 1px solid var(--table-border-color);
|
|
4478
4488
|
background-color: var(--table-line-head-background-color);
|
|
4479
4489
|
}
|
|
4490
|
+
.table[data-layout=line] :where(.table-head) :where(.table-native-cell) :where(.table-cell-content) {
|
|
4491
|
+
border-top: 1px solid var(--table-border-color);
|
|
4492
|
+
}
|
|
4480
4493
|
.table[data-layout=line] :where(.table-body) :where(.table-native-cell):where(.table-td) {
|
|
4481
4494
|
height: var(--table-line-cell-height-body);
|
|
4482
4495
|
}
|
|
@@ -4489,7 +4502,7 @@ figure.chip {
|
|
|
4489
4502
|
.table[data-layout=line] :where(.table-foot) :where(.table-native-cell):where(.table-td) {
|
|
4490
4503
|
height: var(--table-line-cell-height-body);
|
|
4491
4504
|
}
|
|
4492
|
-
.table[data-layout=line] :where(.table-native-cell[rowspan]) {
|
|
4505
|
+
.table[data-layout=line] :where(.table-native-cell[rowspan]) :where(.table-cell-content) {
|
|
4493
4506
|
border-right: 1px solid var(--table-border-color);
|
|
4494
4507
|
}
|
|
4495
4508
|
.table[data-layout=grid] {
|
|
@@ -4502,11 +4515,17 @@ figure.chip {
|
|
|
4502
4515
|
--table-cell-padding-block: var(--table-grid-cell-padding-block);
|
|
4503
4516
|
height: var(--table-grid-cell-height);
|
|
4504
4517
|
}
|
|
4518
|
+
.table[data-layout=grid] :where(.table-native-cell):where(.table-th)[rowspan] {
|
|
4519
|
+
height: calc(attr(rowspan number, 1) * var(--table-grid-cell-height));
|
|
4520
|
+
}
|
|
4505
4521
|
.table[data-layout=grid] :where(.table-native-cell):where(.table-td) {
|
|
4506
4522
|
--table-cell-padding-inline: var(--table-grid-cell-padding-inline);
|
|
4507
4523
|
--table-cell-padding-block: var(--table-grid-cell-padding-block);
|
|
4508
4524
|
height: var(--table-grid-cell-height);
|
|
4509
4525
|
}
|
|
4526
|
+
.table[data-layout=grid] :where(.table-native-cell):where(.table-td)[rowspan] {
|
|
4527
|
+
height: calc(attr(rowspan number, 1) * var(--table-grid-cell-height));
|
|
4528
|
+
}
|
|
4510
4529
|
.table[data-layout=grid] :where(.table-head) :where(.table-native-cell):where(.table-th) {
|
|
4511
4530
|
background-color: var(--table-grid-head-background-color);
|
|
4512
4531
|
}
|
|
@@ -4574,21 +4593,28 @@ figure.chip {
|
|
|
4574
4593
|
.table[data-layout=grid]:not([data-has-footer=true]) :where(.table-body:last-of-type) :where(.table-row:last-child) > :where(.table-native-cell:last-child) :where(.table-cell-content) {
|
|
4575
4594
|
border-bottom-right-radius: inherit;
|
|
4576
4595
|
}
|
|
4577
|
-
.table :where(.table-
|
|
4596
|
+
.table :where(.table-native-cell):where(.table-th) {
|
|
4578
4597
|
position: sticky;
|
|
4579
|
-
|
|
4598
|
+
top: var(--table-cell-sticky-top);
|
|
4599
|
+
left: var(--table-cell-sticky-left);
|
|
4600
|
+
right: var(--table-cell-sticky-right);
|
|
4601
|
+
bottom: var(--table-cell-sticky-bottom);
|
|
4602
|
+
z-index: var(--table-cell-z-index);
|
|
4580
4603
|
}
|
|
4581
|
-
.table :where(.table-head) :where(.table-row) > :where(.table-native-cell
|
|
4582
|
-
z-index:
|
|
4604
|
+
.table :where(.table-head) :where(.table-row) > :where(.table-native-cell):where(.table-th) {
|
|
4605
|
+
--table-cell-z-index: 300;
|
|
4583
4606
|
}
|
|
4584
|
-
.table :where(.table-
|
|
4585
|
-
z-index:
|
|
4607
|
+
.table :where(.table-head) :where(.table-row) > :where(.table-native-cell):where(.table-th):where(.table-cell-sticky) {
|
|
4608
|
+
--table-cell-z-index: 400;
|
|
4586
4609
|
}
|
|
4587
|
-
.table :where(.table-
|
|
4588
|
-
z-index:
|
|
4610
|
+
.table :where(.table-body) :where(.table-row) > :where(.table-native-cell):where(.table-th) {
|
|
4611
|
+
--table-cell-z-index: 200;
|
|
4612
|
+
}
|
|
4613
|
+
.table :where(.table-body) :where(.table-row) > :where(.table-native-cell):where(.table-th):where(.table-cell-sticky) {
|
|
4614
|
+
--table-cell-z-index: 300;
|
|
4589
4615
|
}
|
|
4590
|
-
.table :where(.table-
|
|
4591
|
-
z-index: 200;
|
|
4616
|
+
.table :where(.table-foot) :where(.table-row) > :where(.table-native-cell):where(.table-th) {
|
|
4617
|
+
--table-cell-z-index: 200;
|
|
4592
4618
|
}
|
|
4593
4619
|
.table :where(.table-foot .table-cell-content) {
|
|
4594
4620
|
color: var(--table-td-text-color);
|
|
@@ -4605,6 +4631,8 @@ figure.chip {
|
|
|
4605
4631
|
|
|
4606
4632
|
.table-scroll-wrapper {
|
|
4607
4633
|
width: 100%;
|
|
4634
|
+
}
|
|
4635
|
+
.table-scroll-wrapper[data-layout=grid] {
|
|
4608
4636
|
border-radius: var(--table-grid-border-radius);
|
|
4609
4637
|
}
|
|
4610
4638
|
|
package/package.json
CHANGED
|
@@ -2,28 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useMemo, useState } from "react";
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
DropdownTemplateItem,
|
|
7
|
+
DropdownTemplateProps,
|
|
8
|
+
DropdownTemplateValue,
|
|
9
|
+
} from "../types/props";
|
|
6
10
|
|
|
7
11
|
import DropdownRoot from "./foundation/Root";
|
|
8
12
|
import DropdownContainer from "./foundation/Container";
|
|
9
13
|
import DropdownMenuItem from "./foundation/MenuItem";
|
|
10
14
|
import DropdownMenuList from "./foundation/MenuList";
|
|
11
15
|
import DropdownTrigger from "./foundation/Trigger";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
selectedIds: string[],
|
|
19
|
-
multiple: boolean,
|
|
20
|
-
) => {
|
|
21
|
-
if (multiple) {
|
|
22
|
-
return selectedIds;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
return selectedIds.length > 0 ? [selectedIds[0]] : [];
|
|
26
|
-
};
|
|
16
|
+
import {
|
|
17
|
+
isSameSelectedValue,
|
|
18
|
+
isSameSelectedValueList,
|
|
19
|
+
normalizeSelectedValuesByMode,
|
|
20
|
+
toSelectedValueKey,
|
|
21
|
+
} from "../../../utils/selected-values";
|
|
27
22
|
|
|
28
23
|
/**
|
|
29
24
|
* Dropdown reference template; trigger/panel/menu 조합을 제공한다.
|
|
@@ -50,90 +45,91 @@ const DropdownTemplate = ({
|
|
|
50
45
|
menuListProps,
|
|
51
46
|
alt,
|
|
52
47
|
}: DropdownTemplateProps) => {
|
|
48
|
+
/**
|
|
49
|
+
* item value 해석 함수
|
|
50
|
+
* - value가 없으면 id를 fallback value로 사용한다.
|
|
51
|
+
*/
|
|
52
|
+
const getItemValue = (item: DropdownTemplateItem): DropdownTemplateValue =>
|
|
53
|
+
item.value ?? item.id;
|
|
54
|
+
|
|
53
55
|
/**
|
|
54
56
|
* 1) 선택 정책(single/multiple) 결정
|
|
55
57
|
* - item 중 하나라도 multiple=true이면 multiple 정책으로 해석한다.
|
|
56
|
-
* - multiple 정책에서는 item
|
|
57
|
-
* - single 정책에서는 언제나 최대 1개
|
|
58
|
+
* - multiple 정책에서는 item value 배열 전체를 유지한다.
|
|
59
|
+
* - single 정책에서는 언제나 최대 1개 value만 유지한다.
|
|
58
60
|
*/
|
|
59
61
|
const isMultiple = items.some(item => item.multiple);
|
|
60
62
|
|
|
61
63
|
/**
|
|
62
64
|
* 2) 초기 선택값 계산
|
|
63
65
|
* - source of truth: items[].selected
|
|
64
|
-
* - multiple: selected=true인 모든
|
|
65
|
-
* - single: selected=true가 여러 개여도 첫 번째
|
|
66
|
+
* - multiple: selected=true인 모든 value를 초기값으로 사용한다.
|
|
67
|
+
* - single: selected=true가 여러 개여도 첫 번째 value 하나만 사용한다.
|
|
66
68
|
* - useMemo로 계산해 동일 inputs에서 불필요한 재계산을 줄인다.
|
|
67
69
|
*/
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
+
const selectedValuesFromItems = useMemo(() => {
|
|
71
|
+
const initialSelectedValues = items
|
|
70
72
|
.filter(item => item.selected)
|
|
71
|
-
.map(item => item
|
|
73
|
+
.map(item => getItemValue(item));
|
|
72
74
|
|
|
73
|
-
return
|
|
75
|
+
return normalizeSelectedValuesByMode(initialSelectedValues, isMultiple);
|
|
74
76
|
}, [isMultiple, items]);
|
|
75
77
|
|
|
76
78
|
/**
|
|
77
79
|
* 3) 내부 선택 state 선언
|
|
78
80
|
* - Template은 items[].selected를 초기값으로 받아 내부 상태를 관리한다.
|
|
79
|
-
* - 초기값은
|
|
81
|
+
* - 초기값은 selectedValuesFromItems를 그대로 사용한다.
|
|
80
82
|
*/
|
|
81
|
-
const [
|
|
82
|
-
|
|
83
|
-
>(() =>
|
|
83
|
+
const [uncontrolledSelectedValues, setUncontrolledSelectedValues] = useState<
|
|
84
|
+
DropdownTemplateValue[]
|
|
85
|
+
>(() => selectedValuesFromItems);
|
|
84
86
|
|
|
85
87
|
/**
|
|
86
88
|
* 4) options(items) 변경 동기화
|
|
87
|
-
* -
|
|
88
|
-
*
|
|
89
|
-
* 2) 유효 id가 남아 있으면 그대로 유지한다(single은 1개만 유지).
|
|
90
|
-
* 3) 유효 id가 없으면 selectedIdsFromItems(초기 선택 규칙)로 재초기화한다.
|
|
91
|
-
* - 이 effect는 외부 데이터 재조회/필터 변경 시 stale id를 자동 정리하는 역할을 한다.
|
|
89
|
+
* - source of truth를 items[].selected로 고정해 외부 선택 상태를 즉시 반영한다.
|
|
90
|
+
* - single/multiple 정책은 normalizeSelectedValuesByMode로 통일 적용한다.
|
|
92
91
|
*/
|
|
93
92
|
useEffect(() => {
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
itemIdSet.has(selectedId),
|
|
98
|
-
);
|
|
99
|
-
const nextCandidateIds =
|
|
100
|
-
filteredIds.length > 0 ? filteredIds : selectedIdsFromItems;
|
|
101
|
-
const nextSelectedIds = normalizeSelectedIdsByMode(
|
|
102
|
-
nextCandidateIds,
|
|
93
|
+
setUncontrolledSelectedValues(previousSelectedValues => {
|
|
94
|
+
const nextSelectedValues = normalizeSelectedValuesByMode(
|
|
95
|
+
selectedValuesFromItems,
|
|
103
96
|
isMultiple,
|
|
104
97
|
);
|
|
105
98
|
|
|
106
99
|
// 내용이 동일하면 기존 참조를 재사용해 불필요한 상태 갱신 루프를 차단한다.
|
|
107
|
-
if (
|
|
108
|
-
return
|
|
100
|
+
if (isSameSelectedValueList(previousSelectedValues, nextSelectedValues)) {
|
|
101
|
+
return previousSelectedValues;
|
|
109
102
|
}
|
|
110
103
|
|
|
111
|
-
return
|
|
104
|
+
return nextSelectedValues;
|
|
112
105
|
});
|
|
113
|
-
}, [isMultiple,
|
|
106
|
+
}, [isMultiple, selectedValuesFromItems]);
|
|
114
107
|
|
|
115
108
|
/**
|
|
116
|
-
* 5) 최종 선택
|
|
117
|
-
* - 내부 state(
|
|
109
|
+
* 5) 최종 선택 value 계산
|
|
110
|
+
* - 내부 state(uncontrolledSelectedValues)를 single/multiple 정책으로 정규화한다.
|
|
118
111
|
* - 렌더/이벤트/selected 스타일 계산은 이 값만 참조한다.
|
|
119
112
|
*/
|
|
120
|
-
const
|
|
121
|
-
|
|
113
|
+
const resolvedSelectedValues = normalizeSelectedValuesByMode(
|
|
114
|
+
uncontrolledSelectedValues,
|
|
122
115
|
isMultiple,
|
|
123
116
|
);
|
|
117
|
+
const selectedValueKeySet = useMemo(
|
|
118
|
+
() =>
|
|
119
|
+
new Set(resolvedSelectedValues.map(value => toSelectedValueKey(value))),
|
|
120
|
+
[resolvedSelectedValues],
|
|
121
|
+
);
|
|
124
122
|
|
|
125
123
|
/**
|
|
126
124
|
* 6) empty panel 분기
|
|
127
125
|
* - item이 비어 있으면 alt 또는 기본 문구를 disabled item으로 렌더링한다.
|
|
128
126
|
* - 별도 li를 만들지 않고 MenuItem을 재사용해 구조를 통일한다.
|
|
129
127
|
*/
|
|
130
|
-
const hasItems = items.length > 0;
|
|
131
|
-
|
|
132
128
|
/**
|
|
133
129
|
* 7) item 선택 처리
|
|
134
|
-
* - 클릭된 item(
|
|
130
|
+
* - 클릭된 item(value) 기준으로 다음 선택 배열을 계산한다.
|
|
135
131
|
* - multiple: 토글(add/remove)
|
|
136
|
-
* - single: 클릭한
|
|
132
|
+
* - single: 클릭한 value 하나로 교체
|
|
137
133
|
* - 내부 state를 즉시 반영한다.
|
|
138
134
|
* - onChange(payload)로 "현재 결과 전체"를 전달한다.
|
|
139
135
|
*/
|
|
@@ -150,26 +146,32 @@ const DropdownTemplate = ({
|
|
|
150
146
|
}
|
|
151
147
|
|
|
152
148
|
/**
|
|
153
|
-
* 7-2) 다음
|
|
149
|
+
* 7-2) 다음 selectedValues 계산
|
|
154
150
|
* - wasSelected: 토글 기준 값
|
|
155
|
-
* -
|
|
151
|
+
* - nextSelectedValues: 모드 정책을 적용한 다음 상태
|
|
156
152
|
* - nextSelectedItems: payload 전달용 상세 데이터
|
|
157
153
|
*/
|
|
158
|
-
const
|
|
159
|
-
const
|
|
154
|
+
const currentValue = getItemValue(currentItem);
|
|
155
|
+
const wasSelected = selectedValueKeySet.has(
|
|
156
|
+
toSelectedValueKey(currentValue),
|
|
157
|
+
);
|
|
158
|
+
const nextSelectedValues = isMultiple
|
|
160
159
|
? wasSelected
|
|
161
|
-
?
|
|
162
|
-
|
|
163
|
-
|
|
160
|
+
? resolvedSelectedValues.filter(
|
|
161
|
+
selectedValue => !isSameSelectedValue(selectedValue, currentValue),
|
|
162
|
+
)
|
|
163
|
+
: [...resolvedSelectedValues, currentValue]
|
|
164
|
+
: [currentValue];
|
|
164
165
|
const nextSelectedItems = items.filter(item =>
|
|
165
|
-
|
|
166
|
+
nextSelectedValues.some(selectedValue =>
|
|
167
|
+
isSameSelectedValue(selectedValue, getItemValue(item)),
|
|
168
|
+
),
|
|
166
169
|
);
|
|
167
|
-
|
|
168
170
|
/**
|
|
169
171
|
* 7-3) 내부 state 업데이트
|
|
170
172
|
* - Template 내부 선택 상태를 즉시 반영한다.
|
|
171
173
|
*/
|
|
172
|
-
|
|
174
|
+
setUncontrolledSelectedValues(nextSelectedValues);
|
|
173
175
|
|
|
174
176
|
/**
|
|
175
177
|
* 7-4) 선택 결과 이벤트(onChange)
|
|
@@ -178,8 +180,11 @@ const DropdownTemplate = ({
|
|
|
178
180
|
*/
|
|
179
181
|
onChange?.({
|
|
180
182
|
currentItem,
|
|
183
|
+
currentValue,
|
|
181
184
|
isSelected: isMultiple ? !wasSelected : true,
|
|
182
|
-
|
|
185
|
+
// 변경: 1회성 중간 상수는 제거하고 payload 직전에서 ids를 인라인 계산한다.
|
|
186
|
+
selectedIds: nextSelectedItems.map(item => item.id),
|
|
187
|
+
selectedValues: nextSelectedValues,
|
|
183
188
|
selectedItems: nextSelectedItems,
|
|
184
189
|
multiple: isMultiple,
|
|
185
190
|
});
|
|
@@ -188,7 +193,7 @@ const DropdownTemplate = ({
|
|
|
188
193
|
/**
|
|
189
194
|
* 8) 렌더 구성
|
|
190
195
|
* - Root → Trigger → Container → MenuList depth를 유지한다.
|
|
191
|
-
* - 각 item은
|
|
196
|
+
* - 각 item은 resolvedSelectedValues 기준으로 selected 스타일을 결정한다.
|
|
192
197
|
* - disabled item은 select 이벤트를 차단하고 상태만 노출한다.
|
|
193
198
|
*/
|
|
194
199
|
return (
|
|
@@ -196,7 +201,7 @@ const DropdownTemplate = ({
|
|
|
196
201
|
<DropdownTrigger asChild>{trigger}</DropdownTrigger>
|
|
197
202
|
<DropdownContainer {...containerProps} size={size} width={width}>
|
|
198
203
|
<DropdownMenuList {...menuListProps}>
|
|
199
|
-
{
|
|
204
|
+
{items.length > 0 ? (
|
|
200
205
|
<>
|
|
201
206
|
{items.map(item => (
|
|
202
207
|
<DropdownMenuItem
|
|
@@ -207,7 +212,9 @@ const DropdownTemplate = ({
|
|
|
207
212
|
left={item.left}
|
|
208
213
|
right={item.right}
|
|
209
214
|
multiple={item.multiple}
|
|
210
|
-
isSelected={
|
|
215
|
+
isSelected={selectedValueKeySet.has(
|
|
216
|
+
toSelectedValueKey(getItemValue(item)),
|
|
217
|
+
)}
|
|
211
218
|
onSelect={event => {
|
|
212
219
|
if (item.disabled) {
|
|
213
220
|
event.preventDefault();
|
|
@@ -8,6 +8,11 @@ import type { HTMLAttributes, ReactNode, RefObject } from "react";
|
|
|
8
8
|
import type { CheckboxProps } from "../../checkbox/types";
|
|
9
9
|
import type { DropdownPanelWidth, DropdownSize } from "./base";
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Dropdown template value 타입
|
|
13
|
+
*/
|
|
14
|
+
export type DropdownTemplateValue = string | number;
|
|
15
|
+
|
|
11
16
|
/**
|
|
12
17
|
* Dropdown Container props
|
|
13
18
|
* @property {DropdownSize} [size="medium"] option 높이 스케일
|
|
@@ -89,6 +94,7 @@ export interface DropdownContextValue {
|
|
|
89
94
|
/**
|
|
90
95
|
* Dropdown template item
|
|
91
96
|
* @property {string} id 고유 식별자
|
|
97
|
+
* @property {DropdownTemplateValue} [value] 선택 상태 판정 value(미지정 시 id를 fallback으로 사용)
|
|
92
98
|
* @property {ReactNode} label 옵션 라벨
|
|
93
99
|
* @property {ReactNode} [description] 보조 텍스트
|
|
94
100
|
* @property {boolean} [disabled] 비활성 여부
|
|
@@ -102,6 +108,11 @@ export interface DropdownTemplateItem {
|
|
|
102
108
|
* 고유 식별자
|
|
103
109
|
*/
|
|
104
110
|
id: string;
|
|
111
|
+
/**
|
|
112
|
+
* 선택 상태 판정 value
|
|
113
|
+
* - 미지정 시 id를 fallback value로 사용한다.
|
|
114
|
+
*/
|
|
115
|
+
value?: DropdownTemplateValue;
|
|
105
116
|
/**
|
|
106
117
|
* 옵션 라벨
|
|
107
118
|
*/
|
|
@@ -135,8 +146,10 @@ export interface DropdownTemplateItem {
|
|
|
135
146
|
/**
|
|
136
147
|
* Dropdown template change payload
|
|
137
148
|
* @property {DropdownTemplateItem} currentItem 현재 상호작용한 item
|
|
149
|
+
* @property {DropdownTemplateValue} currentValue 현재 상호작용한 item value
|
|
138
150
|
* @property {boolean} isSelected currentItem 선택 여부
|
|
139
151
|
* @property {string[]} selectedIds 선택된 item id 목록
|
|
152
|
+
* @property {DropdownTemplateValue[]} selectedValues 선택된 item value 목록
|
|
140
153
|
* @property {DropdownTemplateItem[]} selectedItems 선택된 item 목록
|
|
141
154
|
* @property {boolean} multiple 다중 선택 모드 여부
|
|
142
155
|
*/
|
|
@@ -145,6 +158,10 @@ export interface DropdownTemplateChangePayload {
|
|
|
145
158
|
* 현재 상호작용한 item
|
|
146
159
|
*/
|
|
147
160
|
currentItem: DropdownTemplateItem;
|
|
161
|
+
/**
|
|
162
|
+
* 현재 상호작용한 item value
|
|
163
|
+
*/
|
|
164
|
+
currentValue: DropdownTemplateValue;
|
|
148
165
|
/**
|
|
149
166
|
* currentItem 선택 여부
|
|
150
167
|
*/
|
|
@@ -153,6 +170,10 @@ export interface DropdownTemplateChangePayload {
|
|
|
153
170
|
* 선택된 item id 목록
|
|
154
171
|
*/
|
|
155
172
|
selectedIds: string[];
|
|
173
|
+
/**
|
|
174
|
+
* 선택된 item value 목록
|
|
175
|
+
*/
|
|
176
|
+
selectedValues: DropdownTemplateValue[];
|
|
156
177
|
/**
|
|
157
178
|
* 선택된 item 목록
|
|
158
179
|
*/
|
|
@@ -10,13 +10,11 @@ import Container from "./foundation/Container";
|
|
|
10
10
|
import { useSelectDropdownOpenState } from "../hooks";
|
|
11
11
|
import type { SelectDropdownOption } from "../types/option";
|
|
12
12
|
import type { SelectDefaultComponentProps } from "../types/props";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const normalizeSingleSelectedIds = (selectedIds: string[]) =>
|
|
19
|
-
selectedIds.length > 0 ? [selectedIds[0]] : [];
|
|
13
|
+
import {
|
|
14
|
+
isSameSelectedValue,
|
|
15
|
+
normalizeSingleSelectedValue,
|
|
16
|
+
toSelectedValueKey,
|
|
17
|
+
} from "../../../utils/selected-values";
|
|
20
18
|
|
|
21
19
|
const toInputText = (value?: ReactNode): string => {
|
|
22
20
|
if (typeof value === "string" || typeof value === "number") {
|
|
@@ -131,61 +129,68 @@ export function SelectDefault<OptionData = unknown>({
|
|
|
131
129
|
];
|
|
132
130
|
}, [customOptionId, customOptions, items]);
|
|
133
131
|
|
|
134
|
-
// 5)
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
[mergedOptions],
|
|
138
|
-
);
|
|
139
|
-
|
|
140
|
-
// 6) uncontrolled 초기 선택값은 items[].selected의 첫 번째 항목만 사용한다.
|
|
141
|
-
const selectedIdsFromItems = useMemo(() => {
|
|
142
|
-
const initialSelectedIds = items
|
|
132
|
+
// 5) uncontrolled 초기 선택값은 items[].selected의 첫 번째 value만 사용한다.
|
|
133
|
+
const selectedValueFromItems = useMemo(() => {
|
|
134
|
+
const initialSelectedValues = items
|
|
143
135
|
.filter(option => option.selected)
|
|
144
|
-
.map(option => option.
|
|
145
|
-
|
|
136
|
+
.map(option => option.value);
|
|
137
|
+
|
|
138
|
+
return normalizeSingleSelectedValue(initialSelectedValues);
|
|
146
139
|
}, [items]);
|
|
147
140
|
|
|
148
|
-
//
|
|
149
|
-
const [
|
|
150
|
-
|
|
151
|
-
);
|
|
141
|
+
// 6) 내부 선택 state를 선언한다.
|
|
142
|
+
const [uncontrolledSelectedValue, setUncontrolledSelectedValue] = useState<
|
|
143
|
+
string | number | undefined
|
|
144
|
+
>(() => selectedValueFromItems);
|
|
152
145
|
|
|
153
|
-
//
|
|
146
|
+
// 7) 외부 items 변경 시 value 규칙으로 내부 선택 state를 재동기화한다.
|
|
154
147
|
useEffect(() => {
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
return
|
|
148
|
+
setUncontrolledSelectedValue(previousSelectedValue => {
|
|
149
|
+
if (isSameSelectedValue(previousSelectedValue, selectedValueFromItems)) {
|
|
150
|
+
return previousSelectedValue;
|
|
158
151
|
}
|
|
159
|
-
return
|
|
152
|
+
return selectedValueFromItems;
|
|
160
153
|
});
|
|
161
|
-
}, [
|
|
154
|
+
}, [selectedValueFromItems]);
|
|
162
155
|
|
|
163
|
-
//
|
|
164
|
-
const selectedOption =
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
156
|
+
// 8) value 기반으로 현재 선택 option을 계산한다.
|
|
157
|
+
const selectedOption = useMemo(
|
|
158
|
+
() =>
|
|
159
|
+
mergedOptions.find(option =>
|
|
160
|
+
isSameSelectedValue(option.value, uncontrolledSelectedValue),
|
|
161
|
+
),
|
|
162
|
+
[mergedOptions, uncontrolledSelectedValue],
|
|
163
|
+
);
|
|
168
164
|
|
|
169
|
-
//
|
|
165
|
+
// 9) custom mode는 customOptions 존재 + custom option value 선택 상태로 판단한다.
|
|
170
166
|
const isCustomInputActive = Boolean(
|
|
171
|
-
customOptions &&
|
|
167
|
+
customOptions &&
|
|
168
|
+
toSelectedValueKey(selectedOption?.value) ===
|
|
169
|
+
toSelectedValueKey(SELECT_CUSTOM_OPTION_VALUE),
|
|
172
170
|
);
|
|
173
171
|
|
|
174
|
-
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
// 변경: custom mode에서 일반 옵션으로 이탈하면 내부 직접 입력값을 초기화한다.
|
|
174
|
+
if (!isCustomInputActive) {
|
|
175
|
+
setCustomLabelValue("");
|
|
176
|
+
}
|
|
177
|
+
}, [isCustomInputActive]);
|
|
178
|
+
|
|
179
|
+
// 10) 외부 displayLabel이 있으면 우선 사용하고, 없으면 선택 option label을 사용한다.
|
|
175
180
|
const resolvedDisplayLabel =
|
|
176
181
|
displayLabel ?? (isCustomInputActive ? undefined : selectedOption?.label);
|
|
177
182
|
|
|
178
|
-
//
|
|
183
|
+
// 11) open 제어형/비제어형 계약은 공용 hook으로 통합 처리한다.
|
|
179
184
|
const { open: dropdownOpen, setOpen } = useSelectDropdownOpenState({
|
|
180
185
|
open,
|
|
181
186
|
defaultOpen,
|
|
182
187
|
onOpen,
|
|
183
188
|
});
|
|
184
189
|
|
|
185
|
-
//
|
|
190
|
+
// 12) disabled/readOnly 상태에서는 open/option 선택 인터랙션을 모두 차단한다.
|
|
186
191
|
const isInteractionBlocked = disabled || readOnly;
|
|
187
192
|
|
|
188
|
-
//
|
|
193
|
+
// 13) open 상태 변경 처리: 차단 상태면 닫힘 고정, 아니면 nextOpen 반영.
|
|
189
194
|
const handleOpenChange = (nextOpen: boolean) => {
|
|
190
195
|
if (isInteractionBlocked) {
|
|
191
196
|
setOpen(false);
|
|
@@ -194,7 +199,7 @@ export function SelectDefault<OptionData = unknown>({
|
|
|
194
199
|
setOpen(nextOpen);
|
|
195
200
|
};
|
|
196
201
|
|
|
197
|
-
//
|
|
202
|
+
// 14) option 선택 처리: onSelectOption 호출 후, value가 변경될 때만 onSelectChange를 호출한다.
|
|
198
203
|
const handleOptionSelect = (
|
|
199
204
|
option: SelectDropdownOption<OptionData>,
|
|
200
205
|
event: Event,
|
|
@@ -204,19 +209,22 @@ export function SelectDefault<OptionData = unknown>({
|
|
|
204
209
|
}
|
|
205
210
|
|
|
206
211
|
const previousOption = selectedOption;
|
|
207
|
-
const hasChanged =
|
|
212
|
+
const hasChanged = !isSameSelectedValue(
|
|
213
|
+
previousOption?.value,
|
|
214
|
+
option.value,
|
|
215
|
+
);
|
|
208
216
|
|
|
209
217
|
onSelectOption?.(option, previousOption, event);
|
|
210
218
|
|
|
211
219
|
if (hasChanged) {
|
|
212
|
-
|
|
220
|
+
setUncontrolledSelectedValue(option.value);
|
|
213
221
|
onSelectChange?.(option, previousOption, event);
|
|
214
222
|
}
|
|
215
223
|
|
|
216
224
|
setOpen(false);
|
|
217
225
|
};
|
|
218
226
|
|
|
219
|
-
//
|
|
227
|
+
// 15) label input 값은 custom mode에서만 사용자 입력값/controlled inputProps.value를 사용한다.
|
|
220
228
|
const labelInputValue = isCustomInputActive
|
|
221
229
|
? typeof inputProps?.value === "string" ||
|
|
222
230
|
typeof inputProps?.value === "number"
|
|
@@ -224,7 +232,7 @@ export function SelectDefault<OptionData = unknown>({
|
|
|
224
232
|
: customLabelValue
|
|
225
233
|
: toInputText(resolvedDisplayLabel);
|
|
226
234
|
|
|
227
|
-
//
|
|
235
|
+
// 16) 렌더: Container → Dropdown.Root → Trigger → Menu.List 구조를 유지한다.
|
|
228
236
|
return (
|
|
229
237
|
<Container
|
|
230
238
|
className={clsx("select-trigger-container", className)}
|
|
@@ -301,7 +309,10 @@ export function SelectDefault<OptionData = unknown>({
|
|
|
301
309
|
left={option.left}
|
|
302
310
|
right={option.right}
|
|
303
311
|
multiple={Boolean(option.multiple)}
|
|
304
|
-
isSelected={
|
|
312
|
+
isSelected={isSameSelectedValue(
|
|
313
|
+
selectedOption?.value,
|
|
314
|
+
option.value,
|
|
315
|
+
)}
|
|
305
316
|
onSelect={event => {
|
|
306
317
|
if (option.disabled) {
|
|
307
318
|
event.preventDefault();
|