@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.
- package/dist/cjs/src/components/forms-and-input/combo-box/ComboBox.js +109 -28
- package/dist/cjs/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.js +581 -0
- package/dist/cjs/src/generated/scoped-css.js +1 -1
- package/dist/cjs/src/hooks/__tests__/useFloatingPosition.ssr.test.js +29 -0
- package/dist/cjs/src/hooks/__tests__/useFloatingPosition.test.js +322 -0
- package/dist/cjs/src/hooks/dropdown/useDropdown.js +2 -1
- package/dist/cjs/src/hooks/useFloatingPosition.js +14 -53
- package/dist/cjs/src/utils/dropdown/__tests__/dropdownUtils.test.js +316 -0
- package/dist/cjs/src/utils/dropdown/dropdownUtils.js +8 -12
- package/dist/cjs/src/utils/portal-position/__tests__/portalPosition.test.js +227 -0
- package/dist/cjs/src/utils/portal-position/__tests__/portalPosition.tracking.test.js +397 -0
- package/dist/cjs/src/utils/portal-position/index.js +16 -0
- package/dist/cjs/src/utils/portal-position/portalPosition.js +162 -0
- package/dist/esm/src/components/forms-and-input/combo-box/ComboBox.js +109 -28
- package/dist/esm/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.js +578 -0
- package/dist/esm/src/generated/scoped-css.js +1 -1
- package/dist/esm/src/hooks/__tests__/useFloatingPosition.ssr.test.js +26 -0
- package/dist/esm/src/hooks/__tests__/useFloatingPosition.test.js +319 -0
- package/dist/esm/src/hooks/dropdown/useDropdown.js +2 -1
- package/dist/esm/src/hooks/useFloatingPosition.js +15 -54
- package/dist/esm/src/utils/dropdown/__tests__/dropdownUtils.test.js +314 -0
- package/dist/esm/src/utils/dropdown/dropdownUtils.js +8 -12
- package/dist/esm/src/utils/portal-position/__tests__/portalPosition.test.js +225 -0
- package/dist/esm/src/utils/portal-position/__tests__/portalPosition.tracking.test.js +394 -0
- package/dist/esm/src/utils/portal-position/index.js +1 -0
- package/dist/esm/src/utils/portal-position/portalPosition.js +150 -0
- package/dist/temp/assets/scripts/notification/const/sizes.d.ts +1 -1
- package/dist/temp/src/components/action/button-group/ButtonGroup.d.ts +1 -1
- package/dist/temp/src/components/forms-and-input/combo-box/ComboBox.d.ts +6 -0
- package/dist/temp/src/components/forms-and-input/combo-box/ComboBox.js +54 -4
- package/dist/temp/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.d.ts +1 -0
- package/dist/temp/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.js +418 -0
- package/dist/temp/src/generated/scoped-css.js +1 -1
- package/dist/temp/src/hooks/__tests__/useFloatingPosition.ssr.test.d.ts +1 -0
- package/dist/temp/src/hooks/__tests__/useFloatingPosition.ssr.test.js +26 -0
- package/dist/temp/src/hooks/__tests__/useFloatingPosition.test.d.ts +1 -0
- package/dist/temp/src/hooks/__tests__/useFloatingPosition.test.js +205 -0
- package/dist/temp/src/hooks/dropdown/useDropdown.d.ts +3 -1
- package/dist/temp/src/hooks/dropdown/useDropdown.js +2 -2
- package/dist/temp/src/hooks/dropdown/useDropdownPosition.d.ts +3 -3
- package/dist/temp/src/hooks/useFloatingPosition.d.ts +6 -4
- package/dist/temp/src/hooks/useFloatingPosition.js +14 -47
- package/dist/temp/src/types/dropdown/dropdown.d.ts +2 -1
- package/dist/temp/src/utils/dropdown/__tests__/dropdownUtils.test.d.ts +1 -0
- package/dist/temp/src/utils/dropdown/__tests__/dropdownUtils.test.js +174 -0
- package/dist/temp/src/utils/dropdown/dropdownUtils.js +8 -9
- package/dist/temp/src/utils/portal-position/__tests__/portalPosition.test.d.ts +1 -0
- package/dist/temp/src/utils/portal-position/__tests__/portalPosition.test.js +181 -0
- package/dist/temp/src/utils/portal-position/__tests__/portalPosition.tracking.test.d.ts +1 -0
- package/dist/temp/src/utils/portal-position/__tests__/portalPosition.tracking.test.js +301 -0
- package/dist/temp/src/utils/portal-position/index.d.ts +1 -0
- package/dist/temp/src/utils/portal-position/index.js +1 -0
- package/dist/temp/src/utils/portal-position/portalPosition.d.ts +67 -0
- package/dist/temp/src/utils/portal-position/portalPosition.js +88 -0
- package/dist/types/assets/scripts/notification/const/sizes.d.ts +1 -1
- package/dist/types/src/components/action/button-group/ButtonGroup.d.ts +1 -1
- package/dist/types/src/components/forms-and-input/combo-box/ComboBox.d.ts +6 -0
- package/dist/types/src/components/forms-and-input/combo-box/__tests__/ComboBox.portal.test.d.ts +1 -0
- package/dist/types/src/hooks/__tests__/useFloatingPosition.ssr.test.d.ts +1 -0
- package/dist/types/src/hooks/__tests__/useFloatingPosition.test.d.ts +1 -0
- package/dist/types/src/hooks/dropdown/useDropdown.d.ts +3 -1
- package/dist/types/src/hooks/dropdown/useDropdownPosition.d.ts +3 -3
- package/dist/types/src/hooks/useFloatingPosition.d.ts +6 -4
- package/dist/types/src/types/dropdown/dropdown.d.ts +2 -1
- package/dist/types/src/utils/dropdown/__tests__/dropdownUtils.test.d.ts +1 -0
- package/dist/types/src/utils/portal-position/__tests__/portalPosition.test.d.ts +1 -0
- package/dist/types/src/utils/portal-position/__tests__/portalPosition.tracking.test.d.ts +1 -0
- package/dist/types/src/utils/portal-position/index.d.ts +1 -0
- package/dist/types/src/utils/portal-position/portalPosition.d.ts +67 -0
- package/dist/ui-admin/assets/styles/style.css +13 -1
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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: createRef(),
|
|
14
|
+
floatingRef: 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(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 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,205 @@
|
|
|
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, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
9
|
+
const installResizeObserverStub = () => {
|
|
10
|
+
class ResizeObserverStub {
|
|
11
|
+
constructor() {
|
|
12
|
+
this.observe = vi.fn();
|
|
13
|
+
this.unobserve = vi.fn();
|
|
14
|
+
this.disconnect = vi.fn();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
vi.stubGlobal('ResizeObserver', ResizeObserverStub);
|
|
18
|
+
};
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
document.body.innerHTML = '';
|
|
21
|
+
vi.unstubAllGlobals();
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
});
|
|
24
|
+
const makeAnchorRect = (overrides = {}) => ({
|
|
25
|
+
top: 100,
|
|
26
|
+
bottom: 140,
|
|
27
|
+
left: 50,
|
|
28
|
+
right: 250,
|
|
29
|
+
width: 200,
|
|
30
|
+
...overrides,
|
|
31
|
+
});
|
|
32
|
+
const PANEL_HEIGHT = 300;
|
|
33
|
+
const PANEL_WIDTH = 180;
|
|
34
|
+
const DEFAULT_OFFSET = 4;
|
|
35
|
+
const CHANGED_OFFSET = 12;
|
|
36
|
+
const EXPECTED_Z_INDEX = 1500;
|
|
37
|
+
const toDomRect = (rect) => DOMRect.fromRect({ x: rect.left, y: rect.top, width: rect.width, height: rect.bottom - rect.top });
|
|
38
|
+
const createAnchor = (rect = makeAnchorRect()) => {
|
|
39
|
+
const element = document.createElement('button');
|
|
40
|
+
document.body.appendChild(element);
|
|
41
|
+
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(toDomRect(rect));
|
|
42
|
+
return element;
|
|
43
|
+
};
|
|
44
|
+
const createPanel = () => {
|
|
45
|
+
const element = document.createElement('div');
|
|
46
|
+
document.body.appendChild(element);
|
|
47
|
+
Object.defineProperty(element, 'offsetHeight', { value: PANEL_HEIGHT, configurable: true });
|
|
48
|
+
Object.defineProperty(element, 'offsetWidth', { value: PANEL_WIDTH, configurable: true });
|
|
49
|
+
return element;
|
|
50
|
+
};
|
|
51
|
+
const makeRef = (element) => ({ current: element });
|
|
52
|
+
const Probe = ({ params, record }) => {
|
|
53
|
+
record(useFloatingPosition(params));
|
|
54
|
+
return null;
|
|
55
|
+
};
|
|
56
|
+
const renderHook = (initialParams) => {
|
|
57
|
+
const container = document.createElement('div');
|
|
58
|
+
document.body.appendChild(container);
|
|
59
|
+
const root = createRoot(container);
|
|
60
|
+
const record = vi.fn();
|
|
61
|
+
const rerender = (params) => {
|
|
62
|
+
act(() => {
|
|
63
|
+
root.render(createElement(Probe, { params, record }));
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
rerender(initialParams);
|
|
67
|
+
const unmount = () => {
|
|
68
|
+
act(() => {
|
|
69
|
+
root.unmount();
|
|
70
|
+
});
|
|
71
|
+
container.remove();
|
|
72
|
+
};
|
|
73
|
+
return { rerender, unmount, latest: () => record.mock.lastCall?.[0] };
|
|
74
|
+
};
|
|
75
|
+
const baseParams = (overrides = {}) => ({
|
|
76
|
+
enabled: true,
|
|
77
|
+
isOpen: true,
|
|
78
|
+
triggerRef: makeRef(null),
|
|
79
|
+
floatingRef: makeRef(null),
|
|
80
|
+
direction: 'down',
|
|
81
|
+
...overrides,
|
|
82
|
+
});
|
|
83
|
+
const dispatchScroll = () => {
|
|
84
|
+
act(() => {
|
|
85
|
+
window.dispatchEvent(new Event('scroll'));
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
describe('useFloatingPosition ↔ trackPortalPosition 동등성 (E-1)', () => {
|
|
89
|
+
it.each([
|
|
90
|
+
{ direction: 'down', align: 'left', matchTriggerWidth: false },
|
|
91
|
+
{ direction: 'down', align: 'right', matchTriggerWidth: false },
|
|
92
|
+
{ direction: 'up', align: 'left', matchTriggerWidth: true },
|
|
93
|
+
{ direction: 'up', align: 'right', matchTriggerWidth: true },
|
|
94
|
+
])('direction=$direction · align=$align · matchTriggerWidth=$matchTriggerWidth 에서 훅 style 과 onUpdate 인자가 같다', ({ direction, align, matchTriggerWidth }) => {
|
|
95
|
+
installResizeObserverStub();
|
|
96
|
+
const anchor = createAnchor();
|
|
97
|
+
const panel = createPanel();
|
|
98
|
+
const onUpdate = vi.fn();
|
|
99
|
+
const hook = renderHook(baseParams({ triggerRef: makeRef(anchor), floatingRef: makeRef(panel), direction, align, matchTriggerWidth }));
|
|
100
|
+
const cleanup = trackPortalPosition({
|
|
101
|
+
getAnchor: () => anchor,
|
|
102
|
+
getPanel: () => panel,
|
|
103
|
+
direction,
|
|
104
|
+
align,
|
|
105
|
+
matchAnchorWidth: matchTriggerWidth,
|
|
106
|
+
onUpdate,
|
|
107
|
+
});
|
|
108
|
+
expect(hook.latest()).toEqual(onUpdate.mock.lastCall?.[0]);
|
|
109
|
+
expect(hook.latest()).toMatchObject({ position: 'fixed', zIndex: EXPECTED_Z_INDEX });
|
|
110
|
+
cleanup();
|
|
111
|
+
hook.unmount();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
describe('useFloatingPosition — 비활성 (E-2)', () => {
|
|
115
|
+
it('enabled=false 이면 null 을 반환한다', () => {
|
|
116
|
+
installResizeObserverStub();
|
|
117
|
+
const hook = renderHook(baseParams({ enabled: false, triggerRef: makeRef(createAnchor()), floatingRef: makeRef(createPanel()) }));
|
|
118
|
+
expect(hook.latest()).toBeNull();
|
|
119
|
+
hook.unmount();
|
|
120
|
+
});
|
|
121
|
+
it('isOpen=false 이면 null 을 반환한다', () => {
|
|
122
|
+
installResizeObserverStub();
|
|
123
|
+
const hook = renderHook(baseParams({ isOpen: false, triggerRef: makeRef(createAnchor()), floatingRef: makeRef(createPanel()) }));
|
|
124
|
+
expect(hook.latest()).toBeNull();
|
|
125
|
+
hook.unmount();
|
|
126
|
+
});
|
|
127
|
+
it('열린 뒤 닫히면 style 이 null 로 돌아간다', () => {
|
|
128
|
+
installResizeObserverStub();
|
|
129
|
+
const triggerRef = makeRef(createAnchor());
|
|
130
|
+
const hook = renderHook(baseParams({ triggerRef }));
|
|
131
|
+
expect(hook.latest()).not.toBeNull();
|
|
132
|
+
hook.rerender(baseParams({ triggerRef, isOpen: false }));
|
|
133
|
+
expect(hook.latest()).toBeNull();
|
|
134
|
+
hook.unmount();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
describe('useFloatingPosition — 트리거 부재 (E-3)', () => {
|
|
138
|
+
it('triggerRef.current 가 null 이면 scroll·resize 리스너를 붙이지 않고 style 은 null 로 남는다', () => {
|
|
139
|
+
installResizeObserverStub();
|
|
140
|
+
const addEventListener = vi.spyOn(window, 'addEventListener');
|
|
141
|
+
const hook = renderHook(baseParams({ triggerRef: makeRef(null) }));
|
|
142
|
+
const positionListeners = addEventListener.mock.calls.filter(([type]) => type === 'scroll' || type === 'resize');
|
|
143
|
+
expect(positionListeners).toHaveLength(0);
|
|
144
|
+
expect(hook.latest()).toBeNull();
|
|
145
|
+
hook.unmount();
|
|
146
|
+
});
|
|
147
|
+
it('산출된 뒤 트리거가 사라지면 이전 style 을 유지한다', () => {
|
|
148
|
+
installResizeObserverStub();
|
|
149
|
+
const triggerRef = makeRef(createAnchor());
|
|
150
|
+
const hook = renderHook(baseParams({ triggerRef, offset: DEFAULT_OFFSET }));
|
|
151
|
+
const previous = hook.latest();
|
|
152
|
+
expect(previous).not.toBeNull();
|
|
153
|
+
triggerRef.current = null;
|
|
154
|
+
hook.rerender(baseParams({ triggerRef, offset: CHANGED_OFFSET }));
|
|
155
|
+
expect(hook.latest()).toEqual(previous);
|
|
156
|
+
hook.unmount();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
describe('useFloatingPosition — 패널 지연 마운트 (E-4)', () => {
|
|
160
|
+
it('effect 시점에 패널이 없다가 마운트되면 scroll 시 panelHeight 가 반영된다', () => {
|
|
161
|
+
installResizeObserverStub();
|
|
162
|
+
const rect = makeAnchorRect();
|
|
163
|
+
const floatingRef = makeRef(null);
|
|
164
|
+
const hook = renderHook(baseParams({ triggerRef: makeRef(createAnchor(rect)), floatingRef, direction: 'up' }));
|
|
165
|
+
expect(hook.latest()).toMatchObject({ top: rect.top - DEFAULT_OFFSET });
|
|
166
|
+
floatingRef.current = createPanel();
|
|
167
|
+
dispatchScroll();
|
|
168
|
+
expect(hook.latest()).toMatchObject({ top: rect.top - PANEL_HEIGHT - DEFAULT_OFFSET });
|
|
169
|
+
hook.unmount();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
describe('useFloatingPosition — 프롭 변경 재구독 (E-5)', () => {
|
|
173
|
+
it('align 이 left→right 로 바뀌면 left 를 다시 계산한다', () => {
|
|
174
|
+
installResizeObserverStub();
|
|
175
|
+
const rect = makeAnchorRect();
|
|
176
|
+
const triggerRef = makeRef(createAnchor(rect));
|
|
177
|
+
const floatingRef = makeRef(createPanel());
|
|
178
|
+
const hook = renderHook(baseParams({ triggerRef, floatingRef, align: 'left' }));
|
|
179
|
+
expect(hook.latest()).toMatchObject({ left: rect.left });
|
|
180
|
+
hook.rerender(baseParams({ triggerRef, floatingRef, align: 'right' }));
|
|
181
|
+
expect(hook.latest()).toMatchObject({ left: rect.right - PANEL_WIDTH });
|
|
182
|
+
hook.unmount();
|
|
183
|
+
});
|
|
184
|
+
it('offset 이 바뀌면 top 을 다시 계산한다', () => {
|
|
185
|
+
installResizeObserverStub();
|
|
186
|
+
const rect = makeAnchorRect();
|
|
187
|
+
const triggerRef = makeRef(createAnchor(rect));
|
|
188
|
+
const hook = renderHook(baseParams({ triggerRef, offset: DEFAULT_OFFSET }));
|
|
189
|
+
expect(hook.latest()).toMatchObject({ top: rect.bottom + DEFAULT_OFFSET });
|
|
190
|
+
hook.rerender(baseParams({ triggerRef, offset: CHANGED_OFFSET }));
|
|
191
|
+
expect(hook.latest()).toMatchObject({ top: rect.bottom + CHANGED_OFFSET });
|
|
192
|
+
hook.unmount();
|
|
193
|
+
});
|
|
194
|
+
it('matchTriggerWidth=true 이면 minWidth 를 트리거 너비로 낸다', () => {
|
|
195
|
+
installResizeObserverStub();
|
|
196
|
+
const rect = makeAnchorRect();
|
|
197
|
+
const hook = renderHook(baseParams({
|
|
198
|
+
triggerRef: makeRef(createAnchor(rect)),
|
|
199
|
+
floatingRef: makeRef(createPanel()),
|
|
200
|
+
matchTriggerWidth: true,
|
|
201
|
+
}));
|
|
202
|
+
expect(hook.latest()).toMatchObject({ minWidth: rect.width });
|
|
203
|
+
hook.unmount();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -2,6 +2,8 @@ import { type RefObject } from 'react';
|
|
|
2
2
|
interface UseDropdownConfig<T = any> {
|
|
3
3
|
triggerRef: RefObject<HTMLElement>;
|
|
4
4
|
dropdownRef?: RefObject<HTMLElement>;
|
|
5
|
+
/** direction 계산 전용 ref. 미지정 시 triggerRef 사용. Portal 모드에서 anchor box 가 다를 때 지정. */
|
|
6
|
+
positionRef?: RefObject<HTMLElement>;
|
|
5
7
|
maxHeight: number;
|
|
6
8
|
itemCount: number;
|
|
7
9
|
optionItems: T[];
|
|
@@ -15,7 +17,7 @@ interface UseDropdownConfig<T = any> {
|
|
|
15
17
|
*/
|
|
16
18
|
export declare const useDropdown: <T = any>(config: UseDropdownConfig<T>) => {
|
|
17
19
|
isOpen: boolean;
|
|
18
|
-
direction: import("../../
|
|
20
|
+
direction: import("../../utils/portal-position").PortalDirection;
|
|
19
21
|
focusedIndex: number;
|
|
20
22
|
toggleDropdown: () => void;
|
|
21
23
|
closeDropdown: () => void;
|
|
@@ -7,12 +7,12 @@ import { useWindowResize } from './useWindowResize';
|
|
|
7
7
|
* 드롭다운 기능을 통합한 커스텀 훅
|
|
8
8
|
*/
|
|
9
9
|
export const useDropdown = (config) => {
|
|
10
|
-
const { triggerRef, dropdownRef, maxHeight, itemCount, optionItems, disabled = false, multiple = false, onSelect, onChange, } = config;
|
|
10
|
+
const { triggerRef, dropdownRef, positionRef, maxHeight, itemCount, optionItems, disabled = false, multiple = false, onSelect, onChange, } = config;
|
|
11
11
|
const [isOpen, setIsOpen] = useState(false);
|
|
12
12
|
const [focusedIndex, setFocusedIndex] = useState(-1);
|
|
13
13
|
// 드롭다운 위치 계산
|
|
14
14
|
const { direction, updatePosition } = useDropdownPosition({
|
|
15
|
-
triggerRef,
|
|
15
|
+
triggerRef: positionRef ?? triggerRef,
|
|
16
16
|
maxHeight,
|
|
17
17
|
itemCount,
|
|
18
18
|
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { DropdownPositionConfig } from '../../types/dropdown';
|
|
2
2
|
/**
|
|
3
3
|
* 드롭다운 위치 계산을 위한 커스텀 훅
|
|
4
4
|
*/
|
|
5
5
|
export declare const useDropdownPosition: (config: DropdownPositionConfig) => {
|
|
6
|
-
direction:
|
|
6
|
+
direction: import("../../utils/portal-position").PortalDirection;
|
|
7
7
|
updatePosition: () => import("../../types/dropdown").DropdownPositionResult;
|
|
8
|
-
setDirection: import("react").Dispatch<import("react").SetStateAction<
|
|
8
|
+
setDirection: import("react").Dispatch<import("react").SetStateAction<import("../../utils/portal-position").PortalDirection>>;
|
|
9
9
|
};
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { type CSSProperties, type RefObject } from 'react';
|
|
2
|
-
type
|
|
2
|
+
import { type PortalAlign, type PortalDirection } from '../utils/portal-position';
|
|
3
3
|
type UseFloatingPositionParams = {
|
|
4
4
|
enabled: boolean;
|
|
5
5
|
isOpen: boolean;
|
|
6
6
|
triggerRef: RefObject<HTMLElement | null>;
|
|
7
7
|
floatingRef: RefObject<HTMLElement | null>;
|
|
8
|
-
direction:
|
|
8
|
+
direction: PortalDirection;
|
|
9
|
+
/** trigger 와 floating 요소 사이 간격. 미지정 시 4px */
|
|
9
10
|
offset?: number;
|
|
10
|
-
|
|
11
|
+
/** 미지정 시 'left' */
|
|
12
|
+
align?: PortalAlign;
|
|
11
13
|
/**
|
|
12
|
-
* true면 floating 요소의 width를 trigger
|
|
14
|
+
* true면 floating 요소의 min-width를 trigger 너비로 보장한다 (width 고정이 아니다).
|
|
13
15
|
* Portal 모드에서 부모가 body로 바뀌면서 min-width: 100% 같은 CSS가 viewport 너비로
|
|
14
16
|
* 폭주하는 것을 막는다 (예: SelectBox 옵션 패널).
|
|
15
17
|
*/
|
|
@@ -1,55 +1,22 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { trackPortalPosition } from '../utils/portal-position';
|
|
3
|
+
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect';
|
|
4
|
+
export const useFloatingPosition = ({ enabled, isOpen, triggerRef, floatingRef, direction, offset, align, matchTriggerWidth = false, }) => {
|
|
4
5
|
const [style, setStyle] = useState(null);
|
|
5
|
-
|
|
6
|
+
useIsomorphicLayoutEffect(() => {
|
|
6
7
|
if (!enabled || !isOpen) {
|
|
7
8
|
setStyle(null);
|
|
8
9
|
return;
|
|
9
10
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const t = triggerRef.current;
|
|
20
|
-
const f = floatingRef.current;
|
|
21
|
-
if (!t)
|
|
22
|
-
return;
|
|
23
|
-
const r = t.getBoundingClientRect();
|
|
24
|
-
const floatingHeight = f?.offsetHeight ?? 0;
|
|
25
|
-
const floatingWidth = matchTriggerWidth ? r.width : (f?.offsetWidth ?? r.width);
|
|
26
|
-
const { top, left } = calculatePosition(r, floatingHeight, floatingWidth);
|
|
27
|
-
// matchTriggerWidth=true: width는 max-content(콘텐츠 자연 너비), min-width만 trigger 너비로 보장.
|
|
28
|
-
// 옵션이 짧으면 trigger 너비, 길면 자연 확장 — 기존 absolute 모드와 동일한 UX.
|
|
29
|
-
setStyle({
|
|
30
|
-
position: 'fixed',
|
|
31
|
-
top,
|
|
32
|
-
left,
|
|
33
|
-
zIndex: FLOATING_Z_INDEX,
|
|
34
|
-
...(matchTriggerWidth ? { minWidth: r.width } : {}),
|
|
35
|
-
});
|
|
36
|
-
};
|
|
37
|
-
update();
|
|
38
|
-
window.addEventListener('scroll', update, true);
|
|
39
|
-
window.addEventListener('resize', update);
|
|
40
|
-
const triggerObserver = new ResizeObserver(update);
|
|
41
|
-
triggerObserver.observe(trigger);
|
|
42
|
-
let floatingObserver;
|
|
43
|
-
if (floatingRef.current) {
|
|
44
|
-
floatingObserver = new ResizeObserver(update);
|
|
45
|
-
floatingObserver.observe(floatingRef.current);
|
|
46
|
-
}
|
|
47
|
-
return () => {
|
|
48
|
-
window.removeEventListener('scroll', update, true);
|
|
49
|
-
window.removeEventListener('resize', update);
|
|
50
|
-
triggerObserver.disconnect();
|
|
51
|
-
floatingObserver?.disconnect();
|
|
52
|
-
};
|
|
11
|
+
return trackPortalPosition({
|
|
12
|
+
getAnchor: () => triggerRef.current,
|
|
13
|
+
getPanel: () => floatingRef.current,
|
|
14
|
+
direction,
|
|
15
|
+
align,
|
|
16
|
+
offset,
|
|
17
|
+
matchAnchorWidth: matchTriggerWidth,
|
|
18
|
+
onUpdate: setStyle,
|
|
19
|
+
});
|
|
53
20
|
}, [enabled, isOpen, direction, offset, align, matchTriggerWidth, triggerRef, floatingRef]);
|
|
54
21
|
return style;
|
|
55
22
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RefObject } from 'react';
|
|
2
|
-
|
|
2
|
+
import type { PortalDirection } from '../../utils/portal-position';
|
|
3
|
+
export type DropdownDirection = PortalDirection;
|
|
3
4
|
export type DropdownPositionConfig = {
|
|
4
5
|
triggerRef: RefObject<HTMLElement>;
|
|
5
6
|
maxHeight: number;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,174 @@
|
|
|
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 = ({ bottom }) => {
|
|
18
|
+
const element = document.createElement('button');
|
|
19
|
+
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(DOMRect.fromRect({ x: 0, y: bottom - TRIGGER_HEIGHT, width: TRIGGER_WIDTH, height: TRIGGER_HEIGHT }));
|
|
20
|
+
return { current: element };
|
|
21
|
+
};
|
|
22
|
+
const nullTriggerRef = () => ({ current: null });
|
|
23
|
+
describe('calculateDropdownPosition — 위임 후 반환 불변 (E-9)', () => {
|
|
24
|
+
it('triggerRef.current 가 null 이면 down · maxHeight 를 그대로 반환한다', () => {
|
|
25
|
+
vi.stubGlobal('innerHeight', VIEWPORT_HEIGHT);
|
|
26
|
+
const result = calculateDropdownPosition({
|
|
27
|
+
triggerRef: nullTriggerRef(),
|
|
28
|
+
maxHeight: MAX_HEIGHT,
|
|
29
|
+
itemCount: ITEM_COUNT,
|
|
30
|
+
});
|
|
31
|
+
expect(result).toEqual({ direction: 'down', maxHeight: MAX_HEIGHT });
|
|
32
|
+
});
|
|
33
|
+
it.each([
|
|
34
|
+
{ label: '공간 충분', spaceBelow: PANEL_HEIGHT + EXTRA_SPACE, direction: 'down' },
|
|
35
|
+
{ label: '공간 부족', spaceBelow: PANEL_HEIGHT - SHORTFALL, direction: 'up' },
|
|
36
|
+
{ label: '경계 (panelHeight+20)', spaceBelow: PANEL_HEIGHT + DEFAULT_GAP, direction: 'down' },
|
|
37
|
+
{ label: '경계 -1 (panelHeight+19)', spaceBelow: PANEL_HEIGHT + DEFAULT_GAP - 1, direction: 'up' },
|
|
38
|
+
{ label: '경계 +1 (panelHeight+21)', spaceBelow: PANEL_HEIGHT + DEFAULT_GAP + 1, direction: 'down' },
|
|
39
|
+
])('$label → $direction', ({ spaceBelow, direction }) => {
|
|
40
|
+
vi.stubGlobal('innerHeight', VIEWPORT_HEIGHT);
|
|
41
|
+
const result = calculateDropdownPosition({
|
|
42
|
+
triggerRef: createTriggerRef({ bottom: VIEWPORT_HEIGHT - spaceBelow }),
|
|
43
|
+
maxHeight: MAX_HEIGHT,
|
|
44
|
+
itemCount: ITEM_COUNT,
|
|
45
|
+
itemHeight: ITEM_HEIGHT,
|
|
46
|
+
});
|
|
47
|
+
expect(result).toEqual({ direction, maxHeight: MAX_HEIGHT });
|
|
48
|
+
});
|
|
49
|
+
it('itemCount×itemHeight 가 maxHeight 보다 작으면 그 값을 패널 높이로 쓴다', () => {
|
|
50
|
+
vi.stubGlobal('innerHeight', VIEWPORT_HEIGHT);
|
|
51
|
+
const shortPanelHeight = 2 * ITEM_HEIGHT;
|
|
52
|
+
const result = calculateDropdownPosition({
|
|
53
|
+
triggerRef: createTriggerRef({ bottom: VIEWPORT_HEIGHT - (shortPanelHeight + DEFAULT_GAP) }),
|
|
54
|
+
maxHeight: MAX_HEIGHT,
|
|
55
|
+
itemCount: 2,
|
|
56
|
+
itemHeight: ITEM_HEIGHT,
|
|
57
|
+
});
|
|
58
|
+
expect(result.direction).toBe('down');
|
|
59
|
+
});
|
|
60
|
+
it('offset 을 지정하면 그 간격으로 반전 여부를 판정한다', () => {
|
|
61
|
+
vi.stubGlobal('innerHeight', VIEWPORT_HEIGHT);
|
|
62
|
+
const result = calculateDropdownPosition({
|
|
63
|
+
triggerRef: createTriggerRef({ bottom: VIEWPORT_HEIGHT - (PANEL_HEIGHT + DEFAULT_GAP) }),
|
|
64
|
+
maxHeight: MAX_HEIGHT,
|
|
65
|
+
itemCount: ITEM_COUNT,
|
|
66
|
+
itemHeight: ITEM_HEIGHT,
|
|
67
|
+
offset: DEFAULT_GAP + 1,
|
|
68
|
+
});
|
|
69
|
+
expect(result.direction).toBe('up');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('calculateNextFocusIndex — 순환 포커스 이동 (characterisation)', () => {
|
|
73
|
+
const LENGTH = 5;
|
|
74
|
+
const LAST = LENGTH - 1;
|
|
75
|
+
it('옵션이 없으면 -1 을 반환한다', () => {
|
|
76
|
+
expect(calculateNextFocusIndex(0, 'down', 0)).toBe(-1);
|
|
77
|
+
expect(calculateNextFocusIndex(2, 'up', 0)).toBe(-1);
|
|
78
|
+
});
|
|
79
|
+
it.each([
|
|
80
|
+
{ label: 'down · 중간', current: 2, direction: 'down', expected: 3 },
|
|
81
|
+
{ label: 'down · 마지막 → 첫 번째로 순환', current: LAST, direction: 'down', expected: 0 },
|
|
82
|
+
{ label: 'up · 중간', current: 2, direction: 'up', expected: 1 },
|
|
83
|
+
{ label: 'up · 첫 번째 → 마지막으로 순환', current: 0, direction: 'up', expected: LAST },
|
|
84
|
+
{ label: 'up · 초기값 -1 → 마지막', current: -1, direction: 'up', expected: LAST },
|
|
85
|
+
])('$label', ({ current, direction, expected }) => {
|
|
86
|
+
expect(calculateNextFocusIndex(current, direction, LENGTH)).toBe(expected);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
const VIEWPORT_WIDTH = 1000;
|
|
90
|
+
const EDGE_MARGIN = 10;
|
|
91
|
+
const createDropdownElement = (rect) => {
|
|
92
|
+
const element = document.createElement('div');
|
|
93
|
+
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(DOMRect.fromRect({ x: rect.left, y: rect.bottom - rect.height, width: rect.right - rect.left, height: rect.height }));
|
|
94
|
+
return element;
|
|
95
|
+
};
|
|
96
|
+
describe('adjustDropdownPosition — 뷰포트 이탈 보정 (characterisation)', () => {
|
|
97
|
+
it('뷰포트 안에 있으면 style 을 변경하지 않는다', () => {
|
|
98
|
+
vi.stubGlobal('innerWidth', VIEWPORT_WIDTH);
|
|
99
|
+
vi.stubGlobal('innerHeight', VIEWPORT_HEIGHT);
|
|
100
|
+
const element = createDropdownElement({ left: 100, right: 300, bottom: 400, height: 200 });
|
|
101
|
+
adjustDropdownPosition(element);
|
|
102
|
+
expect(element.style.left).toBe('');
|
|
103
|
+
expect(element.style.maxHeight).toBe('');
|
|
104
|
+
});
|
|
105
|
+
it('우측 이탈 시 초과분 + 여백만큼 left 를 음수로 이동한다', () => {
|
|
106
|
+
vi.stubGlobal('innerWidth', VIEWPORT_WIDTH);
|
|
107
|
+
vi.stubGlobal('innerHeight', VIEWPORT_HEIGHT);
|
|
108
|
+
const overflow = 50;
|
|
109
|
+
const element = createDropdownElement({ left: 800, right: VIEWPORT_WIDTH + overflow, bottom: 400, height: 200 });
|
|
110
|
+
adjustDropdownPosition(element);
|
|
111
|
+
expect(element.style.left).toBe(`${-overflow - EDGE_MARGIN}px`);
|
|
112
|
+
});
|
|
113
|
+
it('좌측 이탈 시 left 를 여백값으로 고정한다', () => {
|
|
114
|
+
vi.stubGlobal('innerWidth', VIEWPORT_WIDTH);
|
|
115
|
+
vi.stubGlobal('innerHeight', VIEWPORT_HEIGHT);
|
|
116
|
+
const element = createDropdownElement({ left: -30, right: 170, bottom: 400, height: 200 });
|
|
117
|
+
adjustDropdownPosition(element);
|
|
118
|
+
expect(element.style.left).toBe(`${EDGE_MARGIN}px`);
|
|
119
|
+
});
|
|
120
|
+
it('하단 이탈 시 초과분 + 여백만큼 maxHeight 를 줄인다', () => {
|
|
121
|
+
vi.stubGlobal('innerWidth', VIEWPORT_WIDTH);
|
|
122
|
+
vi.stubGlobal('innerHeight', VIEWPORT_HEIGHT);
|
|
123
|
+
const overflow = 60;
|
|
124
|
+
const height = 200;
|
|
125
|
+
const element = createDropdownElement({ left: 100, right: 300, bottom: VIEWPORT_HEIGHT + overflow, height });
|
|
126
|
+
adjustDropdownPosition(element);
|
|
127
|
+
expect(element.style.maxHeight).toBe(`${height - overflow - EDGE_MARGIN}px`);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
const CLIENT_HEIGHT = 100;
|
|
131
|
+
const OVERFLOWING_SCROLL_HEIGHT = 500;
|
|
132
|
+
const createScrollable = ({ overflowY, scrollable }) => {
|
|
133
|
+
const element = document.createElement('div');
|
|
134
|
+
element.style.overflowY = overflowY;
|
|
135
|
+
Object.defineProperty(element, 'scrollHeight', {
|
|
136
|
+
value: scrollable ? OVERFLOWING_SCROLL_HEIGHT : CLIENT_HEIGHT,
|
|
137
|
+
configurable: true,
|
|
138
|
+
});
|
|
139
|
+
Object.defineProperty(element, 'clientHeight', { value: CLIENT_HEIGHT, configurable: true });
|
|
140
|
+
return element;
|
|
141
|
+
};
|
|
142
|
+
const mountChain = (...chain) => {
|
|
143
|
+
chain.reduce((parent, child) => {
|
|
144
|
+
parent.appendChild(child);
|
|
145
|
+
return child;
|
|
146
|
+
});
|
|
147
|
+
document.body.appendChild(chain[0]);
|
|
148
|
+
return chain;
|
|
149
|
+
};
|
|
150
|
+
describe('findScrollableParent — 스크롤 부모 탐색 (characterisation)', () => {
|
|
151
|
+
afterEach(() => {
|
|
152
|
+
document.body.innerHTML = '';
|
|
153
|
+
});
|
|
154
|
+
it('boundary 이전에 overflow auto 이고 내용이 넘치는 조상이 있으면 그 요소를 반환한다', () => {
|
|
155
|
+
const [boundary, scrollParent, , target] = mountChain(createScrollable({ overflowY: 'visible', scrollable: false }), createScrollable({ overflowY: 'auto', scrollable: true }), createScrollable({ overflowY: 'visible', scrollable: false }), createScrollable({ overflowY: 'visible', scrollable: false }));
|
|
156
|
+
expect(findScrollableParent(target, boundary)).toBe(scrollParent);
|
|
157
|
+
});
|
|
158
|
+
it('overflow scroll 이어도 내용이 넘치지 않으면 건너뛴다', () => {
|
|
159
|
+
const [boundary, , target] = mountChain(createScrollable({ overflowY: 'visible', scrollable: false }), createScrollable({ overflowY: 'scroll', scrollable: false }), createScrollable({ overflowY: 'visible', scrollable: false }));
|
|
160
|
+
expect(findScrollableParent(target, boundary)).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
it('조상 중 없고 boundary 자체가 스크롤 가능하면 boundary 를 반환한다', () => {
|
|
163
|
+
const [boundary, , target] = mountChain(createScrollable({ overflowY: 'auto', scrollable: true }), createScrollable({ overflowY: 'visible', scrollable: false }), createScrollable({ overflowY: 'visible', scrollable: false }));
|
|
164
|
+
expect(findScrollableParent(target, boundary)).toBe(boundary);
|
|
165
|
+
});
|
|
166
|
+
it('boundary 까지 스크롤 가능한 요소가 없으면 null 을 반환한다', () => {
|
|
167
|
+
const [boundary, target] = mountChain(createScrollable({ overflowY: 'hidden', scrollable: true }), createScrollable({ overflowY: 'visible', scrollable: false }));
|
|
168
|
+
expect(findScrollableParent(target, boundary)).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
it('element 가 boundary 자신이면 boundary 의 스크롤 가능 여부로만 판정한다', () => {
|
|
171
|
+
const [boundary] = mountChain(createScrollable({ overflowY: 'scroll', scrollable: true }));
|
|
172
|
+
expect(findScrollableParent(boundary, boundary)).toBe(boundary);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveDropdownDirection } from '../portal-position';
|
|
1
2
|
/**
|
|
2
3
|
* 드롭다운 위치를 계산하는 유틸리티 함수
|
|
3
4
|
*/
|
|
@@ -5,15 +6,13 @@ export const calculateDropdownPosition = ({ triggerRef, maxHeight, itemCount, it
|
|
|
5
6
|
if (!triggerRef?.current) {
|
|
6
7
|
return { direction: 'down', maxHeight };
|
|
7
8
|
}
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
return { direction: 'down', maxHeight };
|
|
9
|
+
const direction = resolveDropdownDirection({
|
|
10
|
+
anchorRect: triggerRef.current.getBoundingClientRect(),
|
|
11
|
+
panelHeight: Math.min(maxHeight, itemCount * itemHeight),
|
|
12
|
+
viewportHeight: window.innerHeight,
|
|
13
|
+
gap: offset,
|
|
14
|
+
});
|
|
15
|
+
return { direction, maxHeight };
|
|
17
16
|
};
|
|
18
17
|
/**
|
|
19
18
|
* 키보드 네비게이션을 위한 포커스 인덱스 계산
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|