@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,181 @@
1
+ import { describe, expect, expectTypeOf, it } from 'vitest';
2
+ import { buildPortalPanelStyle, computePortalPosition, PORTAL_PANEL_Z_INDEX, resolveDropdownDirection, } from '..';
3
+ const makeAnchorRect = (overrides = {}) => ({
4
+ top: 100,
5
+ bottom: 140,
6
+ left: 50,
7
+ right: 250,
8
+ width: 200,
9
+ ...overrides,
10
+ });
11
+ const PANEL_HEIGHT = 300;
12
+ const PANEL_WIDTH = 180;
13
+ const DEFAULT_OFFSET = 4;
14
+ const CUSTOM_OFFSET = 10;
15
+ const DEFAULT_GAP = 20;
16
+ const EXPECTED_Z_INDEX = 1500;
17
+ const VIEWPORT_HEIGHT = 800;
18
+ describe('portalPosition — 모듈 로드 (P-8)', () => {
19
+ it('Node 환경에서 window 없이 import 된다', () => {
20
+ expect(typeof window).toBe('undefined');
21
+ expect(computePortalPosition).toBeTypeOf('function');
22
+ expect(resolveDropdownDirection).toBeTypeOf('function');
23
+ expect(buildPortalPanelStyle).toBeTypeOf('function');
24
+ });
25
+ });
26
+ describe('computePortalPosition — 좌표 공식 (P-1)', () => {
27
+ const anchorRect = makeAnchorRect();
28
+ it.each([
29
+ { direction: 'down', align: 'left', top: 144, left: 50 },
30
+ { direction: 'down', align: 'right', top: 144, left: 70 },
31
+ { direction: 'up', align: 'left', top: -204, left: 50 },
32
+ { direction: 'up', align: 'right', top: -204, left: 70 },
33
+ ])('direction=$direction · align=$align → top=$top · left=$left', ({ direction, align, top, left }) => {
34
+ const position = computePortalPosition({
35
+ anchorRect,
36
+ panelHeight: PANEL_HEIGHT,
37
+ panelWidth: PANEL_WIDTH,
38
+ direction,
39
+ align,
40
+ offset: DEFAULT_OFFSET,
41
+ });
42
+ expect(position).toEqual({ top, left });
43
+ });
44
+ });
45
+ describe('computePortalPosition — 기본값 (P-2)', () => {
46
+ it('offset 을 생략하면 4px 을 적용한다', () => {
47
+ const anchorRect = makeAnchorRect();
48
+ const position = computePortalPosition({
49
+ anchorRect,
50
+ panelHeight: PANEL_HEIGHT,
51
+ panelWidth: PANEL_WIDTH,
52
+ direction: 'down',
53
+ });
54
+ expect(position.top).toBe(anchorRect.bottom + DEFAULT_OFFSET);
55
+ });
56
+ it('align 을 생략하면 left 정렬로 산출한다', () => {
57
+ const anchorRect = makeAnchorRect();
58
+ const position = computePortalPosition({
59
+ anchorRect,
60
+ panelHeight: PANEL_HEIGHT,
61
+ panelWidth: PANEL_WIDTH,
62
+ direction: 'down',
63
+ });
64
+ expect(position.left).toBe(anchorRect.left);
65
+ });
66
+ it('offset 을 지정하면 그 값으로 간격을 계산한다', () => {
67
+ const anchorRect = makeAnchorRect();
68
+ const down = computePortalPosition({
69
+ anchorRect,
70
+ panelHeight: PANEL_HEIGHT,
71
+ panelWidth: PANEL_WIDTH,
72
+ direction: 'down',
73
+ offset: CUSTOM_OFFSET,
74
+ });
75
+ const up = computePortalPosition({
76
+ anchorRect,
77
+ panelHeight: PANEL_HEIGHT,
78
+ panelWidth: PANEL_WIDTH,
79
+ direction: 'up',
80
+ offset: CUSTOM_OFFSET,
81
+ });
82
+ expect(down.top).toBe(anchorRect.bottom + CUSTOM_OFFSET);
83
+ expect(up.top).toBe(anchorRect.top - PANEL_HEIGHT - CUSTOM_OFFSET);
84
+ });
85
+ });
86
+ describe('buildPortalPanelStyle — 스타일 조립 (P-3)', () => {
87
+ it('matchAnchorWidth=false 이고 panelWidth 가 숫자면 그 너비로 계산하고 minWidth 를 내지 않는다', () => {
88
+ const anchorRect = makeAnchorRect();
89
+ const style = buildPortalPanelStyle({
90
+ anchorRect,
91
+ panelHeight: PANEL_HEIGHT,
92
+ panelWidth: PANEL_WIDTH,
93
+ direction: 'down',
94
+ align: 'right',
95
+ });
96
+ expect(style.left).toBe(anchorRect.right - PANEL_WIDTH);
97
+ expect(style).not.toHaveProperty('minWidth');
98
+ });
99
+ it('matchAnchorWidth=false 이고 panelWidth 가 null 이면 anchor 너비로 대체하고 minWidth 를 내지 않는다', () => {
100
+ const anchorRect = makeAnchorRect();
101
+ const style = buildPortalPanelStyle({
102
+ anchorRect,
103
+ panelHeight: PANEL_HEIGHT,
104
+ panelWidth: null,
105
+ direction: 'down',
106
+ align: 'right',
107
+ });
108
+ expect(style.left).toBe(anchorRect.right - anchorRect.width);
109
+ expect(style).not.toHaveProperty('minWidth');
110
+ });
111
+ it('matchAnchorWidth=true 이면 panelWidth 를 무시하고 anchor 너비를 minWidth 로 낸다 — align:right 조합에서 콘텐츠가 앵커보다 넓으면 우측 정렬이 보장되지 않는다', () => {
112
+ const anchorRect = makeAnchorRect();
113
+ const style = buildPortalPanelStyle({
114
+ anchorRect,
115
+ panelHeight: PANEL_HEIGHT,
116
+ panelWidth: PANEL_WIDTH,
117
+ direction: 'down',
118
+ align: 'right',
119
+ matchAnchorWidth: true,
120
+ });
121
+ expect(style.left).toBe(anchorRect.right - anchorRect.width);
122
+ expect(style.minWidth).toBe(anchorRect.width);
123
+ });
124
+ it('direction=up 이면 panelHeight 만큼 위로 올린 top 을 낸다', () => {
125
+ const anchorRect = makeAnchorRect();
126
+ const style = buildPortalPanelStyle({
127
+ anchorRect,
128
+ panelHeight: PANEL_HEIGHT,
129
+ panelWidth: PANEL_WIDTH,
130
+ direction: 'up',
131
+ });
132
+ expect(style.top).toBe(anchorRect.top - PANEL_HEIGHT - DEFAULT_OFFSET);
133
+ });
134
+ });
135
+ describe('buildPortalPanelStyle — 고정 필드 (P-4)', () => {
136
+ it('position 은 fixed, zIndex 는 PORTAL_PANEL_Z_INDEX(1500) 이다', () => {
137
+ const style = buildPortalPanelStyle({
138
+ anchorRect: makeAnchorRect(),
139
+ panelHeight: PANEL_HEIGHT,
140
+ panelWidth: PANEL_WIDTH,
141
+ direction: 'down',
142
+ });
143
+ expect(PORTAL_PANEL_Z_INDEX).toBe(EXPECTED_Z_INDEX);
144
+ expect(style.position).toBe('fixed');
145
+ expect(style.zIndex).toBe(EXPECTED_Z_INDEX);
146
+ });
147
+ it('PortalPanelStyle 은 CSSProperties 에 할당 가능하다 (P-7)', () => {
148
+ expectTypeOf().toExtend();
149
+ });
150
+ });
151
+ describe('resolveDropdownDirection — 경계값 (P-5)', () => {
152
+ it.each([
153
+ { spaceBelow: PANEL_HEIGHT + DEFAULT_GAP, expected: 'down' },
154
+ { spaceBelow: PANEL_HEIGHT + DEFAULT_GAP - 1, expected: 'up' },
155
+ { spaceBelow: PANEL_HEIGHT + DEFAULT_GAP + 1, expected: 'down' },
156
+ { spaceBelow: 0, expected: 'up' },
157
+ ])('spaceBelow=$spaceBelow → $expected', ({ spaceBelow, expected }) => {
158
+ const direction = resolveDropdownDirection({
159
+ anchorRect: { bottom: VIEWPORT_HEIGHT - spaceBelow },
160
+ panelHeight: PANEL_HEIGHT,
161
+ viewportHeight: VIEWPORT_HEIGHT,
162
+ });
163
+ expect(direction).toBe(expected);
164
+ });
165
+ it('gap 을 지정하면 그 간격으로 판정한다 (gap=0: 등호는 반전하지 않는다)', () => {
166
+ const exact = resolveDropdownDirection({
167
+ anchorRect: { bottom: VIEWPORT_HEIGHT - PANEL_HEIGHT },
168
+ panelHeight: PANEL_HEIGHT,
169
+ viewportHeight: VIEWPORT_HEIGHT,
170
+ gap: 0,
171
+ });
172
+ const short = resolveDropdownDirection({
173
+ anchorRect: { bottom: VIEWPORT_HEIGHT - PANEL_HEIGHT + 1 },
174
+ panelHeight: PANEL_HEIGHT,
175
+ viewportHeight: VIEWPORT_HEIGHT,
176
+ gap: 0,
177
+ });
178
+ expect(exact).toBe('down');
179
+ expect(short).toBe('up');
180
+ });
181
+ });
@@ -0,0 +1,301 @@
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 { instances };
16
+ };
17
+ afterEach(() => {
18
+ vi.unstubAllGlobals();
19
+ vi.restoreAllMocks();
20
+ });
21
+ const makeAnchorRect = (overrides = {}) => ({
22
+ top: 100,
23
+ bottom: 140,
24
+ left: 50,
25
+ right: 250,
26
+ width: 200,
27
+ ...overrides,
28
+ });
29
+ const PANEL_HEIGHT = 300;
30
+ const PANEL_WIDTH = 180;
31
+ const DEFAULT_OFFSET = 4;
32
+ const EXPECTED_Z_INDEX = 1500;
33
+ const ON_CHANGE_CALLS = { initial: 1, afterScroll: 2, afterResize: 3 };
34
+ const toDomRect = (rect) => DOMRect.fromRect({ x: rect.left, y: rect.top, width: rect.width, height: rect.bottom - rect.top });
35
+ const createAnchor = (rect = makeAnchorRect()) => {
36
+ const element = document.createElement('button');
37
+ document.body.appendChild(element);
38
+ const boundingRect = vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(toDomRect(rect));
39
+ const moveTo = (next) => boundingRect.mockReturnValue(toDomRect(next));
40
+ return { element, moveTo };
41
+ };
42
+ const createPanel = ({ height, width } = { height: PANEL_HEIGHT, width: PANEL_WIDTH }) => {
43
+ const element = document.createElement('div');
44
+ document.body.appendChild(element);
45
+ Object.defineProperty(element, 'offsetHeight', { value: height, configurable: true });
46
+ Object.defineProperty(element, 'offsetWidth', { value: width, configurable: true });
47
+ return element;
48
+ };
49
+ const dispatchWindow = (type) => window.dispatchEvent(new Event(type));
50
+ describe('observeAnchorGeometry — 구독 (E-6)', () => {
51
+ it('구독 직후 onChange 를 1회 호출하고 window scroll·resize 마다 다시 호출한다', () => {
52
+ installResizeObserverStub();
53
+ const anchor = createAnchor();
54
+ const onChange = vi.fn();
55
+ const cleanup = observeAnchorGeometry({ getAnchor: () => anchor.element, getPanel: () => null, onChange });
56
+ expect(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.initial);
57
+ dispatchWindow('scroll');
58
+ expect(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.afterScroll);
59
+ dispatchWindow('resize');
60
+ expect(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.afterResize);
61
+ cleanup();
62
+ });
63
+ it('중첩 컨테이너의 scroll(비버블링) 도 capture 로 포착한다', () => {
64
+ installResizeObserverStub();
65
+ const anchor = createAnchor();
66
+ const container = document.createElement('div');
67
+ document.body.appendChild(container);
68
+ const onChange = vi.fn();
69
+ const cleanup = observeAnchorGeometry({ getAnchor: () => anchor.element, getPanel: () => null, onChange });
70
+ container.dispatchEvent(new Event('scroll', { bubbles: false }));
71
+ expect(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.afterScroll);
72
+ cleanup();
73
+ });
74
+ it('anchor 와 panel 각각에 ResizeObserver 를 붙인다', () => {
75
+ const { instances } = installResizeObserverStub();
76
+ const anchor = createAnchor();
77
+ const panel = createPanel();
78
+ const cleanup = observeAnchorGeometry({
79
+ getAnchor: () => anchor.element,
80
+ getPanel: () => panel,
81
+ onChange: vi.fn(),
82
+ });
83
+ expect(instances).toHaveLength(2);
84
+ expect(instances[0]?.observe).toHaveBeenCalledWith(anchor.element);
85
+ expect(instances[1]?.observe).toHaveBeenCalledWith(panel);
86
+ cleanup();
87
+ });
88
+ it('구독 시점에 panel 이 없으면 anchor 옵저버만 만든다', () => {
89
+ const { instances } = installResizeObserverStub();
90
+ const anchor = createAnchor();
91
+ const cleanup = observeAnchorGeometry({
92
+ getAnchor: () => anchor.element,
93
+ getPanel: () => null,
94
+ onChange: vi.fn(),
95
+ });
96
+ expect(instances).toHaveLength(1);
97
+ cleanup();
98
+ });
99
+ it('anchor 가 null 이면 구독하지 않고 no-op cleanup 을 반환한다', () => {
100
+ const { instances } = installResizeObserverStub();
101
+ const addEventListener = vi.spyOn(window, 'addEventListener');
102
+ const onChange = vi.fn();
103
+ const cleanup = observeAnchorGeometry({ getAnchor: () => null, getPanel: () => null, onChange });
104
+ expect(onChange).not.toHaveBeenCalled();
105
+ expect(addEventListener).not.toHaveBeenCalled();
106
+ expect(instances).toHaveLength(0);
107
+ expect(() => cleanup()).not.toThrow();
108
+ });
109
+ });
110
+ describe('observeAnchorGeometry — cleanup (E-7)', () => {
111
+ it('cleanup 뒤에는 이벤트가 발생해도 onChange 가 늘지 않고 옵저버가 disconnect 된다', () => {
112
+ const { instances } = installResizeObserverStub();
113
+ const anchor = createAnchor();
114
+ const panel = createPanel();
115
+ const onChange = vi.fn();
116
+ const cleanup = observeAnchorGeometry({ getAnchor: () => anchor.element, getPanel: () => panel, onChange });
117
+ cleanup();
118
+ dispatchWindow('scroll');
119
+ dispatchWindow('resize');
120
+ expect(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.initial);
121
+ for (const instance of instances) {
122
+ expect(instance.disconnect).toHaveBeenCalledTimes(1);
123
+ }
124
+ });
125
+ });
126
+ describe('observeAnchorGeometry — ResizeObserver 미정의 환경 (E-8)', () => {
127
+ it('ResizeObserver 가 없어도 예외 없이 window 이벤트만 구독한다', () => {
128
+ expect('ResizeObserver' in globalThis).toBe(false);
129
+ const anchor = createAnchor();
130
+ const onChange = vi.fn();
131
+ let cleanup = () => undefined;
132
+ expect(() => {
133
+ cleanup = observeAnchorGeometry({ getAnchor: () => anchor.element, getPanel: () => null, onChange });
134
+ }).not.toThrow();
135
+ dispatchWindow('scroll');
136
+ expect(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.afterScroll);
137
+ cleanup();
138
+ });
139
+ });
140
+ describe('trackPortalPosition — 측정·조립 합성', () => {
141
+ it('구독 직후 anchor·panel 실측값으로 조립한 style 을 onUpdate 에 동기 전달한다', () => {
142
+ installResizeObserverStub();
143
+ const rect = makeAnchorRect();
144
+ const anchor = createAnchor(rect);
145
+ const panel = createPanel();
146
+ const onUpdate = vi.fn();
147
+ const cleanup = trackPortalPosition({
148
+ getAnchor: () => anchor.element,
149
+ getPanel: () => panel,
150
+ direction: 'down',
151
+ align: 'right',
152
+ onUpdate,
153
+ });
154
+ expect(onUpdate).toHaveBeenCalledTimes(1);
155
+ expect(onUpdate).toHaveBeenLastCalledWith(buildPortalPanelStyle({
156
+ anchorRect: rect,
157
+ panelHeight: PANEL_HEIGHT,
158
+ panelWidth: PANEL_WIDTH,
159
+ direction: 'down',
160
+ align: 'right',
161
+ }));
162
+ cleanup();
163
+ });
164
+ it('panel 이 아직 없으면 panelHeight 0 · panelWidth 는 anchor 너비로 조립한다', () => {
165
+ installResizeObserverStub();
166
+ const rect = makeAnchorRect();
167
+ const anchor = createAnchor(rect);
168
+ const onUpdate = vi.fn();
169
+ const cleanup = trackPortalPosition({
170
+ getAnchor: () => anchor.element,
171
+ getPanel: () => null,
172
+ direction: 'up',
173
+ align: 'right',
174
+ onUpdate,
175
+ });
176
+ expect(onUpdate).toHaveBeenLastCalledWith({
177
+ position: 'fixed',
178
+ top: rect.top - DEFAULT_OFFSET,
179
+ left: rect.right - rect.width,
180
+ zIndex: EXPECTED_Z_INDEX,
181
+ });
182
+ cleanup();
183
+ });
184
+ it('matchAnchorWidth=true 이면 minWidth 를 anchor 너비로 낸다', () => {
185
+ installResizeObserverStub();
186
+ const rect = makeAnchorRect();
187
+ const anchor = createAnchor(rect);
188
+ const panel = createPanel();
189
+ const onUpdate = vi.fn();
190
+ const cleanup = trackPortalPosition({
191
+ getAnchor: () => anchor.element,
192
+ getPanel: () => panel,
193
+ direction: 'down',
194
+ matchAnchorWidth: true,
195
+ onUpdate,
196
+ });
197
+ expect(onUpdate.mock.lastCall?.[0].minWidth).toBe(rect.width);
198
+ cleanup();
199
+ });
200
+ it('scroll 이 발생하면 anchor 를 다시 측정해 갱신된 style 을 전달한다', () => {
201
+ installResizeObserverStub();
202
+ const anchor = createAnchor(makeAnchorRect());
203
+ const panel = createPanel();
204
+ const movedRect = makeAnchorRect({ top: 60, bottom: 100 });
205
+ const onUpdate = vi.fn();
206
+ const cleanup = trackPortalPosition({
207
+ getAnchor: () => anchor.element,
208
+ getPanel: () => panel,
209
+ direction: 'down',
210
+ onUpdate,
211
+ });
212
+ anchor.moveTo(movedRect);
213
+ dispatchWindow('scroll');
214
+ expect(onUpdate).toHaveBeenCalledTimes(2);
215
+ expect(onUpdate.mock.lastCall?.[0].top).toBe(movedRect.bottom + DEFAULT_OFFSET);
216
+ cleanup();
217
+ });
218
+ it('getPanel 이 나중에 요소를 반환하면 다음 갱신에 panelHeight 가 반영된다', () => {
219
+ installResizeObserverStub();
220
+ const rect = makeAnchorRect();
221
+ const anchor = createAnchor(rect);
222
+ const panel = createPanel();
223
+ const mounted = { current: null };
224
+ const onUpdate = vi.fn();
225
+ const cleanup = trackPortalPosition({
226
+ getAnchor: () => anchor.element,
227
+ getPanel: () => mounted.current,
228
+ direction: 'up',
229
+ onUpdate,
230
+ });
231
+ expect(onUpdate.mock.lastCall?.[0].top).toBe(rect.top - DEFAULT_OFFSET);
232
+ mounted.current = panel;
233
+ dispatchWindow('scroll');
234
+ expect(onUpdate.mock.lastCall?.[0].top).toBe(rect.top - PANEL_HEIGHT - DEFAULT_OFFSET);
235
+ cleanup();
236
+ });
237
+ it('anchor 가 null 이면 onUpdate 를 호출하지 않고 리스너도 붙이지 않는다', () => {
238
+ installResizeObserverStub();
239
+ const addEventListener = vi.spyOn(window, 'addEventListener');
240
+ const onUpdate = vi.fn();
241
+ const cleanup = trackPortalPosition({ getAnchor: () => null, getPanel: () => null, direction: 'down', onUpdate });
242
+ expect(onUpdate).not.toHaveBeenCalled();
243
+ expect(addEventListener).not.toHaveBeenCalled();
244
+ expect(() => cleanup()).not.toThrow();
245
+ });
246
+ it('cleanup 뒤에는 scroll 이 발생해도 onUpdate 가 늘지 않는다', () => {
247
+ installResizeObserverStub();
248
+ const anchor = createAnchor();
249
+ const onUpdate = vi.fn();
250
+ const cleanup = trackPortalPosition({
251
+ getAnchor: () => anchor.element,
252
+ getPanel: () => null,
253
+ direction: 'down',
254
+ onUpdate,
255
+ });
256
+ cleanup();
257
+ dispatchWindow('scroll');
258
+ expect(onUpdate).toHaveBeenCalledTimes(1);
259
+ });
260
+ });
261
+ describe('trackPortalPosition — direction 게터 (추가형 API)', () => {
262
+ it('direction 에 함수를 넘기면 갱신마다 그 반환값으로 조립한다', () => {
263
+ installResizeObserverStub();
264
+ const anchor = createAnchor();
265
+ const panel = createPanel();
266
+ const onUpdate = vi.fn();
267
+ let direction = 'down';
268
+ trackPortalPosition({
269
+ getAnchor: () => anchor.element,
270
+ getPanel: () => panel,
271
+ direction: () => direction,
272
+ onUpdate,
273
+ });
274
+ direction = 'up';
275
+ dispatchWindow('scroll');
276
+ expect(onUpdate.mock.calls[0][0].top).toBe(makeAnchorRect().bottom + DEFAULT_OFFSET);
277
+ expect(onUpdate.mock.calls[1][0].top).toBe(makeAnchorRect().top - PANEL_HEIGHT - DEFAULT_OFFSET);
278
+ });
279
+ });
280
+ describe('applyPortalPanelStyle — vanilla 어댑터 보조', () => {
281
+ it('position·top·left·zIndex 를 px 단위로 element.style 에 적용한다', () => {
282
+ const element = document.createElement('div');
283
+ applyPortalPanelStyle(element, { position: 'fixed', top: 144, left: 50, zIndex: EXPECTED_Z_INDEX });
284
+ expect(element.style.position).toBe('fixed');
285
+ expect(element.style.top).toBe('144px');
286
+ expect(element.style.left).toBe('50px');
287
+ expect(element.style.zIndex).toBe('1500');
288
+ expect(element.style.minWidth).toBe('');
289
+ });
290
+ it('minWidth 가 있으면 px 단위로 함께 적용한다', () => {
291
+ const element = document.createElement('div');
292
+ applyPortalPanelStyle(element, { position: 'fixed', top: 144, left: 50, zIndex: EXPECTED_Z_INDEX, minWidth: 200 });
293
+ expect(element.style.minWidth).toBe('200px');
294
+ });
295
+ it('이전 호출에서 minWidth 가 설정된 요소에 minWidth 없는 스타일을 적용하면 minWidth 를 제거한다', () => {
296
+ const element = document.createElement('div');
297
+ applyPortalPanelStyle(element, { position: 'fixed', top: 0, left: 0, zIndex: EXPECTED_Z_INDEX, minWidth: 200 });
298
+ applyPortalPanelStyle(element, { position: 'fixed', top: 10, left: 0, zIndex: EXPECTED_Z_INDEX });
299
+ expect(element.style.minWidth).toBe('');
300
+ });
301
+ });
@@ -0,0 +1 @@
1
+ export * from './portalPosition';
@@ -0,0 +1 @@
1
+ export * from './portalPosition';
@@ -0,0 +1,67 @@
1
+ export type PortalDirection = 'up' | 'down';
2
+ export type PortalAlign = 'left' | 'right';
3
+ export type AnchorRect = Readonly<Pick<DOMRectReadOnly, 'top' | 'bottom' | 'left' | 'right' | 'width'>>;
4
+ export declare const PORTAL_PANEL_Z_INDEX = 1500;
5
+ export type ComputePortalPositionInput = Readonly<{
6
+ anchorRect: AnchorRect;
7
+ panelHeight: number;
8
+ panelWidth: number;
9
+ direction: PortalDirection;
10
+ align?: PortalAlign;
11
+ offset?: number;
12
+ }>;
13
+ export type PortalPosition = Readonly<{
14
+ top: number;
15
+ left: number;
16
+ }>;
17
+ export declare const computePortalPosition: ({ anchorRect, panelHeight, panelWidth, direction, align, offset, }: ComputePortalPositionInput) => PortalPosition;
18
+ export type ResolveDropdownDirectionInput = Readonly<{
19
+ anchorRect: Pick<AnchorRect, 'bottom'>;
20
+ panelHeight: number;
21
+ viewportHeight: number;
22
+ gap?: number;
23
+ }>;
24
+ export declare const resolveDropdownDirection: ({ anchorRect, panelHeight, viewportHeight, gap, }: ResolveDropdownDirectionInput) => PortalDirection;
25
+ export type BuildPortalPanelStyleInput = Readonly<{
26
+ anchorRect: AnchorRect;
27
+ panelHeight: number;
28
+ panelWidth: number | null;
29
+ direction: PortalDirection;
30
+ align?: PortalAlign;
31
+ offset?: number;
32
+ /**
33
+ * true 이면 panelWidth 를 무시하고 anchorRect.width 로 minWidth 를 설정한다.
34
+ * align: 'right' 와 조합 시 콘텐츠가 앵커보다 넓으면 우측 정렬이 보장되지 않는다.
35
+ */
36
+ matchAnchorWidth?: boolean;
37
+ }>;
38
+ export type PortalPanelStyle = Readonly<{
39
+ position: 'fixed';
40
+ top: number;
41
+ left: number;
42
+ zIndex: number;
43
+ minWidth?: number;
44
+ }>;
45
+ export declare const buildPortalPanelStyle: ({ anchorRect, panelHeight, panelWidth, direction, align, offset, matchAnchorWidth, }: BuildPortalPanelStyleInput) => PortalPanelStyle;
46
+ export type AnchorGeometryObserverOptions = Readonly<{
47
+ getAnchor: () => HTMLElement | null;
48
+ /**
49
+ * 구독 시점에 1회 호출되어 ResizeObserver 가 부착된다.
50
+ * 구독 후 패널이 교체·마운트되어도 옵저버는 갱신되지 않는다.
51
+ */
52
+ getPanel: () => HTMLElement | null;
53
+ onChange: () => void;
54
+ }>;
55
+ export declare const observeAnchorGeometry: ({ getAnchor, getPanel, onChange, }: AnchorGeometryObserverOptions) => (() => void);
56
+ export type TrackPortalPositionOptions = Readonly<{
57
+ getAnchor: () => HTMLElement | null;
58
+ getPanel: () => HTMLElement | null;
59
+ /** 함수를 넘기면 갱신마다 다시 읽는다 — 열린 뒤 패널 높이가 변하는 소비자가 방향을 재판정할 수 있다 */
60
+ direction: PortalDirection | (() => PortalDirection);
61
+ align?: PortalAlign;
62
+ offset?: number;
63
+ matchAnchorWidth?: boolean;
64
+ onUpdate: (style: PortalPanelStyle) => void;
65
+ }>;
66
+ export declare const trackPortalPosition: ({ onUpdate, ...measureOptions }: TrackPortalPositionOptions) => (() => void);
67
+ export declare const applyPortalPanelStyle: (element: HTMLElement, style: PortalPanelStyle) => void;
@@ -0,0 +1,88 @@
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 = ({ getAnchor, getPanel, direction, align, offset, matchAnchorWidth, }) => {
13
+ const anchor = getAnchor();
14
+ if (anchor === null) {
15
+ return null;
16
+ }
17
+ const panel = getPanel();
18
+ return buildPortalPanelStyle({
19
+ anchorRect: anchor.getBoundingClientRect(),
20
+ panelHeight: panel?.offsetHeight ?? 0,
21
+ // matchAnchorWidth 이면 패널 너비를 쓰지 않으므로 offsetWidth 를 읽지 않는다 — 레이아웃 강제 산출 1회 회피
22
+ panelWidth: matchAnchorWidth ? null : (panel?.offsetWidth ?? null),
23
+ direction: typeof direction === 'function' ? direction() : direction,
24
+ align,
25
+ offset,
26
+ matchAnchorWidth,
27
+ });
28
+ };
29
+ export const PORTAL_PANEL_Z_INDEX = 1500;
30
+ export const computePortalPosition = ({ anchorRect, panelHeight, panelWidth, direction, align = 'left', offset = DEFAULT_OFFSET, }) => ({
31
+ top: direction === 'up' ? anchorRect.top - panelHeight - offset : anchorRect.bottom + offset,
32
+ left: align === 'right' ? anchorRect.right - panelWidth : anchorRect.left,
33
+ });
34
+ export const resolveDropdownDirection = ({ anchorRect, panelHeight, viewportHeight, gap = DEFAULT_GAP, }) => viewportHeight - anchorRect.bottom < panelHeight + gap ? 'up' : 'down';
35
+ export const buildPortalPanelStyle = ({ anchorRect, panelHeight, panelWidth, direction, align, offset, matchAnchorWidth = false, }) => {
36
+ const effectivePanelWidth = matchAnchorWidth || panelWidth === null ? anchorRect.width : panelWidth;
37
+ const { top, left } = computePortalPosition({
38
+ anchorRect,
39
+ panelHeight,
40
+ panelWidth: effectivePanelWidth,
41
+ direction,
42
+ align,
43
+ offset,
44
+ });
45
+ const base = { position: 'fixed', top, left, zIndex: PORTAL_PANEL_Z_INDEX };
46
+ // width 가 아니라 min-width 인 이유: 패널은 max-content 로 자연 확장하되 앵커보다 좁아지지만 않게 한다.
47
+ // 옵션이 짧으면 앵커 너비, 길면 자연 확장 — Portal 이전 absolute 모드와 동일한 UX.
48
+ return matchAnchorWidth ? { ...base, minWidth: anchorRect.width } : base;
49
+ };
50
+ export const observeAnchorGeometry = ({ getAnchor, getPanel, onChange, }) => {
51
+ const anchor = getAnchor();
52
+ if (anchor === null) {
53
+ return noop;
54
+ }
55
+ onChange();
56
+ window.addEventListener('scroll', onChange, { passive: true, capture: true });
57
+ window.addEventListener('resize', onChange);
58
+ const observers = [observeElementResize(anchor, onChange), observeElementResize(getPanel(), onChange)];
59
+ return () => {
60
+ window.removeEventListener('scroll', onChange, { capture: true });
61
+ window.removeEventListener('resize', onChange);
62
+ for (const observer of observers) {
63
+ observer?.disconnect();
64
+ }
65
+ };
66
+ };
67
+ export const trackPortalPosition = ({ onUpdate, ...measureOptions }) => observeAnchorGeometry({
68
+ getAnchor: measureOptions.getAnchor,
69
+ getPanel: measureOptions.getPanel,
70
+ onChange: () => {
71
+ const style = measurePortalPanelStyle(measureOptions);
72
+ if (style !== null) {
73
+ onUpdate(style);
74
+ }
75
+ },
76
+ });
77
+ // TODO(#497): vanilla 어댑터 도입 시 소비
78
+ export const applyPortalPanelStyle = (element, style) => {
79
+ element.style.position = style.position;
80
+ element.style.top = `${style.top}px`;
81
+ element.style.left = `${style.left}px`;
82
+ element.style.zIndex = String(style.zIndex);
83
+ if (style.minWidth === undefined) {
84
+ element.style.removeProperty('min-width');
85
+ return;
86
+ }
87
+ element.style.minWidth = `${style.minWidth}px`;
88
+ };
@@ -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;
@@ -16,4 +16,4 @@ type BadgeProps = {
16
16
  };
17
17
  declare const Badge: ({ label, type, color, className, leadingIcon, trailingIcon, size, }: BadgeProps) => import("react/jsx-runtime").JSX.Element;
18
18
  export { Badge };
19
- export type { BadgeType, BadgeColor, BadgeSize, BadgeProps };
19
+ export type { BadgeColor, BadgeProps, BadgeSize, BadgeType };