@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
|
@@ -1,31 +1,135 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import clsx from "clsx";
|
|
4
|
+
import { useEffect, useRef } from "react";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
4
6
|
import type { SelectSelectedProps } from "../../types/trigger";
|
|
5
|
-
|
|
7
|
+
|
|
8
|
+
const toInputText = (value?: ReactNode): string => {
|
|
9
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
10
|
+
return String(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return "";
|
|
14
|
+
};
|
|
6
15
|
|
|
7
16
|
/**
|
|
8
|
-
* Select trigger label
|
|
17
|
+
* Select trigger value renderer; trigger 내부 label/value input 렌더링 컴포넌트
|
|
9
18
|
* @component
|
|
10
19
|
* @param {SelectSelectedProps} props selected view props
|
|
11
|
-
* @param {
|
|
12
|
-
* @param {
|
|
13
|
-
* @param {boolean} [props.isPlaceholder] placeholder
|
|
20
|
+
* @param {ReactNode} [props.label] 선택된 라벨 노드
|
|
21
|
+
* @param {ReactNode} [props.placeholder] label input placeholder
|
|
22
|
+
* @param {boolean} [props.isPlaceholder] placeholder 상태 여부
|
|
23
|
+
* @param {boolean} [props.readOnly=false] label input readOnly 여부
|
|
24
|
+
* @param {UseFormRegisterReturn} [props.register] hidden value input register
|
|
25
|
+
* @param {UseFormRegisterReturn} [props.customRegister] custom label input register
|
|
26
|
+
* @param {Omit<ComponentPropsWithoutRef<"input">, "placeholder">} [props.inputProps] label input native 속성
|
|
27
|
+
* @param {string} [props.valueText=""] label input 표시 문자열
|
|
28
|
+
* @param {SelectOptionValue | ""} [props.valueFieldValue=""] hidden value input 값
|
|
29
|
+
* @param {boolean} [props.shouldFocusInput=false] custom mode 진입 시 label input focus 여부
|
|
30
|
+
* @param {(value: string) => void} [props.onLabelChange] label 입력 변경 콜백
|
|
31
|
+
* @example
|
|
32
|
+
* <SelectTriggerSelected
|
|
33
|
+
* label="사과"
|
|
34
|
+
* valueFieldValue="APPLE"
|
|
35
|
+
* readOnly
|
|
36
|
+
* />
|
|
14
37
|
*/
|
|
15
38
|
const SelectTriggerSelected = ({
|
|
16
39
|
label,
|
|
17
40
|
placeholder,
|
|
18
41
|
isPlaceholder,
|
|
42
|
+
readOnly = false,
|
|
43
|
+
register,
|
|
44
|
+
customRegister,
|
|
45
|
+
inputProps,
|
|
46
|
+
valueText = "",
|
|
47
|
+
valueFieldValue = "",
|
|
48
|
+
shouldFocusInput = false,
|
|
49
|
+
onLabelChange,
|
|
19
50
|
}: SelectSelectedProps) => {
|
|
51
|
+
// 1) custom mode 진입 시 focus 타깃은 label input으로 고정한다.
|
|
52
|
+
const labelInputRef = useRef<HTMLInputElement>(null);
|
|
53
|
+
|
|
54
|
+
// 2) placeholder/label은 text input에 들어갈 수 있는 문자열로 정규화한다.
|
|
55
|
+
const resolvedPlaceholder = toInputText(placeholder);
|
|
56
|
+
const resolvedLabelText = valueText || toInputText(label);
|
|
57
|
+
|
|
58
|
+
// 3) custom mode 활성 시에만 label input focus를 부여한다.
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!shouldFocusInput || readOnly) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
labelInputRef.current?.focus();
|
|
65
|
+
}, [readOnly, shouldFocusInput]);
|
|
66
|
+
|
|
20
67
|
return (
|
|
21
68
|
<div className="select-value">
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
69
|
+
<input
|
|
70
|
+
ref={labelInputRef}
|
|
71
|
+
type="text"
|
|
72
|
+
// 4) 외부 inputProps는 placeholder/value/readOnly보다 먼저 병합하고,
|
|
73
|
+
// 내부 계약값을 마지막에 고정해 동작 우선순위를 명확히 한다.
|
|
74
|
+
{...inputProps}
|
|
75
|
+
className={clsx("select-input-label", inputProps?.className, {
|
|
76
|
+
"select-input-label-placeholder": isPlaceholder,
|
|
25
77
|
})}
|
|
26
|
-
|
|
27
|
-
{
|
|
28
|
-
|
|
78
|
+
placeholder={resolvedPlaceholder}
|
|
79
|
+
value={resolvedLabelText}
|
|
80
|
+
readOnly={readOnly}
|
|
81
|
+
// 5) customRegister는 name/ref/onBlur/onChange 계약을 label input에 연결한다.
|
|
82
|
+
{...customRegister}
|
|
83
|
+
onPointerDownCapture={event => {
|
|
84
|
+
inputProps?.onPointerDownCapture?.(event);
|
|
85
|
+
if (!readOnly) {
|
|
86
|
+
// 변경: 편집 가능 상태에서는 trigger 토글 이벤트 전파를 차단한다.
|
|
87
|
+
event.stopPropagation();
|
|
88
|
+
}
|
|
89
|
+
}}
|
|
90
|
+
onPointerDown={event => {
|
|
91
|
+
inputProps?.onPointerDown?.(event);
|
|
92
|
+
if (!readOnly) {
|
|
93
|
+
event.stopPropagation();
|
|
94
|
+
}
|
|
95
|
+
}}
|
|
96
|
+
onMouseDown={event => {
|
|
97
|
+
inputProps?.onMouseDown?.(event);
|
|
98
|
+
if (!readOnly) {
|
|
99
|
+
event.stopPropagation();
|
|
100
|
+
}
|
|
101
|
+
}}
|
|
102
|
+
onClickCapture={event => {
|
|
103
|
+
inputProps?.onClickCapture?.(event);
|
|
104
|
+
if (!readOnly) {
|
|
105
|
+
event.stopPropagation();
|
|
106
|
+
}
|
|
107
|
+
}}
|
|
108
|
+
onClick={event => {
|
|
109
|
+
inputProps?.onClick?.(event);
|
|
110
|
+
if (!readOnly) {
|
|
111
|
+
event.stopPropagation();
|
|
112
|
+
}
|
|
113
|
+
}}
|
|
114
|
+
onKeyDown={event => {
|
|
115
|
+
inputProps?.onKeyDown?.(event);
|
|
116
|
+
if (!readOnly) {
|
|
117
|
+
event.stopPropagation();
|
|
118
|
+
}
|
|
119
|
+
}}
|
|
120
|
+
onChange={event => {
|
|
121
|
+
// 6) RHF(customRegister) → 외부 inputProps → 내부 state 콜백 순으로 변경을 전파한다.
|
|
122
|
+
customRegister?.onChange?.(event);
|
|
123
|
+
inputProps?.onChange?.(event);
|
|
124
|
+
onLabelChange?.(event.currentTarget.value);
|
|
125
|
+
}}
|
|
126
|
+
/>
|
|
127
|
+
<input
|
|
128
|
+
type="hidden"
|
|
129
|
+
className="select-input-value"
|
|
130
|
+
value={valueFieldValue}
|
|
131
|
+
{...register}
|
|
132
|
+
/>
|
|
29
133
|
</div>
|
|
30
134
|
);
|
|
31
135
|
};
|
|
@@ -5,7 +5,6 @@ import { forwardRef, useEffect, useMemo, useState } from "react";
|
|
|
5
5
|
|
|
6
6
|
import Container from "../foundation/Container";
|
|
7
7
|
import { Dropdown } from "../../../dropdown/markup";
|
|
8
|
-
import type { DropdownSize } from "../../../dropdown/types";
|
|
9
8
|
import type { SelectMultipleComponentProps } from "../../types/props";
|
|
10
9
|
import type { SelectMultipleTag } from "../../types/multiple";
|
|
11
10
|
import type { SelectDropdownOption } from "../../types/option";
|
|
@@ -16,7 +15,7 @@ import { useSelectDropdownOpenState } from "../../hooks";
|
|
|
16
15
|
const isSameIdList = (previousIds: string[], nextIds: string[]) =>
|
|
17
16
|
previousIds.length === nextIds.length &&
|
|
18
17
|
previousIds.every((selectedId, index) => selectedId === nextIds[index]);
|
|
19
|
-
// 변경: synthetic "전체" 옵션 id의 기본 키다. 실제
|
|
18
|
+
// 변경: synthetic "전체" 옵션 id의 기본 키다. 실제 items id와 충돌하면 suffix를 붙여 회피한다.
|
|
20
19
|
const SELECT_MULTIPLE_ALL_OPTION_BASE_ID = "__select_multiple_all__";
|
|
21
20
|
|
|
22
21
|
/**
|
|
@@ -24,29 +23,23 @@ const SELECT_MULTIPLE_ALL_OPTION_BASE_ID = "__select_multiple_all__";
|
|
|
24
23
|
* @component
|
|
25
24
|
* @param {SelectMultipleComponentProps} props multi trigger props
|
|
26
25
|
* @param {SelectMultipleTag[]} [props.tags] 선택된 tag 리스트
|
|
27
|
-
* @param {SelectDropdownOption[]} [props.
|
|
28
|
-
* @param {
|
|
29
|
-
* @param {
|
|
30
|
-
* @param {(option: SelectDropdownOption) => void} [props.onOptionSelect] option 선택 콜백
|
|
26
|
+
* @param {SelectDropdownOption[]} [props.items] dropdown item 목록
|
|
27
|
+
* @param {SelectCallbackParams} [props.onSelectOption] option 선택 액션 콜백
|
|
28
|
+
* @param {SelectCallbackParams} [props.onSelectChange] 선택값 변경 콜백
|
|
31
29
|
* @param {React.ReactNode} [props.displayLabel] fallback 라벨
|
|
32
30
|
* @param {React.ReactNode} [props.placeholder] placeholder 텍스트
|
|
33
31
|
* @param {"primary" | "secondary" | "table"} [props.priority="primary"] priority scale
|
|
34
|
-
* @param {"small" | "medium" | "large"} [props.size="medium"] size scale
|
|
32
|
+
* @param {"xsmall" | "small" | "medium" | "large"} [props.size="medium"] size scale
|
|
35
33
|
* @param {"default" | "focused" | "disabled"} [props.state="default"] 시각 상태
|
|
36
34
|
* @param {boolean} [props.block] block 여부
|
|
37
35
|
* @param {FormFieldWidth} [props.width] container width preset
|
|
38
36
|
* @param {boolean} [props.isOpen] dropdown open 여부
|
|
39
37
|
* @param {boolean} [props.disabled] disabled 여부
|
|
40
38
|
* @param {boolean} [props.readOnly] readOnly 여부
|
|
41
|
-
* @param {
|
|
42
|
-
* @param {"match" | "fit-content" | "max-content" | string | number} [props.dropdownWidth="match"] dropdown panel width
|
|
43
|
-
* @param {Omit<DropdownMenuProps, "open" | "defaultOpen" | "onOpenChange">} [props.dropdownRootProps] Dropdown.Root 전달 props
|
|
44
|
-
* @param {Omit<DropdownContainerProps, "children" | "size" | "width">} [props.dropdownContainerProps] Dropdown.Container 전달 props
|
|
45
|
-
* @param {DropdownMenuListProps} [props.dropdownMenuListProps] Dropdown.Menu.List 전달 props
|
|
46
|
-
* @param {ReactNode} [props.alt] empty 상태 대체 콘텐츠
|
|
39
|
+
* @param {SelectDropdownExtension} [props.dropdown] dropdown 확장 옵션
|
|
47
40
|
* @param {boolean} [props.open] controlled open 상태
|
|
48
41
|
* @param {boolean} [props.defaultOpen] uncontrolled 초기 open 상태
|
|
49
|
-
* @param {(open: boolean) => void} [props.
|
|
42
|
+
* @param {(open: boolean) => void} [props.onOpen] open 상태 변경 콜백
|
|
50
43
|
* @param {boolean} [props.showSelectAllOption] dropdown 첫 번째에 "전체" 옵션 노출 여부
|
|
51
44
|
* @param {React.ReactNode} [props.selectAllLabel="전체"] 전체 옵션 라벨
|
|
52
45
|
*/
|
|
@@ -68,22 +61,16 @@ const SelectMultipleTrigger = forwardRef<
|
|
|
68
61
|
disabled,
|
|
69
62
|
readOnly,
|
|
70
63
|
tags,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
dropdownSize,
|
|
76
|
-
dropdownWidth = "match",
|
|
77
|
-
dropdownRootProps,
|
|
78
|
-
dropdownContainerProps,
|
|
79
|
-
dropdownMenuListProps,
|
|
80
|
-
alt,
|
|
64
|
+
items,
|
|
65
|
+
onSelectOption,
|
|
66
|
+
onSelectChange,
|
|
67
|
+
dropdownOptions: dropdown,
|
|
81
68
|
open,
|
|
82
69
|
defaultOpen,
|
|
83
|
-
|
|
70
|
+
onOpen,
|
|
84
71
|
showSelectAllOption,
|
|
85
72
|
selectAllLabel = "전체",
|
|
86
|
-
|
|
73
|
+
triggerProps,
|
|
87
74
|
},
|
|
88
75
|
ref,
|
|
89
76
|
) => {
|
|
@@ -94,13 +81,9 @@ const SelectMultipleTrigger = forwardRef<
|
|
|
94
81
|
*/
|
|
95
82
|
const resolvedBlock =
|
|
96
83
|
block || (priority === "table" && width === undefined);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
* - selectedOptionIds prop이 있으면 controlled 모드
|
|
101
|
-
* - selectedOptionIds prop이 없으면 uncontrolled 모드
|
|
102
|
-
*/
|
|
103
|
-
const isSelectionControlled = selectedOptionIds !== undefined;
|
|
84
|
+
// 변경: xsmall은 primary 전용이므로 secondary/table에서는 small로 fallback한다.
|
|
85
|
+
const resolvedSize =
|
|
86
|
+
priority !== "primary" && size === "xsmall" ? "small" : size;
|
|
104
87
|
|
|
105
88
|
/**
|
|
106
89
|
* 3) option 조회 최적화 맵 생성
|
|
@@ -108,13 +91,13 @@ const SelectMultipleTrigger = forwardRef<
|
|
|
108
91
|
* - label 계산, tag 파생, payload selectedOptions 계산에 공통 사용한다.
|
|
109
92
|
*/
|
|
110
93
|
const optionMap = useMemo(
|
|
111
|
-
() => new Map(
|
|
112
|
-
[
|
|
94
|
+
() => new Map(items.map(option => [option.id, option])),
|
|
95
|
+
[items],
|
|
113
96
|
);
|
|
114
97
|
// 변경: 전체 선택 대상은 disabled option을 제외한 "선택 가능 옵션"으로 한정한다.
|
|
115
98
|
const selectableOptions = useMemo(
|
|
116
|
-
() =>
|
|
117
|
-
[
|
|
99
|
+
() => items.filter(option => !option.disabled),
|
|
100
|
+
[items],
|
|
118
101
|
);
|
|
119
102
|
// 변경: 전체 선택 토글 계산을 위해 선택 가능 옵션 id 배열을 별도로 보관한다.
|
|
120
103
|
const selectableOptionIds = useMemo(
|
|
@@ -128,7 +111,7 @@ const SelectMultipleTrigger = forwardRef<
|
|
|
128
111
|
);
|
|
129
112
|
const allOptionId = useMemo(() => {
|
|
130
113
|
// 현재 전달된 옵션 id 집합을 먼저 만든다.
|
|
131
|
-
const existingIdSet = new Set(
|
|
114
|
+
const existingIdSet = new Set(items.map(option => option.id));
|
|
132
115
|
// "__select_multiple_all__"가 비어 있으면 그대로 사용한다.
|
|
133
116
|
if (!existingIdSet.has(SELECT_MULTIPLE_ALL_OPTION_BASE_ID)) {
|
|
134
117
|
return SELECT_MULTIPLE_ALL_OPTION_BASE_ID;
|
|
@@ -143,15 +126,15 @@ const SelectMultipleTrigger = forwardRef<
|
|
|
143
126
|
}
|
|
144
127
|
// 최종적으로 충돌하지 않는 synthetic all-option id를 반환한다.
|
|
145
128
|
return nextId;
|
|
146
|
-
}, [
|
|
129
|
+
}, [items]);
|
|
147
130
|
|
|
148
131
|
/**
|
|
149
132
|
* 4) uncontrolled 초기 선택값 계산
|
|
150
|
-
* -
|
|
133
|
+
* - items[].selected를 다중 선택 초기값으로 그대로 반영한다.
|
|
151
134
|
*/
|
|
152
135
|
const selectedIdsFromOptions = useMemo(
|
|
153
|
-
() =>
|
|
154
|
-
[
|
|
136
|
+
() => items.filter(option => option.selected).map(option => option.id),
|
|
137
|
+
[items],
|
|
155
138
|
);
|
|
156
139
|
|
|
157
140
|
/**
|
|
@@ -163,17 +146,13 @@ const SelectMultipleTrigger = forwardRef<
|
|
|
163
146
|
useState<string[]>(() => selectedIdsFromOptions);
|
|
164
147
|
|
|
165
148
|
/**
|
|
166
|
-
* 6)
|
|
167
|
-
* - 기존 선택 id 중 현재
|
|
149
|
+
* 6) items 변경 시 내부 state 정합성 보정
|
|
150
|
+
* - 기존 선택 id 중 현재 items에 남아있는 id만 유지한다.
|
|
168
151
|
* - 남은 id가 없으면 selectedIdsFromOptions(초기 선택 규칙)로 재동기화한다.
|
|
169
152
|
*/
|
|
170
153
|
useEffect(() => {
|
|
171
|
-
if (isSelectionControlled) {
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
154
|
setUncontrolledSelectedOptionIds(previousSelectedIds => {
|
|
176
|
-
const optionIdSet = new Set(
|
|
155
|
+
const optionIdSet = new Set(items.map(option => option.id));
|
|
177
156
|
const filteredIds = previousSelectedIds.filter(selectedId =>
|
|
178
157
|
optionIdSet.has(selectedId),
|
|
179
158
|
);
|
|
@@ -187,27 +166,19 @@ const SelectMultipleTrigger = forwardRef<
|
|
|
187
166
|
|
|
188
167
|
return nextSelectedIds;
|
|
189
168
|
});
|
|
190
|
-
}, [
|
|
169
|
+
}, [items, selectedIdsFromOptions]);
|
|
191
170
|
|
|
192
171
|
/**
|
|
193
172
|
* 7) 최종 선택 id 계산
|
|
194
|
-
* -
|
|
195
|
-
* - uncontrolled: 내부 state 사용
|
|
173
|
+
* - 내부 state 사용
|
|
196
174
|
*/
|
|
197
175
|
const resolvedSelectedIds = useMemo(() => {
|
|
198
|
-
const sourceSelectedIds = isSelectionControlled
|
|
199
|
-
? (selectedOptionIds ?? [])
|
|
200
|
-
: uncontrolledSelectedOptionIds;
|
|
201
|
-
|
|
202
176
|
// 변경: 실제 option id만 유지해 내부 synthetic all-option id가 상태에 저장되지 않도록 차단한다.
|
|
203
177
|
// 이 필터 덕분에 최종 선택 상태는 "실 데이터 옵션"만 source of truth로 유지된다.
|
|
204
|
-
return
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
selectedOptionIds,
|
|
209
|
-
uncontrolledSelectedOptionIds,
|
|
210
|
-
]);
|
|
178
|
+
return uncontrolledSelectedOptionIds.filter(selectedId =>
|
|
179
|
+
optionMap.has(selectedId),
|
|
180
|
+
);
|
|
181
|
+
}, [optionMap, uncontrolledSelectedOptionIds]);
|
|
211
182
|
const selectedIdSet = useMemo(
|
|
212
183
|
() => new Set(resolvedSelectedIds),
|
|
213
184
|
[resolvedSelectedIds],
|
|
@@ -223,7 +194,7 @@ const SelectMultipleTrigger = forwardRef<
|
|
|
223
194
|
);
|
|
224
195
|
const selectAllOption = useMemo<SelectDropdownOption>(
|
|
225
196
|
// 변경: "전체" 옵션은 렌더링을 위한 synthetic option 객체다.
|
|
226
|
-
// 실제
|
|
197
|
+
// 실제 items 배열에 저장하지 않고 render 단계에서만 주입한다.
|
|
227
198
|
() => ({
|
|
228
199
|
id: allOptionId,
|
|
229
200
|
value: allOptionId,
|
|
@@ -283,12 +254,12 @@ const SelectMultipleTrigger = forwardRef<
|
|
|
283
254
|
|
|
284
255
|
/**
|
|
285
256
|
* 11) dropdown open 상태 관리
|
|
286
|
-
* - open/defaultOpen/
|
|
257
|
+
* - open/defaultOpen/onOpen 계약을 hook으로 통합 처리한다.
|
|
287
258
|
*/
|
|
288
259
|
const { open: dropdownOpen, setOpen } = useSelectDropdownOpenState({
|
|
289
260
|
open: open ?? isOpen,
|
|
290
261
|
defaultOpen,
|
|
291
|
-
|
|
262
|
+
onOpen,
|
|
292
263
|
});
|
|
293
264
|
|
|
294
265
|
/**
|
|
@@ -315,17 +286,15 @@ const SelectMultipleTrigger = forwardRef<
|
|
|
315
286
|
* - 최대 3개 tag만 표시하고 나머지는 summary chip(+N)로 축약한다.
|
|
316
287
|
* - 과도한 폭 확장을 방지해 trigger layout 안정성을 유지한다.
|
|
317
288
|
*/
|
|
318
|
-
const
|
|
319
|
-
const hasOptions = options.length > 0;
|
|
289
|
+
const hasOptions = items.length > 0;
|
|
320
290
|
// "전체" 항목은 옵션이 존재할 때만 의미가 있으므로 hasOptions와 함께 gating한다.
|
|
321
291
|
const shouldRenderSelectAllOption = Boolean(
|
|
322
292
|
showSelectAllOption && hasOptions,
|
|
323
293
|
);
|
|
324
294
|
const renderedOptions = useMemo(
|
|
325
295
|
// 변경: all-option은 dropdown 첫 행 고정 요구사항에 맞춰 항상 배열 맨 앞에 주입한다.
|
|
326
|
-
() =>
|
|
327
|
-
|
|
328
|
-
[options, selectAllOption, shouldRenderSelectAllOption],
|
|
296
|
+
() => (shouldRenderSelectAllOption ? [selectAllOption, ...items] : items),
|
|
297
|
+
[items, selectAllOption, shouldRenderSelectAllOption],
|
|
329
298
|
);
|
|
330
299
|
const MAX_VISIBLE_TAGS = 3;
|
|
331
300
|
const visibleTags = hasTags ? derivedTags.slice(0, MAX_VISIBLE_TAGS) : [];
|
|
@@ -336,11 +305,9 @@ const SelectMultipleTrigger = forwardRef<
|
|
|
336
305
|
/**
|
|
337
306
|
* 15) option 선택 처리(multiple toggle)
|
|
338
307
|
* - 이미 선택된 id면 제거, 아니면 추가한다.
|
|
339
|
-
* -
|
|
340
|
-
* - onChange(payload)로 결과 전체를 전달
|
|
341
|
-
* - legacy onOptionSelect는 하위호환으로 유지
|
|
308
|
+
* - onSelectOption/onSelectChange 콜백으로 상호작용과 값 변경을 분리해 전달한다.
|
|
342
309
|
*/
|
|
343
|
-
const handleOptionSelect = (optionId: string) => {
|
|
310
|
+
const handleOptionSelect = (optionId: string, event: Event) => {
|
|
344
311
|
// 현재 클릭된 옵션이 synthetic all-option인지 먼저 판별한다.
|
|
345
312
|
const isSelectAllOption =
|
|
346
313
|
shouldRenderSelectAllOption && optionId === allOptionId;
|
|
@@ -355,36 +322,16 @@ const SelectMultipleTrigger = forwardRef<
|
|
|
355
322
|
: Array.from(
|
|
356
323
|
new Set([...resolvedSelectedIds, ...selectableOptionIds]),
|
|
357
324
|
);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
(
|
|
363
|
-
selectedOption,
|
|
364
|
-
): selectedOption is NonNullable<typeof selectedOption> =>
|
|
365
|
-
Boolean(selectedOption),
|
|
366
|
-
);
|
|
325
|
+
const didChange = !isSameIdList(
|
|
326
|
+
resolvedSelectedIds,
|
|
327
|
+
nextSelectedOptionIds,
|
|
328
|
+
);
|
|
367
329
|
|
|
368
|
-
|
|
369
|
-
if (
|
|
330
|
+
onSelectOption?.(selectAllOption, undefined, event);
|
|
331
|
+
if (didChange) {
|
|
370
332
|
setUncontrolledSelectedOptionIds(nextSelectedOptionIds);
|
|
333
|
+
onSelectChange?.(selectAllOption, undefined, event);
|
|
371
334
|
}
|
|
372
|
-
|
|
373
|
-
// onChange payload는 기존 multiple 계약을 그대로 사용한다.
|
|
374
|
-
// currentOption은 synthetic all-option으로 전달해 "전체 선택 액션"임을 호출부에서 구분 가능하게 한다.
|
|
375
|
-
onChange?.({
|
|
376
|
-
mode: "multiple",
|
|
377
|
-
selectedOptionIds: nextSelectedOptionIds,
|
|
378
|
-
selectedValues: nextSelectedOptions.map(
|
|
379
|
-
selectedOption => selectedOption.value,
|
|
380
|
-
),
|
|
381
|
-
selectedOptions: nextSelectedOptions,
|
|
382
|
-
currentOption: selectAllOption,
|
|
383
|
-
isSelected: !isAllSelectableOptionsSelected,
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
// legacy 하위호환 콜백도 동일하게 호출한다.
|
|
387
|
-
onOptionSelect?.(selectAllOption);
|
|
388
335
|
return;
|
|
389
336
|
}
|
|
390
337
|
|
|
@@ -394,36 +341,15 @@ const SelectMultipleTrigger = forwardRef<
|
|
|
394
341
|
const nextSelectedOptionIds = wasSelected
|
|
395
342
|
? resolvedSelectedIds.filter(selectedId => selectedId !== optionId)
|
|
396
343
|
: [...resolvedSelectedIds, optionId];
|
|
397
|
-
const nextSelectedOptions = nextSelectedOptionIds
|
|
398
|
-
.map(selectedId => optionMap.get(selectedId))
|
|
399
|
-
.filter(
|
|
400
|
-
(
|
|
401
|
-
selectedOption,
|
|
402
|
-
): selectedOption is NonNullable<typeof selectedOption> =>
|
|
403
|
-
Boolean(selectedOption),
|
|
404
|
-
);
|
|
405
344
|
const currentOption = optionMap.get(optionId);
|
|
406
345
|
|
|
407
346
|
if (!currentOption) {
|
|
408
347
|
return;
|
|
409
348
|
}
|
|
410
349
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
onChange?.({
|
|
416
|
-
mode: "multiple",
|
|
417
|
-
selectedOptionIds: nextSelectedOptionIds,
|
|
418
|
-
selectedValues: nextSelectedOptions.map(
|
|
419
|
-
selectedOption => selectedOption.value,
|
|
420
|
-
),
|
|
421
|
-
selectedOptions: nextSelectedOptions,
|
|
422
|
-
currentOption,
|
|
423
|
-
isSelected: !wasSelected,
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
onOptionSelect?.(currentOption);
|
|
350
|
+
onSelectOption?.(currentOption, undefined, event);
|
|
351
|
+
setUncontrolledSelectedOptionIds(nextSelectedOptionIds);
|
|
352
|
+
onSelectChange?.(currentOption, undefined, event);
|
|
427
353
|
};
|
|
428
354
|
|
|
429
355
|
/**
|
|
@@ -442,14 +368,14 @@ const SelectMultipleTrigger = forwardRef<
|
|
|
442
368
|
open={dropdownOpen}
|
|
443
369
|
onOpenChange={handleOpenChange}
|
|
444
370
|
modal={false}
|
|
445
|
-
{...
|
|
371
|
+
{...dropdown?.rootProps}
|
|
446
372
|
>
|
|
447
373
|
{/* Select trigger와 Dropdown trigger를 결합해 동일한 DOM을 공유한다. */}
|
|
448
374
|
<Dropdown.Trigger asChild>
|
|
449
375
|
<SelectTriggerBase
|
|
450
376
|
ref={ref}
|
|
451
377
|
priority={priority}
|
|
452
|
-
size={
|
|
378
|
+
size={resolvedSize}
|
|
453
379
|
state={disabled ? "disabled" : state}
|
|
454
380
|
block={resolvedBlock}
|
|
455
381
|
open={dropdownOpen}
|
|
@@ -457,7 +383,7 @@ const SelectMultipleTrigger = forwardRef<
|
|
|
457
383
|
disabled={disabled}
|
|
458
384
|
readOnly={readOnly}
|
|
459
385
|
as="div"
|
|
460
|
-
{...
|
|
386
|
+
{...triggerProps}
|
|
461
387
|
>
|
|
462
388
|
{hasTags ? (
|
|
463
389
|
<div className="select-tags">
|
|
@@ -485,16 +411,18 @@ const SelectMultipleTrigger = forwardRef<
|
|
|
485
411
|
label={resolvedDisplayLabel}
|
|
486
412
|
placeholder={placeholder}
|
|
487
413
|
isPlaceholder={!hasLabel}
|
|
414
|
+
// 변경: Multiple의 Selected 뷰는 표시 전용이므로 항상 편집을 차단한다.
|
|
415
|
+
readOnly
|
|
488
416
|
/>
|
|
489
417
|
)}
|
|
490
418
|
</SelectTriggerBase>
|
|
491
419
|
</Dropdown.Trigger>
|
|
492
420
|
<Dropdown.Container
|
|
493
|
-
{...
|
|
494
|
-
size={
|
|
495
|
-
width={
|
|
421
|
+
{...dropdown?.containerProps}
|
|
422
|
+
size={dropdown?.size ?? resolvedSize}
|
|
423
|
+
width={dropdown?.width ?? "match"}
|
|
496
424
|
>
|
|
497
|
-
<Dropdown.Menu.List {...
|
|
425
|
+
<Dropdown.Menu.List {...dropdown?.menuListProps}>
|
|
498
426
|
{hasOptions ? (
|
|
499
427
|
<>
|
|
500
428
|
{/* multi select 전용 옵션을 Dropdown.Menu.Item으로 노출한다. */}
|
|
@@ -519,7 +447,7 @@ const SelectMultipleTrigger = forwardRef<
|
|
|
519
447
|
event.preventDefault();
|
|
520
448
|
return;
|
|
521
449
|
}
|
|
522
|
-
handleOptionSelect(option.id);
|
|
450
|
+
handleOptionSelect(option.id, event);
|
|
523
451
|
}}
|
|
524
452
|
/>
|
|
525
453
|
))}
|
|
@@ -527,7 +455,7 @@ const SelectMultipleTrigger = forwardRef<
|
|
|
527
455
|
) : (
|
|
528
456
|
<Dropdown.Menu.Item
|
|
529
457
|
// 변경: 사용처 1회 상수 대신 인라인 fallback으로 empty label을 처리한다.
|
|
530
|
-
label={alt ?? "선택할 항목이 없습니다."}
|
|
458
|
+
label={dropdown?.alt ?? "선택할 항목이 없습니다."}
|
|
531
459
|
disabled
|
|
532
460
|
className="dropdown-menu-alt"
|
|
533
461
|
/>
|