@uniai-fe/uds-primitives 0.3.24 → 0.3.26
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.
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import clsx from "clsx";
|
|
4
|
-
import {
|
|
4
|
+
import { useEffect, useMemo, useState } from "react";
|
|
5
5
|
|
|
6
6
|
import Container from "../foundation/Container";
|
|
7
7
|
import { Dropdown } from "../../../dropdown/markup";
|
|
@@ -42,430 +42,412 @@ const SELECT_MULTIPLE_ALL_OPTION_BASE_ID = "__select_multiple_all__";
|
|
|
42
42
|
* @param {boolean} [props.showSelectAllOption] dropdown 첫 번째에 "전체" 옵션 노출 여부
|
|
43
43
|
* @param {React.ReactNode} [props.selectAllLabel="전체"] 전체 옵션 라벨
|
|
44
44
|
*/
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
* - table priority는 width 미지정 시 full width를 기본 동작으로 사용한다.
|
|
78
|
-
* - trigger/container 양쪽에서 동일한 block 값을 재사용한다.
|
|
79
|
-
*/
|
|
80
|
-
const resolvedBlock =
|
|
81
|
-
block || (priority === "table" && width === undefined);
|
|
82
|
-
// 변경: xsmall은 primary 전용이므로 secondary/table에서는 small로 fallback한다.
|
|
83
|
-
const resolvedSize =
|
|
84
|
-
priority !== "primary" && size === "xsmall" ? "small" : size;
|
|
45
|
+
export function SelectMultipleTrigger<OptionData = unknown>({
|
|
46
|
+
className,
|
|
47
|
+
displayLabel,
|
|
48
|
+
placeholder,
|
|
49
|
+
priority = "primary",
|
|
50
|
+
size = "medium",
|
|
51
|
+
state = "default",
|
|
52
|
+
block,
|
|
53
|
+
width,
|
|
54
|
+
disabled,
|
|
55
|
+
readOnly,
|
|
56
|
+
tags,
|
|
57
|
+
items,
|
|
58
|
+
onSelectOption,
|
|
59
|
+
onSelectChange,
|
|
60
|
+
dropdownOptions: dropdown,
|
|
61
|
+
open,
|
|
62
|
+
defaultOpen,
|
|
63
|
+
onOpen,
|
|
64
|
+
showSelectAllOption,
|
|
65
|
+
selectAllLabel = "전체",
|
|
66
|
+
triggerProps,
|
|
67
|
+
}: SelectMultipleComponentProps<OptionData>) {
|
|
68
|
+
/**
|
|
69
|
+
* 1) 레이아웃 기본값 계산
|
|
70
|
+
* - table priority는 width 미지정 시 full width를 기본 동작으로 사용한다.
|
|
71
|
+
* - trigger/container 양쪽에서 동일한 block 값을 재사용한다.
|
|
72
|
+
*/
|
|
73
|
+
const resolvedBlock = block || (priority === "table" && width === undefined);
|
|
74
|
+
// 변경: xsmall은 primary 전용이므로 secondary/table에서는 small로 fallback한다.
|
|
75
|
+
const resolvedSize =
|
|
76
|
+
priority !== "primary" && size === "xsmall" ? "small" : size;
|
|
85
77
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
78
|
+
/**
|
|
79
|
+
* 3) option 조회 최적화 맵 생성
|
|
80
|
+
* - selected id -> option 조회를 반복하므로 id 기반 맵을 생성한다.
|
|
81
|
+
* - label 계산, tag 파생, payload selectedOptions 계산에 공통 사용한다.
|
|
82
|
+
*/
|
|
83
|
+
const optionMap = useMemo(
|
|
84
|
+
() => new Map(items.map(option => [option.id, option])),
|
|
85
|
+
[items],
|
|
86
|
+
);
|
|
87
|
+
// 변경: 전체 선택 대상은 disabled option을 제외한 "선택 가능 옵션"으로 한정한다.
|
|
88
|
+
const selectableOptions = useMemo(
|
|
89
|
+
() => items.filter(option => !option.disabled),
|
|
90
|
+
[items],
|
|
91
|
+
);
|
|
92
|
+
// 변경: 전체 선택 토글 계산을 위해 선택 가능 옵션 id 배열을 별도로 보관한다.
|
|
93
|
+
const selectableOptionIds = useMemo(
|
|
94
|
+
() => selectableOptions.map(option => option.id),
|
|
95
|
+
[selectableOptions],
|
|
96
|
+
);
|
|
97
|
+
// 변경: 전체 해제 시 빠른 membership 체크를 위해 Set 형태도 함께 보관한다.
|
|
98
|
+
const selectableOptionIdSet = useMemo(
|
|
99
|
+
() => new Set(selectableOptionIds),
|
|
100
|
+
[selectableOptionIds],
|
|
101
|
+
);
|
|
102
|
+
const allOptionId = useMemo(() => {
|
|
103
|
+
// 현재 전달된 옵션 id 집합을 먼저 만든다.
|
|
104
|
+
const existingIdSet = new Set(items.map(option => option.id));
|
|
105
|
+
// "__select_multiple_all__"가 비어 있으면 그대로 사용한다.
|
|
106
|
+
if (!existingIdSet.has(SELECT_MULTIPLE_ALL_OPTION_BASE_ID)) {
|
|
107
|
+
return SELECT_MULTIPLE_ALL_OPTION_BASE_ID;
|
|
108
|
+
}
|
|
117
109
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
110
|
+
// 충돌 시 "__select_multiple_all___1", "_2" 형태로 suffix를 증가시키며 빈 id를 찾는다.
|
|
111
|
+
let offset = 1;
|
|
112
|
+
let nextId = `${SELECT_MULTIPLE_ALL_OPTION_BASE_ID}_${offset}`;
|
|
113
|
+
while (existingIdSet.has(nextId)) {
|
|
114
|
+
offset += 1;
|
|
115
|
+
nextId = `${SELECT_MULTIPLE_ALL_OPTION_BASE_ID}_${offset}`;
|
|
116
|
+
}
|
|
117
|
+
// 최종적으로 충돌하지 않는 synthetic all-option id를 반환한다.
|
|
118
|
+
return nextId;
|
|
119
|
+
}, [items]);
|
|
128
120
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
121
|
+
/**
|
|
122
|
+
* 4) uncontrolled 초기 선택값 계산
|
|
123
|
+
* - items[].selected를 다중 선택 초기값으로 그대로 반영한다.
|
|
124
|
+
*/
|
|
125
|
+
const selectedIdsFromOptions = useMemo(
|
|
126
|
+
() => items.filter(option => option.selected).map(option => option.id),
|
|
127
|
+
[items],
|
|
128
|
+
);
|
|
137
129
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
130
|
+
/**
|
|
131
|
+
* 5) uncontrolled 내부 state 선언
|
|
132
|
+
* - controlled 모드에서는 source로 사용되지 않는다.
|
|
133
|
+
* - uncontrolled 모드에서는 최종 선택 배열을 내부 state가 소유한다.
|
|
134
|
+
*/
|
|
135
|
+
const [uncontrolledSelectedOptionIds, setUncontrolledSelectedOptionIds] =
|
|
136
|
+
useState<string[]>(() => selectedIdsFromOptions);
|
|
145
137
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
138
|
+
/**
|
|
139
|
+
* 6) items 변경 시 내부 state 정합성 보정
|
|
140
|
+
* - 기존 선택 id 중 현재 items에 남아있는 id만 유지한다.
|
|
141
|
+
* - 남은 id가 없으면 selectedIdsFromOptions(초기 선택 규칙)로 재동기화한다.
|
|
142
|
+
*/
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
setUncontrolledSelectedOptionIds(previousSelectedIds => {
|
|
145
|
+
const optionIdSet = new Set(items.map(option => option.id));
|
|
146
|
+
const filteredIds = previousSelectedIds.filter(selectedId =>
|
|
147
|
+
optionIdSet.has(selectedId),
|
|
148
|
+
);
|
|
149
|
+
const nextSelectedIds =
|
|
150
|
+
filteredIds.length > 0 ? filteredIds : selectedIdsFromOptions;
|
|
159
151
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
152
|
+
// 동일한 선택 결과면 기존 배열 참조를 유지해 재렌더 루프를 방지한다.
|
|
153
|
+
if (isSameIdList(previousSelectedIds, nextSelectedIds)) {
|
|
154
|
+
return previousSelectedIds;
|
|
155
|
+
}
|
|
164
156
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
157
|
+
return nextSelectedIds;
|
|
158
|
+
});
|
|
159
|
+
}, [items, selectedIdsFromOptions]);
|
|
168
160
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
);
|
|
179
|
-
}, [optionMap, uncontrolledSelectedOptionIds]);
|
|
180
|
-
const selectedIdSet = useMemo(
|
|
181
|
-
() => new Set(resolvedSelectedIds),
|
|
182
|
-
[resolvedSelectedIds],
|
|
183
|
-
);
|
|
184
|
-
const isAllSelectableOptionsSelected = useMemo(
|
|
185
|
-
// 선택 가능 옵션이 1개 이상이고, 그 id가 전부 selectedIdSet에 포함될 때만 true다.
|
|
186
|
-
() =>
|
|
187
|
-
selectableOptionIds.length > 0 &&
|
|
188
|
-
selectableOptionIds.every(selectableId =>
|
|
189
|
-
selectedIdSet.has(selectableId),
|
|
190
|
-
),
|
|
191
|
-
[selectableOptionIds, selectedIdSet],
|
|
192
|
-
);
|
|
193
|
-
const selectAllOption = useMemo<SelectDropdownOption>(
|
|
194
|
-
// 변경: "전체" 옵션은 렌더링을 위한 synthetic option 객체다.
|
|
195
|
-
// 실제 items 배열에 저장하지 않고 render 단계에서만 주입한다.
|
|
196
|
-
() => ({
|
|
197
|
-
id: allOptionId,
|
|
198
|
-
value: allOptionId,
|
|
199
|
-
label: selectAllLabel,
|
|
200
|
-
multiple: true,
|
|
201
|
-
// 선택 가능한 옵션이 하나도 없으면 "전체" 항목도 disabled로 처리한다.
|
|
202
|
-
disabled: selectableOptionIds.length === 0,
|
|
203
|
-
}),
|
|
204
|
-
[allOptionId, selectAllLabel, selectableOptionIds.length],
|
|
161
|
+
/**
|
|
162
|
+
* 7) 최종 선택 id 계산
|
|
163
|
+
* - 내부 state 사용
|
|
164
|
+
*/
|
|
165
|
+
const resolvedSelectedIds = useMemo(() => {
|
|
166
|
+
// 변경: 실제 option id만 유지해 내부 synthetic all-option id가 상태에 저장되지 않도록 차단한다.
|
|
167
|
+
// 이 필터 덕분에 최종 선택 상태는 "실 데이터 옵션"만 source of truth로 유지된다.
|
|
168
|
+
return uncontrolledSelectedOptionIds.filter(selectedId =>
|
|
169
|
+
optionMap.has(selectedId),
|
|
205
170
|
);
|
|
171
|
+
}, [optionMap, uncontrolledSelectedOptionIds]);
|
|
172
|
+
const selectedIdSet = useMemo(
|
|
173
|
+
() => new Set(resolvedSelectedIds),
|
|
174
|
+
[resolvedSelectedIds],
|
|
175
|
+
);
|
|
176
|
+
const isAllSelectableOptionsSelected = useMemo(
|
|
177
|
+
// 선택 가능 옵션이 1개 이상이고, 그 id가 전부 selectedIdSet에 포함될 때만 true다.
|
|
178
|
+
() =>
|
|
179
|
+
selectableOptionIds.length > 0 &&
|
|
180
|
+
selectableOptionIds.every(selectableId =>
|
|
181
|
+
selectedIdSet.has(selectableId),
|
|
182
|
+
),
|
|
183
|
+
[selectableOptionIds, selectedIdSet],
|
|
184
|
+
);
|
|
185
|
+
const selectAllOption = useMemo<SelectDropdownOption<OptionData>>(
|
|
186
|
+
// 변경: "전체" 옵션은 렌더링을 위한 synthetic option 객체다.
|
|
187
|
+
// 실제 items 배열에 저장하지 않고 render 단계에서만 주입한다.
|
|
188
|
+
() => ({
|
|
189
|
+
id: allOptionId,
|
|
190
|
+
value: allOptionId,
|
|
191
|
+
label: selectAllLabel,
|
|
192
|
+
multiple: true,
|
|
193
|
+
// 선택 가능한 옵션이 하나도 없으면 "전체" 항목도 disabled로 처리한다.
|
|
194
|
+
disabled: selectableOptionIds.length === 0,
|
|
195
|
+
}),
|
|
196
|
+
[allOptionId, selectAllLabel, selectableOptionIds.length],
|
|
197
|
+
);
|
|
206
198
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
199
|
+
/**
|
|
200
|
+
* 8) 표시 라벨 계산(보조)
|
|
201
|
+
* - tags가 비어 있을 때 fallback label 용도로 사용한다.
|
|
202
|
+
* - 외부 displayLabel 우선, 없으면 첫 선택 option label을 사용한다.
|
|
203
|
+
*/
|
|
204
|
+
const resolvedDisplayLabel =
|
|
205
|
+
displayLabel ??
|
|
206
|
+
(resolvedSelectedIds.length > 0
|
|
207
|
+
? optionMap.get(resolvedSelectedIds[0])?.label
|
|
208
|
+
: undefined);
|
|
217
209
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
210
|
+
/**
|
|
211
|
+
* 9) tag 파생 계산
|
|
212
|
+
* - 외부 tags가 주어지면 해당 값을 그대로 사용한다(외부 커스텀 우선).
|
|
213
|
+
* - tags 미지정이면 selected ids 기반으로 option label을 자동 tag로 변환한다.
|
|
214
|
+
*/
|
|
215
|
+
const derivedTags = useMemo<SelectMultipleTag[]>(() => {
|
|
216
|
+
if (tags && tags.length > 0) {
|
|
217
|
+
return tags;
|
|
218
|
+
}
|
|
227
219
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
220
|
+
if (optionMap.size === 0 || resolvedSelectedIds.length === 0) {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
231
223
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}));
|
|
241
|
-
}, [tags, resolvedSelectedIds, optionMap]);
|
|
224
|
+
return resolvedSelectedIds
|
|
225
|
+
.map(selectedId => optionMap.get(selectedId))
|
|
226
|
+
.filter((option): option is NonNullable<typeof option> => Boolean(option))
|
|
227
|
+
.map(option => ({
|
|
228
|
+
label: option.label,
|
|
229
|
+
removable: false,
|
|
230
|
+
}));
|
|
231
|
+
}, [tags, resolvedSelectedIds, optionMap]);
|
|
242
232
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
233
|
+
/**
|
|
234
|
+
* 10) placeholder/label 표시 상태 계산
|
|
235
|
+
* - label 값이 비어 있으면 placeholder 표시로 간주한다.
|
|
236
|
+
*/
|
|
237
|
+
const hasTags = derivedTags.length > 0;
|
|
238
|
+
const hasLabel =
|
|
239
|
+
resolvedDisplayLabel !== undefined &&
|
|
240
|
+
resolvedDisplayLabel !== null &&
|
|
241
|
+
resolvedDisplayLabel !== "";
|
|
252
242
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
243
|
+
/**
|
|
244
|
+
* 11) dropdown open 상태 관리
|
|
245
|
+
* - open/defaultOpen/onOpen 계약을 hook으로 통합 처리한다.
|
|
246
|
+
*/
|
|
247
|
+
const { open: dropdownOpen, setOpen } = useSelectDropdownOpenState({
|
|
248
|
+
open,
|
|
249
|
+
defaultOpen,
|
|
250
|
+
onOpen,
|
|
251
|
+
});
|
|
262
252
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
253
|
+
/**
|
|
254
|
+
* 12) 상호작용 차단 조건
|
|
255
|
+
* - disabled/readOnly이면 open 토글/option toggle을 모두 차단한다.
|
|
256
|
+
*/
|
|
257
|
+
const isInteractionBlocked = disabled || readOnly;
|
|
268
258
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
259
|
+
/**
|
|
260
|
+
* 13) open 상태 변경 핸들러
|
|
261
|
+
* - 차단 상태에서는 항상 닫힘 유지
|
|
262
|
+
* - 허용 상태에서는 nextOpen 반영
|
|
263
|
+
*/
|
|
264
|
+
const handleOpenChange = (nextOpen: boolean) => {
|
|
265
|
+
if (isInteractionBlocked) {
|
|
266
|
+
setOpen(false);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
setOpen(nextOpen);
|
|
270
|
+
};
|
|
281
271
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
272
|
+
/**
|
|
273
|
+
* 14) trigger 내 tag 표시 최적화 파생값
|
|
274
|
+
* - 최대 3개 tag만 표시하고 나머지는 summary chip(+N)로 축약한다.
|
|
275
|
+
* - 과도한 폭 확장을 방지해 trigger layout 안정성을 유지한다.
|
|
276
|
+
*/
|
|
277
|
+
const hasOptions = items.length > 0;
|
|
278
|
+
// "전체" 항목은 옵션이 존재할 때만 의미가 있으므로 hasOptions와 함께 gating한다.
|
|
279
|
+
const shouldRenderSelectAllOption = Boolean(
|
|
280
|
+
showSelectAllOption && hasOptions,
|
|
281
|
+
);
|
|
282
|
+
const renderedOptions = useMemo(
|
|
283
|
+
// 변경: all-option은 dropdown 첫 행 고정 요구사항에 맞춰 항상 배열 맨 앞에 주입한다.
|
|
284
|
+
() => (shouldRenderSelectAllOption ? [selectAllOption, ...items] : items),
|
|
285
|
+
[items, selectAllOption, shouldRenderSelectAllOption],
|
|
286
|
+
);
|
|
287
|
+
const MAX_VISIBLE_TAGS = 3;
|
|
288
|
+
const visibleTags = hasTags ? derivedTags.slice(0, MAX_VISIBLE_TAGS) : [];
|
|
289
|
+
const overflowCount = hasTags
|
|
290
|
+
? Math.max(derivedTags.length - visibleTags.length, 0)
|
|
291
|
+
: 0;
|
|
302
292
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
nextSelectedOptionIds,
|
|
326
|
-
);
|
|
293
|
+
/**
|
|
294
|
+
* 15) option 선택 처리(multiple toggle)
|
|
295
|
+
* - 이미 선택된 id면 제거, 아니면 추가한다.
|
|
296
|
+
* - onSelectOption/onSelectChange 콜백으로 상호작용과 값 변경을 분리해 전달한다.
|
|
297
|
+
*/
|
|
298
|
+
const handleOptionSelect = (optionId: string, event: Event) => {
|
|
299
|
+
// 현재 클릭된 옵션이 synthetic all-option인지 먼저 판별한다.
|
|
300
|
+
const isSelectAllOption =
|
|
301
|
+
shouldRenderSelectAllOption && optionId === allOptionId;
|
|
302
|
+
if (isSelectAllOption) {
|
|
303
|
+
// all-option 클릭 분기
|
|
304
|
+
// - 이미 전체 선택 상태면: selectable option들만 제거(전체 해제)
|
|
305
|
+
// - 일부 선택 상태면: selectable option들을 전부 합집합으로 추가(전체 선택)
|
|
306
|
+
const nextSelectedOptionIds = isAllSelectableOptionsSelected
|
|
307
|
+
? resolvedSelectedIds.filter(
|
|
308
|
+
selectedId => !selectableOptionIdSet.has(selectedId),
|
|
309
|
+
)
|
|
310
|
+
: Array.from(new Set([...resolvedSelectedIds, ...selectableOptionIds]));
|
|
311
|
+
const didChange = !isSameIdList(
|
|
312
|
+
resolvedSelectedIds,
|
|
313
|
+
nextSelectedOptionIds,
|
|
314
|
+
);
|
|
327
315
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
333
|
-
return;
|
|
316
|
+
onSelectOption?.(selectAllOption, undefined, event);
|
|
317
|
+
if (didChange) {
|
|
318
|
+
setUncontrolledSelectedOptionIds(nextSelectedOptionIds);
|
|
319
|
+
onSelectChange?.(selectAllOption, undefined, event);
|
|
334
320
|
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
335
323
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
324
|
+
// 일반 옵션 클릭 분기
|
|
325
|
+
// 이미 선택된 id면 제거, 미선택 id면 추가하는 기본 multiple toggle 로직이다.
|
|
326
|
+
const wasSelected = selectedIdSet.has(optionId);
|
|
327
|
+
const nextSelectedOptionIds = wasSelected
|
|
328
|
+
? resolvedSelectedIds.filter(selectedId => selectedId !== optionId)
|
|
329
|
+
: [...resolvedSelectedIds, optionId];
|
|
330
|
+
const currentOption = optionMap.get(optionId);
|
|
343
331
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
332
|
+
if (!currentOption) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
347
335
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
336
|
+
onSelectOption?.(currentOption, undefined, event);
|
|
337
|
+
setUncontrolledSelectedOptionIds(nextSelectedOptionIds);
|
|
338
|
+
onSelectChange?.(currentOption, undefined, event);
|
|
339
|
+
};
|
|
352
340
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
341
|
+
/**
|
|
342
|
+
* 16) 렌더
|
|
343
|
+
* - Container → Dropdown.Root → Trigger → Container → Menu.List depth 유지
|
|
344
|
+
* - hasTags에 따라 chips 또는 placeholder/label 뷰를 분기한다.
|
|
345
|
+
* - empty 상태는 Dropdown.Menu.Item disabled 조합으로 구조를 통일한다.
|
|
346
|
+
*/
|
|
347
|
+
return (
|
|
348
|
+
<Container
|
|
349
|
+
className={clsx("select-trigger-multiple", className)}
|
|
350
|
+
block={resolvedBlock}
|
|
351
|
+
width={width}
|
|
352
|
+
>
|
|
353
|
+
<Dropdown.Root
|
|
354
|
+
open={dropdownOpen}
|
|
355
|
+
onOpenChange={handleOpenChange}
|
|
356
|
+
modal={false}
|
|
357
|
+
{...dropdown?.rootProps}
|
|
364
358
|
>
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
as="div"
|
|
384
|
-
{...triggerProps}
|
|
385
|
-
>
|
|
386
|
-
{hasTags ? (
|
|
387
|
-
<div className="select-tags">
|
|
388
|
-
{visibleTags.map(
|
|
389
|
-
({ label, suffix, removable, onRemove }, index) => (
|
|
390
|
-
<SelectMultipleSelectedChip
|
|
391
|
-
key={`select-tag-${index}`}
|
|
392
|
-
label={label}
|
|
393
|
-
suffix={suffix}
|
|
394
|
-
removable={removable}
|
|
395
|
-
onRemove={onRemove}
|
|
396
|
-
/>
|
|
397
|
-
),
|
|
398
|
-
)}
|
|
399
|
-
{overflowCount > 0 ? (
|
|
359
|
+
{/* Select trigger와 Dropdown trigger를 결합해 동일한 DOM을 공유한다. */}
|
|
360
|
+
<Dropdown.Trigger asChild>
|
|
361
|
+
<SelectTriggerBase
|
|
362
|
+
priority={priority}
|
|
363
|
+
size={resolvedSize}
|
|
364
|
+
state={disabled ? "disabled" : state}
|
|
365
|
+
block={resolvedBlock}
|
|
366
|
+
open={dropdownOpen}
|
|
367
|
+
multiple
|
|
368
|
+
disabled={disabled}
|
|
369
|
+
readOnly={readOnly}
|
|
370
|
+
as="div"
|
|
371
|
+
{...triggerProps}
|
|
372
|
+
>
|
|
373
|
+
{hasTags ? (
|
|
374
|
+
<div className="select-tags">
|
|
375
|
+
{visibleTags.map(
|
|
376
|
+
({ label, suffix, removable, onRemove }, index) => (
|
|
400
377
|
<SelectMultipleSelectedChip
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
378
|
+
key={`select-tag-${index}`}
|
|
379
|
+
label={label}
|
|
380
|
+
suffix={suffix}
|
|
381
|
+
removable={removable}
|
|
382
|
+
onRemove={onRemove}
|
|
404
383
|
/>
|
|
405
|
-
)
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
384
|
+
),
|
|
385
|
+
)}
|
|
386
|
+
{overflowCount > 0 ? (
|
|
387
|
+
<SelectMultipleSelectedChip
|
|
388
|
+
label={`+${overflowCount}`}
|
|
389
|
+
removable={false}
|
|
390
|
+
kind="summary"
|
|
391
|
+
/>
|
|
392
|
+
) : null}
|
|
393
|
+
</div>
|
|
394
|
+
) : (
|
|
395
|
+
<SelectTriggerSelected
|
|
396
|
+
label={resolvedDisplayLabel}
|
|
397
|
+
placeholder={placeholder}
|
|
398
|
+
isPlaceholder={!hasLabel}
|
|
399
|
+
// 변경: Multiple의 Selected 뷰는 표시 전용이므로 항상 편집을 차단한다.
|
|
400
|
+
readOnly
|
|
401
|
+
/>
|
|
402
|
+
)}
|
|
403
|
+
</SelectTriggerBase>
|
|
404
|
+
</Dropdown.Trigger>
|
|
405
|
+
<Dropdown.Container
|
|
406
|
+
{...dropdown?.containerProps}
|
|
407
|
+
size={dropdown?.size ?? resolvedSize}
|
|
408
|
+
width={dropdown?.width ?? "match"}
|
|
409
|
+
>
|
|
410
|
+
<Dropdown.Menu.List {...dropdown?.menuListProps}>
|
|
411
|
+
{hasOptions ? (
|
|
412
|
+
<>
|
|
413
|
+
{/* multi select 전용 옵션을 Dropdown.Menu.Item으로 노출한다. */}
|
|
414
|
+
{renderedOptions.map(option => (
|
|
415
|
+
<Dropdown.Menu.Item
|
|
416
|
+
key={option.id}
|
|
417
|
+
label={option.label}
|
|
418
|
+
description={option.description}
|
|
419
|
+
disabled={option.disabled}
|
|
420
|
+
left={option.left}
|
|
421
|
+
right={option.right}
|
|
422
|
+
multiple
|
|
423
|
+
isSelected={
|
|
424
|
+
// synthetic all-option의 checked 상태는 "전체가 선택되었는가"로 계산한다.
|
|
425
|
+
// 일반 option은 기존 selectedIdSet membership으로 계산한다.
|
|
426
|
+
option.id === allOptionId
|
|
427
|
+
? isAllSelectableOptionsSelected
|
|
428
|
+
: selectedIdSet.has(option.id)
|
|
429
|
+
}
|
|
430
|
+
onSelect={event => {
|
|
431
|
+
if (option.disabled || isInteractionBlocked) {
|
|
432
|
+
event.preventDefault();
|
|
433
|
+
return;
|
|
442
434
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
</Dropdown.Container>
|
|
463
|
-
</Dropdown.Root>
|
|
464
|
-
</Container>
|
|
465
|
-
);
|
|
466
|
-
},
|
|
467
|
-
);
|
|
468
|
-
|
|
469
|
-
SelectMultipleTrigger.displayName = "SelectMultipleTrigger";
|
|
470
|
-
|
|
471
|
-
export { SelectMultipleTrigger };
|
|
435
|
+
handleOptionSelect(option.id, event);
|
|
436
|
+
}}
|
|
437
|
+
/>
|
|
438
|
+
))}
|
|
439
|
+
</>
|
|
440
|
+
) : (
|
|
441
|
+
<Dropdown.Menu.Item
|
|
442
|
+
// 변경: 사용처 1회 상수 대신 인라인 fallback으로 empty label을 처리한다.
|
|
443
|
+
label={dropdown?.alt ?? "선택할 항목이 없습니다."}
|
|
444
|
+
disabled
|
|
445
|
+
className="dropdown-menu-alt"
|
|
446
|
+
/>
|
|
447
|
+
)}
|
|
448
|
+
</Dropdown.Menu.List>
|
|
449
|
+
</Dropdown.Container>
|
|
450
|
+
</Dropdown.Root>
|
|
451
|
+
</Container>
|
|
452
|
+
);
|
|
453
|
+
}
|