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