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