@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 +1 -1
- package/src/components/dropdown/markup/Template.tsx +187 -3
- package/src/components/dropdown/types/props.ts +52 -3
- package/src/components/select/markup/Default.tsx +175 -6
- package/src/components/select/markup/multiple/Multiple.tsx +177 -12
- package/src/components/select/types/option.ts +74 -13
- package/src/components/select/types/props.ts +20 -6
package/package.json
CHANGED
|
@@ -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={
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
() =>
|
|
83
|
-
|
|
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
|
-
?
|
|
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 (
|
|
190
|
+
if (optionMap.size === 0 || resolvedSelectedIds.length === 0) {
|
|
98
191
|
return [];
|
|
99
192
|
}
|
|
100
193
|
|
|
101
194
|
return resolvedSelectedIds
|
|
102
|
-
.map(
|
|
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,
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
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;
|
|
11
|
-
* @property {string} id
|
|
12
|
-
* @property {SelectOptionValue} value 실제 form
|
|
13
|
-
* @property {ReactNode} label
|
|
14
|
-
* @property {boolean} [disabled]
|
|
15
|
-
* @property {
|
|
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
|
-
*
|
|
20
|
+
* 선택 상태 추적용 고유 id
|
|
21
|
+
* - UI 선택 상태 비교의 기준값이다.
|
|
22
|
+
* - value와 분리해도 되며, 중복되지 않아야 한다.
|
|
20
23
|
*/
|
|
21
24
|
id: string;
|
|
22
25
|
/**
|
|
23
|
-
* 실제 form
|
|
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
|
-
*
|
|
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;
|
|
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 {(
|
|
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 옵션
|