@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.
@@ -2,8 +2,8 @@
2
2
 
3
3
  import clsx from "clsx";
4
4
  import { forwardRef, useEffect, useMemo, useState } from "react";
5
+ import type { ReactNode } from "react";
5
6
 
6
- import type { DropdownSize } from "../../dropdown/types";
7
7
  import { Dropdown } from "../../dropdown/markup";
8
8
  import { SelectTriggerBase, SelectTriggerSelected } from "./foundation";
9
9
  import Container from "./foundation/Container";
@@ -18,31 +18,49 @@ const isSameIdList = (previousIds: string[], nextIds: string[]) =>
18
18
  const normalizeSingleSelectedIds = (selectedIds: string[]) =>
19
19
  selectedIds.length > 0 ? [selectedIds[0]] : [];
20
20
 
21
+ const toInputText = (value?: ReactNode): string => {
22
+ if (typeof value === "string" || typeof value === "number") {
23
+ return String(value);
24
+ }
25
+
26
+ return "";
27
+ };
28
+
29
+ const SELECT_CUSTOM_OPTION_BASE_ID = "__select_custom_input__";
30
+ const SELECT_CUSTOM_OPTION_VALUE = "CUSTOM";
31
+
21
32
  /**
22
33
  * Select default trigger; 단일 선택 드롭다운을 렌더링한다.
23
34
  * @component
24
35
  * @param {SelectDefaultComponentProps} props default trigger props
25
- * @param {SelectDropdownOption[]} [props.options] dropdown option 목록
26
- * @param {string[]} [props.selectedOptionIds] 선택된 option id 리스트
27
- * @param {(payload: SelectOptionChangePayload) => void} [props.onChange] 선택 결과 변경 콜백
28
- * @param {(option: SelectDropdownOption) => void} [props.onOptionSelect] option 선택 콜백
29
- * @param {"primary" | "secondary" | "table"} [props.priority="primary"] priority 스케일
30
- * @param {"small" | "medium" | "large"} [props.size="medium"] size 스케일
31
- * @param {"default" | "focused" | "disabled"} [props.state="default"] 시각 상태
32
- * @param {boolean} [props.block] block 여부
33
- * @param {FormFieldWidth} [props.width] container width preset
36
+ * @param {string} [props.className] container className
37
+ * @param {ReactNode} [props.displayLabel] 강제 표시 라벨
38
+ * @param {ReactNode} [props.placeholder] 기본 placeholder
39
+ * @param {"primary" | "secondary" | "table"} [props.priority="primary"] trigger priority
40
+ * @param {"xsmall" | "small" | "medium" | "large"} [props.size="medium"] trigger size
41
+ * @param {"default" | "focused" | "error" | "disabled"} [props.state="default"] trigger state
42
+ * @param {boolean} [props.block] block 레이아웃 여부
43
+ * @param {FormFieldWidth} [props.width] container width
34
44
  * @param {boolean} [props.disabled] disabled 여부
35
45
  * @param {boolean} [props.readOnly] readOnly 여부
36
- * @param {SelectTriggerButtonType} [props.buttonType] trigger button type
37
- * @param {"small" | "medium" | "large"} [props.dropdownSize] dropdown panel size
38
- * @param {"match" | "fit-content" | "max-content" | string | number} [props.dropdownWidth="match"] dropdown panel width
39
- * @param {Omit<DropdownMenuProps, "open" | "defaultOpen" | "onOpenChange">} [props.dropdownRootProps] Dropdown.Root 전달 props
40
- * @param {Omit<DropdownContainerProps, "children" | "size" | "width">} [props.dropdownContainerProps] Dropdown.Container 전달 props
41
- * @param {DropdownMenuListProps} [props.dropdownMenuListProps] Dropdown.Menu.List 전달 props
42
- * @param {ReactNode} [props.alt] empty 상태 대체 콘텐츠
43
- * @param {boolean} [props.open] controlled open 상태
44
- * @param {boolean} [props.defaultOpen] uncontrolled 초기 open 상태
45
- * @param {(open: boolean) => void} [props.onOpenChange] open 상태 변경 콜백
46
+ * @param {"button" | "submit" | "reset"} [props.buttonType] trigger button type
47
+ * @param {SelectDropdownOption[]} props.items dropdown 아이템 목록
48
+ * @param {SelectCallbackParams} [props.onSelectOption] option interaction 콜백
49
+ * @param {SelectCallbackParams} [props.onSelectChange] selection change 콜백
50
+ * @param {SelectDropdownExtension} [props.dropdownOptions] dropdown 확장 옵션
51
+ * @param {boolean} [props.open] controlled open
52
+ * @param {boolean} [props.defaultOpen] uncontrolled 초기 open
53
+ * @param {(open: boolean) => void} [props.onOpen] open 변경 콜백
54
+ * @param {HTMLAttributes<HTMLElement>} [props.triggerProps] trigger native props
55
+ * @param {UseFormRegisterReturn} [props.register] value field register
56
+ * @param {UseFormRegisterReturn} [props.customRegister] custom label register
57
+ * @param {SelectCustomOptions} [props.customOptions] 직접입력 옵션 구성
58
+ * @param {SelectCustomInputProps} [props.inputProps] label input native 속성
59
+ * @example
60
+ * <Select.Default
61
+ * items={[{ id: "apple", value: "APPLE", label: "사과" }]}
62
+ * placeholder="품목을 선택하세요"
63
+ * />
46
64
  */
47
65
  const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
48
66
  (
@@ -55,168 +73,122 @@ const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
55
73
  state = "default",
56
74
  block,
57
75
  width,
58
- isOpen,
59
76
  disabled,
60
77
  readOnly,
61
78
  buttonType,
62
- options = [],
63
- selectedOptionIds,
64
- onChange,
65
- onOptionSelect,
66
- dropdownSize,
67
- dropdownWidth = "match",
68
- dropdownRootProps,
69
- dropdownContainerProps,
70
- dropdownMenuListProps,
71
- alt,
79
+ items,
80
+ onSelectOption,
81
+ onSelectChange,
82
+ dropdownOptions,
72
83
  open,
73
84
  defaultOpen,
74
- onOpenChange,
75
- ...rest
85
+ onOpen,
86
+ triggerProps,
87
+ register,
88
+ customRegister,
89
+ customOptions,
90
+ inputProps,
76
91
  },
77
92
  ref,
78
93
  ) => {
79
- /**
80
- * 1) 레이아웃 기본값 계산
81
- * - table priority는 셀 컨텍스트에서 full width가 기본 동작이므로
82
- * width 미지정 시 block=true처럼 동작하도록 보정한다.
83
- * - 이 값은 trigger/container 양쪽에서 동일하게 사용한다.
84
- */
94
+ // 1) table priority + width 미지정 조합에서는 block을 기본 동작으로 사용한다.
85
95
  const resolvedBlock =
86
96
  block || (priority === "table" && width === undefined);
97
+ // 1-1) xsmall은 primary 전용이므로 secondary/table에서는 small로 fallback한다.
98
+ const resolvedSize =
99
+ priority !== "primary" && size === "xsmall" ? "small" : size;
100
+
101
+ // 2) custom mode의 입력값은 내부 state로 유지한다.
102
+ const [customLabelValue, setCustomLabelValue] = useState("");
103
+
104
+ // 3) custom option id가 기존 item id와 충돌하지 않도록 안전한 id를 만든다.
105
+ const customOptionId = useMemo(() => {
106
+ const existingIdSet = new Set(items.map(option => option.id));
107
+ if (!existingIdSet.has(SELECT_CUSTOM_OPTION_BASE_ID)) {
108
+ return SELECT_CUSTOM_OPTION_BASE_ID;
109
+ }
110
+
111
+ let offset = 1;
112
+ let nextId = `${SELECT_CUSTOM_OPTION_BASE_ID}_${offset}`;
113
+ while (existingIdSet.has(nextId)) {
114
+ offset += 1;
115
+ nextId = `${SELECT_CUSTOM_OPTION_BASE_ID}_${offset}`;
116
+ }
117
+ return nextId;
118
+ }, [items]);
119
+
120
+ // 4) customOptions가 있으면 dropdown 마지막에 "직접 입력" 항목을 주입한다.
121
+ const mergedOptions = useMemo(() => {
122
+ if (!customOptions) {
123
+ return items;
124
+ }
125
+
126
+ return [
127
+ ...items,
128
+ {
129
+ id: customOptionId,
130
+ value: SELECT_CUSTOM_OPTION_VALUE,
131
+ label: customOptions.optionName ?? "직접 입력",
132
+ data: { isCustomInput: true },
133
+ } satisfies SelectDropdownOption,
134
+ ];
135
+ }, [customOptionId, customOptions, items]);
87
136
 
88
- /**
89
- * 2) 선택 상태 모드 결정
90
- * - selectedOptionIds prop이 들어오면 controlled 모드(외부가 source of truth)
91
- * - selectedOptionIds prop이 없으면 uncontrolled 모드(내부 state가 source of truth)
92
- */
93
- const isSelectionControlled = selectedOptionIds !== undefined;
94
-
95
- /**
96
- * 3) option 조회 최적화 맵 생성
97
- * - 옵션 탐색(find/filter) 반복을 줄이기 위해 id 기반 맵을 만든다.
98
- * - display label 계산, payload selectedOptions 계산, 렌더 시 선택 확인에 재사용한다.
99
- */
137
+ // 5) 선택 id로 option을 즉시 조회할 수 있도록 map을 만든다.
100
138
  const optionMap = useMemo(
101
- () => new Map(options.map(option => [option.id, option])),
102
- [options],
139
+ () => new Map(mergedOptions.map(option => [option.id, option])),
140
+ [mergedOptions],
103
141
  );
104
142
 
105
- /**
106
- * 4) uncontrolled 초기 선택값 계산
107
- * - options[].selected를 초기 선택 입력으로 사용한다.
108
- * - single-select이므로 selected=true가 여러 개여도 첫 id 하나만 채택한다.
109
- */
110
- const selectedIdsFromOptions = useMemo(() => {
111
- const initialSelectedIds = options
143
+ // 6) uncontrolled 초기 선택값은 items[].selected의 첫 번째 항목만 사용한다.
144
+ const selectedIdsFromItems = useMemo(() => {
145
+ const initialSelectedIds = items
112
146
  .filter(option => option.selected)
113
147
  .map(option => option.id);
114
148
  return normalizeSingleSelectedIds(initialSelectedIds);
115
- }, [options]);
116
-
117
- /**
118
- * 5) uncontrolled 내부 state 선언
119
- * - controlled 모드에서는 실제 UI source로 쓰이지 않는다.
120
- * - uncontrolled 모드에서는 최종 선택 id를 내부 state가 소유한다.
121
- */
122
- const [uncontrolledSelectedOptionIds, setUncontrolledSelectedOptionIds] =
123
- useState<string[]>(() => selectedIdsFromOptions);
124
-
125
- /**
126
- * 6) options 변경 시 내부 state 정합성 보정(uncontrolled 전용)
127
- * - controlled 모드는 외부 selectedOptionIds를 신뢰하므로 내부 state를 건드리지 않는다.
128
- * - uncontrolled 모드는 옵션 리스트가 바뀌면 stale id를 정리해야 한다.
129
- * 1) 기존 선택 id가 현재 options에 존재하는지 필터링
130
- * 2) 남아 있으면 single 정책으로 첫 id 하나만 유지
131
- * 3) 남지 않으면 selectedIdsFromOptions(초기 선택 규칙)로 재동기화
132
- */
133
- useEffect(() => {
134
- if (isSelectionControlled) {
135
- return;
136
- }
149
+ }, [items]);
137
150
 
138
- setUncontrolledSelectedOptionIds(previousSelectedIds => {
139
- const optionIdSet = new Set(options.map(option => option.id));
140
- const filteredIds = previousSelectedIds.filter(selectedId =>
141
- optionIdSet.has(selectedId),
142
- );
143
- const nextCandidateIds =
144
- filteredIds.length > 0 ? filteredIds : selectedIdsFromOptions;
145
- const nextSelectedIds = normalizeSingleSelectedIds(nextCandidateIds);
146
-
147
- // 선택 결과가 동일하면 기존 배열 참조를 유지해 effect 루프를 방지한다.
148
- if (isSameIdList(previousSelectedIds, nextSelectedIds)) {
151
+ // 7) 내부 선택 state를 선언한다.
152
+ const [selectedOptionIds, setSelectedOptionIds] = useState<string[]>(
153
+ () => selectedIdsFromItems,
154
+ );
155
+
156
+ // 8) 외부 items 변경 시 동일 규칙으로 내부 선택 state를 재동기화한다.
157
+ useEffect(() => {
158
+ setSelectedOptionIds(previousSelectedIds => {
159
+ if (isSameIdList(previousSelectedIds, selectedIdsFromItems)) {
149
160
  return previousSelectedIds;
150
161
  }
151
-
152
- return nextSelectedIds;
162
+ return selectedIdsFromItems;
153
163
  });
154
- }, [isSelectionControlled, options, selectedIdsFromOptions]);
155
-
156
- /**
157
- * 7) 최종 선택 id 계산
158
- * - controlled: selectedOptionIds 우선 사용
159
- * - uncontrolled: 내부 state를 사용
160
- * - single-select invariant 보장을 위해 항상 최대 1개 id만 반환한다.
161
- */
162
- const resolvedSelectedIds = useMemo(() => {
163
- const sourceIds = isSelectionControlled
164
- ? (selectedOptionIds ?? [])
165
- : uncontrolledSelectedOptionIds;
166
- return normalizeSingleSelectedIds(sourceIds);
167
- }, [
168
- isSelectionControlled,
169
- selectedOptionIds,
170
- uncontrolledSelectedOptionIds,
171
- ]);
172
- const selectedIdSet = useMemo(
173
- () => new Set(resolvedSelectedIds),
174
- [resolvedSelectedIds],
164
+ }, [selectedIdsFromItems]);
165
+
166
+ // 9) 단일 선택이므로 첫 번째 id를 현재 선택 option으로 본다.
167
+ const selectedOption =
168
+ selectedOptionIds.length > 0
169
+ ? optionMap.get(selectedOptionIds[0])
170
+ : undefined;
171
+
172
+ // 10) custom mode는 customOptions 존재 + custom option 선택 상태로 판단한다.
173
+ const isCustomInputActive = Boolean(
174
+ customOptions && selectedOption?.id === customOptionId,
175
175
  );
176
176
 
177
- /**
178
- * 8) 표시 라벨 계산
179
- * - 외부 displayLabel이 있으면 최우선 사용
180
- * - 없으면 현재 선택 id 기준으로 optionMap에서 label을 조회한다.
181
- * - optionMap을 사용해 O(1) 조회로 단순화한다.
182
- */
177
+ // 11) 외부 displayLabel이 있으면 우선 사용하고, 없으면 선택 option label을 사용한다.
183
178
  const resolvedDisplayLabel =
184
- displayLabel ??
185
- (resolvedSelectedIds.length > 0
186
- ? optionMap.get(resolvedSelectedIds[0])?.label
187
- : undefined);
188
-
189
- /**
190
- * 9) placeholder/label 표시 상태 계산
191
- * - null/undefined/빈 문자열이면 placeholder로 간주한다.
192
- */
193
- const hasLabel =
194
- resolvedDisplayLabel !== undefined &&
195
- resolvedDisplayLabel !== null &&
196
- resolvedDisplayLabel !== "";
197
-
198
- /**
199
- * 10) dropdown open 상태 관리
200
- * - open/defaultOpen/onOpenChange 계약은 기존 useSelectDropdownOpenState를 그대로 사용한다.
201
- * - 내부 state와 controlled open 상태를 동시에 지원한다.
202
- */
179
+ displayLabel ?? (isCustomInputActive ? undefined : selectedOption?.label);
180
+
181
+ // 12) open 제어형/비제어형 계약은 공용 hook으로 통합 처리한다.
203
182
  const { open: dropdownOpen, setOpen } = useSelectDropdownOpenState({
204
- open: open ?? isOpen,
183
+ open,
205
184
  defaultOpen,
206
- onOpenChange,
185
+ onOpen,
207
186
  });
208
187
 
209
- /**
210
- * 11) 상호작용 차단 조건 계산
211
- * - disabled/readOnly일 때는 open 토글과 option select를 모두 차단한다.
212
- */
188
+ // 13) disabled/readOnly 상태에서는 open/option 선택 인터랙션을 모두 차단한다.
213
189
  const isInteractionBlocked = disabled || readOnly;
214
190
 
215
- /**
216
- * 12) open 상태 변경 핸들러
217
- * - 차단 상태면 강제로 닫힘(false) 유지
218
- * - 허용 상태면 전달받은 nextOpen 반영
219
- */
191
+ // 14) open 상태 변경 처리: 차단 상태면 닫힘 고정, 아니면 nextOpen 반영.
220
192
  const handleOpenChange = (nextOpen: boolean) => {
221
193
  if (isInteractionBlocked) {
222
194
  setOpen(false);
@@ -225,60 +197,34 @@ const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
225
197
  setOpen(nextOpen);
226
198
  };
227
199
 
228
- /**
229
- * 13) option 선택 처리(single)
230
- * - single-select 정책: 클릭된 option 하나를 최종 선택값으로 고정
231
- * - uncontrolled 모드면 내부 state 갱신
232
- * - onChange(payload)로 현재 선택 결과 전체를 전달
233
- * - legacy onOptionSelect는 하위호환으로 유지
234
- */
235
- const handleOptionSelect = (option: SelectDropdownOption) => {
200
+ // 15) option 선택 처리: onSelectOption 호출 후, 변경이 있을 때만 onSelectChange를 호출한다.
201
+ const handleOptionSelect = (option: SelectDropdownOption, event: Event) => {
236
202
  if (isInteractionBlocked) {
237
203
  return;
238
204
  }
239
205
 
240
- const nextSelectedOptionIds = [option.id];
241
- const nextSelectedOptions = nextSelectedOptionIds
242
- .map(selectedOptionId => optionMap.get(selectedOptionId))
243
- .filter(
244
- (
245
- selectedOption,
246
- ): selectedOption is NonNullable<typeof selectedOption> =>
247
- Boolean(selectedOption),
248
- );
249
-
250
- if (!isSelectionControlled) {
251
- setUncontrolledSelectedOptionIds(nextSelectedOptionIds);
252
- }
206
+ const previousOption = selectedOption;
207
+ const hasChanged = previousOption?.id !== option.id;
253
208
 
254
- onChange?.({
255
- mode: "single",
256
- selectedOptionIds: nextSelectedOptionIds,
257
- selectedValues: nextSelectedOptions.map(
258
- selectedOption => selectedOption.value,
259
- ),
260
- selectedOptions: nextSelectedOptions,
261
- currentOption: option,
262
- isSelected: true,
263
- });
209
+ onSelectOption?.(option, previousOption, event);
210
+
211
+ if (hasChanged) {
212
+ setSelectedOptionIds([option.id]);
213
+ onSelectChange?.(option, previousOption, event);
214
+ }
264
215
 
265
- onOptionSelect?.(option);
266
216
  setOpen(false);
267
217
  };
268
218
 
269
- /**
270
- * 14) dropdown 패널 렌더링 파생값
271
- * - panelSize: trigger size와 동일 축을 기본으로 사용
272
- * - hasOptions: empty 상태 분기
273
- */
274
- const panelSize = (dropdownSize ?? size) as DropdownSize;
275
- const hasOptions = options.length > 0;
276
-
277
- /**
278
- * 15) 렌더
279
- * - Container → Dropdown.Root → Trigger → Container → Menu.List depth 유지
280
- * - empty 상태는 별도 구조를 만들지 않고 Dropdown.Menu.Item을 disabled로 재사용
281
- */
219
+ // 16) label input 값은 custom mode에서만 사용자 입력값/controlled inputProps.value를 사용한다.
220
+ const labelInputValue = isCustomInputActive
221
+ ? typeof inputProps?.value === "string" ||
222
+ typeof inputProps?.value === "number"
223
+ ? String(inputProps.value)
224
+ : customLabelValue
225
+ : toInputText(resolvedDisplayLabel);
226
+
227
+ // 17) 렌더: Container → Dropdown.Root → Trigger → Menu.List 구조를 유지한다.
282
228
  return (
283
229
  <Container
284
230
  className={clsx("select-trigger-container", className)}
@@ -289,39 +235,63 @@ const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
289
235
  open={dropdownOpen}
290
236
  onOpenChange={handleOpenChange}
291
237
  modal={false}
292
- {...dropdownRootProps}
238
+ {...dropdownOptions?.rootProps}
293
239
  >
294
- {/* Dropdown.Trigger를 통해 Select trigger를 그대로 재사용한다. */}
295
240
  <Dropdown.Trigger asChild>
296
241
  <SelectTriggerBase
297
242
  ref={ref}
298
243
  priority={priority}
299
- size={size}
244
+ size={resolvedSize}
300
245
  state={disabled ? "disabled" : state}
301
246
  block={resolvedBlock}
302
247
  open={dropdownOpen}
303
248
  disabled={disabled}
304
249
  readOnly={readOnly}
305
250
  buttonType={buttonType}
306
- {...rest}
251
+ {...triggerProps}
307
252
  >
308
253
  <SelectTriggerSelected
309
254
  label={resolvedDisplayLabel}
310
- placeholder={placeholder}
311
- isPlaceholder={!hasLabel}
255
+ placeholder={
256
+ isCustomInputActive
257
+ ? (customOptions?.placeholder ?? toInputText(placeholder))
258
+ : placeholder
259
+ }
260
+ isPlaceholder={
261
+ isCustomInputActive
262
+ ? false
263
+ : resolvedDisplayLabel === undefined ||
264
+ resolvedDisplayLabel === null ||
265
+ resolvedDisplayLabel === ""
266
+ }
267
+ // 변경: custom mode가 아닐 때는 label input을 읽기 전용으로 고정한다.
268
+ readOnly={readOnly || !isCustomInputActive}
269
+ register={register}
270
+ customRegister={
271
+ isCustomInputActive ? customRegister : undefined
272
+ }
273
+ inputProps={inputProps}
274
+ valueText={labelInputValue}
275
+ valueFieldValue={
276
+ isCustomInputActive && !customRegister
277
+ ? labelInputValue
278
+ : (selectedOption?.value ?? "")
279
+ }
280
+ // 변경: custom mode 진입 시 label input에 focus를 연결한다.
281
+ shouldFocusInput={isCustomInputActive}
282
+ onLabelChange={setCustomLabelValue}
312
283
  />
313
284
  </SelectTriggerBase>
314
285
  </Dropdown.Trigger>
315
286
  <Dropdown.Container
316
- {...dropdownContainerProps}
317
- size={panelSize}
318
- width={dropdownWidth}
287
+ {...dropdownOptions?.containerProps}
288
+ size={dropdownOptions?.size ?? resolvedSize}
289
+ width={dropdownOptions?.width ?? "match"}
319
290
  >
320
- <Dropdown.Menu.List {...dropdownMenuListProps}>
321
- {hasOptions ? (
291
+ <Dropdown.Menu.List {...dropdownOptions?.menuListProps}>
292
+ {mergedOptions.length > 0 ? (
322
293
  <>
323
- {/* Dropdown menu option들을 그대로 매핑해 선택 이벤트를 전달한다. */}
324
- {options.map(option => (
294
+ {mergedOptions.map(option => (
325
295
  <Dropdown.Menu.Item
326
296
  key={option.id}
327
297
  label={option.label}
@@ -330,21 +300,20 @@ const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
330
300
  left={option.left}
331
301
  right={option.right}
332
302
  multiple={Boolean(option.multiple)}
333
- isSelected={selectedIdSet.has(option.id)}
303
+ isSelected={selectedOption?.id === option.id}
334
304
  onSelect={event => {
335
305
  if (option.disabled) {
336
306
  event.preventDefault();
337
307
  return;
338
308
  }
339
- handleOptionSelect(option);
309
+ handleOptionSelect(option, event);
340
310
  }}
341
311
  />
342
312
  ))}
343
313
  </>
344
314
  ) : (
345
315
  <Dropdown.Menu.Item
346
- // 변경: 사용처 1회 상수 대신 인라인 fallback으로 empty label을 처리한다.
347
- label={alt ?? "선택할 항목이 없습니다."}
316
+ label={dropdownOptions?.alt ?? "선택할 항목이 없습니다."}
348
317
  disabled
349
318
  className="dropdown-menu-alt"
350
319
  />
@@ -12,7 +12,7 @@ import type { SelectTriggerBaseProps } from "../../types/trigger";
12
12
  * @component
13
13
  * @param {SelectTriggerBaseProps} props trigger base props
14
14
  * @param {"primary" | "secondary" | "table"} [props.priority="primary"] 스타일 우선순위
15
- * @param {"small" | "medium" | "large"} [props.size="medium"] 높이 스케일
15
+ * @param {"xsmall" | "small" | "medium" | "large"} [props.size="medium"] 높이 스케일
16
16
  * @param {"default" | "focused" | "error" | "disabled"} [props.state="default"] 시각 상태
17
17
  * @param {boolean} [props.open=false] dropdown open 상태
18
18
  * @param {boolean} [props.block=false] block 레이아웃 여부
@@ -47,7 +47,15 @@ const SelectTriggerBase = forwardRef<HTMLElement, SelectTriggerBaseProps>(
47
47
  },
48
48
  ref,
49
49
  ) => {
50
- const Icon = SelectIcon.Chevron[priority][size];
50
+ // 변경: xsmall은 primary 전용 정책이므로 non-primary는 small로만 접근한다.
51
+ const resolvedSize =
52
+ priority !== "primary" && size === "xsmall" ? "small" : size;
53
+ const Icon =
54
+ priority === "primary"
55
+ ? SelectIcon.Chevron.primary[resolvedSize]
56
+ : SelectIcon.Chevron[priority][
57
+ resolvedSize === "xsmall" ? "small" : resolvedSize
58
+ ];
51
59
  const resolvedState = disabled || readOnly ? "disabled" : state;
52
60
  const {
53
61
  ["aria-haspopup"]: ariaHasPopup,
@@ -58,7 +66,7 @@ const SelectTriggerBase = forwardRef<HTMLElement, SelectTriggerBaseProps>(
58
66
  const sharedProps = {
59
67
  className: clsx("select-button", className),
60
68
  "data-priority": priority,
61
- "data-size": size,
69
+ "data-size": resolvedSize,
62
70
  ...restProps,
63
71
  // 변경: Radix Dropdown.Trigger가 주입하는 data-state(open|closed)를 최종 단계에서 덮어써 visual state를 고정한다.
64
72
  "data-state": resolvedState,
@@ -87,7 +95,11 @@ const SelectTriggerBase = forwardRef<HTMLElement, SelectTriggerBaseProps>(
87
95
  return (
88
96
  <Slot.Base ref={ref} as={as} {...sharedProps} {...elementSpecificProps}>
89
97
  {children}
90
- <figure className="select-icon" data-size={size} aria-hidden="true">
98
+ <figure
99
+ className="select-icon"
100
+ data-size={resolvedSize}
101
+ aria-hidden="true"
102
+ >
91
103
  <Icon />
92
104
  </figure>
93
105
  </Slot.Base>
@@ -1,3 +1,4 @@
1
+ import ChevronPrimaryXsmall from "../../img/chevron/primary/xsmall.svg";
1
2
  import ChevronPrimarySmall from "../../img/chevron/primary/small.svg";
2
3
  import ChevronPrimaryMedium from "../../img/chevron/primary/medium.svg";
3
4
  import ChevronPrimaryLarge from "../../img/chevron/primary/large.svg";
@@ -7,6 +8,7 @@ import ChevronSecondaryLarge from "../../img/chevron/secondary/large.svg";
7
8
 
8
9
  import Remove from "../../img/remove.svg";
9
10
  import type {
11
+ SelectChevronNonPrimaryIconSizeMap,
10
12
  SelectIconCollection,
11
13
  SelectIconPriorityMap,
12
14
  SelectIconRemovePriorityMap,
@@ -15,11 +17,13 @@ import type {
15
17
 
16
18
  /**
17
19
  * Select; Chevron 아이콘 - primary
20
+ * - xsmall
18
21
  * - small
19
22
  * - medium
20
23
  * - large
21
24
  */
22
25
  const SelectChevronPrimaryIcon: SelectIconSizeMap = {
26
+ xsmall: ChevronPrimaryXsmall,
23
27
  small: ChevronPrimarySmall,
24
28
  medium: ChevronPrimaryMedium,
25
29
  large: ChevronPrimaryLarge,
@@ -31,7 +35,7 @@ const SelectChevronPrimaryIcon: SelectIconSizeMap = {
31
35
  * - medium
32
36
  * - large
33
37
  */
34
- const SelectChevronSecondaryIcon: SelectIconSizeMap = {
38
+ const SelectChevronSecondaryIcon: SelectChevronNonPrimaryIconSizeMap = {
35
39
  small: ChevronSecondarySmall,
36
40
  medium: ChevronSecondaryMedium,
37
41
  large: ChevronSecondaryLarge,
@@ -39,7 +43,7 @@ const SelectChevronSecondaryIcon: SelectIconSizeMap = {
39
43
 
40
44
  /**
41
45
  * Select; Chevron 아이콘 컬렉션
42
- * - primary (small, medium, large)
46
+ * - primary (xsmall, small, medium, large)
43
47
  * - secondary (small, medium, large)
44
48
  * - table (small, medium, large)
45
49
  */
@@ -52,11 +56,13 @@ const SelectChevronIcon: SelectIconPriorityMap = {
52
56
 
53
57
  /**
54
58
  * Select; Remove 아이콘 - primary
59
+ * - xsmall
55
60
  * - small
56
61
  * - medium
57
62
  * - large
58
63
  */
59
64
  const SelectMultipleRemoveIcon: SelectIconSizeMap = {
65
+ xsmall: Remove,
60
66
  small: Remove,
61
67
  medium: Remove,
62
68
  large: Remove,
@@ -64,7 +70,7 @@ const SelectMultipleRemoveIcon: SelectIconSizeMap = {
64
70
 
65
71
  /**
66
72
  * Select; Remove 아이콘 컬렉션
67
- * - primary/secondary/table (small, medium, large)
73
+ * - primary/secondary/table (xsmall, small, medium, large)
68
74
  */
69
75
  const SelectRemoveIcon: SelectIconRemovePriorityMap = {
70
76
  primary: SelectMultipleRemoveIcon,