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