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