@uniai-fe/uds-primitives 0.3.19 → 0.3.20

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,31 +1,135 @@
1
1
  "use client";
2
2
 
3
3
  import clsx from "clsx";
4
+ import { useEffect, useRef } from "react";
5
+ import type { ReactNode } from "react";
4
6
  import type { SelectSelectedProps } from "../../types/trigger";
5
- import { Slot } from "../../../slot";
7
+
8
+ const toInputText = (value?: ReactNode): string => {
9
+ if (typeof value === "string" || typeof value === "number") {
10
+ return String(value);
11
+ }
12
+
13
+ return "";
14
+ };
6
15
 
7
16
  /**
8
- * Select trigger label renderer
17
+ * Select trigger value renderer; trigger 내부 label/value input 렌더링 컴포넌트
9
18
  * @component
10
19
  * @param {SelectSelectedProps} props selected view props
11
- * @param {React.ReactNode} [props.label] 선택된 라벨
12
- * @param {React.ReactNode} [props.placeholder] placeholder 텍스트
13
- * @param {boolean} [props.isPlaceholder] placeholder 스타일 활성 여부
20
+ * @param {ReactNode} [props.label] 선택된 라벨 노드
21
+ * @param {ReactNode} [props.placeholder] label input placeholder
22
+ * @param {boolean} [props.isPlaceholder] placeholder 상태 여부
23
+ * @param {boolean} [props.readOnly=false] label input readOnly 여부
24
+ * @param {UseFormRegisterReturn} [props.register] hidden value input register
25
+ * @param {UseFormRegisterReturn} [props.customRegister] custom label input register
26
+ * @param {Omit<ComponentPropsWithoutRef<"input">, "placeholder">} [props.inputProps] label input native 속성
27
+ * @param {string} [props.valueText=""] label input 표시 문자열
28
+ * @param {SelectOptionValue | ""} [props.valueFieldValue=""] hidden value input 값
29
+ * @param {boolean} [props.shouldFocusInput=false] custom mode 진입 시 label input focus 여부
30
+ * @param {(value: string) => void} [props.onLabelChange] label 입력 변경 콜백
31
+ * @example
32
+ * <SelectTriggerSelected
33
+ * label="사과"
34
+ * valueFieldValue="APPLE"
35
+ * readOnly
36
+ * />
14
37
  */
15
38
  const SelectTriggerSelected = ({
16
39
  label,
17
40
  placeholder,
18
41
  isPlaceholder,
42
+ readOnly = false,
43
+ register,
44
+ customRegister,
45
+ inputProps,
46
+ valueText = "",
47
+ valueFieldValue = "",
48
+ shouldFocusInput = false,
49
+ onLabelChange,
19
50
  }: SelectSelectedProps) => {
51
+ // 1) custom mode 진입 시 focus 타깃은 label input으로 고정한다.
52
+ const labelInputRef = useRef<HTMLInputElement>(null);
53
+
54
+ // 2) placeholder/label은 text input에 들어갈 수 있는 문자열로 정규화한다.
55
+ const resolvedPlaceholder = toInputText(placeholder);
56
+ const resolvedLabelText = valueText || toInputText(label);
57
+
58
+ // 3) custom mode 활성 시에만 label input focus를 부여한다.
59
+ useEffect(() => {
60
+ if (!shouldFocusInput || readOnly) {
61
+ return;
62
+ }
63
+
64
+ labelInputRef.current?.focus();
65
+ }, [readOnly, shouldFocusInput]);
66
+
20
67
  return (
21
68
  <div className="select-value">
22
- <Slot.Text
23
- className={clsx("select-label", {
24
- "select-label-placeholder": isPlaceholder,
69
+ <input
70
+ ref={labelInputRef}
71
+ type="text"
72
+ // 4) 외부 inputProps는 placeholder/value/readOnly보다 먼저 병합하고,
73
+ // 내부 계약값을 마지막에 고정해 동작 우선순위를 명확히 한다.
74
+ {...inputProps}
75
+ className={clsx("select-input-label", inputProps?.className, {
76
+ "select-input-label-placeholder": isPlaceholder,
25
77
  })}
26
- >
27
- {label ?? placeholder}
28
- </Slot.Text>
78
+ placeholder={resolvedPlaceholder}
79
+ value={resolvedLabelText}
80
+ readOnly={readOnly}
81
+ // 5) customRegister는 name/ref/onBlur/onChange 계약을 label input에 연결한다.
82
+ {...customRegister}
83
+ onPointerDownCapture={event => {
84
+ inputProps?.onPointerDownCapture?.(event);
85
+ if (!readOnly) {
86
+ // 변경: 편집 가능 상태에서는 trigger 토글 이벤트 전파를 차단한다.
87
+ event.stopPropagation();
88
+ }
89
+ }}
90
+ onPointerDown={event => {
91
+ inputProps?.onPointerDown?.(event);
92
+ if (!readOnly) {
93
+ event.stopPropagation();
94
+ }
95
+ }}
96
+ onMouseDown={event => {
97
+ inputProps?.onMouseDown?.(event);
98
+ if (!readOnly) {
99
+ event.stopPropagation();
100
+ }
101
+ }}
102
+ onClickCapture={event => {
103
+ inputProps?.onClickCapture?.(event);
104
+ if (!readOnly) {
105
+ event.stopPropagation();
106
+ }
107
+ }}
108
+ onClick={event => {
109
+ inputProps?.onClick?.(event);
110
+ if (!readOnly) {
111
+ event.stopPropagation();
112
+ }
113
+ }}
114
+ onKeyDown={event => {
115
+ inputProps?.onKeyDown?.(event);
116
+ if (!readOnly) {
117
+ event.stopPropagation();
118
+ }
119
+ }}
120
+ onChange={event => {
121
+ // 6) RHF(customRegister) → 외부 inputProps → 내부 state 콜백 순으로 변경을 전파한다.
122
+ customRegister?.onChange?.(event);
123
+ inputProps?.onChange?.(event);
124
+ onLabelChange?.(event.currentTarget.value);
125
+ }}
126
+ />
127
+ <input
128
+ type="hidden"
129
+ className="select-input-value"
130
+ value={valueFieldValue}
131
+ {...register}
132
+ />
29
133
  </div>
30
134
  );
31
135
  };
@@ -5,7 +5,6 @@ import { forwardRef, useEffect, useMemo, useState } from "react";
5
5
 
6
6
  import Container from "../foundation/Container";
7
7
  import { Dropdown } from "../../../dropdown/markup";
8
- import type { DropdownSize } from "../../../dropdown/types";
9
8
  import type { SelectMultipleComponentProps } from "../../types/props";
10
9
  import type { SelectMultipleTag } from "../../types/multiple";
11
10
  import type { SelectDropdownOption } from "../../types/option";
@@ -16,7 +15,7 @@ import { useSelectDropdownOpenState } from "../../hooks";
16
15
  const isSameIdList = (previousIds: string[], nextIds: string[]) =>
17
16
  previousIds.length === nextIds.length &&
18
17
  previousIds.every((selectedId, index) => selectedId === nextIds[index]);
19
- // 변경: synthetic "전체" 옵션 id의 기본 키다. 실제 options id와 충돌하면 suffix를 붙여 회피한다.
18
+ // 변경: synthetic "전체" 옵션 id의 기본 키다. 실제 items id와 충돌하면 suffix를 붙여 회피한다.
20
19
  const SELECT_MULTIPLE_ALL_OPTION_BASE_ID = "__select_multiple_all__";
21
20
 
22
21
  /**
@@ -24,29 +23,23 @@ const SELECT_MULTIPLE_ALL_OPTION_BASE_ID = "__select_multiple_all__";
24
23
  * @component
25
24
  * @param {SelectMultipleComponentProps} props multi trigger props
26
25
  * @param {SelectMultipleTag[]} [props.tags] 선택된 tag 리스트
27
- * @param {SelectDropdownOption[]} [props.options] dropdown option 목록
28
- * @param {string[]} [props.selectedOptionIds] 선택된 option id 리스트
29
- * @param {(payload: SelectOptionChangePayload) => void} [props.onChange] 선택 결과 변경 콜백
30
- * @param {(option: SelectDropdownOption) => void} [props.onOptionSelect] option 선택 콜백
26
+ * @param {SelectDropdownOption[]} [props.items] dropdown item 목록
27
+ * @param {SelectCallbackParams} [props.onSelectOption] option 선택 액션 콜백
28
+ * @param {SelectCallbackParams} [props.onSelectChange] 선택값 변경 콜백
31
29
  * @param {React.ReactNode} [props.displayLabel] fallback 라벨
32
30
  * @param {React.ReactNode} [props.placeholder] placeholder 텍스트
33
31
  * @param {"primary" | "secondary" | "table"} [props.priority="primary"] priority scale
34
- * @param {"small" | "medium" | "large"} [props.size="medium"] size scale
32
+ * @param {"xsmall" | "small" | "medium" | "large"} [props.size="medium"] size scale
35
33
  * @param {"default" | "focused" | "disabled"} [props.state="default"] 시각 상태
36
34
  * @param {boolean} [props.block] block 여부
37
35
  * @param {FormFieldWidth} [props.width] container width preset
38
36
  * @param {boolean} [props.isOpen] dropdown open 여부
39
37
  * @param {boolean} [props.disabled] disabled 여부
40
38
  * @param {boolean} [props.readOnly] readOnly 여부
41
- * @param {"small" | "medium" | "large"} [props.dropdownSize] dropdown panel size
42
- * @param {"match" | "fit-content" | "max-content" | string | number} [props.dropdownWidth="match"] dropdown panel width
43
- * @param {Omit<DropdownMenuProps, "open" | "defaultOpen" | "onOpenChange">} [props.dropdownRootProps] Dropdown.Root 전달 props
44
- * @param {Omit<DropdownContainerProps, "children" | "size" | "width">} [props.dropdownContainerProps] Dropdown.Container 전달 props
45
- * @param {DropdownMenuListProps} [props.dropdownMenuListProps] Dropdown.Menu.List 전달 props
46
- * @param {ReactNode} [props.alt] empty 상태 대체 콘텐츠
39
+ * @param {SelectDropdownExtension} [props.dropdown] dropdown 확장 옵션
47
40
  * @param {boolean} [props.open] controlled open 상태
48
41
  * @param {boolean} [props.defaultOpen] uncontrolled 초기 open 상태
49
- * @param {(open: boolean) => void} [props.onOpenChange] open 상태 변경 콜백
42
+ * @param {(open: boolean) => void} [props.onOpen] open 상태 변경 콜백
50
43
  * @param {boolean} [props.showSelectAllOption] dropdown 첫 번째에 "전체" 옵션 노출 여부
51
44
  * @param {React.ReactNode} [props.selectAllLabel="전체"] 전체 옵션 라벨
52
45
  */
@@ -68,22 +61,16 @@ const SelectMultipleTrigger = forwardRef<
68
61
  disabled,
69
62
  readOnly,
70
63
  tags,
71
- options = [],
72
- selectedOptionIds,
73
- onChange,
74
- onOptionSelect,
75
- dropdownSize,
76
- dropdownWidth = "match",
77
- dropdownRootProps,
78
- dropdownContainerProps,
79
- dropdownMenuListProps,
80
- alt,
64
+ items,
65
+ onSelectOption,
66
+ onSelectChange,
67
+ dropdownOptions: dropdown,
81
68
  open,
82
69
  defaultOpen,
83
- onOpenChange,
70
+ onOpen,
84
71
  showSelectAllOption,
85
72
  selectAllLabel = "전체",
86
- ...rest
73
+ triggerProps,
87
74
  },
88
75
  ref,
89
76
  ) => {
@@ -94,13 +81,9 @@ const SelectMultipleTrigger = forwardRef<
94
81
  */
95
82
  const resolvedBlock =
96
83
  block || (priority === "table" && width === undefined);
97
-
98
- /**
99
- * 2) 선택 상태 모드 결정
100
- * - selectedOptionIds prop이 있으면 controlled 모드
101
- * - selectedOptionIds prop이 없으면 uncontrolled 모드
102
- */
103
- const isSelectionControlled = selectedOptionIds !== undefined;
84
+ // 변경: xsmall은 primary 전용이므로 secondary/table에서는 small로 fallback한다.
85
+ const resolvedSize =
86
+ priority !== "primary" && size === "xsmall" ? "small" : size;
104
87
 
105
88
  /**
106
89
  * 3) option 조회 최적화 맵 생성
@@ -108,13 +91,13 @@ const SelectMultipleTrigger = forwardRef<
108
91
  * - label 계산, tag 파생, payload selectedOptions 계산에 공통 사용한다.
109
92
  */
110
93
  const optionMap = useMemo(
111
- () => new Map(options.map(option => [option.id, option])),
112
- [options],
94
+ () => new Map(items.map(option => [option.id, option])),
95
+ [items],
113
96
  );
114
97
  // 변경: 전체 선택 대상은 disabled option을 제외한 "선택 가능 옵션"으로 한정한다.
115
98
  const selectableOptions = useMemo(
116
- () => options.filter(option => !option.disabled),
117
- [options],
99
+ () => items.filter(option => !option.disabled),
100
+ [items],
118
101
  );
119
102
  // 변경: 전체 선택 토글 계산을 위해 선택 가능 옵션 id 배열을 별도로 보관한다.
120
103
  const selectableOptionIds = useMemo(
@@ -128,7 +111,7 @@ const SelectMultipleTrigger = forwardRef<
128
111
  );
129
112
  const allOptionId = useMemo(() => {
130
113
  // 현재 전달된 옵션 id 집합을 먼저 만든다.
131
- const existingIdSet = new Set(options.map(option => option.id));
114
+ const existingIdSet = new Set(items.map(option => option.id));
132
115
  // "__select_multiple_all__"가 비어 있으면 그대로 사용한다.
133
116
  if (!existingIdSet.has(SELECT_MULTIPLE_ALL_OPTION_BASE_ID)) {
134
117
  return SELECT_MULTIPLE_ALL_OPTION_BASE_ID;
@@ -143,15 +126,15 @@ const SelectMultipleTrigger = forwardRef<
143
126
  }
144
127
  // 최종적으로 충돌하지 않는 synthetic all-option id를 반환한다.
145
128
  return nextId;
146
- }, [options]);
129
+ }, [items]);
147
130
 
148
131
  /**
149
132
  * 4) uncontrolled 초기 선택값 계산
150
- * - options[].selected를 다중 선택 초기값으로 그대로 반영한다.
133
+ * - items[].selected를 다중 선택 초기값으로 그대로 반영한다.
151
134
  */
152
135
  const selectedIdsFromOptions = useMemo(
153
- () => options.filter(option => option.selected).map(option => option.id),
154
- [options],
136
+ () => items.filter(option => option.selected).map(option => option.id),
137
+ [items],
155
138
  );
156
139
 
157
140
  /**
@@ -163,17 +146,13 @@ const SelectMultipleTrigger = forwardRef<
163
146
  useState<string[]>(() => selectedIdsFromOptions);
164
147
 
165
148
  /**
166
- * 6) options 변경 시 내부 state 정합성 보정(uncontrolled 전용)
167
- * - 기존 선택 id 중 현재 options에 남아있는 id만 유지한다.
149
+ * 6) items 변경 시 내부 state 정합성 보정
150
+ * - 기존 선택 id 중 현재 items에 남아있는 id만 유지한다.
168
151
  * - 남은 id가 없으면 selectedIdsFromOptions(초기 선택 규칙)로 재동기화한다.
169
152
  */
170
153
  useEffect(() => {
171
- if (isSelectionControlled) {
172
- return;
173
- }
174
-
175
154
  setUncontrolledSelectedOptionIds(previousSelectedIds => {
176
- const optionIdSet = new Set(options.map(option => option.id));
155
+ const optionIdSet = new Set(items.map(option => option.id));
177
156
  const filteredIds = previousSelectedIds.filter(selectedId =>
178
157
  optionIdSet.has(selectedId),
179
158
  );
@@ -187,27 +166,19 @@ const SelectMultipleTrigger = forwardRef<
187
166
 
188
167
  return nextSelectedIds;
189
168
  });
190
- }, [isSelectionControlled, options, selectedIdsFromOptions]);
169
+ }, [items, selectedIdsFromOptions]);
191
170
 
192
171
  /**
193
172
  * 7) 최종 선택 id 계산
194
- * - controlled: selectedOptionIds 우선
195
- * - uncontrolled: 내부 state 사용
173
+ * - 내부 state 사용
196
174
  */
197
175
  const resolvedSelectedIds = useMemo(() => {
198
- const sourceSelectedIds = isSelectionControlled
199
- ? (selectedOptionIds ?? [])
200
- : uncontrolledSelectedOptionIds;
201
-
202
176
  // 변경: 실제 option id만 유지해 내부 synthetic all-option id가 상태에 저장되지 않도록 차단한다.
203
177
  // 이 필터 덕분에 최종 선택 상태는 "실 데이터 옵션"만 source of truth로 유지된다.
204
- return sourceSelectedIds.filter(selectedId => optionMap.has(selectedId));
205
- }, [
206
- isSelectionControlled,
207
- optionMap,
208
- selectedOptionIds,
209
- uncontrolledSelectedOptionIds,
210
- ]);
178
+ return uncontrolledSelectedOptionIds.filter(selectedId =>
179
+ optionMap.has(selectedId),
180
+ );
181
+ }, [optionMap, uncontrolledSelectedOptionIds]);
211
182
  const selectedIdSet = useMemo(
212
183
  () => new Set(resolvedSelectedIds),
213
184
  [resolvedSelectedIds],
@@ -223,7 +194,7 @@ const SelectMultipleTrigger = forwardRef<
223
194
  );
224
195
  const selectAllOption = useMemo<SelectDropdownOption>(
225
196
  // 변경: "전체" 옵션은 렌더링을 위한 synthetic option 객체다.
226
- // 실제 options 배열에 저장하지 않고 render 단계에서만 주입한다.
197
+ // 실제 items 배열에 저장하지 않고 render 단계에서만 주입한다.
227
198
  () => ({
228
199
  id: allOptionId,
229
200
  value: allOptionId,
@@ -283,12 +254,12 @@ const SelectMultipleTrigger = forwardRef<
283
254
 
284
255
  /**
285
256
  * 11) dropdown open 상태 관리
286
- * - open/defaultOpen/onOpenChange 계약을 hook으로 통합 처리한다.
257
+ * - open/defaultOpen/onOpen 계약을 hook으로 통합 처리한다.
287
258
  */
288
259
  const { open: dropdownOpen, setOpen } = useSelectDropdownOpenState({
289
260
  open: open ?? isOpen,
290
261
  defaultOpen,
291
- onOpenChange,
262
+ onOpen,
292
263
  });
293
264
 
294
265
  /**
@@ -315,17 +286,15 @@ const SelectMultipleTrigger = forwardRef<
315
286
  * - 최대 3개 tag만 표시하고 나머지는 summary chip(+N)로 축약한다.
316
287
  * - 과도한 폭 확장을 방지해 trigger layout 안정성을 유지한다.
317
288
  */
318
- const panelSize = (dropdownSize ?? size) as DropdownSize;
319
- const hasOptions = options.length > 0;
289
+ const hasOptions = items.length > 0;
320
290
  // "전체" 항목은 옵션이 존재할 때만 의미가 있으므로 hasOptions와 함께 gating한다.
321
291
  const shouldRenderSelectAllOption = Boolean(
322
292
  showSelectAllOption && hasOptions,
323
293
  );
324
294
  const renderedOptions = useMemo(
325
295
  // 변경: all-option은 dropdown 첫 행 고정 요구사항에 맞춰 항상 배열 맨 앞에 주입한다.
326
- () =>
327
- shouldRenderSelectAllOption ? [selectAllOption, ...options] : options,
328
- [options, selectAllOption, shouldRenderSelectAllOption],
296
+ () => (shouldRenderSelectAllOption ? [selectAllOption, ...items] : items),
297
+ [items, selectAllOption, shouldRenderSelectAllOption],
329
298
  );
330
299
  const MAX_VISIBLE_TAGS = 3;
331
300
  const visibleTags = hasTags ? derivedTags.slice(0, MAX_VISIBLE_TAGS) : [];
@@ -336,11 +305,9 @@ const SelectMultipleTrigger = forwardRef<
336
305
  /**
337
306
  * 15) option 선택 처리(multiple toggle)
338
307
  * - 이미 선택된 id면 제거, 아니면 추가한다.
339
- * - uncontrolled 모드면 내부 state 갱신
340
- * - onChange(payload)로 결과 전체를 전달
341
- * - legacy onOptionSelect는 하위호환으로 유지
308
+ * - onSelectOption/onSelectChange 콜백으로 상호작용과 변경을 분리해 전달한다.
342
309
  */
343
- const handleOptionSelect = (optionId: string) => {
310
+ const handleOptionSelect = (optionId: string, event: Event) => {
344
311
  // 현재 클릭된 옵션이 synthetic all-option인지 먼저 판별한다.
345
312
  const isSelectAllOption =
346
313
  shouldRenderSelectAllOption && optionId === allOptionId;
@@ -355,36 +322,16 @@ const SelectMultipleTrigger = forwardRef<
355
322
  : Array.from(
356
323
  new Set([...resolvedSelectedIds, ...selectableOptionIds]),
357
324
  );
358
- // 최종 id 배열을 실제 option 객체 배열로 역매핑해 payload 계약을 유지한다.
359
- const nextSelectedOptions = nextSelectedOptionIds
360
- .map(selectedId => optionMap.get(selectedId))
361
- .filter(
362
- (
363
- selectedOption,
364
- ): selectedOption is NonNullable<typeof selectedOption> =>
365
- Boolean(selectedOption),
366
- );
325
+ const didChange = !isSameIdList(
326
+ resolvedSelectedIds,
327
+ nextSelectedOptionIds,
328
+ );
367
329
 
368
- // uncontrolled 모드에서는 내부 state를 즉시 동기화한다.
369
- if (!isSelectionControlled) {
330
+ onSelectOption?.(selectAllOption, undefined, event);
331
+ if (didChange) {
370
332
  setUncontrolledSelectedOptionIds(nextSelectedOptionIds);
333
+ onSelectChange?.(selectAllOption, undefined, event);
371
334
  }
372
-
373
- // onChange payload는 기존 multiple 계약을 그대로 사용한다.
374
- // currentOption은 synthetic all-option으로 전달해 "전체 선택 액션"임을 호출부에서 구분 가능하게 한다.
375
- onChange?.({
376
- mode: "multiple",
377
- selectedOptionIds: nextSelectedOptionIds,
378
- selectedValues: nextSelectedOptions.map(
379
- selectedOption => selectedOption.value,
380
- ),
381
- selectedOptions: nextSelectedOptions,
382
- currentOption: selectAllOption,
383
- isSelected: !isAllSelectableOptionsSelected,
384
- });
385
-
386
- // legacy 하위호환 콜백도 동일하게 호출한다.
387
- onOptionSelect?.(selectAllOption);
388
335
  return;
389
336
  }
390
337
 
@@ -394,36 +341,15 @@ const SelectMultipleTrigger = forwardRef<
394
341
  const nextSelectedOptionIds = wasSelected
395
342
  ? resolvedSelectedIds.filter(selectedId => selectedId !== optionId)
396
343
  : [...resolvedSelectedIds, optionId];
397
- const nextSelectedOptions = nextSelectedOptionIds
398
- .map(selectedId => optionMap.get(selectedId))
399
- .filter(
400
- (
401
- selectedOption,
402
- ): selectedOption is NonNullable<typeof selectedOption> =>
403
- Boolean(selectedOption),
404
- );
405
344
  const currentOption = optionMap.get(optionId);
406
345
 
407
346
  if (!currentOption) {
408
347
  return;
409
348
  }
410
349
 
411
- if (!isSelectionControlled) {
412
- setUncontrolledSelectedOptionIds(nextSelectedOptionIds);
413
- }
414
-
415
- onChange?.({
416
- mode: "multiple",
417
- selectedOptionIds: nextSelectedOptionIds,
418
- selectedValues: nextSelectedOptions.map(
419
- selectedOption => selectedOption.value,
420
- ),
421
- selectedOptions: nextSelectedOptions,
422
- currentOption,
423
- isSelected: !wasSelected,
424
- });
425
-
426
- onOptionSelect?.(currentOption);
350
+ onSelectOption?.(currentOption, undefined, event);
351
+ setUncontrolledSelectedOptionIds(nextSelectedOptionIds);
352
+ onSelectChange?.(currentOption, undefined, event);
427
353
  };
428
354
 
429
355
  /**
@@ -442,14 +368,14 @@ const SelectMultipleTrigger = forwardRef<
442
368
  open={dropdownOpen}
443
369
  onOpenChange={handleOpenChange}
444
370
  modal={false}
445
- {...dropdownRootProps}
371
+ {...dropdown?.rootProps}
446
372
  >
447
373
  {/* Select trigger와 Dropdown trigger를 결합해 동일한 DOM을 공유한다. */}
448
374
  <Dropdown.Trigger asChild>
449
375
  <SelectTriggerBase
450
376
  ref={ref}
451
377
  priority={priority}
452
- size={size}
378
+ size={resolvedSize}
453
379
  state={disabled ? "disabled" : state}
454
380
  block={resolvedBlock}
455
381
  open={dropdownOpen}
@@ -457,7 +383,7 @@ const SelectMultipleTrigger = forwardRef<
457
383
  disabled={disabled}
458
384
  readOnly={readOnly}
459
385
  as="div"
460
- {...rest}
386
+ {...triggerProps}
461
387
  >
462
388
  {hasTags ? (
463
389
  <div className="select-tags">
@@ -485,16 +411,18 @@ const SelectMultipleTrigger = forwardRef<
485
411
  label={resolvedDisplayLabel}
486
412
  placeholder={placeholder}
487
413
  isPlaceholder={!hasLabel}
414
+ // 변경: Multiple의 Selected 뷰는 표시 전용이므로 항상 편집을 차단한다.
415
+ readOnly
488
416
  />
489
417
  )}
490
418
  </SelectTriggerBase>
491
419
  </Dropdown.Trigger>
492
420
  <Dropdown.Container
493
- {...dropdownContainerProps}
494
- size={panelSize}
495
- width={dropdownWidth}
421
+ {...dropdown?.containerProps}
422
+ size={dropdown?.size ?? resolvedSize}
423
+ width={dropdown?.width ?? "match"}
496
424
  >
497
- <Dropdown.Menu.List {...dropdownMenuListProps}>
425
+ <Dropdown.Menu.List {...dropdown?.menuListProps}>
498
426
  {hasOptions ? (
499
427
  <>
500
428
  {/* multi select 전용 옵션을 Dropdown.Menu.Item으로 노출한다. */}
@@ -519,7 +447,7 @@ const SelectMultipleTrigger = forwardRef<
519
447
  event.preventDefault();
520
448
  return;
521
449
  }
522
- handleOptionSelect(option.id);
450
+ handleOptionSelect(option.id, event);
523
451
  }}
524
452
  />
525
453
  ))}
@@ -527,7 +455,7 @@ const SelectMultipleTrigger = forwardRef<
527
455
  ) : (
528
456
  <Dropdown.Menu.Item
529
457
  // 변경: 사용처 1회 상수 대신 인라인 fallback으로 empty label을 처리한다.
530
- label={alt ?? "선택할 항목이 없습니다."}
458
+ label={dropdown?.alt ?? "선택할 항목이 없습니다."}
531
459
  disabled
532
460
  className="dropdown-menu-alt"
533
461
  />