@ncds/ui-admin 1.8.24 → 1.8.25-alpha.1
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 +109 -28
- package/dist/cjs/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.js +581 -0
- package/dist/cjs/src/components/navigation/bread-crumb/BreadCrumb.js +14 -6
- package/dist/cjs/src/components/navigation/bread-crumb/__tests__/BreadCrumb.test.js +207 -0
- package/dist/cjs/src/generated/scoped-css.js +1 -1
- package/dist/cjs/src/hooks/__tests__/useFloatingPosition.ssr.test.js +29 -0
- package/dist/cjs/src/hooks/__tests__/useFloatingPosition.test.js +322 -0
- package/dist/cjs/src/hooks/dropdown/useDropdown.js +2 -1
- package/dist/cjs/src/hooks/useFloatingPosition.js +14 -53
- package/dist/cjs/src/utils/dropdown/__tests__/dropdownUtils.test.js +316 -0
- package/dist/cjs/src/utils/dropdown/dropdownUtils.js +8 -12
- package/dist/cjs/src/utils/portal-position/__tests__/portalPosition.test.js +227 -0
- package/dist/cjs/src/utils/portal-position/__tests__/portalPosition.tracking.test.js +397 -0
- package/dist/cjs/src/utils/portal-position/index.js +16 -0
- package/dist/cjs/src/utils/portal-position/portalPosition.js +162 -0
- 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 +109 -28
- package/dist/esm/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.js +578 -0
- package/dist/esm/src/components/navigation/bread-crumb/BreadCrumb.js +14 -6
- package/dist/esm/src/components/navigation/bread-crumb/__tests__/BreadCrumb.test.js +204 -0
- package/dist/esm/src/generated/scoped-css.js +1 -1
- package/dist/esm/src/hooks/__tests__/useFloatingPosition.ssr.test.js +26 -0
- package/dist/esm/src/hooks/__tests__/useFloatingPosition.test.js +319 -0
- package/dist/esm/src/hooks/dropdown/useDropdown.js +2 -1
- package/dist/esm/src/hooks/useFloatingPosition.js +15 -54
- package/dist/esm/src/utils/dropdown/__tests__/dropdownUtils.test.js +314 -0
- package/dist/esm/src/utils/dropdown/dropdownUtils.js +8 -12
- package/dist/esm/src/utils/portal-position/__tests__/portalPosition.test.js +225 -0
- package/dist/esm/src/utils/portal-position/__tests__/portalPosition.tracking.test.js +394 -0
- package/dist/esm/src/utils/portal-position/index.js +1 -0
- package/dist/esm/src/utils/portal-position/portalPosition.js +150 -0
- package/dist/temp/assets/scripts/notification/const/sizes.d.ts +1 -1
- package/dist/temp/src/components/action/button-group/ButtonGroup.d.ts +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 +6 -0
- package/dist/temp/src/components/forms-and-input/combo-box/ComboBox.js +54 -4
- package/dist/temp/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.d.ts +1 -0
- package/dist/temp/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.js +418 -0
- package/dist/temp/src/components/navigation/bread-crumb/BreadCrumb.d.ts +3 -1
- package/dist/temp/src/components/navigation/bread-crumb/BreadCrumb.js +9 -5
- package/dist/temp/src/components/navigation/bread-crumb/__tests__/BreadCrumb.test.d.ts +1 -0
- package/dist/temp/src/components/navigation/bread-crumb/__tests__/BreadCrumb.test.js +161 -0
- package/dist/temp/src/generated/scoped-css.js +1 -1
- package/dist/temp/src/hooks/__tests__/useFloatingPosition.ssr.test.d.ts +1 -0
- package/dist/temp/src/hooks/__tests__/useFloatingPosition.ssr.test.js +26 -0
- package/dist/temp/src/hooks/__tests__/useFloatingPosition.test.d.ts +1 -0
- package/dist/temp/src/hooks/__tests__/useFloatingPosition.test.js +205 -0
- package/dist/temp/src/hooks/dropdown/useDropdown.d.ts +3 -1
- package/dist/temp/src/hooks/dropdown/useDropdown.js +2 -2
- package/dist/temp/src/hooks/dropdown/useDropdownPosition.d.ts +3 -3
- package/dist/temp/src/hooks/useFloatingPosition.d.ts +6 -4
- package/dist/temp/src/hooks/useFloatingPosition.js +14 -47
- package/dist/temp/src/types/dropdown/dropdown.d.ts +2 -1
- package/dist/temp/src/utils/dropdown/__tests__/dropdownUtils.test.d.ts +1 -0
- package/dist/temp/src/utils/dropdown/__tests__/dropdownUtils.test.js +174 -0
- package/dist/temp/src/utils/dropdown/dropdownUtils.js +8 -9
- package/dist/temp/src/utils/portal-position/__tests__/portalPosition.test.d.ts +1 -0
- package/dist/temp/src/utils/portal-position/__tests__/portalPosition.test.js +181 -0
- package/dist/temp/src/utils/portal-position/__tests__/portalPosition.tracking.test.d.ts +1 -0
- package/dist/temp/src/utils/portal-position/__tests__/portalPosition.tracking.test.js +301 -0
- package/dist/temp/src/utils/portal-position/index.d.ts +1 -0
- package/dist/temp/src/utils/portal-position/index.js +1 -0
- package/dist/temp/src/utils/portal-position/portalPosition.d.ts +67 -0
- package/dist/temp/src/utils/portal-position/portalPosition.js +88 -0
- package/dist/types/assets/scripts/notification/const/sizes.d.ts +1 -1
- package/dist/types/src/components/action/button-group/ButtonGroup.d.ts +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 +6 -0
- package/dist/types/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.d.ts +1 -0
- package/dist/types/src/components/navigation/bread-crumb/BreadCrumb.d.ts +3 -1
- package/dist/types/src/components/navigation/bread-crumb/__tests__/BreadCrumb.test.d.ts +1 -0
- package/dist/types/src/hooks/__tests__/useFloatingPosition.ssr.test.d.ts +1 -0
- package/dist/types/src/hooks/__tests__/useFloatingPosition.test.d.ts +1 -0
- package/dist/types/src/hooks/dropdown/useDropdown.d.ts +3 -1
- package/dist/types/src/hooks/dropdown/useDropdownPosition.d.ts +3 -3
- package/dist/types/src/hooks/useFloatingPosition.d.ts +6 -4
- package/dist/types/src/types/dropdown/dropdown.d.ts +2 -1
- package/dist/types/src/utils/dropdown/__tests__/dropdownUtils.test.d.ts +1 -0
- package/dist/types/src/utils/portal-position/__tests__/portalPosition.test.d.ts +1 -0
- package/dist/types/src/utils/portal-position/__tests__/portalPosition.tracking.test.d.ts +1 -0
- package/dist/types/src/utils/portal-position/index.d.ts +1 -0
- package/dist/types/src/utils/portal-position/portalPosition.d.ts +67 -0
- package/dist/ui-admin/assets/styles/style.css +26 -16
- package/package.json +2 -2
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { createElement } 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 { FloatingProvider } from '../../../../contexts/FloatingContext';
|
|
7
|
+
import { PORTAL_PANEL_Z_INDEX } from '../../../../utils/portal-position';
|
|
8
|
+
import { ComboBox } from '../ComboBox';
|
|
9
|
+
// @ncds/ui-admin-icon 은 react@19 를 해석해 react-dom@18 이 거부하는 엘리먼트를 만든다.
|
|
10
|
+
// 아이콘은 Portal 좌표와 무관하므로 스텁으로 대체해 이 저장소 전반의 렌더 테스트 차단을 우회한다.
|
|
11
|
+
vi.mock('@ncds/ui-admin-icon', () => {
|
|
12
|
+
const IconStub = () => null;
|
|
13
|
+
return {
|
|
14
|
+
CheckCircle: IconStub,
|
|
15
|
+
ChevronSelectorVertical: IconStub,
|
|
16
|
+
HelpCircle: IconStub,
|
|
17
|
+
InfoCircle: IconStub,
|
|
18
|
+
SearchLg: IconStub,
|
|
19
|
+
X: IconStub,
|
|
20
|
+
XClose: IconStub
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
24
|
+
const PANEL_OFFSET = 4;
|
|
25
|
+
const ANCHOR_TOP = 100;
|
|
26
|
+
const ANCHOR_LEFT = 50;
|
|
27
|
+
const ANCHOR_WIDTH = 320;
|
|
28
|
+
const ANCHOR_HEIGHT = 40;
|
|
29
|
+
const ANCHOR_BOTTOM = ANCHOR_TOP + ANCHOR_HEIGHT;
|
|
30
|
+
/** hintText 가 있을 때 루트가 __content 보다 더 차지하는 높이 */
|
|
31
|
+
const HINT_TEXT_HEIGHT = 40;
|
|
32
|
+
/** viewport 하단이라 direction='up' 으로 열리는 anchor 위치 */
|
|
33
|
+
const LOW_ANCHOR_TOP = 700;
|
|
34
|
+
const SCROLLED_ANCHOR_TOP = 60;
|
|
35
|
+
const GROWN_PANEL_HEIGHT = 200;
|
|
36
|
+
const createOptions = () => [{
|
|
37
|
+
id: 'apple',
|
|
38
|
+
label: '사과'
|
|
39
|
+
}, {
|
|
40
|
+
id: 'banana',
|
|
41
|
+
label: '바나나'
|
|
42
|
+
}, {
|
|
43
|
+
id: 'cherry',
|
|
44
|
+
label: '체리'
|
|
45
|
+
}];
|
|
46
|
+
const stubRect = (element, _ref) => {
|
|
47
|
+
let {
|
|
48
|
+
top,
|
|
49
|
+
left,
|
|
50
|
+
width,
|
|
51
|
+
height
|
|
52
|
+
} = _ref;
|
|
53
|
+
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(DOMRect.fromRect({
|
|
54
|
+
x: left,
|
|
55
|
+
y: top,
|
|
56
|
+
width,
|
|
57
|
+
height
|
|
58
|
+
}));
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* 루트와 __content 의 기하를 함께 고정한다. `extraRootHeight` 는 hintText 처럼 루트에만
|
|
62
|
+
* 얹히는 높이로, 두 박스의 bottom 을 갈라 anchor 기준을 구별할 수 있게 한다.
|
|
63
|
+
*/
|
|
64
|
+
const stubAnchorGeometry = _ref2 => {
|
|
65
|
+
let {
|
|
66
|
+
root,
|
|
67
|
+
content,
|
|
68
|
+
top = ANCHOR_TOP,
|
|
69
|
+
extraRootHeight = 0
|
|
70
|
+
} = _ref2;
|
|
71
|
+
stubRect(content, {
|
|
72
|
+
top,
|
|
73
|
+
left: ANCHOR_LEFT,
|
|
74
|
+
width: ANCHOR_WIDTH,
|
|
75
|
+
height: ANCHOR_HEIGHT
|
|
76
|
+
});
|
|
77
|
+
stubRect(root, {
|
|
78
|
+
top,
|
|
79
|
+
left: ANCHOR_LEFT,
|
|
80
|
+
width: ANCHOR_WIDTH,
|
|
81
|
+
height: ANCHOR_HEIGHT + extraRootHeight
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
const installResizeObserverStub = () => {
|
|
85
|
+
const callbacks = [];
|
|
86
|
+
class ResizeObserverStub {
|
|
87
|
+
constructor(callback) {
|
|
88
|
+
this.observe = vi.fn();
|
|
89
|
+
this.unobserve = vi.fn();
|
|
90
|
+
this.disconnect = vi.fn();
|
|
91
|
+
callbacks.push(callback);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
vi.stubGlobal('ResizeObserver', ResizeObserverStub);
|
|
95
|
+
return {
|
|
96
|
+
triggerAll: () => {
|
|
97
|
+
for (const callback of callbacks) {
|
|
98
|
+
callback([], {});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
const requireElement = (scope, selector) => {
|
|
104
|
+
const element = scope.querySelector(selector);
|
|
105
|
+
if (element === null) {
|
|
106
|
+
throw new Error(`요소를 찾을 수 없습니다: ${selector}`);
|
|
107
|
+
}
|
|
108
|
+
return element;
|
|
109
|
+
};
|
|
110
|
+
// 마운트한 트리는 afterEach 가 일괄 정리한다. 실패한 단언이 unmount 호출을 건너뛰어도
|
|
111
|
+
// 다음 테스트로 잔여 DOM 이 흘러가지 않게 한다.
|
|
112
|
+
const pendingCleanups = [];
|
|
113
|
+
const mountComboBox = function () {
|
|
114
|
+
let {
|
|
115
|
+
props = {},
|
|
116
|
+
floating
|
|
117
|
+
} = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
118
|
+
const container = document.createElement('div');
|
|
119
|
+
document.body.appendChild(container);
|
|
120
|
+
const reactRoot = createRoot(container);
|
|
121
|
+
const comboBox = /*#__PURE__*/createElement(ComboBox, {
|
|
122
|
+
optionItems: createOptions(),
|
|
123
|
+
...props
|
|
124
|
+
});
|
|
125
|
+
const tree = floating ? /*#__PURE__*/createElement(FloatingProvider, {
|
|
126
|
+
value: floating
|
|
127
|
+
}, comboBox) : comboBox;
|
|
128
|
+
act(() => {
|
|
129
|
+
reactRoot.render(tree);
|
|
130
|
+
});
|
|
131
|
+
pendingCleanups.push(() => {
|
|
132
|
+
act(() => {
|
|
133
|
+
reactRoot.unmount();
|
|
134
|
+
});
|
|
135
|
+
container.remove();
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
container,
|
|
139
|
+
root: requireElement(container, '.ncua-combobox'),
|
|
140
|
+
content: requireElement(container, '.ncua-combobox__content'),
|
|
141
|
+
input: requireElement(container, 'input[role="combobox"]'),
|
|
142
|
+
inlinePanels: () => container.querySelectorAll('.ncua-select-dropdown'),
|
|
143
|
+
portalPanels: () => document.body.querySelectorAll(':scope > .ncua-select-dropdown'),
|
|
144
|
+
open: () => {
|
|
145
|
+
act(() => {
|
|
146
|
+
requireElement(container, '.ncua-input__right-icon').click();
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
typeSearch: text => {
|
|
150
|
+
const input = requireElement(container, 'input[role="combobox"]');
|
|
151
|
+
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
|
|
152
|
+
act(() => {
|
|
153
|
+
valueSetter?.call(input, text);
|
|
154
|
+
input.dispatchEvent(new Event('input', {
|
|
155
|
+
bubbles: true
|
|
156
|
+
}));
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
pressEscape: () => {
|
|
160
|
+
act(() => {
|
|
161
|
+
requireElement(container, 'input[role="combobox"]').dispatchEvent(new KeyboardEvent('keydown', {
|
|
162
|
+
key: 'Escape',
|
|
163
|
+
bubbles: true
|
|
164
|
+
}));
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
const mousedownOn = element => {
|
|
170
|
+
act(() => {
|
|
171
|
+
element.dispatchEvent(new MouseEvent('mousedown', {
|
|
172
|
+
bubbles: true
|
|
173
|
+
}));
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
const clickButtonByLabel = (scope, label) => {
|
|
177
|
+
const button = [...scope.querySelectorAll('button')].find(candidate => candidate.textContent?.includes(label));
|
|
178
|
+
if (button === undefined) {
|
|
179
|
+
throw new Error(`버튼을 찾을 수 없습니다: ${label}`);
|
|
180
|
+
}
|
|
181
|
+
act(() => {
|
|
182
|
+
button.click();
|
|
183
|
+
});
|
|
184
|
+
};
|
|
185
|
+
afterEach(() => {
|
|
186
|
+
for (const cleanup of pendingCleanups.splice(0)) {
|
|
187
|
+
cleanup();
|
|
188
|
+
}
|
|
189
|
+
document.body.innerHTML = '';
|
|
190
|
+
vi.unstubAllGlobals();
|
|
191
|
+
vi.restoreAllMocks();
|
|
192
|
+
});
|
|
193
|
+
describe('C-1 ComboBox Portal 마운트 위치', () => {
|
|
194
|
+
it('usePortal 미지정이면 옵션 패널이 __content 내부에만 렌더된다', () => {
|
|
195
|
+
// Given: usePortal 을 지정하지 않은 ComboBox 가 마운트된 상태에서
|
|
196
|
+
const view = mountComboBox();
|
|
197
|
+
// When: 옵션 패널을 연다
|
|
198
|
+
view.open();
|
|
199
|
+
// Then: 패널은 __content 내부에 1개만 존재하고 body 직속에는 없다
|
|
200
|
+
expect(view.content.querySelectorAll('.ncua-select-dropdown')).toHaveLength(1);
|
|
201
|
+
expect(view.portalPanels()).toHaveLength(0);
|
|
202
|
+
});
|
|
203
|
+
it('usePortal 이 true 면 옵션 패널이 컨테이너 직속으로만 렌더된다', () => {
|
|
204
|
+
// Given: usePortal={true} 인 ComboBox 가 마운트된 상태에서
|
|
205
|
+
const view = mountComboBox({
|
|
206
|
+
props: {
|
|
207
|
+
usePortal: true
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
// When: 옵션 패널을 연다
|
|
211
|
+
view.open();
|
|
212
|
+
// Then: __content 내부에는 패널이 없고 portalContainer 직속에 1개 존재한다
|
|
213
|
+
expect(view.content.querySelectorAll('.ncua-select-dropdown')).toHaveLength(0);
|
|
214
|
+
expect(view.portalPanels()).toHaveLength(1);
|
|
215
|
+
});
|
|
216
|
+
it('패널을 닫으면 Portal 노드가 DOM 에서 제거된다', () => {
|
|
217
|
+
// Given: Portal 모드로 열린 ComboBox 에서
|
|
218
|
+
const view = mountComboBox({
|
|
219
|
+
props: {
|
|
220
|
+
usePortal: true
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
view.open();
|
|
224
|
+
expect(view.portalPanels()).toHaveLength(1);
|
|
225
|
+
// When: Escape 로 패널을 닫으면
|
|
226
|
+
view.pressEscape();
|
|
227
|
+
// Then: Portal 노드가 문서에 잔류하지 않는다
|
|
228
|
+
expect(view.portalPanels()).toHaveLength(0);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
describe('C-2 FloatingContext 미위임 — 기존 동작 보존', () => {
|
|
232
|
+
it('usePortal 미지정이면 preferPortal 컨텍스트 안에서도 인라인을 유지한다', () => {
|
|
233
|
+
// Given: preferPortal: true 컨텍스트 안에서 usePortal 을 지정하지 않은 ComboBox 가
|
|
234
|
+
const view = mountComboBox({
|
|
235
|
+
floating: {
|
|
236
|
+
preferPortal: true
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
// When: 옵션 패널을 연다
|
|
240
|
+
view.open();
|
|
241
|
+
// Then: 컨텍스트를 따르지 않고 인라인 렌더가 유지된다 — Table horizontalScroll 안의
|
|
242
|
+
// 기존 ComboBox 가 옵션 지정 없이 Portal 로 전환되지 않아야 한다
|
|
243
|
+
expect(view.portalPanels()).toHaveLength(0);
|
|
244
|
+
expect(view.content.querySelectorAll('.ncua-select-dropdown')).toHaveLength(1);
|
|
245
|
+
});
|
|
246
|
+
it('usePortal={true} 를 명시하면 컨텍스트와 무관하게 Portal 로 렌더된다', () => {
|
|
247
|
+
// Given: 컨텍스트 없이 usePortal={true} 만 명시한 ComboBox 가
|
|
248
|
+
const view = mountComboBox({
|
|
249
|
+
props: {
|
|
250
|
+
usePortal: true
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
// When: 옵션 패널을 연다
|
|
254
|
+
view.open();
|
|
255
|
+
// Then: Portal 렌더가 선택된다
|
|
256
|
+
expect(view.portalPanels()).toHaveLength(1);
|
|
257
|
+
expect(view.content.querySelectorAll('.ncua-select-dropdown')).toHaveLength(0);
|
|
258
|
+
});
|
|
259
|
+
it('usePortal={false} 를 명시해도 preferPortal 컨텍스트 안에서 인라인을 유지한다', () => {
|
|
260
|
+
// Given: preferPortal: true 컨텍스트 안에서 usePortal={false} 를 명시한 ComboBox 가
|
|
261
|
+
const view = mountComboBox({
|
|
262
|
+
props: {
|
|
263
|
+
usePortal: false
|
|
264
|
+
},
|
|
265
|
+
floating: {
|
|
266
|
+
preferPortal: true
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
// When: 옵션 패널을 연다
|
|
270
|
+
view.open();
|
|
271
|
+
// Then: 인라인 렌더가 유지된다
|
|
272
|
+
expect(view.portalPanels()).toHaveLength(0);
|
|
273
|
+
expect(view.content.querySelectorAll('.ncua-select-dropdown')).toHaveLength(1);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
describe('C-3 Portal 패널 스타일', () => {
|
|
277
|
+
it('--portal 클래스와 anchor rect 파생 fixed 좌표를 함께 부여한다', () => {
|
|
278
|
+
// Given: anchor 기하가 고정된 Portal 모드 ComboBox 에서
|
|
279
|
+
const view = mountComboBox({
|
|
280
|
+
props: {
|
|
281
|
+
usePortal: true
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
stubAnchorGeometry({
|
|
285
|
+
root: view.root,
|
|
286
|
+
content: view.content
|
|
287
|
+
});
|
|
288
|
+
// When: 옵션 패널을 연다
|
|
289
|
+
view.open();
|
|
290
|
+
// Then: 패널이 --portal 클래스와 anchor 파생 좌표·최소 너비를 갖는다
|
|
291
|
+
const panel = view.portalPanels()[0];
|
|
292
|
+
expect(panel.classList.contains('ncua-select-dropdown--portal')).toBe(true);
|
|
293
|
+
expect(panel.style.position).toBe('fixed');
|
|
294
|
+
expect(panel.style.top).toBe(`${ANCHOR_BOTTOM + PANEL_OFFSET}px`);
|
|
295
|
+
expect(panel.style.left).toBe(`${ANCHOR_LEFT}px`);
|
|
296
|
+
expect(panel.style.minWidth).toBe(`${ANCHOR_WIDTH}px`);
|
|
297
|
+
expect(panel.style.zIndex).toBe(String(PORTAL_PANEL_Z_INDEX));
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
describe('C-4 anchor 박스는 .ncua-combobox__content 다', () => {
|
|
301
|
+
it('label·hintText 를 함께 지정해도 좌표 기준이 __content 의 rect 다', () => {
|
|
302
|
+
// Given: hintText 때문에 루트가 __content 보다 아래까지 차지하는 ComboBox 에서
|
|
303
|
+
const view = mountComboBox({
|
|
304
|
+
props: {
|
|
305
|
+
usePortal: true,
|
|
306
|
+
label: '과일',
|
|
307
|
+
hintText: '안내 문구'
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
stubAnchorGeometry({
|
|
311
|
+
root: view.root,
|
|
312
|
+
content: view.content,
|
|
313
|
+
extraRootHeight: HINT_TEXT_HEIGHT
|
|
314
|
+
});
|
|
315
|
+
// When: 옵션 패널을 연다
|
|
316
|
+
view.open();
|
|
317
|
+
// Then: top 은 __content 의 bottom 기준이며 루트 bottom 기준값보다 작다
|
|
318
|
+
const panel = view.portalPanels()[0];
|
|
319
|
+
expect(panel.style.top).toBe(`${ANCHOR_BOTTOM + PANEL_OFFSET}px`);
|
|
320
|
+
expect(panel.style.top).not.toBe(`${ANCHOR_BOTTOM + HINT_TEXT_HEIGHT + PANEL_OFFSET}px`);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
describe('C-5 외부 클릭 판정', () => {
|
|
324
|
+
it('Portal 패널 내부 mousedown 은 패널을 닫지 않는다', () => {
|
|
325
|
+
// Given: Portal 모드로 열린 ComboBox 에서
|
|
326
|
+
const view = mountComboBox({
|
|
327
|
+
props: {
|
|
328
|
+
usePortal: true
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
view.open();
|
|
332
|
+
// When: Portal 패널 내부를 mousedown 하면
|
|
333
|
+
mousedownOn(view.portalPanels()[0]);
|
|
334
|
+
// Then: 패널이 유지된다
|
|
335
|
+
expect(view.portalPanels()).toHaveLength(1);
|
|
336
|
+
});
|
|
337
|
+
it('패널·트리거 밖 mousedown 은 패널을 닫는다', () => {
|
|
338
|
+
// Given: Portal 모드로 열린 ComboBox 에서
|
|
339
|
+
const view = mountComboBox({
|
|
340
|
+
props: {
|
|
341
|
+
usePortal: true
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
view.open();
|
|
345
|
+
// When: 문서 본문을 mousedown 하면
|
|
346
|
+
mousedownOn(document.body);
|
|
347
|
+
// Then: 패널이 닫힌다
|
|
348
|
+
expect(view.portalPanels()).toHaveLength(0);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
describe('C-6 multiple footer 버튼', () => {
|
|
352
|
+
it('Portal 모드에서 "선택 완료" 클릭이 onComplete 를 1회 호출하고 패널을 닫는다', () => {
|
|
353
|
+
// Given: multiple Portal 모드로 열린 ComboBox 에서
|
|
354
|
+
const onComplete = vi.fn();
|
|
355
|
+
const view = mountComboBox({
|
|
356
|
+
props: {
|
|
357
|
+
usePortal: true,
|
|
358
|
+
multiple: true,
|
|
359
|
+
value: ['apple'],
|
|
360
|
+
onComplete
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
view.open();
|
|
364
|
+
// When: Portal 패널의 "선택 완료" 버튼을 클릭하면
|
|
365
|
+
clickButtonByLabel(view.portalPanels()[0], '선택 완료');
|
|
366
|
+
// Then: onComplete 가 1회 호출되고 패널이 닫힌다
|
|
367
|
+
expect(onComplete).toHaveBeenCalledTimes(1);
|
|
368
|
+
expect(view.portalPanels()).toHaveLength(0);
|
|
369
|
+
});
|
|
370
|
+
it('Portal 모드에서 "전체 선택" 클릭이 onChange 를 호출한다', () => {
|
|
371
|
+
// Given: multiple Portal 모드로 열린 ComboBox 에서
|
|
372
|
+
const onChange = vi.fn();
|
|
373
|
+
const view = mountComboBox({
|
|
374
|
+
props: {
|
|
375
|
+
usePortal: true,
|
|
376
|
+
multiple: true,
|
|
377
|
+
value: [],
|
|
378
|
+
onChange
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
view.open();
|
|
382
|
+
// When: Portal 패널의 "전체 선택" 버튼을 클릭하면
|
|
383
|
+
clickButtonByLabel(view.portalPanels()[0], '전체 선택');
|
|
384
|
+
// Then: onChange 가 호출된다
|
|
385
|
+
expect(onChange).toHaveBeenCalled();
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
describe('C-7 usePortal props 누출', () => {
|
|
389
|
+
it('usePortal 을 전달해도 루트 div 에 DOM 속성으로 누출되지 않는다', () => {
|
|
390
|
+
// Given: console.error 를 감시하는 상태에서
|
|
391
|
+
const consoleErrorSpy = vi.spyOn(console, 'error');
|
|
392
|
+
// When: usePortal 을 전달해 렌더하면
|
|
393
|
+
const view = mountComboBox({
|
|
394
|
+
props: {
|
|
395
|
+
usePortal: true
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
// Then: 루트에 useportal 속성이 없고 usePortal 에 대한 unknown prop 경고도 발생하지 않는다.
|
|
399
|
+
// 경고 전체가 아니라 usePortal 을 언급한 것만 본다 — 무관한 React 경고까지 잡으면
|
|
400
|
+
// 이 케이스가 다른 컴포넌트의 결함으로 깨진다.
|
|
401
|
+
const portalPropWarnings = consoleErrorSpy.mock.calls.filter(args => args.some(arg => typeof arg === 'string' && arg.toLowerCase().includes('useportal')));
|
|
402
|
+
expect(view.root.hasAttribute('useportal')).toBe(false);
|
|
403
|
+
expect(portalPropWarnings).toEqual([]);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
describe('C-8 좌표 추적', () => {
|
|
407
|
+
it('window scroll 이후 anchor 이동분이 top 에 반영된다', () => {
|
|
408
|
+
// Given: Portal 모드로 열려 좌표가 산출된 ComboBox 에서
|
|
409
|
+
installResizeObserverStub();
|
|
410
|
+
const view = mountComboBox({
|
|
411
|
+
props: {
|
|
412
|
+
usePortal: true
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
stubAnchorGeometry({
|
|
416
|
+
root: view.root,
|
|
417
|
+
content: view.content
|
|
418
|
+
});
|
|
419
|
+
view.open();
|
|
420
|
+
expect(view.portalPanels()[0].style.top).toBe(`${ANCHOR_BOTTOM + PANEL_OFFSET}px`);
|
|
421
|
+
// When: anchor 가 위로 이동한 뒤 window scroll 이 발생하면
|
|
422
|
+
stubAnchorGeometry({
|
|
423
|
+
root: view.root,
|
|
424
|
+
content: view.content,
|
|
425
|
+
top: SCROLLED_ANCHOR_TOP
|
|
426
|
+
});
|
|
427
|
+
act(() => {
|
|
428
|
+
window.dispatchEvent(new Event('scroll'));
|
|
429
|
+
});
|
|
430
|
+
// Then: top 이 재산출된다
|
|
431
|
+
expect(view.portalPanels()[0].style.top).toBe(`${SCROLLED_ANCHOR_TOP + ANCHOR_HEIGHT + PANEL_OFFSET}px`);
|
|
432
|
+
});
|
|
433
|
+
it('패널 ResizeObserver 발화 시 direction=up 의 top 이 패널 높이로 재산출된다', () => {
|
|
434
|
+
// Given: 트리거가 viewport 하단에 있어 direction='up' 으로 열린 Portal ComboBox 에서
|
|
435
|
+
const resizeObserver = installResizeObserverStub();
|
|
436
|
+
const view = mountComboBox({
|
|
437
|
+
props: {
|
|
438
|
+
usePortal: true
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
stubAnchorGeometry({
|
|
442
|
+
root: view.root,
|
|
443
|
+
content: view.content,
|
|
444
|
+
top: LOW_ANCHOR_TOP
|
|
445
|
+
});
|
|
446
|
+
view.open();
|
|
447
|
+
const panel = view.portalPanels()[0];
|
|
448
|
+
expect(panel.style.top).toBe(`${LOW_ANCHOR_TOP - PANEL_OFFSET}px`);
|
|
449
|
+
// When: 검색 결과 변화로 패널 높이가 커지고 ResizeObserver 가 발화하면
|
|
450
|
+
Object.defineProperty(panel, 'offsetHeight', {
|
|
451
|
+
value: GROWN_PANEL_HEIGHT,
|
|
452
|
+
configurable: true
|
|
453
|
+
});
|
|
454
|
+
act(() => {
|
|
455
|
+
resizeObserver.triggerAll();
|
|
456
|
+
});
|
|
457
|
+
// Then: top 이 늘어난 높이만큼 위로 재산출된다
|
|
458
|
+
expect(panel.style.top).toBe(`${LOW_ANCHOR_TOP - GROWN_PANEL_HEIGHT - PANEL_OFFSET}px`);
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
describe('C-9 기본 경로 회귀 기준선', () => {
|
|
462
|
+
it('검색어를 입력하면 패널이 열리고 옵션이 노출된다', () => {
|
|
463
|
+
// Given: usePortal 을 지정하지 않은 ComboBox 에서
|
|
464
|
+
const onSearch = vi.fn();
|
|
465
|
+
const view = mountComboBox({
|
|
466
|
+
props: {
|
|
467
|
+
onSearch
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
// When: 검색어를 입력하면
|
|
471
|
+
view.typeSearch('사');
|
|
472
|
+
// Then: onSearch 가 호출되고 인라인 패널에 옵션이 노출된다
|
|
473
|
+
expect(onSearch).toHaveBeenCalledWith('사');
|
|
474
|
+
expect(view.content.querySelectorAll('.ncua-select-dropdown__option')).toHaveLength(createOptions().length);
|
|
475
|
+
});
|
|
476
|
+
it('옵션을 선택하면 onChange 가 호출되고 선택 태그가 표시된다', () => {
|
|
477
|
+
// Given: 옵션이 노출된 기본 모드 ComboBox 에서
|
|
478
|
+
const onChange = vi.fn();
|
|
479
|
+
const view = mountComboBox({
|
|
480
|
+
props: {
|
|
481
|
+
onChange
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
view.open();
|
|
485
|
+
// When: 첫 옵션을 클릭하면
|
|
486
|
+
act(() => {
|
|
487
|
+
requireElement(view.content, '.ncua-select-dropdown__option').click();
|
|
488
|
+
});
|
|
489
|
+
// Then: onChange 가 선택 값으로 호출되고 태그가 표시된다
|
|
490
|
+
expect(onChange).toHaveBeenCalledWith('apple');
|
|
491
|
+
expect(view.container.querySelectorAll('.ncua-combobox__tags .ncua-tag')).toHaveLength(1);
|
|
492
|
+
});
|
|
493
|
+
it('Escape 를 누르면 인라인 패널이 닫힌다', () => {
|
|
494
|
+
// Given: 열려 있는 기본 모드 ComboBox 에서
|
|
495
|
+
const view = mountComboBox();
|
|
496
|
+
view.open();
|
|
497
|
+
expect(view.inlinePanels()).toHaveLength(1);
|
|
498
|
+
// When: Escape 를 누르면
|
|
499
|
+
view.pressEscape();
|
|
500
|
+
// Then: 패널이 닫힌다
|
|
501
|
+
expect(view.inlinePanels()).toHaveLength(0);
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
describe('C-11 hintText + Portal 모드에서 direction 판정 기준은 contentRef', () => {
|
|
505
|
+
it('root 가 content 보다 아래까지 차지해도 direction 판정은 contentRef.bottom 기준이다', () => {
|
|
506
|
+
// Given: hintText 때문에 root.bottom 이 content.bottom 보다 HINT_TEXT_HEIGHT(40px) 더 아래인 ComboBox 에서
|
|
507
|
+
// content.top = 570, content.bottom = 610, root.bottom = 650 (ANCHOR_HEIGHT + HINT_TEXT_HEIGHT)
|
|
508
|
+
// jsdom viewport = 768, panelHeight = 120(3개×40), gap = 20
|
|
509
|
+
// content 기준: 768 - 610 = 158 ≥ 140 → 'down' (패널이 아래에 열려야 함)
|
|
510
|
+
// root 기준: 768 - 650 = 118 < 140 → 'up' (잘못된 판정)
|
|
511
|
+
const BOUNDARY_TOP = 570;
|
|
512
|
+
const view = mountComboBox({
|
|
513
|
+
props: {
|
|
514
|
+
usePortal: true,
|
|
515
|
+
hintText: '안내 문구'
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
stubAnchorGeometry({
|
|
519
|
+
root: view.root,
|
|
520
|
+
content: view.content,
|
|
521
|
+
top: BOUNDARY_TOP,
|
|
522
|
+
extraRootHeight: HINT_TEXT_HEIGHT
|
|
523
|
+
});
|
|
524
|
+
// When: 옵션 패널을 연다
|
|
525
|
+
view.open();
|
|
526
|
+
// Then: direction 이 'down' 이어야 한다 — contentRef 기준으로 패널이 아래에 들어가는 높이
|
|
527
|
+
const panel = view.portalPanels()[0];
|
|
528
|
+
expect(panel.classList.contains('ncua-select-dropdown--down')).toBe(true);
|
|
529
|
+
expect(panel.classList.contains('ncua-select-dropdown--up')).toBe(false);
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
describe('C-10 portalContainer: null → document.body 대체', () => {
|
|
533
|
+
it('portalContainer: null 컨텍스트에서도 예외 없이 렌더되고 document.body 에 Portal 패널이 1개 생긴다', () => {
|
|
534
|
+
// Given: portalContainer 를 null 로 내린 컨텍스트에서 usePortal 을 명시로 켠 ComboBox 가
|
|
535
|
+
// usePortalState 는 null ?? document.body 로 대체하므로 portalContainer 는 document.body 가 된다
|
|
536
|
+
const view = mountComboBox({
|
|
537
|
+
props: {
|
|
538
|
+
usePortal: true
|
|
539
|
+
},
|
|
540
|
+
floating: {
|
|
541
|
+
preferPortal: true,
|
|
542
|
+
portalContainer: null
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
// When: 옵션 패널을 연다 (렌더 예외가 전파되면 이 호출이 throw 한다)
|
|
546
|
+
view.open();
|
|
547
|
+
// Then: 인라인 패널이 없고 document.body 에 Portal 패널이 1개 존재한다
|
|
548
|
+
expect(view.content.querySelectorAll('.ncua-select-dropdown')).toHaveLength(0);
|
|
549
|
+
expect(view.portalPanels()).toHaveLength(1);
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
describe('C-12 루트 이벤트 핸들러 버블링', () => {
|
|
553
|
+
it.each([['인라인', false], ['Portal', true]])('%s 모드에서 패널 내부 mousedown·keydown 이 소비자 루트 핸들러로 전파된다', (_label, usePortal) => {
|
|
554
|
+
// Given: 루트에 onMouseDown·onKeyDown 을 넘긴 ComboBox 가 열린 상태에서
|
|
555
|
+
const onMouseDown = vi.fn();
|
|
556
|
+
const onKeyDown = vi.fn();
|
|
557
|
+
const view = mountComboBox({
|
|
558
|
+
props: {
|
|
559
|
+
usePortal,
|
|
560
|
+
onMouseDown,
|
|
561
|
+
onKeyDown
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
view.open();
|
|
565
|
+
const panel = usePortal ? view.portalPanels()[0] : view.inlinePanels()[0];
|
|
566
|
+
// When: 패널 내부에서 mousedown·keydown 이 발생하면
|
|
567
|
+
mousedownOn(panel);
|
|
568
|
+
act(() => {
|
|
569
|
+
panel.dispatchEvent(new KeyboardEvent('keydown', {
|
|
570
|
+
key: 'a',
|
|
571
|
+
bubbles: true
|
|
572
|
+
}));
|
|
573
|
+
});
|
|
574
|
+
// Then: React 합성 이벤트가 인라인과 같은 트리 경로로 루트까지 버블링된다
|
|
575
|
+
expect(onMouseDown).toHaveBeenCalledTimes(1);
|
|
576
|
+
expect(onKeyDown).toHaveBeenCalledTimes(1);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
@@ -4,11 +4,15 @@ import classNames from 'classnames';
|
|
|
4
4
|
const BreadcrumbItem = _ref => {
|
|
5
5
|
let {
|
|
6
6
|
href,
|
|
7
|
+
ariaLabel,
|
|
7
8
|
children
|
|
8
9
|
} = _ref;
|
|
9
10
|
const ElementType = href ? 'a' : 'span';
|
|
10
11
|
return _jsx(ElementType, {
|
|
11
12
|
href: href,
|
|
13
|
+
// <span> 은 generic role 이라 aria-label 이 무시될 수 있다. 링크는 이미 이름을 지원하므로 그대로 둔다
|
|
14
|
+
role: !href && ariaLabel ? 'img' : undefined,
|
|
15
|
+
"aria-label": ariaLabel,
|
|
12
16
|
className: "ncua-breadcrumb__item",
|
|
13
17
|
children: children
|
|
14
18
|
});
|
|
@@ -16,23 +20,27 @@ const BreadcrumbItem = _ref => {
|
|
|
16
20
|
const BreadCrumb = _ref2 => {
|
|
17
21
|
let {
|
|
18
22
|
items,
|
|
23
|
+
showHomeIcon = true,
|
|
19
24
|
className
|
|
20
25
|
} = _ref2;
|
|
21
26
|
if (!items || items.length === 0) return null;
|
|
22
27
|
const renderItem = (item, index) => {
|
|
23
28
|
const isLast = index === items.length - 1;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
height: 16,
|
|
27
|
-
color: "gray500"
|
|
28
|
-
}) : item.label;
|
|
29
|
+
// 아이콘 여부는 한 곳에서만 판정한다 — 콘텐츠·모디파이어·aria-label 이 모두 이 값을 따른다
|
|
30
|
+
const isHome = showHomeIcon && index === 0;
|
|
29
31
|
return _jsx("li", {
|
|
30
32
|
className: classNames('ncua-breadcrumb__list-item', {
|
|
33
|
+
'ncua-breadcrumb__list-item--home': isHome,
|
|
31
34
|
'ncua-breadcrumb__list-item--active': isLast
|
|
32
35
|
}),
|
|
33
36
|
children: _jsx(BreadcrumbItem, {
|
|
34
37
|
href: isLast ? undefined : item.href,
|
|
35
|
-
|
|
38
|
+
ariaLabel: isHome ? item.label : undefined,
|
|
39
|
+
children: isHome ? _jsx(Home01, {
|
|
40
|
+
width: 16,
|
|
41
|
+
height: 16,
|
|
42
|
+
color: "gray500"
|
|
43
|
+
}) : item.label
|
|
36
44
|
})
|
|
37
45
|
}, index);
|
|
38
46
|
};
|