@uniai-fe/uds-primitives 0.3.7 → 0.3.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-primitives",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "UNIAI Design System; Primitives Components Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -1,5 +1,7 @@
1
1
  "use client";
2
2
 
3
+ import { useEffect, useMemo, useState } from "react";
4
+
3
5
  import type { DropdownTemplateProps } from "../types/props";
4
6
 
5
7
  import DropdownRoot from "./foundation/Root";
@@ -8,6 +10,21 @@ import DropdownMenuItem from "./foundation/MenuItem";
8
10
  import DropdownMenuList from "./foundation/MenuList";
9
11
  import DropdownTrigger from "./foundation/Trigger";
10
12
 
13
+ const isSameIdList = (previousIds: string[], nextIds: string[]) =>
14
+ previousIds.length === nextIds.length &&
15
+ previousIds.every((selectedId, index) => selectedId === nextIds[index]);
16
+
17
+ const normalizeSelectedIdsByMode = (
18
+ selectedIds: string[],
19
+ multiple: boolean,
20
+ ) => {
21
+ if (multiple) {
22
+ return selectedIds;
23
+ }
24
+
25
+ return selectedIds.length > 0 ? [selectedIds[0]] : [];
26
+ };
27
+
11
28
  /**
12
29
  * Dropdown reference template; trigger/panel/menu 조합을 제공한다.
13
30
  * @component
@@ -15,6 +32,7 @@ import DropdownTrigger from "./foundation/Trigger";
15
32
  * @param {ReactNode} props.trigger trigger 요소
16
33
  * @param {DropdownTemplateItem[]} props.items 렌더링할 menu item 리스트
17
34
  * @param {string[]} [props.selectedIds] 선택된 item id 배열
35
+ * @param {(payload: DropdownTemplateChangePayload) => void} [props.onChange] 선택 결과 변경 콜백
18
36
  * @param {(item: DropdownTemplateItem) => void} [props.onSelect] item 선택 콜백
19
37
  * @param {"small" | "medium" | "large"} [props.size="medium"] menu size scale
20
38
  * @param {"match" | "fit-content" | "max-content" | string | number} [props.width="match"] panel width 옵션
@@ -26,7 +44,8 @@ import DropdownTrigger from "./foundation/Trigger";
26
44
  const DropdownTemplate = ({
27
45
  trigger,
28
46
  items,
29
- selectedIds = [],
47
+ selectedIds,
48
+ onChange,
30
49
  onSelect,
31
50
  size = "medium",
32
51
  width = "match",
@@ -35,8 +54,173 @@ const DropdownTemplate = ({
35
54
  menuListProps,
36
55
  alt,
37
56
  }: DropdownTemplateProps) => {
57
+ /**
58
+ * 1) 선택 상태 모드 결정
59
+ * - selectedIds prop이 들어오면 외부가 상태를 소유하는 controlled 모드다.
60
+ * - selectedIds prop이 없으면 Template 내부 state가 상태를 소유하는 uncontrolled 모드다.
61
+ * - 이 분기 기준은 렌더마다 동일해야 하므로 가장 먼저 계산한다.
62
+ */
63
+ const isSelectionControlled = selectedIds !== undefined;
64
+
65
+ /**
66
+ * 2) 선택 정책(single/multiple) 결정
67
+ * - item 중 하나라도 multiple=true이면 multiple 정책으로 해석한다.
68
+ * - multiple 정책에서는 item id 배열 전체를 유지한다.
69
+ * - single 정책에서는 언제나 최대 1개 id만 유지한다.
70
+ */
71
+ const isMultiple = items.some(item => item.multiple);
72
+
73
+ /**
74
+ * 3) uncontrolled 초기 선택값 계산
75
+ * - source of truth: items[].selected
76
+ * - multiple: selected=true인 모든 id를 초기값으로 사용한다.
77
+ * - single: selected=true가 여러 개여도 첫 번째 id 하나만 사용한다.
78
+ * - useMemo로 계산해 동일 inputs에서 불필요한 재계산을 줄인다.
79
+ */
80
+ const selectedIdsFromItems = useMemo(() => {
81
+ const initialSelectedIds = items
82
+ .filter(item => item.selected)
83
+ .map(item => item.id);
84
+
85
+ return normalizeSelectedIdsByMode(initialSelectedIds, isMultiple);
86
+ }, [isMultiple, items]);
87
+
88
+ /**
89
+ * 4) uncontrolled 내부 state 선언
90
+ * - controlled 모드에서는 이 state가 실제 화면 결정에 사용되지 않는다.
91
+ * - uncontrolled 모드에서는 이 state가 선택 결과의 단일 source가 된다.
92
+ * - 초기값은 selectedIdsFromItems를 그대로 사용한다.
93
+ */
94
+ const [uncontrolledSelectedIds, setUncontrolledSelectedIds] = useState<
95
+ string[]
96
+ >(() => selectedIdsFromItems);
97
+
98
+ /**
99
+ * 5) options(items) 변경 동기화
100
+ * - controlled 모드에서는 외부 selectedIds를 신뢰해야 하므로 내부 state를 건드리지 않는다.
101
+ * - uncontrolled 모드에서는 내부 state를 items 변경에 맞춰 정합성 보정한다.
102
+ * 1) 기존 선택 id 중 현재 items에 존재하는 id만 필터링한다.
103
+ * 2) 유효 id가 남아 있으면 그대로 유지한다(single은 1개만 유지).
104
+ * 3) 유효 id가 없으면 selectedIdsFromItems(초기 선택 규칙)로 재초기화한다.
105
+ * - 이 effect는 외부 데이터 재조회/필터 변경 시 stale id를 자동 정리하는 역할을 한다.
106
+ */
107
+ useEffect(() => {
108
+ if (isSelectionControlled) {
109
+ return;
110
+ }
111
+
112
+ setUncontrolledSelectedIds(previousSelectedIds => {
113
+ const itemIdSet = new Set(items.map(item => item.id));
114
+ const filteredIds = previousSelectedIds.filter(selectedId =>
115
+ itemIdSet.has(selectedId),
116
+ );
117
+ const nextCandidateIds =
118
+ filteredIds.length > 0 ? filteredIds : selectedIdsFromItems;
119
+ const nextSelectedIds = normalizeSelectedIdsByMode(
120
+ nextCandidateIds,
121
+ isMultiple,
122
+ );
123
+
124
+ // 내용이 동일하면 기존 참조를 재사용해 불필요한 상태 갱신 루프를 차단한다.
125
+ if (isSameIdList(previousSelectedIds, nextSelectedIds)) {
126
+ return previousSelectedIds;
127
+ }
128
+
129
+ return nextSelectedIds;
130
+ });
131
+ }, [isMultiple, isSelectionControlled, items, selectedIdsFromItems]);
132
+
133
+ /**
134
+ * 6) 최종 선택 id 계산
135
+ * - controlled: selectedIds prop을 그대로 사용한다(single은 첫 id만 허용).
136
+ * - uncontrolled: 내부 state(uncontrolledSelectedIds)를 그대로 사용한다.
137
+ * - 렌더/이벤트/selected 스타일 계산은 이 값만 참조한다.
138
+ */
139
+ const resolvedSelectedIds = normalizeSelectedIdsByMode(
140
+ isSelectionControlled ? (selectedIds ?? []) : uncontrolledSelectedIds,
141
+ isMultiple,
142
+ );
143
+
144
+ /**
145
+ * 7) empty panel 분기
146
+ * - item이 비어 있으면 alt 또는 기본 문구를 disabled item으로 렌더링한다.
147
+ * - 별도 li를 만들지 않고 MenuItem을 재사용해 구조를 통일한다.
148
+ */
38
149
  const hasItems = items.length > 0;
39
150
 
151
+ /**
152
+ * 8) item 선택 처리
153
+ * - 클릭된 item(id) 기준으로 다음 선택 배열을 계산한다.
154
+ * - multiple: 토글(add/remove)
155
+ * - single: 클릭한 id 하나로 교체
156
+ * - uncontrolled 모드면 내부 state를 즉시 반영한다.
157
+ * - onChange(payload)로 "현재 결과 전체"를 전달한다.
158
+ * - onSelect(item)은 하위호환을 위해 마지막에 유지 호출한다.
159
+ */
160
+ const handleItemSelect = (itemId: string) => {
161
+ /**
162
+ * 8-1) 클릭된 item 조회
163
+ * - items 목록에 없는 id면 안전하게 종료한다.
164
+ * - 외부 데이터 race 조건에서 방어적으로 동작하기 위한 가드다.
165
+ */
166
+ const currentItem = items.find(item => item.id === itemId);
167
+
168
+ if (!currentItem) {
169
+ return;
170
+ }
171
+
172
+ /**
173
+ * 8-2) 다음 selectedIds 계산
174
+ * - wasSelected: 토글 기준 값
175
+ * - nextSelectedIds: 모드 정책을 적용한 다음 상태
176
+ * - nextSelectedItems: payload 전달용 상세 데이터
177
+ */
178
+ const wasSelected = resolvedSelectedIds.includes(itemId);
179
+ const nextSelectedIds = isMultiple
180
+ ? wasSelected
181
+ ? resolvedSelectedIds.filter(selectedId => selectedId !== itemId)
182
+ : [...resolvedSelectedIds, itemId]
183
+ : [itemId];
184
+ const nextSelectedItems = items.filter(item =>
185
+ nextSelectedIds.includes(item.id),
186
+ );
187
+
188
+ /**
189
+ * 8-3) 내부 state 업데이트(uncontrolled 전용)
190
+ * - controlled 모드에서는 외부 selectedIds prop이 곧 source of truth이므로
191
+ * 내부 state를 갱신하지 않는다.
192
+ */
193
+ if (!isSelectionControlled) {
194
+ setUncontrolledSelectedIds(nextSelectedIds);
195
+ }
196
+
197
+ /**
198
+ * 8-4) 신규 계약 이벤트(onChange)
199
+ * - 서비스/상위 컴포넌트가 선택 결과 전체를 즉시 사용할 수 있게
200
+ * 현재 item + 선택 결과 배열을 함께 제공한다.
201
+ */
202
+ onChange?.({
203
+ currentItem,
204
+ isSelected: isMultiple ? !wasSelected : true,
205
+ selectedIds: nextSelectedIds,
206
+ selectedItems: nextSelectedItems,
207
+ multiple: isMultiple,
208
+ });
209
+
210
+ /**
211
+ * 8-5) legacy 계약 이벤트(onSelect)
212
+ * - 하위호환 경로를 유지하기 위해 onChange 이후 동일 선택을 전달한다.
213
+ * - 추후 마이그레이션 완료 시 제거 대상이다.
214
+ */
215
+ onSelect?.(currentItem);
216
+ };
217
+
218
+ /**
219
+ * 9) 렌더 구성
220
+ * - Root → Trigger → Container → MenuList depth를 유지한다.
221
+ * - 각 item은 resolvedSelectedIds 기준으로 selected 스타일을 결정한다.
222
+ * - disabled item은 select 이벤트를 차단하고 상태만 노출한다.
223
+ */
40
224
  return (
41
225
  <DropdownRoot {...rootProps}>
42
226
  <DropdownTrigger asChild>{trigger}</DropdownTrigger>
@@ -53,13 +237,13 @@ const DropdownTemplate = ({
53
237
  left={item.left}
54
238
  right={item.right}
55
239
  multiple={item.multiple}
56
- isSelected={selectedIds?.includes(item.id)}
240
+ isSelected={resolvedSelectedIds.includes(item.id)}
57
241
  onSelect={event => {
58
242
  if (item.disabled) {
59
243
  event.preventDefault();
60
244
  return;
61
245
  }
62
- onSelect?.(item);
246
+ handleItemSelect(item.id);
63
247
  }}
64
248
  />
65
249
  ))}
@@ -3,7 +3,7 @@ import type {
3
3
  DropdownMenuContentProps,
4
4
  DropdownMenuItemProps as RadixDropdownMenuItemProps,
5
5
  } from "@radix-ui/react-dropdown-menu";
6
- import type { HTMLAttributes, MutableRefObject, ReactNode } from "react";
6
+ import type { HTMLAttributes, ReactNode, RefObject } from "react";
7
7
 
8
8
  import type { CheckboxProps } from "../../checkbox/types";
9
9
  import type { DropdownPanelWidth, DropdownSize } from "./base";
@@ -77,13 +77,13 @@ export interface DropdownMenuListProps extends HTMLAttributes<HTMLUListElement>
77
77
 
78
78
  /**
79
79
  * Dropdown context value; trigger ref 공유용
80
- * @property {React.MutableRefObject<HTMLElement | null>} triggerRef trigger DOM ref
80
+ * @property {React.RefObject<HTMLElement | null>} triggerRef trigger DOM ref
81
81
  */
82
82
  export interface DropdownContextValue {
83
83
  /**
84
84
  * trigger DOM ref
85
85
  */
86
- triggerRef: MutableRefObject<HTMLElement | null>;
86
+ triggerRef: RefObject<HTMLElement | null>;
87
87
  }
88
88
 
89
89
  /**
@@ -92,6 +92,7 @@ export interface DropdownContextValue {
92
92
  * @property {ReactNode} label 옵션 라벨
93
93
  * @property {ReactNode} [description] 보조 텍스트
94
94
  * @property {boolean} [disabled] 비활성 여부
95
+ * @property {boolean} [selected] uncontrolled 초기 선택 여부
95
96
  * @property {ReactNode} [left] 좌측 콘텐츠
96
97
  * @property {ReactNode} [right] 우측 콘텐츠
97
98
  * @property {boolean} [multiple] multi select 스타일 여부
@@ -113,6 +114,10 @@ export interface DropdownTemplateItem {
113
114
  * 비활성 여부
114
115
  */
115
116
  disabled?: boolean;
117
+ /**
118
+ * uncontrolled 초기 선택 여부
119
+ */
120
+ selected?: boolean;
116
121
  /**
117
122
  * 좌측 콘텐츠
118
123
  */
@@ -127,11 +132,43 @@ export interface DropdownTemplateItem {
127
132
  multiple?: boolean;
128
133
  }
129
134
 
135
+ /**
136
+ * Dropdown template change payload
137
+ * @property {DropdownTemplateItem} currentItem 현재 상호작용한 item
138
+ * @property {boolean} isSelected currentItem 선택 여부
139
+ * @property {string[]} selectedIds 선택된 item id 목록
140
+ * @property {DropdownTemplateItem[]} selectedItems 선택된 item 목록
141
+ * @property {boolean} multiple 다중 선택 모드 여부
142
+ */
143
+ export interface DropdownTemplateChangePayload {
144
+ /**
145
+ * 현재 상호작용한 item
146
+ */
147
+ currentItem: DropdownTemplateItem;
148
+ /**
149
+ * currentItem 선택 여부
150
+ */
151
+ isSelected: boolean;
152
+ /**
153
+ * 선택된 item id 목록
154
+ */
155
+ selectedIds: string[];
156
+ /**
157
+ * 선택된 item 목록
158
+ */
159
+ selectedItems: DropdownTemplateItem[];
160
+ /**
161
+ * 다중 선택 모드 여부
162
+ */
163
+ multiple: boolean;
164
+ }
165
+
130
166
  /**
131
167
  * Dropdown template props
132
168
  * @property {ReactNode} trigger trigger 요소
133
169
  * @property {DropdownTemplateItem[]} items 렌더링할 menu item 리스트
134
170
  * @property {string[]} [selectedIds] 선택된 item id 배열
171
+ * @property {(payload: DropdownTemplateChangePayload) => void} [onChange] 선택 결과 변경 콜백
135
172
  * @property {(item: DropdownTemplateItem) => void} [onSelect] item 선택 콜백
136
173
  * @property {DropdownSize} [size="medium"] surface height scale
137
174
  * @property {DropdownPanelWidth} [width="match"] panel width 옵션
@@ -143,7 +180,19 @@ export interface DropdownTemplateItem {
143
180
  export interface DropdownTemplateProps {
144
181
  trigger: ReactNode;
145
182
  items: DropdownTemplateItem[];
183
+ /**
184
+ * 선택된 item id 배열
185
+ * @deprecated onChange + items[].selected 기반 계약을 우선 사용한다.
186
+ */
146
187
  selectedIds?: string[];
188
+ /**
189
+ * 선택 결과 변경 콜백
190
+ */
191
+ onChange?: (payload: DropdownTemplateChangePayload) => void;
192
+ /**
193
+ * item 선택 콜백
194
+ * @deprecated onChange로 대체되며 하위호환을 위해 유지된다.
195
+ */
147
196
  onSelect?: (item: DropdownTemplateItem) => void;
148
197
  size?: DropdownSize;
149
198
  width?: DropdownPanelWidth;
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import clsx from "clsx";
4
- import { forwardRef } from "react";
4
+ import { forwardRef, useEffect, useMemo, useState } from "react";
5
5
 
6
6
  import type { DropdownSize } from "../../dropdown/types";
7
7
  import { Dropdown } from "../../dropdown/markup";
@@ -11,12 +11,20 @@ import { useSelectDropdownOpenState } from "../hooks";
11
11
  import type { SelectDropdownOption } from "../types/option";
12
12
  import type { SelectDefaultComponentProps } from "../types/props";
13
13
 
14
+ const isSameIdList = (previousIds: string[], nextIds: string[]) =>
15
+ previousIds.length === nextIds.length &&
16
+ previousIds.every((selectedId, index) => selectedId === nextIds[index]);
17
+
18
+ const normalizeSingleSelectedIds = (selectedIds: string[]) =>
19
+ selectedIds.length > 0 ? [selectedIds[0]] : [];
20
+
14
21
  /**
15
22
  * Select default trigger; 단일 선택 드롭다운을 렌더링한다.
16
23
  * @component
17
24
  * @param {SelectDefaultComponentProps} props default trigger props
18
25
  * @param {SelectDropdownOption[]} [props.options] dropdown option 목록
19
26
  * @param {string[]} [props.selectedOptionIds] 선택된 option id 리스트
27
+ * @param {(payload: SelectOptionChangePayload) => void} [props.onChange] 선택 결과 변경 콜백
20
28
  * @param {(option: SelectDropdownOption) => void} [props.onOptionSelect] option 선택 콜백
21
29
  * @param {"primary" | "secondary" | "table"} [props.priority="primary"] priority 스케일
22
30
  * @param {"small" | "medium" | "large"} [props.size="medium"] size 스케일
@@ -53,6 +61,7 @@ const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
53
61
  buttonType,
54
62
  options = [],
55
63
  selectedOptionIds,
64
+ onChange,
56
65
  onOptionSelect,
57
66
  dropdownSize,
58
67
  dropdownWidth = "match",
@@ -67,30 +76,147 @@ const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
67
76
  },
68
77
  ref,
69
78
  ) => {
70
- // 변경: table priority는 width 미지정 시 기본 full width를 사용한다.
79
+ /**
80
+ * 1) 레이아웃 기본값 계산
81
+ * - table priority는 셀 컨텍스트에서 full width가 기본 동작이므로
82
+ * width 미지정 시 block=true처럼 동작하도록 보정한다.
83
+ * - 이 값은 trigger/container 양쪽에서 동일하게 사용한다.
84
+ */
71
85
  const resolvedBlock =
72
86
  block || (priority === "table" && width === undefined);
73
- const resolvedSelectedIds = selectedOptionIds ?? [];
74
87
 
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
+ */
100
+ const optionMap = useMemo(
101
+ () => new Map(options.map(option => [option.id, option])),
102
+ [options],
103
+ );
104
+
105
+ /**
106
+ * 4) uncontrolled 초기 선택값 계산
107
+ * - options[].selected를 초기 선택 입력으로 사용한다.
108
+ * - single-select이므로 selected=true가 여러 개여도 첫 id 하나만 채택한다.
109
+ */
110
+ const selectedIdsFromOptions = useMemo(() => {
111
+ const initialSelectedIds = options
112
+ .filter(option => option.selected)
113
+ .map(option => option.id);
114
+ 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
+ }
137
+
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)) {
149
+ return previousSelectedIds;
150
+ }
151
+
152
+ return nextSelectedIds;
153
+ });
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],
175
+ );
176
+
177
+ /**
178
+ * 8) 표시 라벨 계산
179
+ * - 외부 displayLabel이 있으면 최우선 사용
180
+ * - 없으면 현재 선택 id 기준으로 optionMap에서 label을 조회한다.
181
+ * - optionMap을 사용해 O(1) 조회로 단순화한다.
182
+ */
75
183
  const resolvedDisplayLabel =
76
184
  displayLabel ??
77
185
  (resolvedSelectedIds.length > 0
78
- ? options.find(option => option.id === resolvedSelectedIds[0])?.label
186
+ ? optionMap.get(resolvedSelectedIds[0])?.label
79
187
  : undefined);
80
188
 
189
+ /**
190
+ * 9) placeholder/label 표시 상태 계산
191
+ * - null/undefined/빈 문자열이면 placeholder로 간주한다.
192
+ */
81
193
  const hasLabel =
82
194
  resolvedDisplayLabel !== undefined &&
83
195
  resolvedDisplayLabel !== null &&
84
196
  resolvedDisplayLabel !== "";
85
197
 
198
+ /**
199
+ * 10) dropdown open 상태 관리
200
+ * - open/defaultOpen/onOpenChange 계약은 기존 useSelectDropdownOpenState를 그대로 사용한다.
201
+ * - 내부 state와 controlled open 상태를 동시에 지원한다.
202
+ */
86
203
  const { open: dropdownOpen, setOpen } = useSelectDropdownOpenState({
87
204
  open: open ?? isOpen,
88
205
  defaultOpen,
89
206
  onOpenChange,
90
207
  });
91
- // 변경: outside close는 Radix onOpenChange 기본 동작을 사용한다.
208
+
209
+ /**
210
+ * 11) 상호작용 차단 조건 계산
211
+ * - disabled/readOnly일 때는 open 토글과 option select를 모두 차단한다.
212
+ */
92
213
  const isInteractionBlocked = disabled || readOnly;
93
214
 
215
+ /**
216
+ * 12) open 상태 변경 핸들러
217
+ * - 차단 상태면 강제로 닫힘(false) 유지
218
+ * - 허용 상태면 전달받은 nextOpen 반영
219
+ */
94
220
  const handleOpenChange = (nextOpen: boolean) => {
95
221
  if (isInteractionBlocked) {
96
222
  setOpen(false);
@@ -99,17 +225,60 @@ const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
99
225
  setOpen(nextOpen);
100
226
  };
101
227
 
228
+ /**
229
+ * 13) option 선택 처리(single)
230
+ * - single-select 정책: 클릭된 option 하나를 최종 선택값으로 고정
231
+ * - uncontrolled 모드면 내부 state 갱신
232
+ * - onChange(payload)로 현재 선택 결과 전체를 전달
233
+ * - legacy onOptionSelect는 하위호환으로 유지
234
+ */
102
235
  const handleOptionSelect = (option: SelectDropdownOption) => {
103
236
  if (isInteractionBlocked) {
104
237
  return;
105
238
  }
239
+
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
+ }
253
+
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
+ });
264
+
106
265
  onOptionSelect?.(option);
107
266
  setOpen(false);
108
267
  };
109
268
 
269
+ /**
270
+ * 14) dropdown 패널 렌더링 파생값
271
+ * - panelSize: trigger size와 동일 축을 기본으로 사용
272
+ * - hasOptions: empty 상태 분기
273
+ */
110
274
  const panelSize = (dropdownSize ?? size) as DropdownSize;
111
275
  const hasOptions = options.length > 0;
112
276
 
277
+ /**
278
+ * 15) 렌더
279
+ * - Container → Dropdown.Root → Trigger → Container → Menu.List depth 유지
280
+ * - empty 상태는 별도 구조를 만들지 않고 Dropdown.Menu.Item을 disabled로 재사용
281
+ */
113
282
  return (
114
283
  <Container
115
284
  className={clsx("select-trigger-container", className)}
@@ -161,7 +330,7 @@ const SelectDefault = forwardRef<HTMLElement, SelectDefaultComponentProps>(
161
330
  left={option.left}
162
331
  right={option.right}
163
332
  multiple={Boolean(option.multiple)}
164
- isSelected={resolvedSelectedIds.includes(option.id)}
333
+ isSelected={selectedIdSet.has(option.id)}
165
334
  onSelect={event => {
166
335
  if (option.disabled) {
167
336
  event.preventDefault();
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import clsx from "clsx";
4
- import { forwardRef, useMemo } from "react";
4
+ import { forwardRef, useEffect, useMemo, useState } from "react";
5
5
 
6
6
  import Container from "../foundation/Container";
7
7
  import { Dropdown } from "../../../dropdown/markup";
@@ -12,6 +12,10 @@ import { SelectMultipleSelectedChip } from "./SelectedChip";
12
12
  import { SelectTriggerBase, SelectTriggerSelected } from "../foundation";
13
13
  import { useSelectDropdownOpenState } from "../../hooks";
14
14
 
15
+ const isSameIdList = (previousIds: string[], nextIds: string[]) =>
16
+ previousIds.length === nextIds.length &&
17
+ previousIds.every((selectedId, index) => selectedId === nextIds[index]);
18
+
15
19
  /**
16
20
  * Select trigger for multi select; 선택된 tag들을 chip 형태로 렌더링한다.
17
21
  * @component
@@ -19,6 +23,7 @@ import { useSelectDropdownOpenState } from "../../hooks";
19
23
  * @param {SelectMultipleTag[]} [props.tags] 선택된 tag 리스트
20
24
  * @param {SelectDropdownOption[]} [props.options] dropdown option 목록
21
25
  * @param {string[]} [props.selectedOptionIds] 선택된 option id 리스트
26
+ * @param {(payload: SelectOptionChangePayload) => void} [props.onChange] 선택 결과 변경 콜백
22
27
  * @param {(option: SelectDropdownOption) => void} [props.onOptionSelect] option 선택 콜백
23
28
  * @param {React.ReactNode} [props.displayLabel] fallback 라벨
24
29
  * @param {React.ReactNode} [props.placeholder] placeholder 텍스트
@@ -60,6 +65,7 @@ const SelectMultipleTrigger = forwardRef<
60
65
  tags,
61
66
  options = [],
62
67
  selectedOptionIds,
68
+ onChange,
63
69
  onOptionSelect,
64
70
  dropdownSize,
65
71
  dropdownWidth = "match",
@@ -74,32 +80,119 @@ const SelectMultipleTrigger = forwardRef<
74
80
  },
75
81
  ref,
76
82
  ) => {
77
- // 변경: table priority는 width 미지정 시 기본 full width를 사용한다.
83
+ /**
84
+ * 1) 레이아웃 기본값 계산
85
+ * - table priority는 width 미지정 시 full width를 기본 동작으로 사용한다.
86
+ * - trigger/container 양쪽에서 동일한 block 값을 재사용한다.
87
+ */
78
88
  const resolvedBlock =
79
89
  block || (priority === "table" && width === undefined);
80
- // hook dependency 안정화를 위해 memoized selected id 배열을 유지한다.
90
+
91
+ /**
92
+ * 2) 선택 상태 모드 결정
93
+ * - selectedOptionIds prop이 있으면 controlled 모드
94
+ * - selectedOptionIds prop이 없으면 uncontrolled 모드
95
+ */
96
+ const isSelectionControlled = selectedOptionIds !== undefined;
97
+
98
+ /**
99
+ * 3) option 조회 최적화 맵 생성
100
+ * - selected id -> option 조회를 반복하므로 id 기반 맵을 생성한다.
101
+ * - label 계산, tag 파생, payload selectedOptions 계산에 공통 사용한다.
102
+ */
103
+ const optionMap = useMemo(
104
+ () => new Map(options.map(option => [option.id, option])),
105
+ [options],
106
+ );
107
+
108
+ /**
109
+ * 4) uncontrolled 초기 선택값 계산
110
+ * - options[].selected를 다중 선택 초기값으로 그대로 반영한다.
111
+ */
112
+ const selectedIdsFromOptions = useMemo(
113
+ () => options.filter(option => option.selected).map(option => option.id),
114
+ [options],
115
+ );
116
+
117
+ /**
118
+ * 5) uncontrolled 내부 state 선언
119
+ * - controlled 모드에서는 source로 사용되지 않는다.
120
+ * - uncontrolled 모드에서는 최종 선택 배열을 내부 state가 소유한다.
121
+ */
122
+ const [uncontrolledSelectedOptionIds, setUncontrolledSelectedOptionIds] =
123
+ useState<string[]>(() => selectedIdsFromOptions);
124
+
125
+ /**
126
+ * 6) options 변경 시 내부 state 정합성 보정(uncontrolled 전용)
127
+ * - 기존 선택 id 중 현재 options에 남아있는 id만 유지한다.
128
+ * - 남은 id가 없으면 selectedIdsFromOptions(초기 선택 규칙)로 재동기화한다.
129
+ */
130
+ useEffect(() => {
131
+ if (isSelectionControlled) {
132
+ return;
133
+ }
134
+
135
+ setUncontrolledSelectedOptionIds(previousSelectedIds => {
136
+ const optionIdSet = new Set(options.map(option => option.id));
137
+ const filteredIds = previousSelectedIds.filter(selectedId =>
138
+ optionIdSet.has(selectedId),
139
+ );
140
+ const nextSelectedIds =
141
+ filteredIds.length > 0 ? filteredIds : selectedIdsFromOptions;
142
+
143
+ // 동일한 선택 결과면 기존 배열 참조를 유지해 재렌더 루프를 방지한다.
144
+ if (isSameIdList(previousSelectedIds, nextSelectedIds)) {
145
+ return previousSelectedIds;
146
+ }
147
+
148
+ return nextSelectedIds;
149
+ });
150
+ }, [isSelectionControlled, options, selectedIdsFromOptions]);
151
+
152
+ /**
153
+ * 7) 최종 선택 id 계산
154
+ * - controlled: selectedOptionIds 우선
155
+ * - uncontrolled: 내부 state 사용
156
+ */
81
157
  const resolvedSelectedIds = useMemo(
82
- () => selectedOptionIds ?? [],
83
- [selectedOptionIds],
158
+ () =>
159
+ isSelectionControlled
160
+ ? (selectedOptionIds ?? [])
161
+ : uncontrolledSelectedOptionIds,
162
+ [isSelectionControlled, selectedOptionIds, uncontrolledSelectedOptionIds],
163
+ );
164
+ const selectedIdSet = useMemo(
165
+ () => new Set(resolvedSelectedIds),
166
+ [resolvedSelectedIds],
84
167
  );
85
168
 
169
+ /**
170
+ * 8) 표시 라벨 계산(보조)
171
+ * - tags가 비어 있을 때 fallback label 용도로 사용한다.
172
+ * - 외부 displayLabel 우선, 없으면 첫 선택 option label을 사용한다.
173
+ */
86
174
  const resolvedDisplayLabel =
87
175
  displayLabel ??
88
176
  (resolvedSelectedIds.length > 0
89
- ? options.find(option => option.id === resolvedSelectedIds[0])?.label
177
+ ? optionMap.get(resolvedSelectedIds[0])?.label
90
178
  : undefined);
91
179
 
180
+ /**
181
+ * 9) tag 파생 계산
182
+ * - 외부 tags가 주어지면 해당 값을 그대로 사용한다(외부 커스텀 우선).
183
+ * - tags 미지정이면 selected ids 기반으로 option label을 자동 tag로 변환한다.
184
+ */
92
185
  const derivedTags = useMemo<SelectMultipleTag[]>(() => {
93
186
  if (tags && tags.length > 0) {
94
187
  return tags;
95
188
  }
96
189
 
97
- if (options.length === 0 || resolvedSelectedIds.length === 0) {
190
+ if (optionMap.size === 0 || resolvedSelectedIds.length === 0) {
98
191
  return [];
99
192
  }
100
193
 
101
194
  return resolvedSelectedIds
102
- .map(id => options.find(option => option.id === id))
195
+ .map(selectedId => optionMap.get(selectedId))
103
196
  .filter((option): option is NonNullable<typeof option> =>
104
197
  Boolean(option),
105
198
  )
@@ -107,22 +200,39 @@ const SelectMultipleTrigger = forwardRef<
107
200
  label: option.label,
108
201
  removable: false,
109
202
  }));
110
- }, [tags, options, resolvedSelectedIds]);
203
+ }, [tags, resolvedSelectedIds, optionMap]);
111
204
 
205
+ /**
206
+ * 10) placeholder/label 표시 상태 계산
207
+ * - label 값이 비어 있으면 placeholder 표시로 간주한다.
208
+ */
112
209
  const hasTags = derivedTags.length > 0;
113
210
  const hasLabel =
114
211
  resolvedDisplayLabel !== undefined &&
115
212
  resolvedDisplayLabel !== null &&
116
213
  resolvedDisplayLabel !== "";
117
214
 
215
+ /**
216
+ * 11) dropdown open 상태 관리
217
+ * - open/defaultOpen/onOpenChange 계약을 hook으로 통합 처리한다.
218
+ */
118
219
  const { open: dropdownOpen, setOpen } = useSelectDropdownOpenState({
119
220
  open: open ?? isOpen,
120
221
  defaultOpen,
121
222
  onOpenChange,
122
223
  });
123
- // 변경: outside close는 Radix onOpenChange 기본 동작을 사용한다.
224
+
225
+ /**
226
+ * 12) 상호작용 차단 조건
227
+ * - disabled/readOnly이면 open 토글/option toggle을 모두 차단한다.
228
+ */
124
229
  const isInteractionBlocked = disabled || readOnly;
125
230
 
231
+ /**
232
+ * 13) open 상태 변경 핸들러
233
+ * - 차단 상태에서는 항상 닫힘 유지
234
+ * - 허용 상태에서는 nextOpen 반영
235
+ */
126
236
  const handleOpenChange = (nextOpen: boolean) => {
127
237
  if (isInteractionBlocked) {
128
238
  setOpen(false);
@@ -131,6 +241,11 @@ const SelectMultipleTrigger = forwardRef<
131
241
  setOpen(nextOpen);
132
242
  };
133
243
 
244
+ /**
245
+ * 14) trigger 내 tag 표시 최적화 파생값
246
+ * - 최대 3개 tag만 표시하고 나머지는 summary chip(+N)로 축약한다.
247
+ * - 과도한 폭 확장을 방지해 trigger layout 안정성을 유지한다.
248
+ */
134
249
  const panelSize = (dropdownSize ?? size) as DropdownSize;
135
250
  const hasOptions = options.length > 0;
136
251
  const MAX_VISIBLE_TAGS = 3;
@@ -139,6 +254,56 @@ const SelectMultipleTrigger = forwardRef<
139
254
  ? Math.max(derivedTags.length - visibleTags.length, 0)
140
255
  : 0;
141
256
 
257
+ /**
258
+ * 15) option 선택 처리(multiple toggle)
259
+ * - 이미 선택된 id면 제거, 아니면 추가한다.
260
+ * - uncontrolled 모드면 내부 state 갱신
261
+ * - onChange(payload)로 결과 전체를 전달
262
+ * - legacy onOptionSelect는 하위호환으로 유지
263
+ */
264
+ const handleOptionSelect = (optionId: string) => {
265
+ const wasSelected = selectedIdSet.has(optionId);
266
+ const nextSelectedOptionIds = wasSelected
267
+ ? resolvedSelectedIds.filter(selectedId => selectedId !== optionId)
268
+ : [...resolvedSelectedIds, optionId];
269
+ const nextSelectedOptions = nextSelectedOptionIds
270
+ .map(selectedId => optionMap.get(selectedId))
271
+ .filter(
272
+ (
273
+ selectedOption,
274
+ ): selectedOption is NonNullable<typeof selectedOption> =>
275
+ Boolean(selectedOption),
276
+ );
277
+ const currentOption = optionMap.get(optionId);
278
+
279
+ if (!currentOption) {
280
+ return;
281
+ }
282
+
283
+ if (!isSelectionControlled) {
284
+ setUncontrolledSelectedOptionIds(nextSelectedOptionIds);
285
+ }
286
+
287
+ onChange?.({
288
+ mode: "multiple",
289
+ selectedOptionIds: nextSelectedOptionIds,
290
+ selectedValues: nextSelectedOptions.map(
291
+ selectedOption => selectedOption.value,
292
+ ),
293
+ selectedOptions: nextSelectedOptions,
294
+ currentOption,
295
+ isSelected: !wasSelected,
296
+ });
297
+
298
+ onOptionSelect?.(currentOption);
299
+ };
300
+
301
+ /**
302
+ * 16) 렌더
303
+ * - Container → Dropdown.Root → Trigger → Container → Menu.List depth 유지
304
+ * - hasTags에 따라 chips 또는 placeholder/label 뷰를 분기한다.
305
+ * - empty 상태는 Dropdown.Menu.Item disabled 조합으로 구조를 통일한다.
306
+ */
142
307
  return (
143
308
  <Container
144
309
  className={clsx("select-trigger-multiple", className)}
@@ -214,13 +379,13 @@ const SelectMultipleTrigger = forwardRef<
214
379
  left={option.left}
215
380
  right={option.right}
216
381
  multiple
217
- isSelected={resolvedSelectedIds.includes(option.id)}
382
+ isSelected={selectedIdSet.has(option.id)}
218
383
  onSelect={event => {
219
384
  if (option.disabled || isInteractionBlocked) {
220
385
  event.preventDefault();
221
386
  return;
222
387
  }
223
- onOptionSelect?.(option);
388
+ handleOptionSelect(option.id);
224
389
  }}
225
390
  />
226
391
  ))}
@@ -7,38 +7,50 @@ import type { ReactNode } from "react";
7
7
  export type SelectOptionValue = string | number;
8
8
 
9
9
  /**
10
- * Select option data; form 상태와 직결되는 데이터 계약
11
- * @property {string} id 렌더링/선택 추적용 고유 id
12
- * @property {SelectOptionValue} value 실제 form value
13
- * @property {ReactNode} label 사용자 노출 라벨
14
- * @property {boolean} [disabled] 비활성 여부
15
- * @property {OptionData} [data] 추가 데이터 payload
10
+ * Select option data; 옵션 식별/제출/표시를 번에 담는 표준 데이터 계약
11
+ * @property {string} id 선택 상태 추적용 고유 id(권장: 영속 key)
12
+ * @property {SelectOptionValue} value 실제 제출값(form/value source of truth)
13
+ * @property {ReactNode} label 사용자에게 노출할 표시 텍스트/노드
14
+ * @property {boolean} [disabled] 선택 불가 상태 여부
15
+ * @property {boolean} [selected] uncontrolled 초기 선택 여부(권장 초기화 필드)
16
+ * @property {OptionData} [data] 도메인 확장 payload(서비스 2차 활용 데이터)
16
17
  */
17
18
  export interface SelectOptionData<OptionData = unknown> {
18
19
  /**
19
- * 렌더링/선택 추적용 고유 id
20
+ * 선택 상태 추적용 고유 id
21
+ * - UI 선택 상태 비교의 기준값이다.
22
+ * - value와 분리해도 되며, 중복되지 않아야 한다.
20
23
  */
21
24
  id: string;
22
25
  /**
23
- * 실제 form value
26
+ * 실제 제출값(form/value source of truth)
27
+ * - API 전송 또는 저장에 사용하는 값이다.
28
+ * - label과 분리해 사람이 읽는 문자열에 의존하지 않도록 유지한다.
24
29
  */
25
30
  value: SelectOptionValue;
26
31
  /**
27
- * 사용자 노출 라벨
32
+ * 사용자에게 노출할 표시 라벨
33
+ * - 문자열뿐 아니라 ReactNode도 허용한다.
28
34
  */
29
35
  label: ReactNode;
30
36
  /**
31
- * 비활성 여부
37
+ * 선택 불가 상태 여부
32
38
  */
33
39
  disabled?: boolean;
34
40
  /**
35
- * 추가 데이터 payload
41
+ * uncontrolled 초기 선택 여부
42
+ * - selectedOptionIds 없이도 초기값을 선언할 수 있는 권장 필드다.
43
+ */
44
+ selected?: boolean;
45
+ /**
46
+ * 도메인 확장 payload
47
+ * - 선택 후 서비스 코드에서 2차 데이터로 활용할 수 있다.
36
48
  */
37
49
  data?: OptionData;
38
50
  }
39
51
 
40
52
  /**
41
- * Select option render data; Dropdown.Menu.Item 렌더링 보조 정보
53
+ * Select option render data; Dropdown.Menu.Item 시각 보조 정보
42
54
  * @property {ReactNode} [description] 보조 텍스트
43
55
  * @property {ReactNode} [left] 좌측 콘텐츠
44
56
  * @property {ReactNode} [right] 우측 콘텐츠
@@ -47,30 +59,35 @@ export interface SelectOptionData<OptionData = unknown> {
47
59
  export interface SelectOptionRenderData {
48
60
  /**
49
61
  * 보조 텍스트
62
+ * - label 하단에 부가 정보를 노출할 때 사용한다.
50
63
  */
51
64
  description?: ReactNode;
52
65
  /**
53
66
  * 좌측 콘텐츠
67
+ * - 아이콘/체크마크 등 leading 영역 커스텀에 사용한다.
54
68
  */
55
69
  left?: ReactNode;
56
70
  /**
57
71
  * 우측 콘텐츠
72
+ * - shortcut/badge 등 trailing 영역 커스텀에 사용한다.
58
73
  */
59
74
  right?: ReactNode;
60
75
  /**
61
76
  * multi select 스타일 여부
77
+ * - Dropdown.Menu.Item 다중 선택 스타일 토글에 사용한다.
62
78
  */
63
79
  multiple?: boolean;
64
80
  }
65
81
 
66
82
  /**
67
- * Select dropdown option; data 계약과 render 계약을 합쳐 Select 입력 모델을 구성한다.
83
+ * Select dropdown option; 데이터 계약 + 렌더 계약을 결합한 Select 표준 입력 모델
68
84
  * @extends SelectOptionData
69
85
  * @extends SelectOptionRenderData
70
86
  * @property {string} id 렌더링/선택 추적용 고유 id
71
87
  * @property {SelectOptionValue} value 실제 form value
72
88
  * @property {ReactNode} label 사용자 노출 라벨
73
89
  * @property {boolean} [disabled] 비활성 여부
90
+ * @property {boolean} [selected] uncontrolled 초기 선택 여부
74
91
  * @property {OptionData} [data] 추가 데이터 payload
75
92
  * @property {ReactNode} [description] 보조 텍스트
76
93
  * @property {ReactNode} [left] 좌측 콘텐츠
@@ -80,6 +97,49 @@ export interface SelectOptionRenderData {
80
97
  export interface SelectDropdownOption<OptionData = unknown>
81
98
  extends SelectOptionData<OptionData>, SelectOptionRenderData {}
82
99
 
100
+ /**
101
+ * Select option change payload; 단일/다중 선택 결과를 서비스가 즉시 소비할 수 있게 전달한다.
102
+ * @property {"single" | "multiple"} mode 선택 모드
103
+ * @property {string[]} selectedOptionIds 선택된 option id 목록
104
+ * @property {SelectOptionValue[]} selectedValues 선택된 value 목록
105
+ * @property {SelectDropdownOption<OptionData>[]} selectedOptions 선택된 option 목록
106
+ * @property {SelectDropdownOption<OptionData>} currentOption 현재 상호작용한 option
107
+ * @property {boolean} isSelected currentOption 선택 여부
108
+ */
109
+ export interface SelectOptionChangePayload<OptionData = unknown> {
110
+ /**
111
+ * 선택 모드
112
+ * - "single": 단일 선택
113
+ * - "multiple": 다중 선택
114
+ */
115
+ mode: "single" | "multiple";
116
+ /**
117
+ * 선택된 option id 목록
118
+ * - legacy controlled 상태 동기화가 필요할 때 사용한다.
119
+ */
120
+ selectedOptionIds: string[];
121
+ /**
122
+ * 선택된 value 목록
123
+ * - form 제출값을 바로 반영할 때 사용한다.
124
+ */
125
+ selectedValues: SelectOptionValue[];
126
+ /**
127
+ * 선택된 option 전체 데이터 목록
128
+ * - label/data 등 2차 데이터를 즉시 사용하려는 권장 소비 지점이다.
129
+ */
130
+ selectedOptions: SelectDropdownOption<OptionData>[];
131
+ /**
132
+ * 현재 상호작용한 option
133
+ * - 어떤 option 클릭으로 상태가 변경되었는지 추적할 때 사용한다.
134
+ */
135
+ currentOption: SelectDropdownOption<OptionData>;
136
+ /**
137
+ * currentOption 선택 여부
138
+ * - multiple 토글 시 add/remove 방향을 구분할 때 사용한다.
139
+ */
140
+ isSelected: boolean;
141
+ }
142
+
83
143
  /**
84
144
  * Select legacy option 데이터 구조; 과거 key/optionName 스키마 호환을 위해 유지한다.
85
145
  * @property {string} key 렌더링 키
@@ -88,6 +148,7 @@ export interface SelectDropdownOption<OptionData = unknown>
88
148
  * @property {boolean} [selected] 선택 여부
89
149
  * @property {boolean} [disabled] 비활성 여부
90
150
  * @property {OptionData} [data] 추가 데이터 payload
151
+ * @deprecated SelectDropdownOption으로 마이그레이션한다.
91
152
  */
92
153
  export interface SelectLegacyOption<OptionData = unknown> {
93
154
  /**
@@ -8,7 +8,7 @@ import type {
8
8
  } from "../../dropdown/types";
9
9
  import type { FormFieldWidth } from "../../form/types";
10
10
  import type { SelectPriority, SelectSize, SelectState } from "./base";
11
- import type { SelectDropdownOption } from "./option";
11
+ import type { SelectDropdownOption, SelectOptionChangePayload } from "./option";
12
12
  import type {
13
13
  SelectTriggerDefaultProps,
14
14
  SelectTriggerMultipleProps,
@@ -134,10 +134,11 @@ export type SelectProps = SelectStyleOptions &
134
134
  SelectWidthOption;
135
135
 
136
136
  /**
137
- * Select dropdown 옵션 구성 props
138
- * @property {SelectDropdownOption[]} [options] dropdown option 리스트
139
- * @property {string[]} [selectedOptionIds] 선택된 option id 리스트
140
- * @property {(option: SelectDropdownOption) => void} [onOptionSelect] option 선택 콜백
137
+ * Select dropdown 옵션 구성 props; 옵션 모델/이벤트/하위호환 제어 축을 정의한다.
138
+ * @property {SelectDropdownOption[]} [options] dropdown option 리스트(권장: options[].selected로 초기값 선언)
139
+ * @property {string[]} [selectedOptionIds] 선택된 option id 리스트(legacy controlled)
140
+ * @property {(payload: SelectOptionChangePayload) => void} [onChange] 선택 결과 변경 콜백(권장)
141
+ * @property {(option: SelectDropdownOption) => void} [onOptionSelect] option 선택 콜백(legacy)
141
142
  * @property {SelectSize} [dropdownSize] dropdown surface size 스케일
142
143
  * @property {DropdownPanelWidth} [dropdownWidth="match"] dropdown panel width 옵션
143
144
  * @property {DropdownMenuProps} [dropdownRootProps] Dropdown.Root 전달 props(제어 props 제외)
@@ -148,14 +149,22 @@ export type SelectProps = SelectStyleOptions &
148
149
  export interface SelectDropdownConfigProps {
149
150
  /**
150
151
  * dropdown option 리스트
152
+ * - 권장: uncontrolled 초기 선택은 options[].selected를 사용한다.
151
153
  */
152
154
  options?: SelectDropdownOption[];
153
155
  /**
154
156
  * 선택된 option id 리스트
157
+ * @deprecated onChange + options[].selected 기반 계약을 우선 사용한다.
155
158
  */
156
159
  selectedOptionIds?: string[];
160
+ /**
161
+ * 선택 결과 변경 콜백
162
+ * - 권장: payload.selectedOptions / payload.selectedValues를 직접 소비한다.
163
+ */
164
+ onChange?: (payload: SelectOptionChangePayload) => void;
157
165
  /**
158
166
  * option 선택 콜백
167
+ * @deprecated onChange로 대체되며 하위호환을 위해 유지된다.
159
168
  */
160
169
  onOptionSelect?: (option: SelectDropdownOption) => void;
161
170
  /**
@@ -192,7 +201,7 @@ export interface SelectDropdownConfigProps {
192
201
  }
193
202
 
194
203
  /**
195
- * Select dropdown 개방 제어 props
204
+ * Select dropdown 개방 제어 props; open controlled/uncontrolled 축을 정의한다.
196
205
  * @property {boolean} [open] dropdown open 상태
197
206
  * @property {boolean} [defaultOpen] uncontrolled 초기 open 상태
198
207
  * @property {(open: boolean) => void} [onOpenChange] open state change 콜백
@@ -200,14 +209,17 @@ export interface SelectDropdownConfigProps {
200
209
  export interface SelectDropdownBehaviorProps {
201
210
  /**
202
211
  * dropdown open 상태
212
+ * - 값이 있으면 controlled open 모드로 동작한다.
203
213
  */
204
214
  open?: boolean;
205
215
  /**
206
216
  * uncontrolled 초기 open 상태
217
+ * - open prop이 없을 때만 초기값으로 사용된다.
207
218
  */
208
219
  defaultOpen?: boolean;
209
220
  /**
210
221
  * open state change 콜백
222
+ * - controlled/uncontrolled 모두에서 호출된다.
211
223
  */
212
224
  onOpenChange?: (open: boolean) => void;
213
225
  }
@@ -228,6 +240,7 @@ export interface SelectDropdownBehaviorProps {
228
240
  * @property {FormFieldWidth} [width] width preset 옵션
229
241
  * @property {SelectDropdownOption[]} [options] dropdown option 리스트
230
242
  * @property {string[]} [selectedOptionIds] 선택된 option id 리스트
243
+ * @property {(payload: SelectOptionChangePayload) => void} [onChange] 선택 결과 변경 콜백
231
244
  * @property {(option: SelectDropdownOption) => void} [onOptionSelect] option 선택 콜백
232
245
  * @property {SelectSize} [dropdownSize] dropdown surface size 스케일
233
246
  * @property {DropdownPanelWidth} [dropdownWidth="match"] dropdown panel width 옵션
@@ -260,6 +273,7 @@ export type SelectDefaultComponentProps = SelectTriggerDefaultProps &
260
273
  * @property {FormFieldWidth} [width] width preset 옵션
261
274
  * @property {SelectDropdownOption[]} [options] dropdown option 리스트
262
275
  * @property {string[]} [selectedOptionIds] 선택된 option id 리스트
276
+ * @property {(payload: SelectOptionChangePayload) => void} [onChange] 선택 결과 변경 콜백
263
277
  * @property {(option: SelectDropdownOption) => void} [onOptionSelect] option 선택 콜백
264
278
  * @property {SelectSize} [dropdownSize] dropdown surface size 스케일
265
279
  * @property {DropdownPanelWidth} [dropdownWidth="match"] dropdown panel width 옵션