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