@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
|
@@ -316,4 +316,36 @@ describe('useFloatingPosition — 프롭 변경 재구독 (E-5)', () => {
|
|
|
316
316
|
});
|
|
317
317
|
hook.unmount();
|
|
318
318
|
});
|
|
319
|
+
});
|
|
320
|
+
describe('useFloatingPosition — 좌표 불변 갱신', () => {
|
|
321
|
+
it('scroll 뒤에도 top·left·minWidth 가 같으면 이전 style 참조를 유지해 하위 트리 리렌더를 건너뛴다', () => {
|
|
322
|
+
installResizeObserverStub();
|
|
323
|
+
const hook = renderHook(baseParams({
|
|
324
|
+
triggerRef: makeRef(createAnchor()),
|
|
325
|
+
floatingRef: makeRef(createPanel()),
|
|
326
|
+
matchTriggerWidth: true
|
|
327
|
+
}));
|
|
328
|
+
const styleBeforeScroll = hook.latest();
|
|
329
|
+
dispatchScroll();
|
|
330
|
+
expect(hook.latest()).toBe(styleBeforeScroll);
|
|
331
|
+
hook.unmount();
|
|
332
|
+
});
|
|
333
|
+
it('scroll 로 좌표가 바뀌면 새 style 로 다시 렌더링한다', () => {
|
|
334
|
+
installResizeObserverStub();
|
|
335
|
+
const anchor = createAnchor();
|
|
336
|
+
const hook = renderHook(baseParams({
|
|
337
|
+
triggerRef: makeRef(anchor),
|
|
338
|
+
floatingRef: makeRef(createPanel())
|
|
339
|
+
}));
|
|
340
|
+
const moved = makeAnchorRect({
|
|
341
|
+
top: 300,
|
|
342
|
+
bottom: 340
|
|
343
|
+
});
|
|
344
|
+
vi.spyOn(anchor, 'getBoundingClientRect').mockReturnValue(toDomRect(moved));
|
|
345
|
+
dispatchScroll();
|
|
346
|
+
expect(hook.latest()).toMatchObject({
|
|
347
|
+
top: moved.bottom + DEFAULT_OFFSET
|
|
348
|
+
});
|
|
349
|
+
hook.unmount();
|
|
350
|
+
});
|
|
319
351
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { trackPortalPosition } from '../utils/portal-position';
|
|
3
3
|
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect';
|
|
4
|
+
const isSamePlacement = (prev, next) => prev !== null && prev.top === next.top && prev.left === next.left && prev.minWidth === next.minWidth;
|
|
4
5
|
export const useFloatingPosition = _ref => {
|
|
5
6
|
let {
|
|
6
7
|
enabled,
|
|
@@ -25,7 +26,7 @@ export const useFloatingPosition = _ref => {
|
|
|
25
26
|
align,
|
|
26
27
|
offset,
|
|
27
28
|
matchAnchorWidth: matchTriggerWidth,
|
|
28
|
-
onUpdate: setStyle
|
|
29
|
+
onUpdate: next => setStyle(prev => isSamePlacement(prev, next) ? prev : next)
|
|
29
30
|
});
|
|
30
31
|
}, [enabled, isOpen, direction, offset, align, matchTriggerWidth, triggerRef, floatingRef]);
|
|
31
32
|
return style;
|
|
@@ -112,6 +112,27 @@ describe('observeAnchorGeometry — 구독 (E-6)', () => {
|
|
|
112
112
|
expect(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.afterScroll);
|
|
113
113
|
cleanup();
|
|
114
114
|
});
|
|
115
|
+
it('panel 자신과 그 내부 요소의 scroll 은 anchor 를 움직이지 않으므로 onChange 를 호출하지 않는다', () => {
|
|
116
|
+
installResizeObserverStub();
|
|
117
|
+
const anchor = createAnchor();
|
|
118
|
+
const panel = createPanel();
|
|
119
|
+
const optionList = document.createElement('ul');
|
|
120
|
+
panel.appendChild(optionList);
|
|
121
|
+
const onChange = vi.fn();
|
|
122
|
+
const cleanup = observeAnchorGeometry({
|
|
123
|
+
getAnchor: () => anchor.element,
|
|
124
|
+
getPanel: () => panel,
|
|
125
|
+
onChange
|
|
126
|
+
});
|
|
127
|
+
panel.dispatchEvent(new Event('scroll', {
|
|
128
|
+
bubbles: false
|
|
129
|
+
}));
|
|
130
|
+
optionList.dispatchEvent(new Event('scroll', {
|
|
131
|
+
bubbles: false
|
|
132
|
+
}));
|
|
133
|
+
expect(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.initial);
|
|
134
|
+
cleanup();
|
|
135
|
+
});
|
|
115
136
|
it('anchor 와 panel 각각에 ResizeObserver 를 붙인다', () => {
|
|
116
137
|
const {
|
|
117
138
|
instances
|
|
@@ -104,14 +104,19 @@ export const observeAnchorGeometry = _ref5 => {
|
|
|
104
104
|
return noop;
|
|
105
105
|
}
|
|
106
106
|
onChange();
|
|
107
|
-
|
|
107
|
+
// 패널 내부 스크롤은 anchor 를 움직이지 않는다
|
|
108
|
+
const onScroll = event => {
|
|
109
|
+
if (event.target instanceof Node && getPanel()?.contains(event.target)) return;
|
|
110
|
+
onChange();
|
|
111
|
+
};
|
|
112
|
+
window.addEventListener('scroll', onScroll, {
|
|
108
113
|
passive: true,
|
|
109
114
|
capture: true
|
|
110
115
|
});
|
|
111
116
|
window.addEventListener('resize', onChange);
|
|
112
117
|
const observers = [observeElementResize(anchor, onChange), observeElementResize(getPanel(), onChange)];
|
|
113
118
|
return () => {
|
|
114
|
-
window.removeEventListener('scroll',
|
|
119
|
+
window.removeEventListener('scroll', onScroll, {
|
|
115
120
|
capture: true
|
|
116
121
|
});
|
|
117
122
|
window.removeEventListener('resize', onChange);
|
|
@@ -136,7 +141,6 @@ export const trackPortalPosition = _ref6 => {
|
|
|
136
141
|
}
|
|
137
142
|
});
|
|
138
143
|
};
|
|
139
|
-
// TODO(#497): vanilla 어댑터 도입 시 소비
|
|
140
144
|
export const applyPortalPanelStyle = (element, style) => {
|
|
141
145
|
element.style.position = style.position;
|
|
142
146
|
element.style.top = `${style.top}px`;
|
|
@@ -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,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,29 +23,107 @@ 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
|
+
};
|
|
26
49
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* useLayoutEffect 가 paint 전에 좌표를 채우므로 빈 fixed 가 노출되지 않는다.
|
|
50
|
+
* 단일 모드 Tag — value 와 optionItems 에서 계산한다.
|
|
51
|
+
* 검색 필터로 목록에서 빠진 경우에만, 같은 id 로 기억해 둔 Tag 를 대신 쓴다.
|
|
30
52
|
*/
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
return
|
|
34
|
-
|
|
35
|
-
|
|
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;
|
|
36
85
|
};
|
|
37
86
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
87
|
+
* multiple 모드 Tag — 「선택 완료」·X 클릭·외부 value 변경 때만 확정 id 를 바꾼다.
|
|
88
|
+
* ComboBox 가 직접 onChange 로 내보낸 값은 markEmitted 로 기록해 두고, 그 값이 value 로 돌아오면 한 번만 무시한다(확정 아님).
|
|
89
|
+
* 바깥 값이 들어오면 남아 있던 기록도 지운다 — 오래된 기록 때문에 이후 같은 바깥 값이 무시되지 않게 하기 위함.
|
|
90
|
+
* optionItems 만 바뀌면 확정 id 는 그대로 두고 라벨만 다시 찾는다 — 「선택 완료」 전 검색으로 Tag 가 확정되지 않게 하기 위함.
|
|
40
91
|
*/
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 };
|
|
49
127
|
};
|
|
50
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) => {
|
|
51
129
|
const internalRef = useRef(null);
|
|
@@ -57,7 +135,8 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
|
|
|
57
135
|
const { shouldPortal, portalContainer } = usePortalState(usePortal);
|
|
58
136
|
const [inputValue, setInputValue] = useState(searchValue);
|
|
59
137
|
const [showAllItems, setShowAllItems] = useState(false);
|
|
60
|
-
const
|
|
138
|
+
const singleTags = useSingleSelectedTags(value, optionItems, !multiple);
|
|
139
|
+
const { selectedTags, commitTags, removeCommittedTag, markEmitted } = useMultipleSelectedTags(value, optionItems, multiple);
|
|
61
140
|
const handleOptionSelect = (option) => {
|
|
62
141
|
if (disabled)
|
|
63
142
|
return;
|
|
@@ -65,13 +144,12 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
|
|
|
65
144
|
const newValue = tryToggle(option.id, Array.isArray(value) ? value : []);
|
|
66
145
|
if (newValue === null)
|
|
67
146
|
return;
|
|
147
|
+
markEmitted(newValue);
|
|
68
148
|
onChange?.(newValue);
|
|
69
149
|
notifyFormChange(register, multiple, newValue);
|
|
70
150
|
return;
|
|
71
151
|
}
|
|
72
152
|
const newValue = option.id;
|
|
73
|
-
const tag = { id: option.id, label: option.label };
|
|
74
|
-
setSelectedTags([tag]);
|
|
75
153
|
onChange?.(newValue);
|
|
76
154
|
notifyFormChange(register, multiple, newValue);
|
|
77
155
|
};
|
|
@@ -154,11 +232,12 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
|
|
|
154
232
|
color: 'gray300',
|
|
155
233
|
};
|
|
156
234
|
const currentSelectedValues = multiple && Array.isArray(value) ? value : [];
|
|
157
|
-
const { buttonText: selectAllButtonText, toggleSelectAll,
|
|
235
|
+
const { buttonText: selectAllButtonText, toggleSelectAll, removeTag, isMaxSelectionActive, tryToggle, } = useMultiSelect(currentSelectedValues, optionItems, { maxSelection });
|
|
158
236
|
const handleSelectAll = () => {
|
|
159
237
|
if (!multiple || !onChange)
|
|
160
238
|
return;
|
|
161
239
|
const newSelectedValues = toggleSelectAll();
|
|
240
|
+
markEmitted(newSelectedValues);
|
|
162
241
|
onChange(newSelectedValues);
|
|
163
242
|
};
|
|
164
243
|
const handleEdit = () => {
|
|
@@ -167,33 +246,36 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
|
|
|
167
246
|
const handleComplete = (completedValue) => {
|
|
168
247
|
if (!multiple)
|
|
169
248
|
return;
|
|
170
|
-
|
|
171
|
-
setSelectedTags(tags);
|
|
249
|
+
commitTags(toIdArray(completedValue));
|
|
172
250
|
setInputValue('');
|
|
173
251
|
closeDropdown();
|
|
174
252
|
onComplete?.(completedValue);
|
|
175
253
|
};
|
|
176
254
|
const handleRemoveTag = (tagId) => {
|
|
255
|
+
// X 버튼은 disabled 에서 숨기지만, 다른 호출 경로가 생겨도 값이 지워지지 않게 함수에서도 막는다
|
|
256
|
+
if (disabled)
|
|
257
|
+
return;
|
|
177
258
|
if (!onChange)
|
|
178
259
|
return;
|
|
179
260
|
if (multiple) {
|
|
180
261
|
const newSelectedValues = removeTag(tagId);
|
|
262
|
+
markEmitted(newSelectedValues);
|
|
181
263
|
onChange(newSelectedValues);
|
|
182
|
-
|
|
264
|
+
removeCommittedTag(tagId);
|
|
183
265
|
return;
|
|
184
266
|
}
|
|
185
267
|
onChange('');
|
|
186
|
-
setSelectedTags([]);
|
|
187
268
|
};
|
|
188
269
|
const comboboxId = `combobox-options-${id || 'default'}`;
|
|
189
270
|
const hasVisibleOptions = showAllItems || inputValue;
|
|
190
271
|
const displayedOptions = hasVisibleOptions ? optionItems : [];
|
|
191
272
|
const showNoResults = !hasVisibleOptions || optionItems.length === 0;
|
|
192
273
|
const showFooter = multiple || showFooterButtons;
|
|
193
|
-
const hiddenInputValue =
|
|
194
|
-
const
|
|
274
|
+
const hiddenInputValue = toHiddenInputValue(value, multiple);
|
|
275
|
+
const tags = multiple ? selectedTags : singleTags;
|
|
276
|
+
const hasTags = tags.length > 0;
|
|
195
277
|
const { className: portalPanelClassName, style: portalPanelStyle } = resolvePortalPanelBinding(shouldPortal, floatingStyle);
|
|
196
|
-
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] }));
|
|
197
279
|
const { inlineDropdown, portaledDropdown } = resolveDropdownMounts({
|
|
198
280
|
node: selectDropdownNode,
|
|
199
281
|
shouldPortal,
|
|
@@ -207,7 +289,7 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
|
|
|
207
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: () => {
|
|
208
290
|
setInputValue('');
|
|
209
291
|
onSearch?.('');
|
|
210
|
-
} }), 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))) }))] }));
|
|
211
293
|
});
|
|
212
294
|
ComboBox.displayName = 'ComboBox';
|
|
213
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 {};
|