@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,394 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { applyPortalPanelStyle, buildPortalPanelStyle, observeAnchorGeometry, trackPortalPosition } from '..';
4
+ const installResizeObserverStub = () => {
5
+ const instances = [];
6
+ class ResizeObserverStub {
7
+ constructor(_callback) {
8
+ this.observe = vi.fn();
9
+ this.unobserve = vi.fn();
10
+ this.disconnect = vi.fn();
11
+ instances.push(this);
12
+ }
13
+ }
14
+ vi.stubGlobal('ResizeObserver', ResizeObserverStub);
15
+ return {
16
+ instances
17
+ };
18
+ };
19
+ afterEach(() => {
20
+ vi.unstubAllGlobals();
21
+ vi.restoreAllMocks();
22
+ });
23
+ const makeAnchorRect = function () {
24
+ let overrides = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
25
+ return {
26
+ top: 100,
27
+ bottom: 140,
28
+ left: 50,
29
+ right: 250,
30
+ width: 200,
31
+ ...overrides
32
+ };
33
+ };
34
+ const PANEL_HEIGHT = 300;
35
+ const PANEL_WIDTH = 180;
36
+ const DEFAULT_OFFSET = 4;
37
+ const EXPECTED_Z_INDEX = 1500;
38
+ const ON_CHANGE_CALLS = {
39
+ initial: 1,
40
+ afterScroll: 2,
41
+ afterResize: 3
42
+ };
43
+ const toDomRect = rect => DOMRect.fromRect({
44
+ x: rect.left,
45
+ y: rect.top,
46
+ width: rect.width,
47
+ height: rect.bottom - rect.top
48
+ });
49
+ const createAnchor = function () {
50
+ let rect = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : makeAnchorRect();
51
+ const element = document.createElement('button');
52
+ document.body.appendChild(element);
53
+ const boundingRect = vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(toDomRect(rect));
54
+ const moveTo = next => boundingRect.mockReturnValue(toDomRect(next));
55
+ return {
56
+ element,
57
+ moveTo
58
+ };
59
+ };
60
+ const createPanel = function () {
61
+ let {
62
+ height,
63
+ width
64
+ } = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {
65
+ height: PANEL_HEIGHT,
66
+ width: PANEL_WIDTH
67
+ };
68
+ const element = document.createElement('div');
69
+ document.body.appendChild(element);
70
+ Object.defineProperty(element, 'offsetHeight', {
71
+ value: height,
72
+ configurable: true
73
+ });
74
+ Object.defineProperty(element, 'offsetWidth', {
75
+ value: width,
76
+ configurable: true
77
+ });
78
+ return element;
79
+ };
80
+ const dispatchWindow = type => window.dispatchEvent(new Event(type));
81
+ describe('observeAnchorGeometry — 구독 (E-6)', () => {
82
+ it('구독 직후 onChange 를 1회 호출하고 window scroll·resize 마다 다시 호출한다', () => {
83
+ installResizeObserverStub();
84
+ const anchor = createAnchor();
85
+ const onChange = vi.fn();
86
+ const cleanup = observeAnchorGeometry({
87
+ getAnchor: () => anchor.element,
88
+ getPanel: () => null,
89
+ onChange
90
+ });
91
+ expect(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.initial);
92
+ dispatchWindow('scroll');
93
+ expect(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.afterScroll);
94
+ dispatchWindow('resize');
95
+ expect(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.afterResize);
96
+ cleanup();
97
+ });
98
+ it('중첩 컨테이너의 scroll(비버블링) 도 capture 로 포착한다', () => {
99
+ installResizeObserverStub();
100
+ const anchor = createAnchor();
101
+ const container = document.createElement('div');
102
+ document.body.appendChild(container);
103
+ const onChange = vi.fn();
104
+ const cleanup = observeAnchorGeometry({
105
+ getAnchor: () => anchor.element,
106
+ getPanel: () => null,
107
+ onChange
108
+ });
109
+ container.dispatchEvent(new Event('scroll', {
110
+ bubbles: false
111
+ }));
112
+ expect(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.afterScroll);
113
+ cleanup();
114
+ });
115
+ it('anchor 와 panel 각각에 ResizeObserver 를 붙인다', () => {
116
+ const {
117
+ instances
118
+ } = installResizeObserverStub();
119
+ const anchor = createAnchor();
120
+ const panel = createPanel();
121
+ const cleanup = observeAnchorGeometry({
122
+ getAnchor: () => anchor.element,
123
+ getPanel: () => panel,
124
+ onChange: vi.fn()
125
+ });
126
+ expect(instances).toHaveLength(2);
127
+ expect(instances[0]?.observe).toHaveBeenCalledWith(anchor.element);
128
+ expect(instances[1]?.observe).toHaveBeenCalledWith(panel);
129
+ cleanup();
130
+ });
131
+ it('구독 시점에 panel 이 없으면 anchor 옵저버만 만든다', () => {
132
+ const {
133
+ instances
134
+ } = installResizeObserverStub();
135
+ const anchor = createAnchor();
136
+ const cleanup = observeAnchorGeometry({
137
+ getAnchor: () => anchor.element,
138
+ getPanel: () => null,
139
+ onChange: vi.fn()
140
+ });
141
+ expect(instances).toHaveLength(1);
142
+ cleanup();
143
+ });
144
+ it('anchor 가 null 이면 구독하지 않고 no-op cleanup 을 반환한다', () => {
145
+ const {
146
+ instances
147
+ } = installResizeObserverStub();
148
+ const addEventListener = vi.spyOn(window, 'addEventListener');
149
+ const onChange = vi.fn();
150
+ const cleanup = observeAnchorGeometry({
151
+ getAnchor: () => null,
152
+ getPanel: () => null,
153
+ onChange
154
+ });
155
+ expect(onChange).not.toHaveBeenCalled();
156
+ expect(addEventListener).not.toHaveBeenCalled();
157
+ expect(instances).toHaveLength(0);
158
+ expect(() => cleanup()).not.toThrow();
159
+ });
160
+ });
161
+ describe('observeAnchorGeometry — cleanup (E-7)', () => {
162
+ it('cleanup 뒤에는 이벤트가 발생해도 onChange 가 늘지 않고 옵저버가 disconnect 된다', () => {
163
+ const {
164
+ instances
165
+ } = installResizeObserverStub();
166
+ const anchor = createAnchor();
167
+ const panel = createPanel();
168
+ const onChange = vi.fn();
169
+ const cleanup = observeAnchorGeometry({
170
+ getAnchor: () => anchor.element,
171
+ getPanel: () => panel,
172
+ onChange
173
+ });
174
+ cleanup();
175
+ dispatchWindow('scroll');
176
+ dispatchWindow('resize');
177
+ expect(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.initial);
178
+ for (const instance of instances) {
179
+ expect(instance.disconnect).toHaveBeenCalledTimes(1);
180
+ }
181
+ });
182
+ });
183
+ describe('observeAnchorGeometry — ResizeObserver 미정의 환경 (E-8)', () => {
184
+ it('ResizeObserver 가 없어도 예외 없이 window 이벤트만 구독한다', () => {
185
+ expect('ResizeObserver' in globalThis).toBe(false);
186
+ const anchor = createAnchor();
187
+ const onChange = vi.fn();
188
+ let cleanup = () => undefined;
189
+ expect(() => {
190
+ cleanup = observeAnchorGeometry({
191
+ getAnchor: () => anchor.element,
192
+ getPanel: () => null,
193
+ onChange
194
+ });
195
+ }).not.toThrow();
196
+ dispatchWindow('scroll');
197
+ expect(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.afterScroll);
198
+ cleanup();
199
+ });
200
+ });
201
+ describe('trackPortalPosition — 측정·조립 합성', () => {
202
+ it('구독 직후 anchor·panel 실측값으로 조립한 style 을 onUpdate 에 동기 전달한다', () => {
203
+ installResizeObserverStub();
204
+ const rect = makeAnchorRect();
205
+ const anchor = createAnchor(rect);
206
+ const panel = createPanel();
207
+ const onUpdate = vi.fn();
208
+ const cleanup = trackPortalPosition({
209
+ getAnchor: () => anchor.element,
210
+ getPanel: () => panel,
211
+ direction: 'down',
212
+ align: 'right',
213
+ onUpdate
214
+ });
215
+ expect(onUpdate).toHaveBeenCalledTimes(1);
216
+ expect(onUpdate).toHaveBeenLastCalledWith(buildPortalPanelStyle({
217
+ anchorRect: rect,
218
+ panelHeight: PANEL_HEIGHT,
219
+ panelWidth: PANEL_WIDTH,
220
+ direction: 'down',
221
+ align: 'right'
222
+ }));
223
+ cleanup();
224
+ });
225
+ it('panel 이 아직 없으면 panelHeight 0 · panelWidth 는 anchor 너비로 조립한다', () => {
226
+ installResizeObserverStub();
227
+ const rect = makeAnchorRect();
228
+ const anchor = createAnchor(rect);
229
+ const onUpdate = vi.fn();
230
+ const cleanup = trackPortalPosition({
231
+ getAnchor: () => anchor.element,
232
+ getPanel: () => null,
233
+ direction: 'up',
234
+ align: 'right',
235
+ onUpdate
236
+ });
237
+ expect(onUpdate).toHaveBeenLastCalledWith({
238
+ position: 'fixed',
239
+ top: rect.top - DEFAULT_OFFSET,
240
+ left: rect.right - rect.width,
241
+ zIndex: EXPECTED_Z_INDEX
242
+ });
243
+ cleanup();
244
+ });
245
+ it('matchAnchorWidth=true 이면 minWidth 를 anchor 너비로 낸다', () => {
246
+ installResizeObserverStub();
247
+ const rect = makeAnchorRect();
248
+ const anchor = createAnchor(rect);
249
+ const panel = createPanel();
250
+ const onUpdate = vi.fn();
251
+ const cleanup = trackPortalPosition({
252
+ getAnchor: () => anchor.element,
253
+ getPanel: () => panel,
254
+ direction: 'down',
255
+ matchAnchorWidth: true,
256
+ onUpdate
257
+ });
258
+ expect(onUpdate.mock.lastCall?.[0].minWidth).toBe(rect.width);
259
+ cleanup();
260
+ });
261
+ it('scroll 이 발생하면 anchor 를 다시 측정해 갱신된 style 을 전달한다', () => {
262
+ installResizeObserverStub();
263
+ const anchor = createAnchor(makeAnchorRect());
264
+ const panel = createPanel();
265
+ const movedRect = makeAnchorRect({
266
+ top: 60,
267
+ bottom: 100
268
+ });
269
+ const onUpdate = vi.fn();
270
+ const cleanup = trackPortalPosition({
271
+ getAnchor: () => anchor.element,
272
+ getPanel: () => panel,
273
+ direction: 'down',
274
+ onUpdate
275
+ });
276
+ anchor.moveTo(movedRect);
277
+ dispatchWindow('scroll');
278
+ expect(onUpdate).toHaveBeenCalledTimes(2);
279
+ expect(onUpdate.mock.lastCall?.[0].top).toBe(movedRect.bottom + DEFAULT_OFFSET);
280
+ cleanup();
281
+ });
282
+ it('getPanel 이 나중에 요소를 반환하면 다음 갱신에 panelHeight 가 반영된다', () => {
283
+ installResizeObserverStub();
284
+ const rect = makeAnchorRect();
285
+ const anchor = createAnchor(rect);
286
+ const panel = createPanel();
287
+ const mounted = {
288
+ current: null
289
+ };
290
+ const onUpdate = vi.fn();
291
+ const cleanup = trackPortalPosition({
292
+ getAnchor: () => anchor.element,
293
+ getPanel: () => mounted.current,
294
+ direction: 'up',
295
+ onUpdate
296
+ });
297
+ expect(onUpdate.mock.lastCall?.[0].top).toBe(rect.top - DEFAULT_OFFSET);
298
+ mounted.current = panel;
299
+ dispatchWindow('scroll');
300
+ expect(onUpdate.mock.lastCall?.[0].top).toBe(rect.top - PANEL_HEIGHT - DEFAULT_OFFSET);
301
+ cleanup();
302
+ });
303
+ it('anchor 가 null 이면 onUpdate 를 호출하지 않고 리스너도 붙이지 않는다', () => {
304
+ installResizeObserverStub();
305
+ const addEventListener = vi.spyOn(window, 'addEventListener');
306
+ const onUpdate = vi.fn();
307
+ const cleanup = trackPortalPosition({
308
+ getAnchor: () => null,
309
+ getPanel: () => null,
310
+ direction: 'down',
311
+ onUpdate
312
+ });
313
+ expect(onUpdate).not.toHaveBeenCalled();
314
+ expect(addEventListener).not.toHaveBeenCalled();
315
+ expect(() => cleanup()).not.toThrow();
316
+ });
317
+ it('cleanup 뒤에는 scroll 이 발생해도 onUpdate 가 늘지 않는다', () => {
318
+ installResizeObserverStub();
319
+ const anchor = createAnchor();
320
+ const onUpdate = vi.fn();
321
+ const cleanup = trackPortalPosition({
322
+ getAnchor: () => anchor.element,
323
+ getPanel: () => null,
324
+ direction: 'down',
325
+ onUpdate
326
+ });
327
+ cleanup();
328
+ dispatchWindow('scroll');
329
+ expect(onUpdate).toHaveBeenCalledTimes(1);
330
+ });
331
+ });
332
+ describe('trackPortalPosition — direction 게터 (추가형 API)', () => {
333
+ it('direction 에 함수를 넘기면 갱신마다 그 반환값으로 조립한다', () => {
334
+ installResizeObserverStub();
335
+ const anchor = createAnchor();
336
+ const panel = createPanel();
337
+ const onUpdate = vi.fn();
338
+ let direction = 'down';
339
+ trackPortalPosition({
340
+ getAnchor: () => anchor.element,
341
+ getPanel: () => panel,
342
+ direction: () => direction,
343
+ onUpdate
344
+ });
345
+ direction = 'up';
346
+ dispatchWindow('scroll');
347
+ expect(onUpdate.mock.calls[0][0].top).toBe(makeAnchorRect().bottom + DEFAULT_OFFSET);
348
+ expect(onUpdate.mock.calls[1][0].top).toBe(makeAnchorRect().top - PANEL_HEIGHT - DEFAULT_OFFSET);
349
+ });
350
+ });
351
+ describe('applyPortalPanelStyle — vanilla 어댑터 보조', () => {
352
+ it('position·top·left·zIndex 를 px 단위로 element.style 에 적용한다', () => {
353
+ const element = document.createElement('div');
354
+ applyPortalPanelStyle(element, {
355
+ position: 'fixed',
356
+ top: 144,
357
+ left: 50,
358
+ zIndex: EXPECTED_Z_INDEX
359
+ });
360
+ expect(element.style.position).toBe('fixed');
361
+ expect(element.style.top).toBe('144px');
362
+ expect(element.style.left).toBe('50px');
363
+ expect(element.style.zIndex).toBe('1500');
364
+ expect(element.style.minWidth).toBe('');
365
+ });
366
+ it('minWidth 가 있으면 px 단위로 함께 적용한다', () => {
367
+ const element = document.createElement('div');
368
+ applyPortalPanelStyle(element, {
369
+ position: 'fixed',
370
+ top: 144,
371
+ left: 50,
372
+ zIndex: EXPECTED_Z_INDEX,
373
+ minWidth: 200
374
+ });
375
+ expect(element.style.minWidth).toBe('200px');
376
+ });
377
+ it('이전 호출에서 minWidth 가 설정된 요소에 minWidth 없는 스타일을 적용하면 minWidth 를 제거한다', () => {
378
+ const element = document.createElement('div');
379
+ applyPortalPanelStyle(element, {
380
+ position: 'fixed',
381
+ top: 0,
382
+ left: 0,
383
+ zIndex: EXPECTED_Z_INDEX,
384
+ minWidth: 200
385
+ });
386
+ applyPortalPanelStyle(element, {
387
+ position: 'fixed',
388
+ top: 10,
389
+ left: 0,
390
+ zIndex: EXPECTED_Z_INDEX
391
+ });
392
+ expect(element.style.minWidth).toBe('');
393
+ });
394
+ });
@@ -0,0 +1 @@
1
+ export * from './portalPosition';
@@ -0,0 +1,150 @@
1
+ const DEFAULT_OFFSET = 4;
2
+ const DEFAULT_GAP = 20;
3
+ const noop = () => undefined;
4
+ const observeElementResize = (target, onChange) => {
5
+ if (target === null || typeof ResizeObserver === 'undefined') {
6
+ return null;
7
+ }
8
+ const observer = new ResizeObserver(onChange);
9
+ observer.observe(target);
10
+ return observer;
11
+ };
12
+ const measurePortalPanelStyle = _ref => {
13
+ let {
14
+ getAnchor,
15
+ getPanel,
16
+ direction,
17
+ align,
18
+ offset,
19
+ matchAnchorWidth
20
+ } = _ref;
21
+ const anchor = getAnchor();
22
+ if (anchor === null) {
23
+ return null;
24
+ }
25
+ const panel = getPanel();
26
+ return buildPortalPanelStyle({
27
+ anchorRect: anchor.getBoundingClientRect(),
28
+ panelHeight: panel?.offsetHeight ?? 0,
29
+ // matchAnchorWidth 이면 패널 너비를 쓰지 않으므로 offsetWidth 를 읽지 않는다 — 레이아웃 강제 산출 1회 회피
30
+ panelWidth: matchAnchorWidth ? null : panel?.offsetWidth ?? null,
31
+ direction: typeof direction === 'function' ? direction() : direction,
32
+ align,
33
+ offset,
34
+ matchAnchorWidth
35
+ });
36
+ };
37
+ export const PORTAL_PANEL_Z_INDEX = 1500;
38
+ export const computePortalPosition = _ref2 => {
39
+ let {
40
+ anchorRect,
41
+ panelHeight,
42
+ panelWidth,
43
+ direction,
44
+ align = 'left',
45
+ offset = DEFAULT_OFFSET
46
+ } = _ref2;
47
+ return {
48
+ top: direction === 'up' ? anchorRect.top - panelHeight - offset : anchorRect.bottom + offset,
49
+ left: align === 'right' ? anchorRect.right - panelWidth : anchorRect.left
50
+ };
51
+ };
52
+ export const resolveDropdownDirection = _ref3 => {
53
+ let {
54
+ anchorRect,
55
+ panelHeight,
56
+ viewportHeight,
57
+ gap = DEFAULT_GAP
58
+ } = _ref3;
59
+ return viewportHeight - anchorRect.bottom < panelHeight + gap ? 'up' : 'down';
60
+ };
61
+ export const buildPortalPanelStyle = _ref4 => {
62
+ let {
63
+ anchorRect,
64
+ panelHeight,
65
+ panelWidth,
66
+ direction,
67
+ align,
68
+ offset,
69
+ matchAnchorWidth = false
70
+ } = _ref4;
71
+ const effectivePanelWidth = matchAnchorWidth || panelWidth === null ? anchorRect.width : panelWidth;
72
+ const {
73
+ top,
74
+ left
75
+ } = computePortalPosition({
76
+ anchorRect,
77
+ panelHeight,
78
+ panelWidth: effectivePanelWidth,
79
+ direction,
80
+ align,
81
+ offset
82
+ });
83
+ const base = {
84
+ position: 'fixed',
85
+ top,
86
+ left,
87
+ zIndex: PORTAL_PANEL_Z_INDEX
88
+ };
89
+ // width 가 아니라 min-width 인 이유: 패널은 max-content 로 자연 확장하되 앵커보다 좁아지지만 않게 한다.
90
+ // 옵션이 짧으면 앵커 너비, 길면 자연 확장 — Portal 이전 absolute 모드와 동일한 UX.
91
+ return matchAnchorWidth ? {
92
+ ...base,
93
+ minWidth: anchorRect.width
94
+ } : base;
95
+ };
96
+ export const observeAnchorGeometry = _ref5 => {
97
+ let {
98
+ getAnchor,
99
+ getPanel,
100
+ onChange
101
+ } = _ref5;
102
+ const anchor = getAnchor();
103
+ if (anchor === null) {
104
+ return noop;
105
+ }
106
+ onChange();
107
+ window.addEventListener('scroll', onChange, {
108
+ passive: true,
109
+ capture: true
110
+ });
111
+ window.addEventListener('resize', onChange);
112
+ const observers = [observeElementResize(anchor, onChange), observeElementResize(getPanel(), onChange)];
113
+ return () => {
114
+ window.removeEventListener('scroll', onChange, {
115
+ capture: true
116
+ });
117
+ window.removeEventListener('resize', onChange);
118
+ for (const observer of observers) {
119
+ observer?.disconnect();
120
+ }
121
+ };
122
+ };
123
+ export const trackPortalPosition = _ref6 => {
124
+ let {
125
+ onUpdate,
126
+ ...measureOptions
127
+ } = _ref6;
128
+ return observeAnchorGeometry({
129
+ getAnchor: measureOptions.getAnchor,
130
+ getPanel: measureOptions.getPanel,
131
+ onChange: () => {
132
+ const style = measurePortalPanelStyle(measureOptions);
133
+ if (style !== null) {
134
+ onUpdate(style);
135
+ }
136
+ }
137
+ });
138
+ };
139
+ // TODO(#497): vanilla 어댑터 도입 시 소비
140
+ export const applyPortalPanelStyle = (element, style) => {
141
+ element.style.position = style.position;
142
+ element.style.top = `${style.top}px`;
143
+ element.style.left = `${style.left}px`;
144
+ element.style.zIndex = String(style.zIndex);
145
+ if (style.minWidth === undefined) {
146
+ element.style.removeProperty('min-width');
147
+ return;
148
+ }
149
+ element.style.minWidth = `${style.minWidth}px`;
150
+ };
@@ -28,5 +28,5 @@ export declare const getSizes: {
28
28
  readonly featuredIcon: (isMobile: boolean) => "sm" | "md";
29
29
  readonly iconPixel: (isMobile: boolean) => "20" | "16";
30
30
  readonly closeButton: (isMobile: boolean) => "xs" | "sm";
31
- readonly closeButtonSvg: (size: keyof typeof CLOSE_BUTTON_SVG_SIZES) => 16 | 20;
31
+ readonly closeButtonSvg: (size: keyof typeof CLOSE_BUTTON_SVG_SIZES) => 20 | 16;
32
32
  };
@@ -107,7 +107,7 @@ declare const ButtonGroup: {
107
107
  "aria-description"?: string | undefined;
108
108
  "aria-details"?: string | undefined;
109
109
  "aria-disabled"?: (boolean | "true" | "false") | undefined;
110
- "aria-dropeffect"?: "link" | "none" | "copy" | "execute" | "move" | "popup" | undefined;
110
+ "aria-dropeffect"?: "copy" | "link" | "none" | "execute" | "move" | "popup" | undefined;
111
111
  "aria-errormessage"?: string | undefined;
112
112
  "aria-expanded"?: (boolean | "true" | "false") | undefined;
113
113
  "aria-flowto"?: string | undefined;
@@ -34,6 +34,12 @@ interface ComboBoxProps extends Omit<ComponentPropsWithRef<'div'>, 'size' | 'onC
34
34
  * 완료 버튼 클릭 시 호출되는 콜백 — 현재 선택된 값을 인자로 받음 (multiple 모드에서만 트리거)
35
35
  */
36
36
  onComplete?: OptionChangeHandler;
37
+ /**
38
+ * 옵션 패널을 React Portal로 body에 렌더한다. 기본값 false — 명시적으로 켠 경우에만 동작한다.
39
+ * SelectBox·Dropdown 과 달리 FloatingContext.preferPortal 을 따르지 않는다. 컨텍스트를 따르면
40
+ * Table horizontalScroll 안의 기존 ComboBox 가 옵션 지정 없이 Portal 로 전환되기 때문이다.
41
+ */
42
+ usePortal?: boolean;
37
43
  }
38
44
  declare const ComboBox: import("react").ForwardRefExoticComponent<Omit<ComboBoxProps, "ref"> & import("react").RefAttributes<HTMLDivElement>>;
39
45
  export { defaultMaxHeight, ComboBox };
@@ -1,8 +1,11 @@
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';
4
+ import { forwardRef, useImperativeHandle, useRef, useState, } from 'react';
5
+ import { createPortal } from 'react-dom';
5
6
  import { useDropdown } from '../../../hooks/dropdown';
7
+ import { useFloatingPosition } from '../../../hooks/useFloatingPosition';
8
+ import { usePortalState } from '../../../hooks/usePortalState';
6
9
  import { useMultiSelect } from '../../../utils/dropdown/multiSelect';
7
10
  import { Button } from '../../action/button';
8
11
  import { Tag } from '../../feedback-and-status/tag';
@@ -20,10 +23,38 @@ const notifyFormChange = (register, multiple, newValue) => {
20
23
  });
21
24
  }
22
25
  };
23
- 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, ...props }, ref) => {
26
+ /**
27
+ * Portal 모드에서만 패널에 `--portal` 클래스와 fixed 좌표를 얹는다.
28
+ * floatingStyle 이 null 인 초기 레이아웃 직전에는 클래스만 붙지만,
29
+ * useLayoutEffect 가 paint 전에 좌표를 채우므로 빈 fixed 가 노출되지 않는다.
30
+ */
31
+ const resolvePortalPanelBinding = (shouldPortal, floatingStyle) => {
32
+ if (!shouldPortal) {
33
+ return {};
34
+ }
35
+ return { className: 'ncua-select-dropdown--portal', style: floatingStyle ?? undefined };
36
+ };
37
+ /**
38
+ * 패널 노드를 인라인·Portal 두 마운트 지점 중 한 곳에만 넘긴다. 양쪽에 두면 dropdownRef 가
39
+ * 나중 노드로 덮여 외부 클릭 판정과 키보드 스크롤이 유령 노드를 가리키게 된다.
40
+ */
41
+ const resolveDropdownMounts = ({ node, shouldPortal, portalContainer, isOpen, }) => {
42
+ if (!shouldPortal) {
43
+ return { inlineDropdown: node, portaledDropdown: null };
44
+ }
45
+ if (!portalContainer || !isOpen) {
46
+ return { inlineDropdown: null, portaledDropdown: null };
47
+ }
48
+ return { inlineDropdown: null, portaledDropdown: createPortal(node, portalContainer) };
49
+ };
50
+ 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) => {
24
51
  const internalRef = useRef(null);
52
+ // 인라인 패널의 offset parent 가 __content 다. Portal 좌표도 같은 박스를 기준으로 잡아야
53
+ // label·hintText 가 있을 때 두 모드의 패널 위치가 어긋나지 않는다.
54
+ const contentRef = useRef(null);
25
55
  const dropdownRef = useRef(null);
26
56
  const inputRef = useRef(null);
57
+ const { shouldPortal, portalContainer } = usePortalState(usePortal);
27
58
  const [inputValue, setInputValue] = useState(searchValue);
28
59
  const [showAllItems, setShowAllItems] = useState(false);
29
60
  const [selectedTags, setSelectedTags] = useState([]);
@@ -65,6 +96,9 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
65
96
  const { isOpen, direction: dropdownDirection, focusedIndex, toggleDropdown, closeDropdown: dropdownClose, handleKeyDown: dropdownHandleKeyDown, handleOptionSelect: handleDropdownSelect, handleMouseMove, isKeyboardNavigation, } = useDropdown({
66
97
  triggerRef: internalRef,
67
98
  dropdownRef,
99
+ // Portal 모드에서는 hintText 등으로 root 가 content 보다 아래까지 차지할 수 있어
100
+ // internalRef 기준 direction 판정이 잘못될 수 있다. contentRef 로 교정한다.
101
+ positionRef: shouldPortal ? contentRef : undefined,
68
102
  maxHeight,
69
103
  itemCount: optionItems.length,
70
104
  optionItems: optionItems,
@@ -98,6 +132,14 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
98
132
  // 나머지는 useDropdown 훅에서 처리
99
133
  dropdownHandleKeyDown(e);
100
134
  };
135
+ const floatingStyle = useFloatingPosition({
136
+ enabled: shouldPortal,
137
+ isOpen,
138
+ triggerRef: contentRef,
139
+ floatingRef: dropdownRef,
140
+ direction: dropdownDirection,
141
+ matchTriggerWidth: true,
142
+ });
101
143
  // biome-ignore lint/style/noNonNullAssertion: forwardRef 패턴에서 internalRef는 첫 렌더 후 항상 존재
102
144
  useImperativeHandle(ref, () => internalRef.current, []);
103
145
  const trailingElement = {
@@ -150,14 +192,22 @@ const ComboBox = forwardRef(({ placeholder = '검색하세요', id, className, h
150
192
  const showFooter = multiple || showFooterButtons;
151
193
  const hiddenInputValue = multiple ? JSON.stringify(value || []) : String(value || '');
152
194
  const hasTags = selectedTags.length > 0;
195
+ 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] }));
197
+ const { inlineDropdown, portaledDropdown } = resolveDropdownMounts({
198
+ node: selectDropdownNode,
199
+ shouldPortal,
200
+ portalContainer,
201
+ isOpen,
202
+ });
153
203
  return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: internalRef, className: classNames('ncua-combobox', `ncua-combobox--${size}`, {
154
204
  'ncua-combobox--open': isOpen,
155
205
  'ncua-combobox--disabled': disabled,
156
206
  destructive: destructive,
157
- }, className), ...props, children: [_jsxs("div", { 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: () => {
207
+ }, 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: () => {
158
208
  setInputValue('');
159
209
  onSearch?.('');
160
- } }), _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", children: [showNoResults ? _jsx("li", { className: "ncua-select-dropdown__no-results", children: "\uC77C\uCE58\uD558\uB294 \uACB0\uACFC\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." }) : null, children] })] }), register && _jsx("input", { type: "hidden", ...register, value: hiddenInputValue }), hintText && _jsx(HintText, { destructive: destructive, children: hintText })] }), hasTags && (_jsx("div", { className: "ncua-combobox__tags", children: selectedTags.map((tag) => (_jsx(Tag, { text: tag.label, size: "sm", close: true, onButtonClick: () => handleRemoveTag(tag.id) }, tag.id))) }))] }));
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: selectedTags.map((tag) => (_jsx(Tag, { text: tag.label, size: "sm", close: true, onButtonClick: () => handleRemoveTag(tag.id) }, tag.id))) }))] }));
161
211
  });
162
212
  ComboBox.displayName = 'ComboBox';
163
213
  export { defaultMaxHeight, ComboBox };