@ncds/ui-admin 1.8.25-alpha.2 → 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 +122 -54
- 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/components/forms-and-input/combo-box/utils.js +51 -0
- package/dist/cjs/src/generated/scoped-css.js +1 -1
- package/dist/cjs/src/hooks/__tests__/useFloatingPosition.test.js +32 -0
- package/dist/cjs/src/hooks/useFloatingPosition.js +2 -1
- package/dist/cjs/src/utils/portal-position/__tests__/portalPosition.tracking.test.js +21 -0
- package/dist/cjs/src/utils/portal-position/portalPosition.js +7 -3
- 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 +121 -53
- 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/components/forms-and-input/combo-box/utils.js +43 -0
- package/dist/esm/src/generated/scoped-css.js +1 -1
- package/dist/esm/src/hooks/__tests__/useFloatingPosition.test.js +32 -0
- package/dist/esm/src/hooks/useFloatingPosition.js +2 -1
- package/dist/esm/src/utils/portal-position/__tests__/portalPosition.tracking.test.js +21 -0
- package/dist/esm/src/utils/portal-position/portalPosition.js +7 -3
- 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 +114 -32
- 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/components/forms-and-input/combo-box/utils.d.ts +27 -0
- package/dist/temp/src/components/forms-and-input/combo-box/utils.js +25 -0
- package/dist/temp/src/generated/scoped-css.js +1 -1
- package/dist/temp/src/hooks/__tests__/useFloatingPosition.test.js +24 -0
- package/dist/temp/src/hooks/useFloatingPosition.js +3 -2
- package/dist/temp/src/utils/portal-position/__tests__/portalPosition.tracking.test.js +13 -0
- package/dist/temp/src/utils/portal-position/portalPosition.d.ts +11 -14
- package/dist/temp/src/utils/portal-position/portalPosition.js +9 -4
- 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/types/src/components/forms-and-input/combo-box/utils.d.ts +27 -0
- package/dist/types/src/utils/portal-position/portalPosition.d.ts +11 -14
- package/dist/ui-admin/assets/styles/style.css +14 -12
- package/package.json +1 -1
|
@@ -319,4 +319,36 @@ const dispatchScroll = () => {
|
|
|
319
319
|
});
|
|
320
320
|
hook.unmount();
|
|
321
321
|
});
|
|
322
|
+
});
|
|
323
|
+
(0, _vitest.describe)('useFloatingPosition — 좌표 불변 갱신', () => {
|
|
324
|
+
(0, _vitest.it)('scroll 뒤에도 top·left·minWidth 가 같으면 이전 style 참조를 유지해 하위 트리 리렌더를 건너뛴다', () => {
|
|
325
|
+
installResizeObserverStub();
|
|
326
|
+
const hook = renderHook(baseParams({
|
|
327
|
+
triggerRef: makeRef(createAnchor()),
|
|
328
|
+
floatingRef: makeRef(createPanel()),
|
|
329
|
+
matchTriggerWidth: true
|
|
330
|
+
}));
|
|
331
|
+
const styleBeforeScroll = hook.latest();
|
|
332
|
+
dispatchScroll();
|
|
333
|
+
(0, _vitest.expect)(hook.latest()).toBe(styleBeforeScroll);
|
|
334
|
+
hook.unmount();
|
|
335
|
+
});
|
|
336
|
+
(0, _vitest.it)('scroll 로 좌표가 바뀌면 새 style 로 다시 렌더링한다', () => {
|
|
337
|
+
installResizeObserverStub();
|
|
338
|
+
const anchor = createAnchor();
|
|
339
|
+
const hook = renderHook(baseParams({
|
|
340
|
+
triggerRef: makeRef(anchor),
|
|
341
|
+
floatingRef: makeRef(createPanel())
|
|
342
|
+
}));
|
|
343
|
+
const moved = makeAnchorRect({
|
|
344
|
+
top: 300,
|
|
345
|
+
bottom: 340
|
|
346
|
+
});
|
|
347
|
+
_vitest.vi.spyOn(anchor, 'getBoundingClientRect').mockReturnValue(toDomRect(moved));
|
|
348
|
+
dispatchScroll();
|
|
349
|
+
(0, _vitest.expect)(hook.latest()).toMatchObject({
|
|
350
|
+
top: moved.bottom + DEFAULT_OFFSET
|
|
351
|
+
});
|
|
352
|
+
hook.unmount();
|
|
353
|
+
});
|
|
322
354
|
});
|
|
@@ -7,6 +7,7 @@ exports.useFloatingPosition = void 0;
|
|
|
7
7
|
var _react = require("react");
|
|
8
8
|
var _portalPosition = require("../utils/portal-position");
|
|
9
9
|
var _useIsomorphicLayoutEffect = require("./useIsomorphicLayoutEffect");
|
|
10
|
+
const isSamePlacement = (prev, next) => prev !== null && prev.top === next.top && prev.left === next.left && prev.minWidth === next.minWidth;
|
|
10
11
|
const useFloatingPosition = _ref => {
|
|
11
12
|
let {
|
|
12
13
|
enabled,
|
|
@@ -31,7 +32,7 @@ const useFloatingPosition = _ref => {
|
|
|
31
32
|
align,
|
|
32
33
|
offset,
|
|
33
34
|
matchAnchorWidth: matchTriggerWidth,
|
|
34
|
-
onUpdate: setStyle
|
|
35
|
+
onUpdate: next => setStyle(prev => isSamePlacement(prev, next) ? prev : next)
|
|
35
36
|
});
|
|
36
37
|
}, [enabled, isOpen, direction, offset, align, matchTriggerWidth, triggerRef, floatingRef]);
|
|
37
38
|
return style;
|
|
@@ -115,6 +115,27 @@ const dispatchWindow = type => window.dispatchEvent(new Event(type));
|
|
|
115
115
|
(0, _vitest.expect)(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.afterScroll);
|
|
116
116
|
cleanup();
|
|
117
117
|
});
|
|
118
|
+
(0, _vitest.it)('panel 자신과 그 내부 요소의 scroll 은 anchor 를 움직이지 않으므로 onChange 를 호출하지 않는다', () => {
|
|
119
|
+
installResizeObserverStub();
|
|
120
|
+
const anchor = createAnchor();
|
|
121
|
+
const panel = createPanel();
|
|
122
|
+
const optionList = document.createElement('ul');
|
|
123
|
+
panel.appendChild(optionList);
|
|
124
|
+
const onChange = _vitest.vi.fn();
|
|
125
|
+
const cleanup = (0, _.observeAnchorGeometry)({
|
|
126
|
+
getAnchor: () => anchor.element,
|
|
127
|
+
getPanel: () => panel,
|
|
128
|
+
onChange
|
|
129
|
+
});
|
|
130
|
+
panel.dispatchEvent(new Event('scroll', {
|
|
131
|
+
bubbles: false
|
|
132
|
+
}));
|
|
133
|
+
optionList.dispatchEvent(new Event('scroll', {
|
|
134
|
+
bubbles: false
|
|
135
|
+
}));
|
|
136
|
+
(0, _vitest.expect)(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.initial);
|
|
137
|
+
cleanup();
|
|
138
|
+
});
|
|
118
139
|
(0, _vitest.it)('anchor 와 panel 각각에 ResizeObserver 를 붙인다', () => {
|
|
119
140
|
const {
|
|
120
141
|
instances
|
|
@@ -113,14 +113,19 @@ const observeAnchorGeometry = _ref5 => {
|
|
|
113
113
|
return noop;
|
|
114
114
|
}
|
|
115
115
|
onChange();
|
|
116
|
-
|
|
116
|
+
// 패널 내부 스크롤은 anchor 를 움직이지 않는다
|
|
117
|
+
const onScroll = event => {
|
|
118
|
+
if (event.target instanceof Node && getPanel()?.contains(event.target)) return;
|
|
119
|
+
onChange();
|
|
120
|
+
};
|
|
121
|
+
window.addEventListener('scroll', onScroll, {
|
|
117
122
|
passive: true,
|
|
118
123
|
capture: true
|
|
119
124
|
});
|
|
120
125
|
window.addEventListener('resize', onChange);
|
|
121
126
|
const observers = [observeElementResize(anchor, onChange), observeElementResize(getPanel(), onChange)];
|
|
122
127
|
return () => {
|
|
123
|
-
window.removeEventListener('scroll',
|
|
128
|
+
window.removeEventListener('scroll', onScroll, {
|
|
124
129
|
capture: true
|
|
125
130
|
});
|
|
126
131
|
window.removeEventListener('resize', onChange);
|
|
@@ -146,7 +151,6 @@ const trackPortalPosition = _ref6 => {
|
|
|
146
151
|
}
|
|
147
152
|
});
|
|
148
153
|
};
|
|
149
|
-
// TODO(#497): vanilla 어댑터 도입 시 소비
|
|
150
154
|
exports.trackPortalPosition = trackPortalPosition;
|
|
151
155
|
const applyPortalPanelStyle = (element, style) => {
|
|
152
156
|
element.style.position = style.position;
|
|
@@ -1,8 +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';
|
|
5
|
-
import { createPortal } from 'react-dom';
|
|
4
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
|
6
5
|
import { useDropdown } from '../../../hooks/dropdown';
|
|
7
6
|
import { useFloatingPosition } from '../../../hooks/useFloatingPosition';
|
|
8
7
|
import { usePortalState } from '../../../hooks/usePortalState';
|
|
@@ -12,6 +11,7 @@ import { Tag } from '../../feedback-and-status/tag';
|
|
|
12
11
|
import { SelectDropdown } from '../../select-dropdown';
|
|
13
12
|
import { HintText } from '../../shared/hintText/HintText';
|
|
14
13
|
import { InputBase } from '../input-base/InputBase';
|
|
14
|
+
import { resolveDropdownMounts, resolvePortalPanelBinding } from './utils';
|
|
15
15
|
const defaultMaxHeight = 275;
|
|
16
16
|
const notifyFormChange = (register, multiple, newValue) => {
|
|
17
17
|
if (register?.onChange) {
|
|
@@ -23,49 +23,113 @@ 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) return JSON.stringify(value || []);
|
|
31
|
+
return isEmptyValue(value) ? '' : String(value);
|
|
32
|
+
};
|
|
33
|
+
/** SelectDropdown 은 null 을 받지 않으므로 undefined 로 바꾼다 */
|
|
34
|
+
const toDropdownValue = value => value ?? undefined;
|
|
35
|
+
/** multiple 용 — 배열이 아니면(null · undefined 포함) 빈 배열 */
|
|
36
|
+
const toIdArray = value => Array.isArray(value) ? value : [];
|
|
37
|
+
/** 두 id 목록이 같은 id 들로 이루어졌는지 — 순서는 보지 않는다 (사용처가 onChange 값을 정렬해 돌려줘도 같은 값) */
|
|
38
|
+
const isSameIds = (currentIds, nextIds) => currentIds.length === nextIds.length && currentIds.every(id => nextIds.includes(id));
|
|
39
|
+
const isSameTags = (nextTags, prevTags) => nextTags.length === prevTags.length && nextTags.every((tag, index) => tag.id === prevTags[index].id && tag.label === prevTags[index].label);
|
|
40
|
+
const findOption = (optionItems, id) => optionItems.find(option => option.id === id);
|
|
41
|
+
/** 단일 모드 value 에 해당하는 옵션 — 빈 값·배열이면 없음 */
|
|
42
|
+
const findSingleOption = (value, optionItems) => {
|
|
43
|
+
if (isEmptyValue(value) || Array.isArray(value)) return undefined;
|
|
44
|
+
return findOption(optionItems, value);
|
|
45
|
+
};
|
|
26
46
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* useLayoutEffect 가 paint 전에 좌표를 채우므로 빈 fixed 가 노출되지 않는다.
|
|
47
|
+
* 단일 모드 Tag — value 와 optionItems 에서 계산한다.
|
|
48
|
+
* 검색 필터로 목록에서 빠진 경우에만, 같은 id 로 기억해 둔 Tag 를 대신 쓴다.
|
|
30
49
|
*/
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
style: floatingStyle ?? undefined
|
|
50
|
+
const resolveSingleTag = (value, optionItems, lastResolvedTag) => {
|
|
51
|
+
if (isEmptyValue(value) || Array.isArray(value)) return null;
|
|
52
|
+
const found = findOption(optionItems, value);
|
|
53
|
+
if (found) return {
|
|
54
|
+
id: found.id,
|
|
55
|
+
label: found.label
|
|
38
56
|
};
|
|
57
|
+
return lastResolvedTag?.id === value ? lastResolvedTag : null;
|
|
58
|
+
};
|
|
59
|
+
/** 단일 모드 Tag 목록 (0~1개). 목록에서 찾은 Tag 를 기억해 검색 필터 중에도 라벨을 유지한다 */
|
|
60
|
+
const useSingleSelectedTags = (value, optionItems, enabled) => {
|
|
61
|
+
const [lastResolvedTag, setLastResolvedTag] = useState(null);
|
|
62
|
+
const found = enabled ? findSingleOption(value, optionItems) : undefined;
|
|
63
|
+
const foundId = found?.id;
|
|
64
|
+
const foundLabel = found?.label;
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (foundId === undefined || foundLabel === undefined) return;
|
|
67
|
+
setLastResolvedTag({
|
|
68
|
+
id: foundId,
|
|
69
|
+
label: foundLabel
|
|
70
|
+
});
|
|
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.map(id => {
|
|
78
|
+
const found = findOption(optionItems, id);
|
|
79
|
+
return found ? {
|
|
80
|
+
id: found.id,
|
|
81
|
+
label: found.label
|
|
82
|
+
} : prevTags.find(tag => tag.id === id);
|
|
83
|
+
}).filter(tag => tag !== undefined);
|
|
84
|
+
// 결과가 같으면 이전 배열을 그대로 돌려준다 — 새 배열로 바꾸면 effect 가 다시 렌더를 일으킨다
|
|
85
|
+
return isSameTags(nextTags, prevTags) ? prevTags : nextTags;
|
|
39
86
|
};
|
|
40
87
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
88
|
+
* multiple 모드 Tag — 「선택 완료」·X 클릭·외부 value 변경 때만 확정 id 를 바꾼다.
|
|
89
|
+
* ComboBox 가 직접 onChange 로 내보낸 값은 markEmitted 로 기록해 두고, 그 값이 value 로 돌아오면 한 번만 무시한다(확정 아님).
|
|
90
|
+
* 바깥 값이 들어오면 남아 있던 기록도 지운다 — 오래된 기록 때문에 이후 같은 바깥 값이 무시되지 않게 하기 위함.
|
|
91
|
+
* optionItems 만 바뀌면 확정 id 는 그대로 두고 라벨만 다시 찾는다 — 「선택 완료」 전 검색으로 Tag 가 확정되지 않게 하기 위함.
|
|
43
92
|
*/
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
93
|
+
const useMultipleSelectedTags = (value, optionItems, enabled) => {
|
|
94
|
+
const [selectedTags, setSelectedTags] = useState([]);
|
|
95
|
+
const lastEmittedRef = useRef(null);
|
|
96
|
+
const committedIdsRef = useRef([]);
|
|
97
|
+
const prevValueIdsRef = useRef(null);
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!enabled) 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
|
+
} else if (isValueChanged) {
|
|
107
|
+
committedIdsRef.current = ids;
|
|
108
|
+
lastEmittedRef.current = null;
|
|
109
|
+
}
|
|
110
|
+
setSelectedTags(prev => resolveMultipleTags(committedIdsRef.current, optionItems, prev));
|
|
111
|
+
}, [enabled, value, optionItems]);
|
|
112
|
+
/** 「선택 완료」로 Tag 를 확정한다 */
|
|
113
|
+
const commitTags = ids => {
|
|
114
|
+
committedIdsRef.current = ids;
|
|
115
|
+
setSelectedTags(prev => resolveMultipleTags(ids, optionItems, prev));
|
|
116
|
+
};
|
|
117
|
+
/** X 클릭 — 확정 id 에서 그 id 만 뺀다. value 기준으로 계산하면 「선택 완료」 전에 클릭한 항목까지 확정된다 */
|
|
118
|
+
const removeCommittedTag = tagId => {
|
|
119
|
+
commitTags(committedIdsRef.current.filter(id => id !== tagId));
|
|
120
|
+
};
|
|
121
|
+
/** ComboBox 가 onChange 로 내보낸 값을 기록한다 — value 로 돌아왔을 때 확정으로 보지 않기 위함 */
|
|
122
|
+
const markEmitted = ids => {
|
|
123
|
+
lastEmittedRef.current = ids;
|
|
124
|
+
};
|
|
63
125
|
return {
|
|
64
|
-
|
|
65
|
-
|
|
126
|
+
selectedTags,
|
|
127
|
+
commitTags,
|
|
128
|
+
removeCommittedTag,
|
|
129
|
+
markEmitted
|
|
66
130
|
};
|
|
67
131
|
};
|
|
68
|
-
const ComboBox = /*#__PURE__*/forwardRef((
|
|
132
|
+
const ComboBox = /*#__PURE__*/forwardRef((_ref, ref) => {
|
|
69
133
|
let {
|
|
70
134
|
placeholder = '검색하세요',
|
|
71
135
|
id,
|
|
@@ -91,7 +155,7 @@ const ComboBox = /*#__PURE__*/forwardRef((_ref2, ref) => {
|
|
|
91
155
|
onComplete,
|
|
92
156
|
usePortal = false,
|
|
93
157
|
...props
|
|
94
|
-
} =
|
|
158
|
+
} = _ref;
|
|
95
159
|
const internalRef = useRef(null);
|
|
96
160
|
// 인라인 패널의 offset parent 가 __content 다. Portal 좌표도 같은 박스를 기준으로 잡아야
|
|
97
161
|
// label·hintText 가 있을 때 두 모드의 패널 위치가 어긋나지 않는다.
|
|
@@ -104,22 +168,24 @@ const ComboBox = /*#__PURE__*/forwardRef((_ref2, ref) => {
|
|
|
104
168
|
} = usePortalState(usePortal);
|
|
105
169
|
const [inputValue, setInputValue] = useState(searchValue);
|
|
106
170
|
const [showAllItems, setShowAllItems] = useState(false);
|
|
107
|
-
const
|
|
171
|
+
const singleTags = useSingleSelectedTags(value, optionItems, !multiple);
|
|
172
|
+
const {
|
|
173
|
+
selectedTags,
|
|
174
|
+
commitTags,
|
|
175
|
+
removeCommittedTag,
|
|
176
|
+
markEmitted
|
|
177
|
+
} = useMultipleSelectedTags(value, optionItems, multiple);
|
|
108
178
|
const handleOptionSelect = option => {
|
|
109
179
|
if (disabled) return;
|
|
110
180
|
if (multiple) {
|
|
111
181
|
const newValue = tryToggle(option.id, Array.isArray(value) ? value : []);
|
|
112
182
|
if (newValue === null) return;
|
|
183
|
+
markEmitted(newValue);
|
|
113
184
|
onChange?.(newValue);
|
|
114
185
|
notifyFormChange(register, multiple, newValue);
|
|
115
186
|
return;
|
|
116
187
|
}
|
|
117
188
|
const newValue = option.id;
|
|
118
|
-
const tag = {
|
|
119
|
-
id: option.id,
|
|
120
|
-
label: option.label
|
|
121
|
-
};
|
|
122
|
-
setSelectedTags([tag]);
|
|
123
189
|
onChange?.(newValue);
|
|
124
190
|
notifyFormChange(register, multiple, newValue);
|
|
125
191
|
};
|
|
@@ -226,7 +292,6 @@ const ComboBox = /*#__PURE__*/forwardRef((_ref2, ref) => {
|
|
|
226
292
|
const {
|
|
227
293
|
buttonText: selectAllButtonText,
|
|
228
294
|
toggleSelectAll,
|
|
229
|
-
getSelectedTagsData,
|
|
230
295
|
removeTag,
|
|
231
296
|
isMaxSelectionActive,
|
|
232
297
|
tryToggle
|
|
@@ -236,6 +301,7 @@ const ComboBox = /*#__PURE__*/forwardRef((_ref2, ref) => {
|
|
|
236
301
|
const handleSelectAll = () => {
|
|
237
302
|
if (!multiple || !onChange) return;
|
|
238
303
|
const newSelectedValues = toggleSelectAll();
|
|
304
|
+
markEmitted(newSelectedValues);
|
|
239
305
|
onChange(newSelectedValues);
|
|
240
306
|
};
|
|
241
307
|
const handleEdit = () => {
|
|
@@ -243,30 +309,32 @@ const ComboBox = /*#__PURE__*/forwardRef((_ref2, ref) => {
|
|
|
243
309
|
};
|
|
244
310
|
const handleComplete = completedValue => {
|
|
245
311
|
if (!multiple) return;
|
|
246
|
-
|
|
247
|
-
setSelectedTags(tags);
|
|
312
|
+
commitTags(toIdArray(completedValue));
|
|
248
313
|
setInputValue('');
|
|
249
314
|
closeDropdown();
|
|
250
315
|
onComplete?.(completedValue);
|
|
251
316
|
};
|
|
252
317
|
const handleRemoveTag = tagId => {
|
|
318
|
+
// X 버튼은 disabled 에서 숨기지만, 다른 호출 경로가 생겨도 값이 지워지지 않게 함수에서도 막는다
|
|
319
|
+
if (disabled) return;
|
|
253
320
|
if (!onChange) return;
|
|
254
321
|
if (multiple) {
|
|
255
322
|
const newSelectedValues = removeTag(tagId);
|
|
323
|
+
markEmitted(newSelectedValues);
|
|
256
324
|
onChange(newSelectedValues);
|
|
257
|
-
|
|
325
|
+
removeCommittedTag(tagId);
|
|
258
326
|
return;
|
|
259
327
|
}
|
|
260
328
|
onChange('');
|
|
261
|
-
setSelectedTags([]);
|
|
262
329
|
};
|
|
263
330
|
const comboboxId = `combobox-options-${id || 'default'}`;
|
|
264
331
|
const hasVisibleOptions = showAllItems || inputValue;
|
|
265
332
|
const displayedOptions = hasVisibleOptions ? optionItems : [];
|
|
266
333
|
const showNoResults = !hasVisibleOptions || optionItems.length === 0;
|
|
267
334
|
const showFooter = multiple || showFooterButtons;
|
|
268
|
-
const hiddenInputValue =
|
|
269
|
-
const
|
|
335
|
+
const hiddenInputValue = toHiddenInputValue(value, multiple);
|
|
336
|
+
const tags = multiple ? selectedTags : singleTags;
|
|
337
|
+
const hasTags = tags.length > 0;
|
|
270
338
|
const {
|
|
271
339
|
className: portalPanelClassName,
|
|
272
340
|
style: portalPanelStyle
|
|
@@ -277,7 +345,7 @@ const ComboBox = /*#__PURE__*/forwardRef((_ref2, ref) => {
|
|
|
277
345
|
direction: dropdownDirection,
|
|
278
346
|
size: size,
|
|
279
347
|
options: displayedOptions,
|
|
280
|
-
value: value,
|
|
348
|
+
value: toDropdownValue(value),
|
|
281
349
|
focusedIndex: focusedIndex,
|
|
282
350
|
maxHeight: maxHeight,
|
|
283
351
|
listboxId: comboboxId,
|
|
@@ -356,10 +424,10 @@ const ComboBox = /*#__PURE__*/forwardRef((_ref2, ref) => {
|
|
|
356
424
|
})]
|
|
357
425
|
}), hasTags && _jsx("div", {
|
|
358
426
|
className: "ncua-combobox__tags",
|
|
359
|
-
children:
|
|
427
|
+
children: tags.map(tag => _jsx(Tag, {
|
|
360
428
|
text: tag.label,
|
|
361
429
|
size: "sm",
|
|
362
|
-
close:
|
|
430
|
+
close: !disabled,
|
|
363
431
|
onButtonClick: () => handleRemoveTag(tag.id)
|
|
364
432
|
}, tag.id))
|
|
365
433
|
})]
|
|
@@ -473,7 +473,7 @@ describe('C-9 기본 경로 회귀 기준선', () => {
|
|
|
473
473
|
expect(onSearch).toHaveBeenCalledWith('사');
|
|
474
474
|
expect(view.content.querySelectorAll('.ncua-select-dropdown__option')).toHaveLength(createOptions().length);
|
|
475
475
|
});
|
|
476
|
-
it('옵션을 선택하면 onChange 가
|
|
476
|
+
it('옵션을 선택하면 onChange 가 호출된다', () => {
|
|
477
477
|
// Given: 옵션이 노출된 기본 모드 ComboBox 에서
|
|
478
478
|
const onChange = vi.fn();
|
|
479
479
|
const view = mountComboBox({
|
|
@@ -486,9 +486,9 @@ describe('C-9 기본 경로 회귀 기준선', () => {
|
|
|
486
486
|
act(() => {
|
|
487
487
|
requireElement(view.content, '.ncua-select-dropdown__option').click();
|
|
488
488
|
});
|
|
489
|
-
// Then: onChange 가 선택 값으로
|
|
489
|
+
// Then: onChange 가 선택 값으로 호출된다
|
|
490
|
+
// 선택 태그는 value 를 따르므로(#530) onChange 값을 value 로 돌려주는 경우의 태그 표시는 ComboBox.test.ts S-10 에서 검증한다
|
|
490
491
|
expect(onChange).toHaveBeenCalledWith('apple');
|
|
491
|
-
expect(view.container.querySelectorAll('.ncua-combobox__tags .ncua-tag')).toHaveLength(1);
|
|
492
492
|
});
|
|
493
493
|
it('Escape 를 누르면 인라인 패널이 닫힌다', () => {
|
|
494
494
|
// Given: 열려 있는 기본 모드 ComboBox 에서
|