@ncds/ui-admin 1.8.25-alpha.3 → 1.8.25
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/cjs/src/components/feedback-and-status/badge/Badge.js +1 -1
- package/dist/cjs/src/components/forms-and-input/combo-box/ComboBox.js +126 -16
- package/dist/cjs/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.js +3 -3
- package/dist/cjs/src/components/forms-and-input/combo-box/__tests__/ComboBox.test.js +455 -0
- package/dist/cjs/src/generated/scoped-css.js +1 -1
- package/dist/esm/src/components/feedback-and-status/badge/Badge.js +1 -1
- package/dist/esm/src/components/forms-and-input/combo-box/ComboBox.js +127 -17
- package/dist/esm/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.js +3 -3
- package/dist/esm/src/components/forms-and-input/combo-box/__tests__/ComboBox.test.js +452 -0
- package/dist/esm/src/generated/scoped-css.js +1 -1
- package/dist/temp/src/components/feedback-and-status/badge/Badge.d.ts +1 -1
- package/dist/temp/src/components/feedback-and-status/badge/Badge.js +1 -1
- package/dist/temp/src/components/forms-and-input/combo-box/ComboBox.d.ts +1 -1
- package/dist/temp/src/components/forms-and-input/combo-box/ComboBox.js +119 -13
- package/dist/temp/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.js +3 -3
- package/dist/temp/src/components/forms-and-input/combo-box/__tests__/ComboBox.test.d.ts +1 -0
- package/dist/temp/src/components/forms-and-input/combo-box/__tests__/ComboBox.test.js +293 -0
- package/dist/temp/src/generated/scoped-css.js +1 -1
- package/dist/types/src/components/feedback-and-status/badge/Badge.d.ts +1 -1
- package/dist/types/src/components/forms-and-input/combo-box/ComboBox.d.ts +1 -1
- package/dist/types/src/components/forms-and-input/combo-box/__tests__/ComboBox.test.d.ts +1 -0
- package/dist/ui-admin/assets/styles/style.css +14 -12
- package/package.json +1 -1
|
@@ -16,4 +16,4 @@ type BadgeProps = {
|
|
|
16
16
|
};
|
|
17
17
|
declare const Badge: ({ label, type, color, className, leadingIcon, trailingIcon, size, }: BadgeProps) => import("react/jsx-runtime").JSX.Element;
|
|
18
18
|
export { Badge };
|
|
19
|
-
export type {
|
|
19
|
+
export type { BadgeType, BadgeColor, BadgeSize, BadgeProps };
|
|
@@ -7,7 +7,7 @@ import { sideSlotRender } from './utils';
|
|
|
7
7
|
*/
|
|
8
8
|
const BADGE_ICON_SIZE = 12;
|
|
9
9
|
/** new-badge 박스 안에 들어가는 'N' 아이콘 크기. 박스는 sm 16px / md 20px 이며 아이콘은 그보다 작다(패딩 포함). */
|
|
10
|
-
const NEW_BADGE_ICON_SIZE = { sm: 12, md:
|
|
10
|
+
const NEW_BADGE_ICON_SIZE = { sm: 12, md: 16 };
|
|
11
11
|
const Badge = ({ label, type = 'pill-outline', color = 'neutral', className, leadingIcon, trailingIcon, size = 'xs', }) => {
|
|
12
12
|
// new-badge: 신규 콘텐츠 'N' 마크 전용 타입. label·color·leadingIcon·trailingIcon은 무시되고
|
|
13
13
|
// 색은 --pink-600 으로 고정된다. (디자이너 명세: Icon/FeaturedIcon/Badge로 대체 불가한 전용 표시)
|
|
@@ -10,7 +10,7 @@ interface ComboBoxProps extends Omit<ComponentPropsWithRef<'div'>, 'size' | 'onC
|
|
|
10
10
|
destructive?: boolean;
|
|
11
11
|
size?: Extract<Size, 'xs' | 'sm'>;
|
|
12
12
|
optionItems?: OptionType[];
|
|
13
|
-
value?: OptionValue;
|
|
13
|
+
value?: OptionValue | null;
|
|
14
14
|
onChange?: OptionChangeHandler;
|
|
15
15
|
onSearch?: (searchValue: string) => void;
|
|
16
16
|
disabled?: boolean;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { ChevronSelectorVertical, SearchLg } from '@ncds/ui-admin-icon';
|
|
3
3
|
import classNames from 'classnames';
|
|
4
|
-
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
|
|
4
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef, useState, } from 'react';
|
|
5
5
|
import { useDropdown } from '../../../hooks/dropdown';
|
|
6
6
|
import { useFloatingPosition } from '../../../hooks/useFloatingPosition';
|
|
7
7
|
import { usePortalState } from '../../../hooks/usePortalState';
|
|
@@ -23,6 +23,108 @@ const notifyFormChange = (register, multiple, newValue) => {
|
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
25
|
};
|
|
26
|
+
/** '' · undefined · null 이면 빈 값. 숫자 0은 빈 값이 아니다 */
|
|
27
|
+
const isEmptyValue = (value) => value === '' || value == null;
|
|
28
|
+
/** react-hook-form hidden input 값 — Tag 와 같은 빈 값 규칙(0은 값)을 따른다 */
|
|
29
|
+
const toHiddenInputValue = (value, multiple) => {
|
|
30
|
+
if (multiple)
|
|
31
|
+
return JSON.stringify(value || []);
|
|
32
|
+
return isEmptyValue(value) ? '' : String(value);
|
|
33
|
+
};
|
|
34
|
+
/** SelectDropdown 은 null 을 받지 않으므로 undefined 로 바꾼다 */
|
|
35
|
+
const toDropdownValue = (value) => value ?? undefined;
|
|
36
|
+
/** multiple 용 — 배열이 아니면(null · undefined 포함) 빈 배열 */
|
|
37
|
+
const toIdArray = (value) => (Array.isArray(value) ? value : []);
|
|
38
|
+
/** 두 id 목록이 같은 id 들로 이루어졌는지 — 순서는 보지 않는다 (사용처가 onChange 값을 정렬해 돌려줘도 같은 값) */
|
|
39
|
+
const isSameIds = (currentIds, nextIds) => currentIds.length === nextIds.length && currentIds.every((id) => nextIds.includes(id));
|
|
40
|
+
const isSameTags = (nextTags, prevTags) => nextTags.length === prevTags.length &&
|
|
41
|
+
nextTags.every((tag, index) => tag.id === prevTags[index].id && tag.label === prevTags[index].label);
|
|
42
|
+
const findOption = (optionItems, id) => optionItems.find((option) => option.id === id);
|
|
43
|
+
/** 단일 모드 value 에 해당하는 옵션 — 빈 값·배열이면 없음 */
|
|
44
|
+
const findSingleOption = (value, optionItems) => {
|
|
45
|
+
if (isEmptyValue(value) || Array.isArray(value))
|
|
46
|
+
return undefined;
|
|
47
|
+
return findOption(optionItems, value);
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* 단일 모드 Tag — value 와 optionItems 에서 계산한다.
|
|
51
|
+
* 검색 필터로 목록에서 빠진 경우에만, 같은 id 로 기억해 둔 Tag 를 대신 쓴다.
|
|
52
|
+
*/
|
|
53
|
+
const resolveSingleTag = (value, optionItems, lastResolvedTag) => {
|
|
54
|
+
if (isEmptyValue(value) || Array.isArray(value))
|
|
55
|
+
return null;
|
|
56
|
+
const found = findOption(optionItems, value);
|
|
57
|
+
if (found)
|
|
58
|
+
return { id: found.id, label: found.label };
|
|
59
|
+
return lastResolvedTag?.id === value ? lastResolvedTag : null;
|
|
60
|
+
};
|
|
61
|
+
/** 단일 모드 Tag 목록 (0~1개). 목록에서 찾은 Tag 를 기억해 검색 필터 중에도 라벨을 유지한다 */
|
|
62
|
+
const useSingleSelectedTags = (value, optionItems, enabled) => {
|
|
63
|
+
const [lastResolvedTag, setLastResolvedTag] = useState(null);
|
|
64
|
+
const found = enabled ? findSingleOption(value, optionItems) : undefined;
|
|
65
|
+
const foundId = found?.id;
|
|
66
|
+
const foundLabel = found?.label;
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (foundId === undefined || foundLabel === undefined)
|
|
69
|
+
return;
|
|
70
|
+
setLastResolvedTag({ id: foundId, label: foundLabel });
|
|
71
|
+
}, [foundId, foundLabel]);
|
|
72
|
+
const selectedTag = enabled ? resolveSingleTag(value, optionItems, lastResolvedTag) : null;
|
|
73
|
+
return selectedTag ? [selectedTag] : [];
|
|
74
|
+
};
|
|
75
|
+
/** multiple 모드 Tag — 목록에서 못 찾은 id 는 이미 표시 중인 Tag 의 라벨을 쓴다 */
|
|
76
|
+
const resolveMultipleTags = (ids, optionItems, prevTags) => {
|
|
77
|
+
const nextTags = ids
|
|
78
|
+
.map((id) => {
|
|
79
|
+
const found = findOption(optionItems, id);
|
|
80
|
+
return found ? { id: found.id, label: found.label } : prevTags.find((tag) => tag.id === id);
|
|
81
|
+
})
|
|
82
|
+
.filter((tag) => tag !== undefined);
|
|
83
|
+
// 결과가 같으면 이전 배열을 그대로 돌려준다 — 새 배열로 바꾸면 effect 가 다시 렌더를 일으킨다
|
|
84
|
+
return isSameTags(nextTags, prevTags) ? prevTags : nextTags;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* multiple 모드 Tag — 「선택 완료」·X 클릭·외부 value 변경 때만 확정 id 를 바꾼다.
|
|
88
|
+
* ComboBox 가 직접 onChange 로 내보낸 값은 markEmitted 로 기록해 두고, 그 값이 value 로 돌아오면 한 번만 무시한다(확정 아님).
|
|
89
|
+
* 바깥 값이 들어오면 남아 있던 기록도 지운다 — 오래된 기록 때문에 이후 같은 바깥 값이 무시되지 않게 하기 위함.
|
|
90
|
+
* optionItems 만 바뀌면 확정 id 는 그대로 두고 라벨만 다시 찾는다 — 「선택 완료」 전 검색으로 Tag 가 확정되지 않게 하기 위함.
|
|
91
|
+
*/
|
|
92
|
+
const useMultipleSelectedTags = (value, optionItems, enabled) => {
|
|
93
|
+
const [selectedTags, setSelectedTags] = useState([]);
|
|
94
|
+
const lastEmittedRef = useRef(null);
|
|
95
|
+
const committedIdsRef = useRef([]);
|
|
96
|
+
const prevValueIdsRef = useRef(null);
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!enabled)
|
|
99
|
+
return;
|
|
100
|
+
const ids = toIdArray(value);
|
|
101
|
+
const isEcho = lastEmittedRef.current !== null && isSameIds(ids, lastEmittedRef.current);
|
|
102
|
+
const isValueChanged = prevValueIdsRef.current === null || !isSameIds(ids, prevValueIdsRef.current);
|
|
103
|
+
prevValueIdsRef.current = ids;
|
|
104
|
+
if (isEcho) {
|
|
105
|
+
lastEmittedRef.current = null;
|
|
106
|
+
}
|
|
107
|
+
else if (isValueChanged) {
|
|
108
|
+
committedIdsRef.current = ids;
|
|
109
|
+
lastEmittedRef.current = null;
|
|
110
|
+
}
|
|
111
|
+
setSelectedTags((prev) => resolveMultipleTags(committedIdsRef.current, optionItems, prev));
|
|
112
|
+
}, [enabled, value, optionItems]);
|
|
113
|
+
/** 「선택 완료」로 Tag 를 확정한다 */
|
|
114
|
+
const commitTags = (ids) => {
|
|
115
|
+
committedIdsRef.current = ids;
|
|
116
|
+
setSelectedTags((prev) => resolveMultipleTags(ids, optionItems, prev));
|
|
117
|
+
};
|
|
118
|
+
/** X 클릭 — 확정 id 에서 그 id 만 뺀다. value 기준으로 계산하면 「선택 완료」 전에 클릭한 항목까지 확정된다 */
|
|
119
|
+
const removeCommittedTag = (tagId) => {
|
|
120
|
+
commitTags(committedIdsRef.current.filter((id) => id !== tagId));
|
|
121
|
+
};
|
|
122
|
+
/** ComboBox 가 onChange 로 내보낸 값을 기록한다 — value 로 돌아왔을 때 확정으로 보지 않기 위함 */
|
|
123
|
+
const markEmitted = (ids) => {
|
|
124
|
+
lastEmittedRef.current = ids;
|
|
125
|
+
};
|
|
126
|
+
return { selectedTags, commitTags, removeCommittedTag, markEmitted };
|
|
127
|
+
};
|
|
26
128
|
const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, hintText, children, size = 'xs', destructive = false, value, optionItems = [], onChange, onSearch, disabled = false, register, maxHeight = defaultMaxHeight, searchValue = '', label, required = false, multiple = false, showFooterButtons = false, maxSelection, onEdit, onComplete, usePortal = false, ...props }, ref) => {
|
|
27
129
|
const internalRef = useRef(null);
|
|
28
130
|
// 인라인 패널의 offset parent 가 __content 다. Portal 좌표도 같은 박스를 기준으로 잡아야
|
|
@@ -33,7 +135,8 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
|
|
|
33
135
|
const { shouldPortal, portalContainer } = usePortalState(usePortal);
|
|
34
136
|
const [inputValue, setInputValue] = useState(searchValue);
|
|
35
137
|
const [showAllItems, setShowAllItems] = useState(false);
|
|
36
|
-
const
|
|
138
|
+
const singleTags = useSingleSelectedTags(value, optionItems, !multiple);
|
|
139
|
+
const { selectedTags, commitTags, removeCommittedTag, markEmitted } = useMultipleSelectedTags(value, optionItems, multiple);
|
|
37
140
|
const handleOptionSelect = (option) => {
|
|
38
141
|
if (disabled)
|
|
39
142
|
return;
|
|
@@ -41,13 +144,12 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
|
|
|
41
144
|
const newValue = tryToggle(option.id, Array.isArray(value) ? value : []);
|
|
42
145
|
if (newValue === null)
|
|
43
146
|
return;
|
|
147
|
+
markEmitted(newValue);
|
|
44
148
|
onChange?.(newValue);
|
|
45
149
|
notifyFormChange(register, multiple, newValue);
|
|
46
150
|
return;
|
|
47
151
|
}
|
|
48
152
|
const newValue = option.id;
|
|
49
|
-
const tag = { id: option.id, label: option.label };
|
|
50
|
-
setSelectedTags([tag]);
|
|
51
153
|
onChange?.(newValue);
|
|
52
154
|
notifyFormChange(register, multiple, newValue);
|
|
53
155
|
};
|
|
@@ -130,11 +232,12 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
|
|
|
130
232
|
color: 'gray300',
|
|
131
233
|
};
|
|
132
234
|
const currentSelectedValues = multiple && Array.isArray(value) ? value : [];
|
|
133
|
-
const { buttonText: selectAllButtonText, toggleSelectAll,
|
|
235
|
+
const { buttonText: selectAllButtonText, toggleSelectAll, removeTag, isMaxSelectionActive, tryToggle, } = useMultiSelect(currentSelectedValues, optionItems, { maxSelection });
|
|
134
236
|
const handleSelectAll = () => {
|
|
135
237
|
if (!multiple || !onChange)
|
|
136
238
|
return;
|
|
137
239
|
const newSelectedValues = toggleSelectAll();
|
|
240
|
+
markEmitted(newSelectedValues);
|
|
138
241
|
onChange(newSelectedValues);
|
|
139
242
|
};
|
|
140
243
|
const handleEdit = () => {
|
|
@@ -143,33 +246,36 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
|
|
|
143
246
|
const handleComplete = (completedValue) => {
|
|
144
247
|
if (!multiple)
|
|
145
248
|
return;
|
|
146
|
-
|
|
147
|
-
setSelectedTags(tags);
|
|
249
|
+
commitTags(toIdArray(completedValue));
|
|
148
250
|
setInputValue('');
|
|
149
251
|
closeDropdown();
|
|
150
252
|
onComplete?.(completedValue);
|
|
151
253
|
};
|
|
152
254
|
const handleRemoveTag = (tagId) => {
|
|
255
|
+
// X 버튼은 disabled 에서 숨기지만, 다른 호출 경로가 생겨도 값이 지워지지 않게 함수에서도 막는다
|
|
256
|
+
if (disabled)
|
|
257
|
+
return;
|
|
153
258
|
if (!onChange)
|
|
154
259
|
return;
|
|
155
260
|
if (multiple) {
|
|
156
261
|
const newSelectedValues = removeTag(tagId);
|
|
262
|
+
markEmitted(newSelectedValues);
|
|
157
263
|
onChange(newSelectedValues);
|
|
158
|
-
|
|
264
|
+
removeCommittedTag(tagId);
|
|
159
265
|
return;
|
|
160
266
|
}
|
|
161
267
|
onChange('');
|
|
162
|
-
setSelectedTags([]);
|
|
163
268
|
};
|
|
164
269
|
const comboboxId = `combobox-options-${id || 'default'}`;
|
|
165
270
|
const hasVisibleOptions = showAllItems || inputValue;
|
|
166
271
|
const displayedOptions = hasVisibleOptions ? optionItems : [];
|
|
167
272
|
const showNoResults = !hasVisibleOptions || optionItems.length === 0;
|
|
168
273
|
const showFooter = multiple || showFooterButtons;
|
|
169
|
-
const hiddenInputValue =
|
|
170
|
-
const
|
|
274
|
+
const hiddenInputValue = toHiddenInputValue(value, multiple);
|
|
275
|
+
const tags = multiple ? selectedTags : singleTags;
|
|
276
|
+
const hasTags = tags.length > 0;
|
|
171
277
|
const { className: portalPanelClassName, style: portalPanelStyle } = resolvePortalPanelBinding(shouldPortal, floatingStyle);
|
|
172
|
-
const selectDropdownNode = (_jsxs(SelectDropdown, { ref: dropdownRef, isOpen: isOpen, direction: dropdownDirection, size: size, options: displayedOptions, value: value, focusedIndex: focusedIndex, maxHeight: maxHeight, listboxId: comboboxId, onOptionSelect: handleDropdownSelect, onMouseMove: handleMouseMove, isKeyboardNavigation: isKeyboardNavigation, multiple: multiple, showFooterButtons: showFooter, selectAllButtonText: selectAllButtonText, showSelectAllAction: !isMaxSelectionActive, onSelectAll: handleSelectAll, onEdit: handleEdit, onComplete: handleComplete, componentType: "combobox", className: portalPanelClassName, style: portalPanelStyle, children: [showNoResults ? _jsx("li", { className: "ncua-select-dropdown__no-results", children: "\uC77C\uCE58\uD558\uB294 \uACB0\uACFC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." }) : null, children] }));
|
|
278
|
+
const selectDropdownNode = (_jsxs(SelectDropdown, { ref: dropdownRef, isOpen: isOpen, direction: dropdownDirection, size: size, options: displayedOptions, value: toDropdownValue(value), focusedIndex: focusedIndex, maxHeight: maxHeight, listboxId: comboboxId, onOptionSelect: handleDropdownSelect, onMouseMove: handleMouseMove, isKeyboardNavigation: isKeyboardNavigation, multiple: multiple, showFooterButtons: showFooter, selectAllButtonText: selectAllButtonText, showSelectAllAction: !isMaxSelectionActive, onSelectAll: handleSelectAll, onEdit: handleEdit, onComplete: handleComplete, componentType: "combobox", className: portalPanelClassName, style: portalPanelStyle, children: [showNoResults ? _jsx("li", { className: "ncua-select-dropdown__no-results", children: "\uC77C\uCE58\uD558\uB294 \uACB0\uACFC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." }) : null, children] }));
|
|
173
279
|
const { inlineDropdown, portaledDropdown } = resolveDropdownMounts({
|
|
174
280
|
node: selectDropdownNode,
|
|
175
281
|
shouldPortal,
|
|
@@ -183,7 +289,7 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
|
|
|
183
289
|
}, className), ...props, children: [_jsxs("div", { ref: contentRef, className: "ncua-combobox__content", children: [_jsx(InputBase, { ref: inputRef, size: size, label: label, required: required, placeholder: placeholder, value: inputValue, onChange: handleInputChange, onKeyDown: handleKeyDown, onClick: handleInputClick, disabled: disabled, destructive: destructive, leadingElement: leadingElement, trailingElement: trailingElement, role: "combobox", "aria-expanded": isOpen, "aria-haspopup": "listbox", "aria-controls": comboboxId, "aria-disabled": disabled, autoComplete: "off", clearText: !!inputValue, onClearText: () => {
|
|
184
290
|
setInputValue('');
|
|
185
291
|
onSearch?.('');
|
|
186
|
-
} }), inlineDropdown, portaledDropdown] }), register && _jsx("input", { type: "hidden", ...register, value: hiddenInputValue }), hintText && _jsx(HintText, { destructive: destructive, children: hintText })] }), hasTags && (_jsx("div", { className: "ncua-combobox__tags", children:
|
|
292
|
+
} }), inlineDropdown, portaledDropdown] }), register && _jsx("input", { type: "hidden", ...register, value: hiddenInputValue }), hintText && _jsx(HintText, { destructive: destructive, children: hintText })] }), hasTags && (_jsx("div", { className: "ncua-combobox__tags", children: tags.map((tag) => (_jsx(Tag, { text: tag.label, size: "sm", close: !disabled, onButtonClick: () => handleRemoveTag(tag.id) }, tag.id))) }))] }));
|
|
187
293
|
});
|
|
188
294
|
ComboBox.displayName = 'ComboBox';
|
|
189
295
|
export { defaultMaxHeight, ComboBox };
|
package/dist/temp/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.js
CHANGED
|
@@ -333,7 +333,7 @@ describe('C-9 기본 경로 회귀 기준선', () => {
|
|
|
333
333
|
expect(onSearch).toHaveBeenCalledWith('사');
|
|
334
334
|
expect(view.content.querySelectorAll('.ncua-select-dropdown__option')).toHaveLength(createOptions().length);
|
|
335
335
|
});
|
|
336
|
-
it('옵션을 선택하면 onChange 가
|
|
336
|
+
it('옵션을 선택하면 onChange 가 호출된다', () => {
|
|
337
337
|
// Given: 옵션이 노출된 기본 모드 ComboBox 에서
|
|
338
338
|
const onChange = vi.fn();
|
|
339
339
|
const view = mountComboBox({ props: { onChange } });
|
|
@@ -342,9 +342,9 @@ describe('C-9 기본 경로 회귀 기준선', () => {
|
|
|
342
342
|
act(() => {
|
|
343
343
|
requireElement(view.content, '.ncua-select-dropdown__option').click();
|
|
344
344
|
});
|
|
345
|
-
// Then: onChange 가 선택 값으로
|
|
345
|
+
// Then: onChange 가 선택 값으로 호출된다
|
|
346
|
+
// 선택 태그는 value 를 따르므로(#530) onChange 값을 value 로 돌려주는 경우의 태그 표시는 ComboBox.test.ts S-10 에서 검증한다
|
|
346
347
|
expect(onChange).toHaveBeenCalledWith('apple');
|
|
347
|
-
expect(view.container.querySelectorAll('.ncua-combobox__tags .ncua-tag')).toHaveLength(1);
|
|
348
348
|
});
|
|
349
349
|
it('Escape 를 누르면 인라인 패널이 닫힌다', () => {
|
|
350
350
|
// Given: 열려 있는 기본 모드 ComboBox 에서
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { createElement, useState } from 'react';
|
|
3
|
+
import { createRoot } from 'react-dom/client';
|
|
4
|
+
import { act } from 'react-dom/test-utils';
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { ComboBox } from '../ComboBox';
|
|
7
|
+
// React 18에서 act() 사용 시 필요한 플래그 (미설정 시 console.error 경고 발생)
|
|
8
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
const OPTIONS = [
|
|
10
|
+
{ id: 1, label: '사과' },
|
|
11
|
+
{ id: 2, label: '바나나' },
|
|
12
|
+
{ id: 3, label: '체리' },
|
|
13
|
+
];
|
|
14
|
+
/** id 1·2가 빠진 목록 — 검색 필터로 optionItems가 줄어든 상황 */
|
|
15
|
+
const FILTERED_OPTIONS = [{ id: 3, label: '체리' }];
|
|
16
|
+
/** optionItems에 없는 id */
|
|
17
|
+
const MISSING_ID = 99;
|
|
18
|
+
/** 바깥에서 reset 으로 넣는 값 — 기존 선택(1·2)과 겹치지 않는 id */
|
|
19
|
+
const RESET_ID = 3;
|
|
20
|
+
/** 「전체 선택」 결과 — OPTIONS 의 모든 id */
|
|
21
|
+
const ALL_IDS = OPTIONS.map((option) => option.id);
|
|
22
|
+
let root = null;
|
|
23
|
+
let container = null;
|
|
24
|
+
/** 같은 root에 다시 render하면 props 변경(rerender)이 된다. */
|
|
25
|
+
function render(props) {
|
|
26
|
+
if (!container) {
|
|
27
|
+
container = document.createElement('div');
|
|
28
|
+
document.body.appendChild(container);
|
|
29
|
+
root = createRoot(container);
|
|
30
|
+
}
|
|
31
|
+
act(() => {
|
|
32
|
+
root?.render(createElement(ComboBox, props));
|
|
33
|
+
});
|
|
34
|
+
return container;
|
|
35
|
+
}
|
|
36
|
+
/** onChange로 받은 값을 value로 되돌려주는 사용처 흉내 (controlled) */
|
|
37
|
+
function Harness({ initialValue, onChangeSpy, transform, ...rest }) {
|
|
38
|
+
const [value, setValue] = useState(initialValue);
|
|
39
|
+
return createElement(ComboBox, {
|
|
40
|
+
...rest,
|
|
41
|
+
value,
|
|
42
|
+
onChange: (next) => {
|
|
43
|
+
onChangeSpy(next);
|
|
44
|
+
setValue(transform ? transform(next) : next);
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function renderHarness(props) {
|
|
49
|
+
if (!container) {
|
|
50
|
+
container = document.createElement('div');
|
|
51
|
+
document.body.appendChild(container);
|
|
52
|
+
root = createRoot(container);
|
|
53
|
+
}
|
|
54
|
+
act(() => {
|
|
55
|
+
root?.render(createElement(Harness, props));
|
|
56
|
+
});
|
|
57
|
+
return container;
|
|
58
|
+
}
|
|
59
|
+
function getTagTexts(el) {
|
|
60
|
+
return Array.from(el.querySelectorAll('.ncua-combobox__tags .ncua-tag .ncua-tag__text')).map((node) => node.textContent ?? '');
|
|
61
|
+
}
|
|
62
|
+
function getCloseButtons(el) {
|
|
63
|
+
return Array.from(el.querySelectorAll('.ncua-combobox__tags .ncua-tag__close'));
|
|
64
|
+
}
|
|
65
|
+
function click(target) {
|
|
66
|
+
if (!target)
|
|
67
|
+
throw new Error('클릭 대상을 찾을 수 없습니다');
|
|
68
|
+
act(() => {
|
|
69
|
+
target.click();
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** 「전체」 버튼으로 드롭다운을 연다 (입력값이 없어도 전체 목록 표시). */
|
|
73
|
+
function openDropdown(el) {
|
|
74
|
+
click(el.querySelector('.ncua-input__right-icon'));
|
|
75
|
+
}
|
|
76
|
+
function clickOption(el, label) {
|
|
77
|
+
const option = Array.from(el.querySelectorAll('.ncua-select-dropdown__option')).find((node) => node.textContent === label);
|
|
78
|
+
click(option);
|
|
79
|
+
}
|
|
80
|
+
function clickButton(el, label) {
|
|
81
|
+
const button = Array.from(el.querySelectorAll('button')).find((node) => node.textContent?.includes(label));
|
|
82
|
+
click(button);
|
|
83
|
+
}
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
act(() => {
|
|
86
|
+
root?.unmount();
|
|
87
|
+
});
|
|
88
|
+
container?.remove();
|
|
89
|
+
root = null;
|
|
90
|
+
container = null;
|
|
91
|
+
});
|
|
92
|
+
describe('ComboBox — 단일 모드 외부 value Tag', () => {
|
|
93
|
+
it('S-01: value에 해당하는 옵션이 있으면 Tag 1개를 라벨로 표시한다', () => {
|
|
94
|
+
const el = render({ value: 1, optionItems: OPTIONS });
|
|
95
|
+
expect(getTagTexts(el)).toEqual(['사과']);
|
|
96
|
+
});
|
|
97
|
+
it('S-02: optionItems가 늦게 도착하면 도착 시 Tag를 표시한다', () => {
|
|
98
|
+
const el = render({ value: 1, optionItems: [] });
|
|
99
|
+
expect(getTagTexts(el)).toEqual([]);
|
|
100
|
+
render({ value: 1, optionItems: OPTIONS });
|
|
101
|
+
expect(getTagTexts(el)).toEqual(['사과']);
|
|
102
|
+
});
|
|
103
|
+
it.each([
|
|
104
|
+
['빈 문자열', ''],
|
|
105
|
+
['undefined', undefined],
|
|
106
|
+
['null', null],
|
|
107
|
+
])('S-03: value가 %s이면 Tag를 표시하지 않는다', (_, value) => {
|
|
108
|
+
const el = render({ value: value, optionItems: OPTIONS });
|
|
109
|
+
expect(el.querySelector('.ncua-combobox__tags')).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
it.each([
|
|
112
|
+
['빈 문자열', ''],
|
|
113
|
+
['undefined', undefined],
|
|
114
|
+
])('S-04: Tag 표시 중 value가 %s로 바뀌면 Tag가 사라진다', (_, value) => {
|
|
115
|
+
const el = render({ value: 1, optionItems: OPTIONS });
|
|
116
|
+
expect(getTagTexts(el)).toEqual(['사과']);
|
|
117
|
+
render({ value, optionItems: OPTIONS });
|
|
118
|
+
expect(el.querySelector('.ncua-combobox__tags')).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
it('S-05: 검색 필터로 optionItems에서 선택 항목이 빠져도 같은 라벨로 Tag를 유지한다', () => {
|
|
121
|
+
const el = render({ value: 1, optionItems: OPTIONS });
|
|
122
|
+
expect(getTagTexts(el)).toEqual(['사과']);
|
|
123
|
+
render({ value: 1, optionItems: FILTERED_OPTIONS });
|
|
124
|
+
expect(getTagTexts(el)).toEqual(['사과']);
|
|
125
|
+
});
|
|
126
|
+
it('S-06: 목록에 없는 다른 id로 바뀌면 이전 라벨을 쓰지 않고 Tag를 표시하지 않는다', () => {
|
|
127
|
+
const el = render({ value: 1, optionItems: OPTIONS });
|
|
128
|
+
expect(getTagTexts(el)).toEqual(['사과']);
|
|
129
|
+
render({ value: MISSING_ID, optionItems: OPTIONS });
|
|
130
|
+
expect(el.querySelector('.ncua-combobox__tags')).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
it('S-07: value가 다른 id로 바뀌면 그 id의 라벨로 Tag를 바꾼다', () => {
|
|
133
|
+
const el = render({ value: 1, optionItems: OPTIONS });
|
|
134
|
+
render({ value: 2, optionItems: OPTIONS });
|
|
135
|
+
expect(getTagTexts(el)).toEqual(['바나나']);
|
|
136
|
+
});
|
|
137
|
+
it('S-08: disabled면 Tag는 표시하고 X 버튼은 표시하지 않는다', () => {
|
|
138
|
+
const el = render({ value: 1, optionItems: OPTIONS, disabled: true });
|
|
139
|
+
expect(getTagTexts(el)).toEqual(['사과']);
|
|
140
|
+
expect(getCloseButtons(el)).toHaveLength(0);
|
|
141
|
+
});
|
|
142
|
+
it('S-09: value가 0이어도 빈 값으로 보지 않고 Tag를 표시한다', () => {
|
|
143
|
+
const el = render({ value: 0, optionItems: [{ id: 0, label: '없음' }, ...OPTIONS] });
|
|
144
|
+
expect(getTagTexts(el)).toEqual(['없음']);
|
|
145
|
+
});
|
|
146
|
+
it.each([
|
|
147
|
+
[0, '0'],
|
|
148
|
+
[null, ''],
|
|
149
|
+
])('S-12: register 사용 시 value가 %s이면 hidden input 값은 %j이다 (Tag 표시 규칙과 같게 0은 값으로 본다)', (value, expected) => {
|
|
150
|
+
const register = { name: 'item', onChange: vi.fn(), onBlur: vi.fn(), ref: vi.fn() };
|
|
151
|
+
const el = render({ value, register, optionItems: [{ id: 0, label: '없음' }, ...OPTIONS] });
|
|
152
|
+
expect(el.querySelector('input[type="hidden"]')?.value).toBe(expected);
|
|
153
|
+
});
|
|
154
|
+
it('S-10 (회귀): 옵션을 클릭하면 onChange(id)를 호출하고 Tag를 표시한다', () => {
|
|
155
|
+
const onChangeSpy = vi.fn();
|
|
156
|
+
const el = renderHarness({ initialValue: '', onChangeSpy, optionItems: OPTIONS });
|
|
157
|
+
openDropdown(el);
|
|
158
|
+
clickOption(el, '바나나');
|
|
159
|
+
expect(onChangeSpy).toHaveBeenCalledWith(2);
|
|
160
|
+
expect(getTagTexts(el)).toEqual(['바나나']);
|
|
161
|
+
});
|
|
162
|
+
it('S-11 (회귀): X를 클릭하면 onChange("")를 호출하고 Tag가 사라진다', () => {
|
|
163
|
+
const onChangeSpy = vi.fn();
|
|
164
|
+
const el = renderHarness({ initialValue: 1, onChangeSpy, optionItems: OPTIONS });
|
|
165
|
+
click(getCloseButtons(el)[0]);
|
|
166
|
+
expect(onChangeSpy).toHaveBeenCalledWith('');
|
|
167
|
+
expect(el.querySelector('.ncua-combobox__tags')).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
describe('ComboBox — multiple 모드 외부 value Tag', () => {
|
|
171
|
+
it('M-01: value 배열에 해당하는 Tag를 모두 표시한다', () => {
|
|
172
|
+
const el = render({ multiple: true, value: [1, 2], optionItems: OPTIONS });
|
|
173
|
+
expect(getTagTexts(el)).toEqual(['사과', '바나나']);
|
|
174
|
+
});
|
|
175
|
+
it('M-02: optionItems가 늦게 도착하면 도착 시 Tag를 표시한다', () => {
|
|
176
|
+
const el = render({ multiple: true, value: [1, 2], optionItems: [] });
|
|
177
|
+
expect(getTagTexts(el)).toEqual([]);
|
|
178
|
+
render({ multiple: true, value: [1, 2], optionItems: OPTIONS });
|
|
179
|
+
expect(getTagTexts(el)).toEqual(['사과', '바나나']);
|
|
180
|
+
});
|
|
181
|
+
it.each([
|
|
182
|
+
['빈 배열', []],
|
|
183
|
+
['undefined', undefined],
|
|
184
|
+
['null', null],
|
|
185
|
+
])('M-03: value가 %s이면 Tag를 표시하지 않는다', (_, value) => {
|
|
186
|
+
const el = render({ multiple: true, value: value, optionItems: OPTIONS });
|
|
187
|
+
expect(el.querySelector('.ncua-combobox__tags')).toBeNull();
|
|
188
|
+
});
|
|
189
|
+
it.each([
|
|
190
|
+
['빈 배열', []],
|
|
191
|
+
['undefined', undefined],
|
|
192
|
+
])('M-04: Tag 표시 중 value가 %s로 바뀌면 Tag가 사라진다', (_, value) => {
|
|
193
|
+
const el = render({ multiple: true, value: [1, 2], optionItems: OPTIONS });
|
|
194
|
+
expect(getTagTexts(el)).toEqual(['사과', '바나나']);
|
|
195
|
+
render({ multiple: true, value, optionItems: OPTIONS });
|
|
196
|
+
expect(el.querySelector('.ncua-combobox__tags')).toBeNull();
|
|
197
|
+
});
|
|
198
|
+
it('M-05: 바깥에서 value를 바꾸면(reset) 새 value로 Tag를 다시 그린다', () => {
|
|
199
|
+
const el = render({ multiple: true, value: [1, 2], optionItems: OPTIONS });
|
|
200
|
+
render({ multiple: true, value: [RESET_ID], optionItems: OPTIONS });
|
|
201
|
+
expect(getTagTexts(el)).toEqual(['체리']);
|
|
202
|
+
});
|
|
203
|
+
it('M-06: 검색 필터로 optionItems에서 선택 항목이 빠져도 Tag를 유지한다', () => {
|
|
204
|
+
const el = render({ multiple: true, value: [1, 2], optionItems: OPTIONS });
|
|
205
|
+
render({ multiple: true, value: [1, 2], optionItems: FILTERED_OPTIONS });
|
|
206
|
+
expect(getTagTexts(el)).toEqual(['사과', '바나나']);
|
|
207
|
+
});
|
|
208
|
+
it('M-07: disabled면 Tag는 표시하고 X 버튼은 표시하지 않는다', () => {
|
|
209
|
+
const el = render({ multiple: true, value: [1], optionItems: OPTIONS, disabled: true });
|
|
210
|
+
expect(getTagTexts(el)).toEqual(['사과']);
|
|
211
|
+
expect(getCloseButtons(el)).toHaveLength(0);
|
|
212
|
+
});
|
|
213
|
+
it('M-08 (회귀): 옵션을 클릭하면 onChange만 호출하고 Tag는 「선택 완료」 전까지 그대로다', () => {
|
|
214
|
+
const onChangeSpy = vi.fn();
|
|
215
|
+
const el = renderHarness({ multiple: true, initialValue: [1], onChangeSpy, optionItems: OPTIONS });
|
|
216
|
+
expect(getTagTexts(el)).toEqual(['사과']);
|
|
217
|
+
openDropdown(el);
|
|
218
|
+
clickOption(el, '바나나');
|
|
219
|
+
expect(onChangeSpy).toHaveBeenCalledWith([1, 2]);
|
|
220
|
+
expect(getTagTexts(el)).toEqual(['사과']);
|
|
221
|
+
});
|
|
222
|
+
it('M-09 (회귀): 옵션 클릭 후 「선택 완료」를 누르면 Tag를 반영하고 onComplete를 호출한다', () => {
|
|
223
|
+
const onChangeSpy = vi.fn();
|
|
224
|
+
const onComplete = vi.fn();
|
|
225
|
+
const el = renderHarness({ multiple: true, initialValue: [1], onChangeSpy, onComplete, optionItems: OPTIONS });
|
|
226
|
+
openDropdown(el);
|
|
227
|
+
clickOption(el, '바나나');
|
|
228
|
+
clickButton(el, '선택 완료');
|
|
229
|
+
expect(getTagTexts(el)).toEqual(['사과', '바나나']);
|
|
230
|
+
expect(onComplete).toHaveBeenCalledWith([1, 2]);
|
|
231
|
+
});
|
|
232
|
+
it('M-10 (회귀): X를 클릭하면 onChange(남은 배열)를 호출하고 해당 Tag만 제거한다', () => {
|
|
233
|
+
const onChangeSpy = vi.fn();
|
|
234
|
+
const el = renderHarness({ multiple: true, initialValue: [1, 2], onChangeSpy, optionItems: OPTIONS });
|
|
235
|
+
click(getCloseButtons(el)[0]);
|
|
236
|
+
expect(onChangeSpy).toHaveBeenCalledWith([2]);
|
|
237
|
+
expect(getTagTexts(el)).toEqual(['바나나']);
|
|
238
|
+
});
|
|
239
|
+
it('M-11 (회귀): 「전체 선택」을 누르면 onChange(전체 id)만 호출하고 Tag는 그대로다', () => {
|
|
240
|
+
const onChangeSpy = vi.fn();
|
|
241
|
+
const el = renderHarness({ multiple: true, initialValue: [], onChangeSpy, optionItems: OPTIONS });
|
|
242
|
+
openDropdown(el);
|
|
243
|
+
clickButton(el, '전체 선택');
|
|
244
|
+
expect(onChangeSpy).toHaveBeenCalledWith(ALL_IDS);
|
|
245
|
+
expect(el.querySelector('.ncua-combobox__tags')).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
it('M-12: 클릭으로 고른 값과 같은 값을 바깥에서 비웠다가 다시 넣어도 Tag를 표시한다', () => {
|
|
248
|
+
const props = { multiple: true, optionItems: OPTIONS, onChange: vi.fn() };
|
|
249
|
+
const el = render({ ...props, value: [] });
|
|
250
|
+
openDropdown(el);
|
|
251
|
+
clickOption(el, '사과');
|
|
252
|
+
render({ ...props, value: [1] });
|
|
253
|
+
clickButton(el, '선택 완료');
|
|
254
|
+
expect(getTagTexts(el)).toEqual(['사과']);
|
|
255
|
+
render({ ...props, value: [] });
|
|
256
|
+
expect(getTagTexts(el)).toEqual([]);
|
|
257
|
+
render({ ...props, value: [1] });
|
|
258
|
+
expect(getTagTexts(el)).toEqual(['사과']);
|
|
259
|
+
});
|
|
260
|
+
it('M-13 (회귀): 클릭 후 「선택 완료」 전에 optionItems가 바뀌어도(검색) Tag는 그대로다', () => {
|
|
261
|
+
const onChangeSpy = vi.fn();
|
|
262
|
+
const el = renderHarness({ multiple: true, initialValue: [1], onChangeSpy, optionItems: OPTIONS });
|
|
263
|
+
openDropdown(el);
|
|
264
|
+
clickOption(el, '바나나');
|
|
265
|
+
renderHarness({ multiple: true, initialValue: [1], onChangeSpy, optionItems: FILTERED_OPTIONS });
|
|
266
|
+
expect(onChangeSpy).toHaveBeenCalledWith([1, 2]);
|
|
267
|
+
expect(getTagTexts(el)).toEqual(['사과']);
|
|
268
|
+
});
|
|
269
|
+
it('M-14 (회귀): 「선택 완료」 전에 클릭한 항목이 있을 때 X를 누르면 그 Tag만 빠지고 클릭한 항목은 확정되지 않는다', () => {
|
|
270
|
+
const onChangeSpy = vi.fn();
|
|
271
|
+
const el = renderHarness({ multiple: true, initialValue: [1], onChangeSpy, optionItems: OPTIONS });
|
|
272
|
+
openDropdown(el);
|
|
273
|
+
clickOption(el, '바나나');
|
|
274
|
+
click(getCloseButtons(el)[0]);
|
|
275
|
+
expect(onChangeSpy).toHaveBeenLastCalledWith([2]);
|
|
276
|
+
expect(el.querySelector('.ncua-combobox__tags')).toBeNull();
|
|
277
|
+
});
|
|
278
|
+
it('M-15 (회귀): 사용처가 onChange 값의 순서를 바꿔 돌려줘도 「선택 완료」 전에는 Tag가 그대로다', () => {
|
|
279
|
+
const onChangeSpy = vi.fn();
|
|
280
|
+
const sortIds = (value) => (Array.isArray(value) ? [...value].sort() : value);
|
|
281
|
+
const el = renderHarness({
|
|
282
|
+
multiple: true,
|
|
283
|
+
initialValue: [RESET_ID],
|
|
284
|
+
onChangeSpy,
|
|
285
|
+
transform: sortIds,
|
|
286
|
+
optionItems: OPTIONS,
|
|
287
|
+
});
|
|
288
|
+
openDropdown(el);
|
|
289
|
+
clickOption(el, '사과');
|
|
290
|
+
expect(onChangeSpy).toHaveBeenCalledWith([RESET_ID, 1]);
|
|
291
|
+
expect(getTagTexts(el)).toEqual(['체리']);
|
|
292
|
+
});
|
|
293
|
+
});
|