@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.
- package/dist/styles.css +171 -87
- package/package.json +1 -1
- package/src/components/dropdown/styles/dropdown.scss +44 -23
- package/src/components/dropdown/styles/variables.scss +25 -0
- package/src/components/dropdown/types/base.ts +2 -2
- package/src/components/select/hooks/interaction.ts +5 -5
- package/src/components/select/img/chevron/primary/xsmall.svg +3 -0
- package/src/components/select/markup/Default.tsx +183 -212
- package/src/components/select/markup/foundation/Base.tsx +16 -4
- package/src/components/select/markup/foundation/Icon.tsx +9 -3
- package/src/components/select/markup/foundation/Selected.tsx +115 -11
- package/src/components/select/markup/multiple/Multiple.tsx +63 -135
- package/src/components/select/styles/select.scss +128 -72
- package/src/components/select/styles/variables.scss +11 -0
- package/src/components/select/types/base.ts +3 -2
- package/src/components/select/types/icon.ts +34 -3
- package/src/components/select/types/interaction.ts +1 -1
- package/src/components/select/types/option.ts +0 -80
- package/src/components/select/types/props.ts +167 -92
- package/src/components/select/types/trigger.ts +52 -1
|
@@ -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 {
|
|
26
|
-
* @param {
|
|
27
|
-
* @param {
|
|
28
|
-
* @param {
|
|
29
|
-
* @param {"
|
|
30
|
-
* @param {"
|
|
31
|
-
* @param {
|
|
32
|
-
* @param {
|
|
33
|
-
* @param {
|
|
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 {
|
|
37
|
-
* @param {
|
|
38
|
-
* @param {
|
|
39
|
-
* @param {
|
|
40
|
-
* @param {
|
|
41
|
-
* @param {
|
|
42
|
-
* @param {
|
|
43
|
-
* @param {boolean} [props.
|
|
44
|
-
* @param {
|
|
45
|
-
* @param {
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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(
|
|
102
|
-
[
|
|
141
|
+
() => new Map(mergedOptions.map(option => [option.id, option])),
|
|
142
|
+
[mergedOptions],
|
|
103
143
|
);
|
|
104
144
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
}, [
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
}, [
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
241
|
-
const
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
{...
|
|
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={
|
|
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
|
-
{...
|
|
253
|
+
{...triggerProps}
|
|
307
254
|
>
|
|
308
255
|
<SelectTriggerSelected
|
|
309
256
|
label={resolvedDisplayLabel}
|
|
310
|
-
placeholder={
|
|
311
|
-
|
|
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
|
-
{...
|
|
317
|
-
size={
|
|
318
|
-
width={
|
|
289
|
+
{...dropdownOptions?.containerProps}
|
|
290
|
+
size={dropdownOptions?.size ?? resolvedSize}
|
|
291
|
+
width={dropdownOptions?.width ?? "match"}
|
|
319
292
|
>
|
|
320
|
-
<Dropdown.Menu.List {...
|
|
321
|
-
{
|
|
293
|
+
<Dropdown.Menu.List {...dropdownOptions?.menuListProps}>
|
|
294
|
+
{mergedOptions.length > 0 ? (
|
|
322
295
|
<>
|
|
323
|
-
{
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
|
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:
|
|
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,
|