@ncds/ui-admin 1.8.25-alpha.0 → 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.
Files changed (71) hide show
  1. package/dist/cjs/src/components/forms-and-input/combo-box/ComboBox.js +109 -28
  2. package/dist/cjs/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.js +581 -0
  3. package/dist/cjs/src/generated/scoped-css.js +1 -1
  4. package/dist/cjs/src/hooks/__tests__/useFloatingPosition.ssr.test.js +29 -0
  5. package/dist/cjs/src/hooks/__tests__/useFloatingPosition.test.js +322 -0
  6. package/dist/cjs/src/hooks/dropdown/useDropdown.js +2 -1
  7. package/dist/cjs/src/hooks/useFloatingPosition.js +14 -53
  8. package/dist/cjs/src/utils/dropdown/__tests__/dropdownUtils.test.js +316 -0
  9. package/dist/cjs/src/utils/dropdown/dropdownUtils.js +8 -12
  10. package/dist/cjs/src/utils/portal-position/__tests__/portalPosition.test.js +227 -0
  11. package/dist/cjs/src/utils/portal-position/__tests__/portalPosition.tracking.test.js +397 -0
  12. package/dist/cjs/src/utils/portal-position/index.js +16 -0
  13. package/dist/cjs/src/utils/portal-position/portalPosition.js +162 -0
  14. package/dist/esm/src/components/forms-and-input/combo-box/ComboBox.js +109 -28
  15. package/dist/esm/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.js +578 -0
  16. package/dist/esm/src/generated/scoped-css.js +1 -1
  17. package/dist/esm/src/hooks/__tests__/useFloatingPosition.ssr.test.js +26 -0
  18. package/dist/esm/src/hooks/__tests__/useFloatingPosition.test.js +319 -0
  19. package/dist/esm/src/hooks/dropdown/useDropdown.js +2 -1
  20. package/dist/esm/src/hooks/useFloatingPosition.js +15 -54
  21. package/dist/esm/src/utils/dropdown/__tests__/dropdownUtils.test.js +314 -0
  22. package/dist/esm/src/utils/dropdown/dropdownUtils.js +8 -12
  23. package/dist/esm/src/utils/portal-position/__tests__/portalPosition.test.js +225 -0
  24. package/dist/esm/src/utils/portal-position/__tests__/portalPosition.tracking.test.js +394 -0
  25. package/dist/esm/src/utils/portal-position/index.js +1 -0
  26. package/dist/esm/src/utils/portal-position/portalPosition.js +150 -0
  27. package/dist/temp/assets/scripts/notification/const/sizes.d.ts +1 -1
  28. package/dist/temp/src/components/action/button-group/ButtonGroup.d.ts +1 -1
  29. package/dist/temp/src/components/forms-and-input/combo-box/ComboBox.d.ts +6 -0
  30. package/dist/temp/src/components/forms-and-input/combo-box/ComboBox.js +54 -4
  31. package/dist/temp/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.d.ts +1 -0
  32. package/dist/temp/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.js +418 -0
  33. package/dist/temp/src/generated/scoped-css.js +1 -1
  34. package/dist/temp/src/hooks/__tests__/useFloatingPosition.ssr.test.d.ts +1 -0
  35. package/dist/temp/src/hooks/__tests__/useFloatingPosition.ssr.test.js +26 -0
  36. package/dist/temp/src/hooks/__tests__/useFloatingPosition.test.d.ts +1 -0
  37. package/dist/temp/src/hooks/__tests__/useFloatingPosition.test.js +205 -0
  38. package/dist/temp/src/hooks/dropdown/useDropdown.d.ts +3 -1
  39. package/dist/temp/src/hooks/dropdown/useDropdown.js +2 -2
  40. package/dist/temp/src/hooks/dropdown/useDropdownPosition.d.ts +3 -3
  41. package/dist/temp/src/hooks/useFloatingPosition.d.ts +6 -4
  42. package/dist/temp/src/hooks/useFloatingPosition.js +14 -47
  43. package/dist/temp/src/types/dropdown/dropdown.d.ts +2 -1
  44. package/dist/temp/src/utils/dropdown/__tests__/dropdownUtils.test.d.ts +1 -0
  45. package/dist/temp/src/utils/dropdown/__tests__/dropdownUtils.test.js +174 -0
  46. package/dist/temp/src/utils/dropdown/dropdownUtils.js +8 -9
  47. package/dist/temp/src/utils/portal-position/__tests__/portalPosition.test.d.ts +1 -0
  48. package/dist/temp/src/utils/portal-position/__tests__/portalPosition.test.js +181 -0
  49. package/dist/temp/src/utils/portal-position/__tests__/portalPosition.tracking.test.d.ts +1 -0
  50. package/dist/temp/src/utils/portal-position/__tests__/portalPosition.tracking.test.js +301 -0
  51. package/dist/temp/src/utils/portal-position/index.d.ts +1 -0
  52. package/dist/temp/src/utils/portal-position/index.js +1 -0
  53. package/dist/temp/src/utils/portal-position/portalPosition.d.ts +67 -0
  54. package/dist/temp/src/utils/portal-position/portalPosition.js +88 -0
  55. package/dist/types/assets/scripts/notification/const/sizes.d.ts +1 -1
  56. package/dist/types/src/components/action/button-group/ButtonGroup.d.ts +1 -1
  57. package/dist/types/src/components/forms-and-input/combo-box/ComboBox.d.ts +6 -0
  58. package/dist/types/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.d.ts +1 -0
  59. package/dist/types/src/hooks/__tests__/useFloatingPosition.ssr.test.d.ts +1 -0
  60. package/dist/types/src/hooks/__tests__/useFloatingPosition.test.d.ts +1 -0
  61. package/dist/types/src/hooks/dropdown/useDropdown.d.ts +3 -1
  62. package/dist/types/src/hooks/dropdown/useDropdownPosition.d.ts +3 -3
  63. package/dist/types/src/hooks/useFloatingPosition.d.ts +6 -4
  64. package/dist/types/src/types/dropdown/dropdown.d.ts +2 -1
  65. package/dist/types/src/utils/dropdown/__tests__/dropdownUtils.test.d.ts +1 -0
  66. package/dist/types/src/utils/portal-position/__tests__/portalPosition.test.d.ts +1 -0
  67. package/dist/types/src/utils/portal-position/__tests__/portalPosition.tracking.test.d.ts +1 -0
  68. package/dist/types/src/utils/portal-position/index.d.ts +1 -0
  69. package/dist/types/src/utils/portal-position/portalPosition.d.ts +67 -0
  70. package/dist/ui-admin/assets/styles/style.css +13 -1
  71. package/package.json +1 -1
@@ -0,0 +1,316 @@
1
+ "use strict";
2
+
3
+ var _vitest = require("vitest");
4
+ var _dropdownUtils = require("../dropdownUtils");
5
+ (0, _vitest.afterEach)(() => {
6
+ _vitest.vi.unstubAllGlobals();
7
+ _vitest.vi.restoreAllMocks();
8
+ });
9
+ const VIEWPORT_HEIGHT = 800;
10
+ const MAX_HEIGHT = 300;
11
+ const ITEM_HEIGHT = 40;
12
+ const ITEM_COUNT = 10;
13
+ const PANEL_HEIGHT = Math.min(MAX_HEIGHT, ITEM_COUNT * ITEM_HEIGHT);
14
+ const DEFAULT_GAP = 20;
15
+ const EXTRA_SPACE = 200;
16
+ const SHORTFALL = 100;
17
+ const TRIGGER_HEIGHT = 40;
18
+ const TRIGGER_WIDTH = 200;
19
+ const createTriggerRef = _ref => {
20
+ let {
21
+ bottom
22
+ } = _ref;
23
+ const element = document.createElement('button');
24
+ _vitest.vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(DOMRect.fromRect({
25
+ x: 0,
26
+ y: bottom - TRIGGER_HEIGHT,
27
+ width: TRIGGER_WIDTH,
28
+ height: TRIGGER_HEIGHT
29
+ }));
30
+ return {
31
+ current: element
32
+ };
33
+ };
34
+ const nullTriggerRef = () => ({
35
+ current: null
36
+ });
37
+ (0, _vitest.describe)('calculateDropdownPosition — 위임 후 반환 불변 (E-9)', () => {
38
+ (0, _vitest.it)('triggerRef.current 가 null 이면 down · maxHeight 를 그대로 반환한다', () => {
39
+ _vitest.vi.stubGlobal('innerHeight', VIEWPORT_HEIGHT);
40
+ const result = (0, _dropdownUtils.calculateDropdownPosition)({
41
+ triggerRef: nullTriggerRef(),
42
+ maxHeight: MAX_HEIGHT,
43
+ itemCount: ITEM_COUNT
44
+ });
45
+ (0, _vitest.expect)(result).toEqual({
46
+ direction: 'down',
47
+ maxHeight: MAX_HEIGHT
48
+ });
49
+ });
50
+ _vitest.it.each([{
51
+ label: '공간 충분',
52
+ spaceBelow: PANEL_HEIGHT + EXTRA_SPACE,
53
+ direction: 'down'
54
+ }, {
55
+ label: '공간 부족',
56
+ spaceBelow: PANEL_HEIGHT - SHORTFALL,
57
+ direction: 'up'
58
+ }, {
59
+ label: '경계 (panelHeight+20)',
60
+ spaceBelow: PANEL_HEIGHT + DEFAULT_GAP,
61
+ direction: 'down'
62
+ }, {
63
+ label: '경계 -1 (panelHeight+19)',
64
+ spaceBelow: PANEL_HEIGHT + DEFAULT_GAP - 1,
65
+ direction: 'up'
66
+ }, {
67
+ label: '경계 +1 (panelHeight+21)',
68
+ spaceBelow: PANEL_HEIGHT + DEFAULT_GAP + 1,
69
+ direction: 'down'
70
+ }])('$label → $direction', _ref2 => {
71
+ let {
72
+ spaceBelow,
73
+ direction
74
+ } = _ref2;
75
+ _vitest.vi.stubGlobal('innerHeight', VIEWPORT_HEIGHT);
76
+ const result = (0, _dropdownUtils.calculateDropdownPosition)({
77
+ triggerRef: createTriggerRef({
78
+ bottom: VIEWPORT_HEIGHT - spaceBelow
79
+ }),
80
+ maxHeight: MAX_HEIGHT,
81
+ itemCount: ITEM_COUNT,
82
+ itemHeight: ITEM_HEIGHT
83
+ });
84
+ (0, _vitest.expect)(result).toEqual({
85
+ direction,
86
+ maxHeight: MAX_HEIGHT
87
+ });
88
+ });
89
+ (0, _vitest.it)('itemCount×itemHeight 가 maxHeight 보다 작으면 그 값을 패널 높이로 쓴다', () => {
90
+ _vitest.vi.stubGlobal('innerHeight', VIEWPORT_HEIGHT);
91
+ const shortPanelHeight = 2 * ITEM_HEIGHT;
92
+ const result = (0, _dropdownUtils.calculateDropdownPosition)({
93
+ triggerRef: createTriggerRef({
94
+ bottom: VIEWPORT_HEIGHT - (shortPanelHeight + DEFAULT_GAP)
95
+ }),
96
+ maxHeight: MAX_HEIGHT,
97
+ itemCount: 2,
98
+ itemHeight: ITEM_HEIGHT
99
+ });
100
+ (0, _vitest.expect)(result.direction).toBe('down');
101
+ });
102
+ (0, _vitest.it)('offset 을 지정하면 그 간격으로 반전 여부를 판정한다', () => {
103
+ _vitest.vi.stubGlobal('innerHeight', VIEWPORT_HEIGHT);
104
+ const result = (0, _dropdownUtils.calculateDropdownPosition)({
105
+ triggerRef: createTriggerRef({
106
+ bottom: VIEWPORT_HEIGHT - (PANEL_HEIGHT + DEFAULT_GAP)
107
+ }),
108
+ maxHeight: MAX_HEIGHT,
109
+ itemCount: ITEM_COUNT,
110
+ itemHeight: ITEM_HEIGHT,
111
+ offset: DEFAULT_GAP + 1
112
+ });
113
+ (0, _vitest.expect)(result.direction).toBe('up');
114
+ });
115
+ });
116
+ (0, _vitest.describe)('calculateNextFocusIndex — 순환 포커스 이동 (characterisation)', () => {
117
+ const LENGTH = 5;
118
+ const LAST = LENGTH - 1;
119
+ (0, _vitest.it)('옵션이 없으면 -1 을 반환한다', () => {
120
+ (0, _vitest.expect)((0, _dropdownUtils.calculateNextFocusIndex)(0, 'down', 0)).toBe(-1);
121
+ (0, _vitest.expect)((0, _dropdownUtils.calculateNextFocusIndex)(2, 'up', 0)).toBe(-1);
122
+ });
123
+ _vitest.it.each([{
124
+ label: 'down · 중간',
125
+ current: 2,
126
+ direction: 'down',
127
+ expected: 3
128
+ }, {
129
+ label: 'down · 마지막 → 첫 번째로 순환',
130
+ current: LAST,
131
+ direction: 'down',
132
+ expected: 0
133
+ }, {
134
+ label: 'up · 중간',
135
+ current: 2,
136
+ direction: 'up',
137
+ expected: 1
138
+ }, {
139
+ label: 'up · 첫 번째 → 마지막으로 순환',
140
+ current: 0,
141
+ direction: 'up',
142
+ expected: LAST
143
+ }, {
144
+ label: 'up · 초기값 -1 → 마지막',
145
+ current: -1,
146
+ direction: 'up',
147
+ expected: LAST
148
+ }])('$label', _ref3 => {
149
+ let {
150
+ current,
151
+ direction,
152
+ expected
153
+ } = _ref3;
154
+ (0, _vitest.expect)((0, _dropdownUtils.calculateNextFocusIndex)(current, direction, LENGTH)).toBe(expected);
155
+ });
156
+ });
157
+ const VIEWPORT_WIDTH = 1000;
158
+ const EDGE_MARGIN = 10;
159
+ const createDropdownElement = rect => {
160
+ const element = document.createElement('div');
161
+ _vitest.vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(DOMRect.fromRect({
162
+ x: rect.left,
163
+ y: rect.bottom - rect.height,
164
+ width: rect.right - rect.left,
165
+ height: rect.height
166
+ }));
167
+ return element;
168
+ };
169
+ (0, _vitest.describe)('adjustDropdownPosition — 뷰포트 이탈 보정 (characterisation)', () => {
170
+ (0, _vitest.it)('뷰포트 안에 있으면 style 을 변경하지 않는다', () => {
171
+ _vitest.vi.stubGlobal('innerWidth', VIEWPORT_WIDTH);
172
+ _vitest.vi.stubGlobal('innerHeight', VIEWPORT_HEIGHT);
173
+ const element = createDropdownElement({
174
+ left: 100,
175
+ right: 300,
176
+ bottom: 400,
177
+ height: 200
178
+ });
179
+ (0, _dropdownUtils.adjustDropdownPosition)(element);
180
+ (0, _vitest.expect)(element.style.left).toBe('');
181
+ (0, _vitest.expect)(element.style.maxHeight).toBe('');
182
+ });
183
+ (0, _vitest.it)('우측 이탈 시 초과분 + 여백만큼 left 를 음수로 이동한다', () => {
184
+ _vitest.vi.stubGlobal('innerWidth', VIEWPORT_WIDTH);
185
+ _vitest.vi.stubGlobal('innerHeight', VIEWPORT_HEIGHT);
186
+ const overflow = 50;
187
+ const element = createDropdownElement({
188
+ left: 800,
189
+ right: VIEWPORT_WIDTH + overflow,
190
+ bottom: 400,
191
+ height: 200
192
+ });
193
+ (0, _dropdownUtils.adjustDropdownPosition)(element);
194
+ (0, _vitest.expect)(element.style.left).toBe(`${-overflow - EDGE_MARGIN}px`);
195
+ });
196
+ (0, _vitest.it)('좌측 이탈 시 left 를 여백값으로 고정한다', () => {
197
+ _vitest.vi.stubGlobal('innerWidth', VIEWPORT_WIDTH);
198
+ _vitest.vi.stubGlobal('innerHeight', VIEWPORT_HEIGHT);
199
+ const element = createDropdownElement({
200
+ left: -30,
201
+ right: 170,
202
+ bottom: 400,
203
+ height: 200
204
+ });
205
+ (0, _dropdownUtils.adjustDropdownPosition)(element);
206
+ (0, _vitest.expect)(element.style.left).toBe(`${EDGE_MARGIN}px`);
207
+ });
208
+ (0, _vitest.it)('하단 이탈 시 초과분 + 여백만큼 maxHeight 를 줄인다', () => {
209
+ _vitest.vi.stubGlobal('innerWidth', VIEWPORT_WIDTH);
210
+ _vitest.vi.stubGlobal('innerHeight', VIEWPORT_HEIGHT);
211
+ const overflow = 60;
212
+ const height = 200;
213
+ const element = createDropdownElement({
214
+ left: 100,
215
+ right: 300,
216
+ bottom: VIEWPORT_HEIGHT + overflow,
217
+ height
218
+ });
219
+ (0, _dropdownUtils.adjustDropdownPosition)(element);
220
+ (0, _vitest.expect)(element.style.maxHeight).toBe(`${height - overflow - EDGE_MARGIN}px`);
221
+ });
222
+ });
223
+ const CLIENT_HEIGHT = 100;
224
+ const OVERFLOWING_SCROLL_HEIGHT = 500;
225
+ const createScrollable = _ref4 => {
226
+ let {
227
+ overflowY,
228
+ scrollable
229
+ } = _ref4;
230
+ const element = document.createElement('div');
231
+ element.style.overflowY = overflowY;
232
+ Object.defineProperty(element, 'scrollHeight', {
233
+ value: scrollable ? OVERFLOWING_SCROLL_HEIGHT : CLIENT_HEIGHT,
234
+ configurable: true
235
+ });
236
+ Object.defineProperty(element, 'clientHeight', {
237
+ value: CLIENT_HEIGHT,
238
+ configurable: true
239
+ });
240
+ return element;
241
+ };
242
+ const mountChain = function () {
243
+ for (var _len = arguments.length, chain = new Array(_len), _key = 0; _key < _len; _key++) {
244
+ chain[_key] = arguments[_key];
245
+ }
246
+ chain.reduce((parent, child) => {
247
+ parent.appendChild(child);
248
+ return child;
249
+ });
250
+ document.body.appendChild(chain[0]);
251
+ return chain;
252
+ };
253
+ (0, _vitest.describe)('findScrollableParent — 스크롤 부모 탐색 (characterisation)', () => {
254
+ (0, _vitest.afterEach)(() => {
255
+ document.body.innerHTML = '';
256
+ });
257
+ (0, _vitest.it)('boundary 이전에 overflow auto 이고 내용이 넘치는 조상이 있으면 그 요소를 반환한다', () => {
258
+ const [boundary, scrollParent,, target] = mountChain(createScrollable({
259
+ overflowY: 'visible',
260
+ scrollable: false
261
+ }), createScrollable({
262
+ overflowY: 'auto',
263
+ scrollable: true
264
+ }), createScrollable({
265
+ overflowY: 'visible',
266
+ scrollable: false
267
+ }), createScrollable({
268
+ overflowY: 'visible',
269
+ scrollable: false
270
+ }));
271
+ (0, _vitest.expect)((0, _dropdownUtils.findScrollableParent)(target, boundary)).toBe(scrollParent);
272
+ });
273
+ (0, _vitest.it)('overflow scroll 이어도 내용이 넘치지 않으면 건너뛴다', () => {
274
+ const [boundary,, target] = mountChain(createScrollable({
275
+ overflowY: 'visible',
276
+ scrollable: false
277
+ }), createScrollable({
278
+ overflowY: 'scroll',
279
+ scrollable: false
280
+ }), createScrollable({
281
+ overflowY: 'visible',
282
+ scrollable: false
283
+ }));
284
+ (0, _vitest.expect)((0, _dropdownUtils.findScrollableParent)(target, boundary)).toBeNull();
285
+ });
286
+ (0, _vitest.it)('조상 중 없고 boundary 자체가 스크롤 가능하면 boundary 를 반환한다', () => {
287
+ const [boundary,, target] = mountChain(createScrollable({
288
+ overflowY: 'auto',
289
+ scrollable: true
290
+ }), createScrollable({
291
+ overflowY: 'visible',
292
+ scrollable: false
293
+ }), createScrollable({
294
+ overflowY: 'visible',
295
+ scrollable: false
296
+ }));
297
+ (0, _vitest.expect)((0, _dropdownUtils.findScrollableParent)(target, boundary)).toBe(boundary);
298
+ });
299
+ (0, _vitest.it)('boundary 까지 스크롤 가능한 요소가 없으면 null 을 반환한다', () => {
300
+ const [boundary, target] = mountChain(createScrollable({
301
+ overflowY: 'hidden',
302
+ scrollable: true
303
+ }), createScrollable({
304
+ overflowY: 'visible',
305
+ scrollable: false
306
+ }));
307
+ (0, _vitest.expect)((0, _dropdownUtils.findScrollableParent)(target, boundary)).toBeNull();
308
+ });
309
+ (0, _vitest.it)('element 가 boundary 자신이면 boundary 의 스크롤 가능 여부로만 판정한다', () => {
310
+ const [boundary] = mountChain(createScrollable({
311
+ overflowY: 'scroll',
312
+ scrollable: true
313
+ }));
314
+ (0, _vitest.expect)((0, _dropdownUtils.findScrollableParent)(boundary, boundary)).toBe(boundary);
315
+ });
316
+ });
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.findScrollableParent = exports.calculateNextFocusIndex = exports.calculateDropdownPosition = exports.adjustDropdownPosition = void 0;
7
+ var _portalPosition = require("../portal-position");
7
8
  /**
8
9
  * 드롭다운 위치를 계산하는 유틸리티 함수
9
10
  */
@@ -21,19 +22,14 @@ const calculateDropdownPosition = _ref => {
21
22
  maxHeight
22
23
  };
23
24
  }
24
- const rect = triggerRef?.current.getBoundingClientRect();
25
- const viewportHeight = window.innerHeight;
26
- const dropdownHeight = Math.min(maxHeight, itemCount * itemHeight);
27
- // 아래쪽 공간이 충분한지 확인
28
- const spaceBelow = viewportHeight - rect.bottom;
29
- if (spaceBelow < dropdownHeight + offset) {
30
- return {
31
- direction: 'up',
32
- maxHeight
33
- };
34
- }
25
+ const direction = (0, _portalPosition.resolveDropdownDirection)({
26
+ anchorRect: triggerRef.current.getBoundingClientRect(),
27
+ panelHeight: Math.min(maxHeight, itemCount * itemHeight),
28
+ viewportHeight: window.innerHeight,
29
+ gap: offset
30
+ });
35
31
  return {
36
- direction: 'down',
32
+ direction,
37
33
  maxHeight
38
34
  };
39
35
  };
@@ -0,0 +1,227 @@
1
+ "use strict";
2
+
3
+ var _vitest = require("vitest");
4
+ var _ = require("..");
5
+ const makeAnchorRect = function () {
6
+ let overrides = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
7
+ return {
8
+ top: 100,
9
+ bottom: 140,
10
+ left: 50,
11
+ right: 250,
12
+ width: 200,
13
+ ...overrides
14
+ };
15
+ };
16
+ const PANEL_HEIGHT = 300;
17
+ const PANEL_WIDTH = 180;
18
+ const DEFAULT_OFFSET = 4;
19
+ const CUSTOM_OFFSET = 10;
20
+ const DEFAULT_GAP = 20;
21
+ const EXPECTED_Z_INDEX = 1500;
22
+ const VIEWPORT_HEIGHT = 800;
23
+ (0, _vitest.describe)('portalPosition — 모듈 로드 (P-8)', () => {
24
+ (0, _vitest.it)('Node 환경에서 window 없이 import 된다', () => {
25
+ (0, _vitest.expect)(typeof window).toBe('undefined');
26
+ (0, _vitest.expect)(_.computePortalPosition).toBeTypeOf('function');
27
+ (0, _vitest.expect)(_.resolveDropdownDirection).toBeTypeOf('function');
28
+ (0, _vitest.expect)(_.buildPortalPanelStyle).toBeTypeOf('function');
29
+ });
30
+ });
31
+ (0, _vitest.describe)('computePortalPosition — 좌표 공식 (P-1)', () => {
32
+ const anchorRect = makeAnchorRect();
33
+ _vitest.it.each([{
34
+ direction: 'down',
35
+ align: 'left',
36
+ top: 144,
37
+ left: 50
38
+ }, {
39
+ direction: 'down',
40
+ align: 'right',
41
+ top: 144,
42
+ left: 70
43
+ }, {
44
+ direction: 'up',
45
+ align: 'left',
46
+ top: -204,
47
+ left: 50
48
+ }, {
49
+ direction: 'up',
50
+ align: 'right',
51
+ top: -204,
52
+ left: 70
53
+ }])('direction=$direction · align=$align → top=$top · left=$left', _ref => {
54
+ let {
55
+ direction,
56
+ align,
57
+ top,
58
+ left
59
+ } = _ref;
60
+ const position = (0, _.computePortalPosition)({
61
+ anchorRect,
62
+ panelHeight: PANEL_HEIGHT,
63
+ panelWidth: PANEL_WIDTH,
64
+ direction,
65
+ align,
66
+ offset: DEFAULT_OFFSET
67
+ });
68
+ (0, _vitest.expect)(position).toEqual({
69
+ top,
70
+ left
71
+ });
72
+ });
73
+ });
74
+ (0, _vitest.describe)('computePortalPosition — 기본값 (P-2)', () => {
75
+ (0, _vitest.it)('offset 을 생략하면 4px 을 적용한다', () => {
76
+ const anchorRect = makeAnchorRect();
77
+ const position = (0, _.computePortalPosition)({
78
+ anchorRect,
79
+ panelHeight: PANEL_HEIGHT,
80
+ panelWidth: PANEL_WIDTH,
81
+ direction: 'down'
82
+ });
83
+ (0, _vitest.expect)(position.top).toBe(anchorRect.bottom + DEFAULT_OFFSET);
84
+ });
85
+ (0, _vitest.it)('align 을 생략하면 left 정렬로 산출한다', () => {
86
+ const anchorRect = makeAnchorRect();
87
+ const position = (0, _.computePortalPosition)({
88
+ anchorRect,
89
+ panelHeight: PANEL_HEIGHT,
90
+ panelWidth: PANEL_WIDTH,
91
+ direction: 'down'
92
+ });
93
+ (0, _vitest.expect)(position.left).toBe(anchorRect.left);
94
+ });
95
+ (0, _vitest.it)('offset 을 지정하면 그 값으로 간격을 계산한다', () => {
96
+ const anchorRect = makeAnchorRect();
97
+ const down = (0, _.computePortalPosition)({
98
+ anchorRect,
99
+ panelHeight: PANEL_HEIGHT,
100
+ panelWidth: PANEL_WIDTH,
101
+ direction: 'down',
102
+ offset: CUSTOM_OFFSET
103
+ });
104
+ const up = (0, _.computePortalPosition)({
105
+ anchorRect,
106
+ panelHeight: PANEL_HEIGHT,
107
+ panelWidth: PANEL_WIDTH,
108
+ direction: 'up',
109
+ offset: CUSTOM_OFFSET
110
+ });
111
+ (0, _vitest.expect)(down.top).toBe(anchorRect.bottom + CUSTOM_OFFSET);
112
+ (0, _vitest.expect)(up.top).toBe(anchorRect.top - PANEL_HEIGHT - CUSTOM_OFFSET);
113
+ });
114
+ });
115
+ (0, _vitest.describe)('buildPortalPanelStyle — 스타일 조립 (P-3)', () => {
116
+ (0, _vitest.it)('matchAnchorWidth=false 이고 panelWidth 가 숫자면 그 너비로 계산하고 minWidth 를 내지 않는다', () => {
117
+ const anchorRect = makeAnchorRect();
118
+ const style = (0, _.buildPortalPanelStyle)({
119
+ anchorRect,
120
+ panelHeight: PANEL_HEIGHT,
121
+ panelWidth: PANEL_WIDTH,
122
+ direction: 'down',
123
+ align: 'right'
124
+ });
125
+ (0, _vitest.expect)(style.left).toBe(anchorRect.right - PANEL_WIDTH);
126
+ (0, _vitest.expect)(style).not.toHaveProperty('minWidth');
127
+ });
128
+ (0, _vitest.it)('matchAnchorWidth=false 이고 panelWidth 가 null 이면 anchor 너비로 대체하고 minWidth 를 내지 않는다', () => {
129
+ const anchorRect = makeAnchorRect();
130
+ const style = (0, _.buildPortalPanelStyle)({
131
+ anchorRect,
132
+ panelHeight: PANEL_HEIGHT,
133
+ panelWidth: null,
134
+ direction: 'down',
135
+ align: 'right'
136
+ });
137
+ (0, _vitest.expect)(style.left).toBe(anchorRect.right - anchorRect.width);
138
+ (0, _vitest.expect)(style).not.toHaveProperty('minWidth');
139
+ });
140
+ (0, _vitest.it)('matchAnchorWidth=true 이면 panelWidth 를 무시하고 anchor 너비를 minWidth 로 낸다 — align:right 조합에서 콘텐츠가 앵커보다 넓으면 우측 정렬이 보장되지 않는다', () => {
141
+ const anchorRect = makeAnchorRect();
142
+ const style = (0, _.buildPortalPanelStyle)({
143
+ anchorRect,
144
+ panelHeight: PANEL_HEIGHT,
145
+ panelWidth: PANEL_WIDTH,
146
+ direction: 'down',
147
+ align: 'right',
148
+ matchAnchorWidth: true
149
+ });
150
+ (0, _vitest.expect)(style.left).toBe(anchorRect.right - anchorRect.width);
151
+ (0, _vitest.expect)(style.minWidth).toBe(anchorRect.width);
152
+ });
153
+ (0, _vitest.it)('direction=up 이면 panelHeight 만큼 위로 올린 top 을 낸다', () => {
154
+ const anchorRect = makeAnchorRect();
155
+ const style = (0, _.buildPortalPanelStyle)({
156
+ anchorRect,
157
+ panelHeight: PANEL_HEIGHT,
158
+ panelWidth: PANEL_WIDTH,
159
+ direction: 'up'
160
+ });
161
+ (0, _vitest.expect)(style.top).toBe(anchorRect.top - PANEL_HEIGHT - DEFAULT_OFFSET);
162
+ });
163
+ });
164
+ (0, _vitest.describe)('buildPortalPanelStyle — 고정 필드 (P-4)', () => {
165
+ (0, _vitest.it)('position 은 fixed, zIndex 는 PORTAL_PANEL_Z_INDEX(1500) 이다', () => {
166
+ const style = (0, _.buildPortalPanelStyle)({
167
+ anchorRect: makeAnchorRect(),
168
+ panelHeight: PANEL_HEIGHT,
169
+ panelWidth: PANEL_WIDTH,
170
+ direction: 'down'
171
+ });
172
+ (0, _vitest.expect)(_.PORTAL_PANEL_Z_INDEX).toBe(EXPECTED_Z_INDEX);
173
+ (0, _vitest.expect)(style.position).toBe('fixed');
174
+ (0, _vitest.expect)(style.zIndex).toBe(EXPECTED_Z_INDEX);
175
+ });
176
+ (0, _vitest.it)('PortalPanelStyle 은 CSSProperties 에 할당 가능하다 (P-7)', () => {
177
+ (0, _vitest.expectTypeOf)().toExtend();
178
+ });
179
+ });
180
+ (0, _vitest.describe)('resolveDropdownDirection — 경계값 (P-5)', () => {
181
+ _vitest.it.each([{
182
+ spaceBelow: PANEL_HEIGHT + DEFAULT_GAP,
183
+ expected: 'down'
184
+ }, {
185
+ spaceBelow: PANEL_HEIGHT + DEFAULT_GAP - 1,
186
+ expected: 'up'
187
+ }, {
188
+ spaceBelow: PANEL_HEIGHT + DEFAULT_GAP + 1,
189
+ expected: 'down'
190
+ }, {
191
+ spaceBelow: 0,
192
+ expected: 'up'
193
+ }])('spaceBelow=$spaceBelow → $expected', _ref2 => {
194
+ let {
195
+ spaceBelow,
196
+ expected
197
+ } = _ref2;
198
+ const direction = (0, _.resolveDropdownDirection)({
199
+ anchorRect: {
200
+ bottom: VIEWPORT_HEIGHT - spaceBelow
201
+ },
202
+ panelHeight: PANEL_HEIGHT,
203
+ viewportHeight: VIEWPORT_HEIGHT
204
+ });
205
+ (0, _vitest.expect)(direction).toBe(expected);
206
+ });
207
+ (0, _vitest.it)('gap 을 지정하면 그 간격으로 판정한다 (gap=0: 등호는 반전하지 않는다)', () => {
208
+ const exact = (0, _.resolveDropdownDirection)({
209
+ anchorRect: {
210
+ bottom: VIEWPORT_HEIGHT - PANEL_HEIGHT
211
+ },
212
+ panelHeight: PANEL_HEIGHT,
213
+ viewportHeight: VIEWPORT_HEIGHT,
214
+ gap: 0
215
+ });
216
+ const short = (0, _.resolveDropdownDirection)({
217
+ anchorRect: {
218
+ bottom: VIEWPORT_HEIGHT - PANEL_HEIGHT + 1
219
+ },
220
+ panelHeight: PANEL_HEIGHT,
221
+ viewportHeight: VIEWPORT_HEIGHT,
222
+ gap: 0
223
+ });
224
+ (0, _vitest.expect)(exact).toBe('down');
225
+ (0, _vitest.expect)(short).toBe('up');
226
+ });
227
+ });