@ncds/ui-admin 1.8.25-alpha.0 → 1.8.25-alpha.2

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 +2 -2
@@ -0,0 +1,26 @@
1
+ // @vitest-environment node
2
+ import { createElement, createRef } from 'react';
3
+ import { renderToString } from 'react-dom/server';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { useFloatingPosition } from '../useFloatingPosition';
6
+ afterEach(() => {
7
+ vi.restoreAllMocks();
8
+ });
9
+ const FloatingProbe = () => {
10
+ useFloatingPosition({
11
+ enabled: true,
12
+ isOpen: true,
13
+ triggerRef: /*#__PURE__*/createRef(),
14
+ floatingRef: /*#__PURE__*/createRef(),
15
+ direction: 'down'
16
+ });
17
+ return null;
18
+ };
19
+ describe('useFloatingPosition — SSR', () => {
20
+ it('서버 렌더에서 useLayoutEffect 경고를 내지 않는다', () => {
21
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
22
+ renderToString(/*#__PURE__*/createElement(FloatingProbe));
23
+ const layoutEffectWarnings = consoleError.mock.calls.filter(args => args.some(arg => typeof arg === 'string' && arg.includes('useLayoutEffect')));
24
+ expect(layoutEffectWarnings).toHaveLength(0);
25
+ });
26
+ });
@@ -0,0 +1,319 @@
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 { trackPortalPosition } from '../../utils/portal-position';
7
+ import { useFloatingPosition } from '../useFloatingPosition';
8
+ Object.assign(globalThis, {
9
+ IS_REACT_ACT_ENVIRONMENT: true
10
+ });
11
+ const installResizeObserverStub = () => {
12
+ class ResizeObserverStub {
13
+ constructor() {
14
+ this.observe = vi.fn();
15
+ this.unobserve = vi.fn();
16
+ this.disconnect = vi.fn();
17
+ }
18
+ }
19
+ vi.stubGlobal('ResizeObserver', ResizeObserverStub);
20
+ };
21
+ afterEach(() => {
22
+ document.body.innerHTML = '';
23
+ vi.unstubAllGlobals();
24
+ vi.restoreAllMocks();
25
+ });
26
+ const makeAnchorRect = function () {
27
+ let overrides = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
28
+ return {
29
+ top: 100,
30
+ bottom: 140,
31
+ left: 50,
32
+ right: 250,
33
+ width: 200,
34
+ ...overrides
35
+ };
36
+ };
37
+ const PANEL_HEIGHT = 300;
38
+ const PANEL_WIDTH = 180;
39
+ const DEFAULT_OFFSET = 4;
40
+ const CHANGED_OFFSET = 12;
41
+ const EXPECTED_Z_INDEX = 1500;
42
+ const toDomRect = rect => DOMRect.fromRect({
43
+ x: rect.left,
44
+ y: rect.top,
45
+ width: rect.width,
46
+ height: rect.bottom - rect.top
47
+ });
48
+ const createAnchor = function () {
49
+ let rect = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : makeAnchorRect();
50
+ const element = document.createElement('button');
51
+ document.body.appendChild(element);
52
+ vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(toDomRect(rect));
53
+ return element;
54
+ };
55
+ const createPanel = () => {
56
+ const element = document.createElement('div');
57
+ document.body.appendChild(element);
58
+ Object.defineProperty(element, 'offsetHeight', {
59
+ value: PANEL_HEIGHT,
60
+ configurable: true
61
+ });
62
+ Object.defineProperty(element, 'offsetWidth', {
63
+ value: PANEL_WIDTH,
64
+ configurable: true
65
+ });
66
+ return element;
67
+ };
68
+ const makeRef = element => ({
69
+ current: element
70
+ });
71
+ const Probe = _ref => {
72
+ let {
73
+ params,
74
+ record
75
+ } = _ref;
76
+ record(useFloatingPosition(params));
77
+ return null;
78
+ };
79
+ const renderHook = initialParams => {
80
+ const container = document.createElement('div');
81
+ document.body.appendChild(container);
82
+ const root = createRoot(container);
83
+ const record = vi.fn();
84
+ const rerender = params => {
85
+ act(() => {
86
+ root.render(/*#__PURE__*/createElement(Probe, {
87
+ params,
88
+ record
89
+ }));
90
+ });
91
+ };
92
+ rerender(initialParams);
93
+ const unmount = () => {
94
+ act(() => {
95
+ root.unmount();
96
+ });
97
+ container.remove();
98
+ };
99
+ return {
100
+ rerender,
101
+ unmount,
102
+ latest: () => record.mock.lastCall?.[0]
103
+ };
104
+ };
105
+ const baseParams = function () {
106
+ let overrides = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
107
+ return {
108
+ enabled: true,
109
+ isOpen: true,
110
+ triggerRef: makeRef(null),
111
+ floatingRef: makeRef(null),
112
+ direction: 'down',
113
+ ...overrides
114
+ };
115
+ };
116
+ const dispatchScroll = () => {
117
+ act(() => {
118
+ window.dispatchEvent(new Event('scroll'));
119
+ });
120
+ };
121
+ describe('useFloatingPosition ↔ trackPortalPosition 동등성 (E-1)', () => {
122
+ it.each([{
123
+ direction: 'down',
124
+ align: 'left',
125
+ matchTriggerWidth: false
126
+ }, {
127
+ direction: 'down',
128
+ align: 'right',
129
+ matchTriggerWidth: false
130
+ }, {
131
+ direction: 'up',
132
+ align: 'left',
133
+ matchTriggerWidth: true
134
+ }, {
135
+ direction: 'up',
136
+ align: 'right',
137
+ matchTriggerWidth: true
138
+ }])('direction=$direction · align=$align · matchTriggerWidth=$matchTriggerWidth 에서 훅 style 과 onUpdate 인자가 같다', _ref2 => {
139
+ let {
140
+ direction,
141
+ align,
142
+ matchTriggerWidth
143
+ } = _ref2;
144
+ installResizeObserverStub();
145
+ const anchor = createAnchor();
146
+ const panel = createPanel();
147
+ const onUpdate = vi.fn();
148
+ const hook = renderHook(baseParams({
149
+ triggerRef: makeRef(anchor),
150
+ floatingRef: makeRef(panel),
151
+ direction,
152
+ align,
153
+ matchTriggerWidth
154
+ }));
155
+ const cleanup = trackPortalPosition({
156
+ getAnchor: () => anchor,
157
+ getPanel: () => panel,
158
+ direction,
159
+ align,
160
+ matchAnchorWidth: matchTriggerWidth,
161
+ onUpdate
162
+ });
163
+ expect(hook.latest()).toEqual(onUpdate.mock.lastCall?.[0]);
164
+ expect(hook.latest()).toMatchObject({
165
+ position: 'fixed',
166
+ zIndex: EXPECTED_Z_INDEX
167
+ });
168
+ cleanup();
169
+ hook.unmount();
170
+ });
171
+ });
172
+ describe('useFloatingPosition — 비활성 (E-2)', () => {
173
+ it('enabled=false 이면 null 을 반환한다', () => {
174
+ installResizeObserverStub();
175
+ const hook = renderHook(baseParams({
176
+ enabled: false,
177
+ triggerRef: makeRef(createAnchor()),
178
+ floatingRef: makeRef(createPanel())
179
+ }));
180
+ expect(hook.latest()).toBeNull();
181
+ hook.unmount();
182
+ });
183
+ it('isOpen=false 이면 null 을 반환한다', () => {
184
+ installResizeObserverStub();
185
+ const hook = renderHook(baseParams({
186
+ isOpen: false,
187
+ triggerRef: makeRef(createAnchor()),
188
+ floatingRef: makeRef(createPanel())
189
+ }));
190
+ expect(hook.latest()).toBeNull();
191
+ hook.unmount();
192
+ });
193
+ it('열린 뒤 닫히면 style 이 null 로 돌아간다', () => {
194
+ installResizeObserverStub();
195
+ const triggerRef = makeRef(createAnchor());
196
+ const hook = renderHook(baseParams({
197
+ triggerRef
198
+ }));
199
+ expect(hook.latest()).not.toBeNull();
200
+ hook.rerender(baseParams({
201
+ triggerRef,
202
+ isOpen: false
203
+ }));
204
+ expect(hook.latest()).toBeNull();
205
+ hook.unmount();
206
+ });
207
+ });
208
+ describe('useFloatingPosition — 트리거 부재 (E-3)', () => {
209
+ it('triggerRef.current 가 null 이면 scroll·resize 리스너를 붙이지 않고 style 은 null 로 남는다', () => {
210
+ installResizeObserverStub();
211
+ const addEventListener = vi.spyOn(window, 'addEventListener');
212
+ const hook = renderHook(baseParams({
213
+ triggerRef: makeRef(null)
214
+ }));
215
+ const positionListeners = addEventListener.mock.calls.filter(_ref3 => {
216
+ let [type] = _ref3;
217
+ return type === 'scroll' || type === 'resize';
218
+ });
219
+ expect(positionListeners).toHaveLength(0);
220
+ expect(hook.latest()).toBeNull();
221
+ hook.unmount();
222
+ });
223
+ it('산출된 뒤 트리거가 사라지면 이전 style 을 유지한다', () => {
224
+ installResizeObserverStub();
225
+ const triggerRef = makeRef(createAnchor());
226
+ const hook = renderHook(baseParams({
227
+ triggerRef,
228
+ offset: DEFAULT_OFFSET
229
+ }));
230
+ const previous = hook.latest();
231
+ expect(previous).not.toBeNull();
232
+ triggerRef.current = null;
233
+ hook.rerender(baseParams({
234
+ triggerRef,
235
+ offset: CHANGED_OFFSET
236
+ }));
237
+ expect(hook.latest()).toEqual(previous);
238
+ hook.unmount();
239
+ });
240
+ });
241
+ describe('useFloatingPosition — 패널 지연 마운트 (E-4)', () => {
242
+ it('effect 시점에 패널이 없다가 마운트되면 scroll 시 panelHeight 가 반영된다', () => {
243
+ installResizeObserverStub();
244
+ const rect = makeAnchorRect();
245
+ const floatingRef = makeRef(null);
246
+ const hook = renderHook(baseParams({
247
+ triggerRef: makeRef(createAnchor(rect)),
248
+ floatingRef,
249
+ direction: 'up'
250
+ }));
251
+ expect(hook.latest()).toMatchObject({
252
+ top: rect.top - DEFAULT_OFFSET
253
+ });
254
+ floatingRef.current = createPanel();
255
+ dispatchScroll();
256
+ expect(hook.latest()).toMatchObject({
257
+ top: rect.top - PANEL_HEIGHT - DEFAULT_OFFSET
258
+ });
259
+ hook.unmount();
260
+ });
261
+ });
262
+ describe('useFloatingPosition — 프롭 변경 재구독 (E-5)', () => {
263
+ it('align 이 left→right 로 바뀌면 left 를 다시 계산한다', () => {
264
+ installResizeObserverStub();
265
+ const rect = makeAnchorRect();
266
+ const triggerRef = makeRef(createAnchor(rect));
267
+ const floatingRef = makeRef(createPanel());
268
+ const hook = renderHook(baseParams({
269
+ triggerRef,
270
+ floatingRef,
271
+ align: 'left'
272
+ }));
273
+ expect(hook.latest()).toMatchObject({
274
+ left: rect.left
275
+ });
276
+ hook.rerender(baseParams({
277
+ triggerRef,
278
+ floatingRef,
279
+ align: 'right'
280
+ }));
281
+ expect(hook.latest()).toMatchObject({
282
+ left: rect.right - PANEL_WIDTH
283
+ });
284
+ hook.unmount();
285
+ });
286
+ it('offset 이 바뀌면 top 을 다시 계산한다', () => {
287
+ installResizeObserverStub();
288
+ const rect = makeAnchorRect();
289
+ const triggerRef = makeRef(createAnchor(rect));
290
+ const hook = renderHook(baseParams({
291
+ triggerRef,
292
+ offset: DEFAULT_OFFSET
293
+ }));
294
+ expect(hook.latest()).toMatchObject({
295
+ top: rect.bottom + DEFAULT_OFFSET
296
+ });
297
+ hook.rerender(baseParams({
298
+ triggerRef,
299
+ offset: CHANGED_OFFSET
300
+ }));
301
+ expect(hook.latest()).toMatchObject({
302
+ top: rect.bottom + CHANGED_OFFSET
303
+ });
304
+ hook.unmount();
305
+ });
306
+ it('matchTriggerWidth=true 이면 minWidth 를 트리거 너비로 낸다', () => {
307
+ installResizeObserverStub();
308
+ const rect = makeAnchorRect();
309
+ const hook = renderHook(baseParams({
310
+ triggerRef: makeRef(createAnchor(rect)),
311
+ floatingRef: makeRef(createPanel()),
312
+ matchTriggerWidth: true
313
+ }));
314
+ expect(hook.latest()).toMatchObject({
315
+ minWidth: rect.width
316
+ });
317
+ hook.unmount();
318
+ });
319
+ });
@@ -10,6 +10,7 @@ export const useDropdown = config => {
10
10
  const {
11
11
  triggerRef,
12
12
  dropdownRef,
13
+ positionRef,
13
14
  maxHeight,
14
15
  itemCount,
15
16
  optionItems,
@@ -25,7 +26,7 @@ export const useDropdown = config => {
25
26
  direction,
26
27
  updatePosition
27
28
  } = useDropdownPosition({
28
- triggerRef,
29
+ triggerRef: positionRef ?? triggerRef,
29
30
  maxHeight,
30
31
  itemCount
31
32
  });
@@ -1,5 +1,6 @@
1
- import { useLayoutEffect, useState } from 'react';
2
- const FLOATING_Z_INDEX = 1500;
1
+ import { useState } from 'react';
2
+ import { trackPortalPosition } from '../utils/portal-position';
3
+ import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect';
3
4
  export const useFloatingPosition = _ref => {
4
5
  let {
5
6
  enabled,
@@ -7,65 +8,25 @@ export const useFloatingPosition = _ref => {
7
8
  triggerRef,
8
9
  floatingRef,
9
10
  direction,
10
- offset = 4,
11
- align = 'left',
11
+ offset,
12
+ align,
12
13
  matchTriggerWidth = false
13
14
  } = _ref;
14
15
  const [style, setStyle] = useState(null);
15
- useLayoutEffect(() => {
16
+ useIsomorphicLayoutEffect(() => {
16
17
  if (!enabled || !isOpen) {
17
18
  setStyle(null);
18
19
  return;
19
20
  }
20
- const trigger = triggerRef.current;
21
- if (!trigger) return;
22
- const calculatePosition = (trigger, floatingHeight, floatingWidth) => {
23
- const top = direction === 'up' ? trigger.top - floatingHeight - offset : trigger.bottom + offset;
24
- const left = align === 'right' ? trigger.right - floatingWidth : trigger.left;
25
- return {
26
- top,
27
- left
28
- };
29
- };
30
- const update = () => {
31
- const t = triggerRef.current;
32
- const f = floatingRef.current;
33
- if (!t) return;
34
- const r = t.getBoundingClientRect();
35
- const floatingHeight = f?.offsetHeight ?? 0;
36
- const floatingWidth = matchTriggerWidth ? r.width : f?.offsetWidth ?? r.width;
37
- const {
38
- top,
39
- left
40
- } = calculatePosition(r, floatingHeight, floatingWidth);
41
- // matchTriggerWidth=true: width는 max-content(콘텐츠 자연 너비), min-width만 trigger 너비로 보장.
42
- // 옵션이 짧으면 trigger 너비, 길면 자연 확장 — 기존 absolute 모드와 동일한 UX.
43
- setStyle({
44
- position: 'fixed',
45
- top,
46
- left,
47
- zIndex: FLOATING_Z_INDEX,
48
- ...(matchTriggerWidth ? {
49
- minWidth: r.width
50
- } : {})
51
- });
52
- };
53
- update();
54
- window.addEventListener('scroll', update, true);
55
- window.addEventListener('resize', update);
56
- const triggerObserver = new ResizeObserver(update);
57
- triggerObserver.observe(trigger);
58
- let floatingObserver;
59
- if (floatingRef.current) {
60
- floatingObserver = new ResizeObserver(update);
61
- floatingObserver.observe(floatingRef.current);
62
- }
63
- return () => {
64
- window.removeEventListener('scroll', update, true);
65
- window.removeEventListener('resize', update);
66
- triggerObserver.disconnect();
67
- floatingObserver?.disconnect();
68
- };
21
+ return trackPortalPosition({
22
+ getAnchor: () => triggerRef.current,
23
+ getPanel: () => floatingRef.current,
24
+ direction,
25
+ align,
26
+ offset,
27
+ matchAnchorWidth: matchTriggerWidth,
28
+ onUpdate: setStyle
29
+ });
69
30
  }, [enabled, isOpen, direction, offset, align, matchTriggerWidth, triggerRef, floatingRef]);
70
31
  return style;
71
32
  };