@uniai-fe/uds-primitives 0.3.19 → 0.3.21

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,22 @@ 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
- * @param {boolean} [props.isOpen] dropdown open 여부
39
36
  * @param {boolean} [props.disabled] disabled 여부
40
37
  * @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 상태 대체 콘텐츠
38
+ * @param {SelectDropdownExtension} [props.dropdown] dropdown 확장 옵션
47
39
  * @param {boolean} [props.open] controlled open 상태
48
40
  * @param {boolean} [props.defaultOpen] uncontrolled 초기 open 상태
49
- * @param {(open: boolean) => void} [props.onOpenChange] open 상태 변경 콜백
41
+ * @param {(open: boolean) => void} [props.onOpen] open 상태 변경 콜백
50
42
  * @param {boolean} [props.showSelectAllOption] dropdown 첫 번째에 "전체" 옵션 노출 여부
51
43
  * @param {React.ReactNode} [props.selectAllLabel="전체"] 전체 옵션 라벨
52
44
  */
@@ -64,26 +56,19 @@ const SelectMultipleTrigger = forwardRef<
64
56
  state = "default",
65
57
  block,
66
58
  width,
67
- isOpen,
68
59
  disabled,
69
60
  readOnly,
70
61
  tags,
71
- options = [],
72
- selectedOptionIds,
73
- onChange,
74
- onOptionSelect,
75
- dropdownSize,
76
- dropdownWidth = "match",
77
- dropdownRootProps,
78
- dropdownContainerProps,
79
- dropdownMenuListProps,
80
- alt,
62
+ items,
63
+ onSelectOption,
64
+ onSelectChange,
65
+ dropdownOptions: dropdown,
81
66
  open,
82
67
  defaultOpen,
83
- onOpenChange,
68
+ onOpen,
84
69
  showSelectAllOption,
85
70
  selectAllLabel = "전체",
86
- ...rest
71
+ triggerProps,
87
72
  },
88
73
  ref,
89
74
  ) => {
@@ -94,13 +79,9 @@ const SelectMultipleTrigger = forwardRef<
94
79
  */
95
80
  const resolvedBlock =
96
81
  block || (priority === "table" && width === undefined);
97
-
98
- /**
99
- * 2) 선택 상태 모드 결정
100
- * - selectedOptionIds prop이 있으면 controlled 모드
101
- * - selectedOptionIds prop이 없으면 uncontrolled 모드
102
- */
103
- const isSelectionControlled = selectedOptionIds !== undefined;
82
+ // 변경: xsmall은 primary 전용이므로 secondary/table에서는 small로 fallback한다.
83
+ const resolvedSize =
84
+ priority !== "primary" && size === "xsmall" ? "small" : size;
104
85
 
105
86
  /**
106
87
  * 3) option 조회 최적화 맵 생성
@@ -108,13 +89,13 @@ const SelectMultipleTrigger = forwardRef<
108
89
  * - label 계산, tag 파생, payload selectedOptions 계산에 공통 사용한다.
109
90
  */
110
91
  const optionMap = useMemo(
111
- () => new Map(options.map(option => [option.id, option])),
112
- [options],
92
+ () => new Map(items.map(option => [option.id, option])),
93
+ [items],
113
94
  );
114
95
  // 변경: 전체 선택 대상은 disabled option을 제외한 "선택 가능 옵션"으로 한정한다.
115
96
  const selectableOptions = useMemo(
116
- () => options.filter(option => !option.disabled),
117
- [options],
97
+ () => items.filter(option => !option.disabled),
98
+ [items],
118
99
  );
119
100
  // 변경: 전체 선택 토글 계산을 위해 선택 가능 옵션 id 배열을 별도로 보관한다.
120
101
  const selectableOptionIds = useMemo(
@@ -128,7 +109,7 @@ const SelectMultipleTrigger = forwardRef<
128
109
  );
129
110
  const allOptionId = useMemo(() => {
130
111
  // 현재 전달된 옵션 id 집합을 먼저 만든다.
131
- const existingIdSet = new Set(options.map(option => option.id));
112
+ const existingIdSet = new Set(items.map(option => option.id));
132
113
  // "__select_multiple_all__"가 비어 있으면 그대로 사용한다.
133
114
  if (!existingIdSet.has(SELECT_MULTIPLE_ALL_OPTION_BASE_ID)) {
134
115
  return SELECT_MULTIPLE_ALL_OPTION_BASE_ID;
@@ -143,15 +124,15 @@ const SelectMultipleTrigger = forwardRef<
143
124
  }
144
125
  // 최종적으로 충돌하지 않는 synthetic all-option id를 반환한다.
145
126
  return nextId;
146
- }, [options]);
127
+ }, [items]);
147
128
 
148
129
  /**
149
130
  * 4) uncontrolled 초기 선택값 계산
150
- * - options[].selected를 다중 선택 초기값으로 그대로 반영한다.
131
+ * - items[].selected를 다중 선택 초기값으로 그대로 반영한다.
151
132
  */
152
133
  const selectedIdsFromOptions = useMemo(
153
- () => options.filter(option => option.selected).map(option => option.id),
154
- [options],
134
+ () => items.filter(option => option.selected).map(option => option.id),
135
+ [items],
155
136
  );
156
137
 
157
138
  /**
@@ -163,17 +144,13 @@ const SelectMultipleTrigger = forwardRef<
163
144
  useState<string[]>(() => selectedIdsFromOptions);
164
145
 
165
146
  /**
166
- * 6) options 변경 시 내부 state 정합성 보정(uncontrolled 전용)
167
- * - 기존 선택 id 중 현재 options에 남아있는 id만 유지한다.
147
+ * 6) items 변경 시 내부 state 정합성 보정
148
+ * - 기존 선택 id 중 현재 items에 남아있는 id만 유지한다.
168
149
  * - 남은 id가 없으면 selectedIdsFromOptions(초기 선택 규칙)로 재동기화한다.
169
150
  */
170
151
  useEffect(() => {
171
- if (isSelectionControlled) {
172
- return;
173
- }
174
-
175
152
  setUncontrolledSelectedOptionIds(previousSelectedIds => {
176
- const optionIdSet = new Set(options.map(option => option.id));
153
+ const optionIdSet = new Set(items.map(option => option.id));
177
154
  const filteredIds = previousSelectedIds.filter(selectedId =>
178
155
  optionIdSet.has(selectedId),
179
156
  );
@@ -187,27 +164,19 @@ const SelectMultipleTrigger = forwardRef<
187
164
 
188
165
  return nextSelectedIds;
189
166
  });
190
- }, [isSelectionControlled, options, selectedIdsFromOptions]);
167
+ }, [items, selectedIdsFromOptions]);
191
168
 
192
169
  /**
193
170
  * 7) 최종 선택 id 계산
194
- * - controlled: selectedOptionIds 우선
195
- * - uncontrolled: 내부 state 사용
171
+ * - 내부 state 사용
196
172
  */
197
173
  const resolvedSelectedIds = useMemo(() => {
198
- const sourceSelectedIds = isSelectionControlled
199
- ? (selectedOptionIds ?? [])
200
- : uncontrolledSelectedOptionIds;
201
-
202
174
  // 변경: 실제 option id만 유지해 내부 synthetic all-option id가 상태에 저장되지 않도록 차단한다.
203
175
  // 이 필터 덕분에 최종 선택 상태는 "실 데이터 옵션"만 source of truth로 유지된다.
204
- return sourceSelectedIds.filter(selectedId => optionMap.has(selectedId));
205
- }, [
206
- isSelectionControlled,
207
- optionMap,
208
- selectedOptionIds,
209
- uncontrolledSelectedOptionIds,
210
- ]);
176
+ return uncontrolledSelectedOptionIds.filter(selectedId =>
177
+ optionMap.has(selectedId),
178
+ );
179
+ }, [optionMap, uncontrolledSelectedOptionIds]);
211
180
  const selectedIdSet = useMemo(
212
181
  () => new Set(resolvedSelectedIds),
213
182
  [resolvedSelectedIds],
@@ -223,7 +192,7 @@ const SelectMultipleTrigger = forwardRef<
223
192
  );
224
193
  const selectAllOption = useMemo<SelectDropdownOption>(
225
194
  // 변경: "전체" 옵션은 렌더링을 위한 synthetic option 객체다.
226
- // 실제 options 배열에 저장하지 않고 render 단계에서만 주입한다.
195
+ // 실제 items 배열에 저장하지 않고 render 단계에서만 주입한다.
227
196
  () => ({
228
197
  id: allOptionId,
229
198
  value: allOptionId,
@@ -283,12 +252,12 @@ const SelectMultipleTrigger = forwardRef<
283
252
 
284
253
  /**
285
254
  * 11) dropdown open 상태 관리
286
- * - open/defaultOpen/onOpenChange 계약을 hook으로 통합 처리한다.
255
+ * - open/defaultOpen/onOpen 계약을 hook으로 통합 처리한다.
287
256
  */
288
257
  const { open: dropdownOpen, setOpen } = useSelectDropdownOpenState({
289
- open: open ?? isOpen,
258
+ open,
290
259
  defaultOpen,
291
- onOpenChange,
260
+ onOpen,
292
261
  });
293
262
 
294
263
  /**
@@ -315,17 +284,15 @@ const SelectMultipleTrigger = forwardRef<
315
284
  * - 최대 3개 tag만 표시하고 나머지는 summary chip(+N)로 축약한다.
316
285
  * - 과도한 폭 확장을 방지해 trigger layout 안정성을 유지한다.
317
286
  */
318
- const panelSize = (dropdownSize ?? size) as DropdownSize;
319
- const hasOptions = options.length > 0;
287
+ const hasOptions = items.length > 0;
320
288
  // "전체" 항목은 옵션이 존재할 때만 의미가 있으므로 hasOptions와 함께 gating한다.
321
289
  const shouldRenderSelectAllOption = Boolean(
322
290
  showSelectAllOption && hasOptions,
323
291
  );
324
292
  const renderedOptions = useMemo(
325
293
  // 변경: all-option은 dropdown 첫 행 고정 요구사항에 맞춰 항상 배열 맨 앞에 주입한다.
326
- () =>
327
- shouldRenderSelectAllOption ? [selectAllOption, ...options] : options,
328
- [options, selectAllOption, shouldRenderSelectAllOption],
294
+ () => (shouldRenderSelectAllOption ? [selectAllOption, ...items] : items),
295
+ [items, selectAllOption, shouldRenderSelectAllOption],
329
296
  );
330
297
  const MAX_VISIBLE_TAGS = 3;
331
298
  const visibleTags = hasTags ? derivedTags.slice(0, MAX_VISIBLE_TAGS) : [];
@@ -336,11 +303,9 @@ const SelectMultipleTrigger = forwardRef<
336
303
  /**
337
304
  * 15) option 선택 처리(multiple toggle)
338
305
  * - 이미 선택된 id면 제거, 아니면 추가한다.
339
- * - uncontrolled 모드면 내부 state 갱신
340
- * - onChange(payload)로 결과 전체를 전달
341
- * - legacy onOptionSelect는 하위호환으로 유지
306
+ * - onSelectOption/onSelectChange 콜백으로 상호작용과 변경을 분리해 전달한다.
342
307
  */
343
- const handleOptionSelect = (optionId: string) => {
308
+ const handleOptionSelect = (optionId: string, event: Event) => {
344
309
  // 현재 클릭된 옵션이 synthetic all-option인지 먼저 판별한다.
345
310
  const isSelectAllOption =
346
311
  shouldRenderSelectAllOption && optionId === allOptionId;
@@ -355,36 +320,16 @@ const SelectMultipleTrigger = forwardRef<
355
320
  : Array.from(
356
321
  new Set([...resolvedSelectedIds, ...selectableOptionIds]),
357
322
  );
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
- );
323
+ const didChange = !isSameIdList(
324
+ resolvedSelectedIds,
325
+ nextSelectedOptionIds,
326
+ );
367
327
 
368
- // uncontrolled 모드에서는 내부 state를 즉시 동기화한다.
369
- if (!isSelectionControlled) {
328
+ onSelectOption?.(selectAllOption, undefined, event);
329
+ if (didChange) {
370
330
  setUncontrolledSelectedOptionIds(nextSelectedOptionIds);
331
+ onSelectChange?.(selectAllOption, undefined, event);
371
332
  }
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
333
  return;
389
334
  }
390
335
 
@@ -394,36 +339,15 @@ const SelectMultipleTrigger = forwardRef<
394
339
  const nextSelectedOptionIds = wasSelected
395
340
  ? resolvedSelectedIds.filter(selectedId => selectedId !== optionId)
396
341
  : [...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
342
  const currentOption = optionMap.get(optionId);
406
343
 
407
344
  if (!currentOption) {
408
345
  return;
409
346
  }
410
347
 
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);
348
+ onSelectOption?.(currentOption, undefined, event);
349
+ setUncontrolledSelectedOptionIds(nextSelectedOptionIds);
350
+ onSelectChange?.(currentOption, undefined, event);
427
351
  };
428
352
 
429
353
  /**
@@ -442,14 +366,14 @@ const SelectMultipleTrigger = forwardRef<
442
366
  open={dropdownOpen}
443
367
  onOpenChange={handleOpenChange}
444
368
  modal={false}
445
- {...dropdownRootProps}
369
+ {...dropdown?.rootProps}
446
370
  >
447
371
  {/* Select trigger와 Dropdown trigger를 결합해 동일한 DOM을 공유한다. */}
448
372
  <Dropdown.Trigger asChild>
449
373
  <SelectTriggerBase
450
374
  ref={ref}
451
375
  priority={priority}
452
- size={size}
376
+ size={resolvedSize}
453
377
  state={disabled ? "disabled" : state}
454
378
  block={resolvedBlock}
455
379
  open={dropdownOpen}
@@ -457,7 +381,7 @@ const SelectMultipleTrigger = forwardRef<
457
381
  disabled={disabled}
458
382
  readOnly={readOnly}
459
383
  as="div"
460
- {...rest}
384
+ {...triggerProps}
461
385
  >
462
386
  {hasTags ? (
463
387
  <div className="select-tags">
@@ -485,16 +409,18 @@ const SelectMultipleTrigger = forwardRef<
485
409
  label={resolvedDisplayLabel}
486
410
  placeholder={placeholder}
487
411
  isPlaceholder={!hasLabel}
412
+ // 변경: Multiple의 Selected 뷰는 표시 전용이므로 항상 편집을 차단한다.
413
+ readOnly
488
414
  />
489
415
  )}
490
416
  </SelectTriggerBase>
491
417
  </Dropdown.Trigger>
492
418
  <Dropdown.Container
493
- {...dropdownContainerProps}
494
- size={panelSize}
495
- width={dropdownWidth}
419
+ {...dropdown?.containerProps}
420
+ size={dropdown?.size ?? resolvedSize}
421
+ width={dropdown?.width ?? "match"}
496
422
  >
497
- <Dropdown.Menu.List {...dropdownMenuListProps}>
423
+ <Dropdown.Menu.List {...dropdown?.menuListProps}>
498
424
  {hasOptions ? (
499
425
  <>
500
426
  {/* multi select 전용 옵션을 Dropdown.Menu.Item으로 노출한다. */}
@@ -519,7 +445,7 @@ const SelectMultipleTrigger = forwardRef<
519
445
  event.preventDefault();
520
446
  return;
521
447
  }
522
- handleOptionSelect(option.id);
448
+ handleOptionSelect(option.id, event);
523
449
  }}
524
450
  />
525
451
  ))}
@@ -527,7 +453,7 @@ const SelectMultipleTrigger = forwardRef<
527
453
  ) : (
528
454
  <Dropdown.Menu.Item
529
455
  // 변경: 사용처 1회 상수 대신 인라인 fallback으로 empty label을 처리한다.
530
- label={alt ?? "선택할 항목이 없습니다."}
456
+ label={dropdown?.alt ?? "선택할 항목이 없습니다."}
531
457
  disabled
532
458
  className="dropdown-menu-alt"
533
459
  />