@ncds/ui-admin 1.8.25-alpha.2 → 1.8.25-alpha.3
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 +5 -47
- package/dist/cjs/src/components/forms-and-input/combo-box/utils.js +51 -0
- package/dist/cjs/src/hooks/__tests__/useFloatingPosition.test.js +32 -0
- package/dist/cjs/src/hooks/useFloatingPosition.js +2 -1
- package/dist/cjs/src/utils/portal-position/__tests__/portalPosition.tracking.test.js +21 -0
- package/dist/cjs/src/utils/portal-position/portalPosition.js +7 -3
- package/dist/esm/src/components/forms-and-input/combo-box/ComboBox.js +3 -45
- package/dist/esm/src/components/forms-and-input/combo-box/utils.js +43 -0
- package/dist/esm/src/hooks/__tests__/useFloatingPosition.test.js +32 -0
- package/dist/esm/src/hooks/useFloatingPosition.js +2 -1
- package/dist/esm/src/utils/portal-position/__tests__/portalPosition.tracking.test.js +21 -0
- package/dist/esm/src/utils/portal-position/portalPosition.js +7 -3
- package/dist/temp/src/components/forms-and-input/combo-box/ComboBox.js +2 -26
- package/dist/temp/src/components/forms-and-input/combo-box/utils.d.ts +27 -0
- package/dist/temp/src/components/forms-and-input/combo-box/utils.js +25 -0
- package/dist/temp/src/hooks/__tests__/useFloatingPosition.test.js +24 -0
- package/dist/temp/src/hooks/useFloatingPosition.js +3 -2
- package/dist/temp/src/utils/portal-position/__tests__/portalPosition.tracking.test.js +13 -0
- package/dist/temp/src/utils/portal-position/portalPosition.d.ts +11 -14
- package/dist/temp/src/utils/portal-position/portalPosition.js +9 -4
- package/dist/types/src/components/forms-and-input/combo-box/utils.d.ts +27 -0
- package/dist/types/src/utils/portal-position/portalPosition.d.ts +11 -14
- package/package.json +1 -1
|
@@ -8,7 +8,6 @@ var _jsxRuntime = require("react/jsx-runtime");
|
|
|
8
8
|
var _uiAdminIcon = require("@ncds/ui-admin-icon");
|
|
9
9
|
var _classnames = _interopRequireDefault(require("classnames"));
|
|
10
10
|
var _react = require("react");
|
|
11
|
-
var _reactDom = require("react-dom");
|
|
12
11
|
var _dropdown = require("../../../hooks/dropdown");
|
|
13
12
|
var _useFloatingPosition = require("../../../hooks/useFloatingPosition");
|
|
14
13
|
var _usePortalState = require("../../../hooks/usePortalState");
|
|
@@ -18,6 +17,7 @@ var _tag = require("../../feedback-and-status/tag");
|
|
|
18
17
|
var _selectDropdown = require("../../select-dropdown");
|
|
19
18
|
var _HintText = require("../../shared/hintText/HintText");
|
|
20
19
|
var _InputBase = require("../input-base/InputBase");
|
|
20
|
+
var _utils = require("./utils");
|
|
21
21
|
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
22
22
|
const defaultMaxHeight = exports.defaultMaxHeight = 275;
|
|
23
23
|
const notifyFormChange = (register, multiple, newValue) => {
|
|
@@ -30,49 +30,7 @@ const notifyFormChange = (register, multiple, newValue) => {
|
|
|
30
30
|
});
|
|
31
31
|
}
|
|
32
32
|
};
|
|
33
|
-
|
|
34
|
-
* Portal 모드에서만 패널에 `--portal` 클래스와 fixed 좌표를 얹는다.
|
|
35
|
-
* floatingStyle 이 null 인 초기 레이아웃 직전에는 클래스만 붙지만,
|
|
36
|
-
* useLayoutEffect 가 paint 전에 좌표를 채우므로 빈 fixed 가 노출되지 않는다.
|
|
37
|
-
*/
|
|
38
|
-
const resolvePortalPanelBinding = (shouldPortal, floatingStyle) => {
|
|
39
|
-
if (!shouldPortal) {
|
|
40
|
-
return {};
|
|
41
|
-
}
|
|
42
|
-
return {
|
|
43
|
-
className: 'ncua-select-dropdown--portal',
|
|
44
|
-
style: floatingStyle ?? undefined
|
|
45
|
-
};
|
|
46
|
-
};
|
|
47
|
-
/**
|
|
48
|
-
* 패널 노드를 인라인·Portal 두 마운트 지점 중 한 곳에만 넘긴다. 양쪽에 두면 dropdownRef 가
|
|
49
|
-
* 나중 노드로 덮여 외부 클릭 판정과 키보드 스크롤이 유령 노드를 가리키게 된다.
|
|
50
|
-
*/
|
|
51
|
-
const resolveDropdownMounts = _ref => {
|
|
52
|
-
let {
|
|
53
|
-
node,
|
|
54
|
-
shouldPortal,
|
|
55
|
-
portalContainer,
|
|
56
|
-
isOpen
|
|
57
|
-
} = _ref;
|
|
58
|
-
if (!shouldPortal) {
|
|
59
|
-
return {
|
|
60
|
-
inlineDropdown: node,
|
|
61
|
-
portaledDropdown: null
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
if (!portalContainer || !isOpen) {
|
|
65
|
-
return {
|
|
66
|
-
inlineDropdown: null,
|
|
67
|
-
portaledDropdown: null
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
return {
|
|
71
|
-
inlineDropdown: null,
|
|
72
|
-
portaledDropdown: /*#__PURE__*/(0, _reactDom.createPortal)(node, portalContainer)
|
|
73
|
-
};
|
|
74
|
-
};
|
|
75
|
-
const ComboBox = exports.ComboBox = /*#__PURE__*/(0, _react.forwardRef)((_ref2, ref) => {
|
|
33
|
+
const ComboBox = exports.ComboBox = /*#__PURE__*/(0, _react.forwardRef)((_ref, ref) => {
|
|
76
34
|
let {
|
|
77
35
|
placeholder = '검색하세요',
|
|
78
36
|
id,
|
|
@@ -98,7 +56,7 @@ const ComboBox = exports.ComboBox = /*#__PURE__*/(0, _react.forwardRef)((_ref2,
|
|
|
98
56
|
onComplete,
|
|
99
57
|
usePortal = false,
|
|
100
58
|
...props
|
|
101
|
-
} =
|
|
59
|
+
} = _ref;
|
|
102
60
|
const internalRef = (0, _react.useRef)(null);
|
|
103
61
|
// 인라인 패널의 offset parent 가 __content 다. Portal 좌표도 같은 박스를 기준으로 잡아야
|
|
104
62
|
// label·hintText 가 있을 때 두 모드의 패널 위치가 어긋나지 않는다.
|
|
@@ -277,7 +235,7 @@ const ComboBox = exports.ComboBox = /*#__PURE__*/(0, _react.forwardRef)((_ref2,
|
|
|
277
235
|
const {
|
|
278
236
|
className: portalPanelClassName,
|
|
279
237
|
style: portalPanelStyle
|
|
280
|
-
} = resolvePortalPanelBinding(shouldPortal, floatingStyle);
|
|
238
|
+
} = (0, _utils.resolvePortalPanelBinding)(shouldPortal, floatingStyle);
|
|
281
239
|
const selectDropdownNode = (0, _jsxRuntime.jsxs)(_selectDropdown.SelectDropdown, {
|
|
282
240
|
ref: dropdownRef,
|
|
283
241
|
isOpen: isOpen,
|
|
@@ -309,7 +267,7 @@ const ComboBox = exports.ComboBox = /*#__PURE__*/(0, _react.forwardRef)((_ref2,
|
|
|
309
267
|
const {
|
|
310
268
|
inlineDropdown,
|
|
311
269
|
portaledDropdown
|
|
312
|
-
} = resolveDropdownMounts({
|
|
270
|
+
} = (0, _utils.resolveDropdownMounts)({
|
|
313
271
|
node: selectDropdownNode,
|
|
314
272
|
shouldPortal,
|
|
315
273
|
portalContainer,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.resolvePortalPanelBinding = exports.resolveDropdownMounts = void 0;
|
|
7
|
+
var _reactDom = require("react-dom");
|
|
8
|
+
/**
|
|
9
|
+
* Portal 모드에서만 패널에 `--portal` 클래스와 fixed 좌표를 얹는다.
|
|
10
|
+
* floatingStyle 이 null 인 초기 레이아웃 직전에는 클래스만 붙지만,
|
|
11
|
+
* useLayoutEffect 가 paint 전에 좌표를 채우므로 빈 fixed 가 노출되지 않는다.
|
|
12
|
+
*/
|
|
13
|
+
const resolvePortalPanelBinding = (shouldPortal, floatingStyle) => {
|
|
14
|
+
if (!shouldPortal) {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
className: 'ncua-select-dropdown--portal',
|
|
19
|
+
style: floatingStyle ?? undefined
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* 패널 노드를 인라인·Portal 두 마운트 지점 중 한 곳에만 넘긴다. 양쪽에 두면 dropdownRef 가
|
|
24
|
+
* 나중 노드로 덮여 외부 클릭 판정과 키보드 스크롤이 유령 노드를 가리키게 된다.
|
|
25
|
+
*/
|
|
26
|
+
exports.resolvePortalPanelBinding = resolvePortalPanelBinding;
|
|
27
|
+
const resolveDropdownMounts = _ref => {
|
|
28
|
+
let {
|
|
29
|
+
node,
|
|
30
|
+
shouldPortal,
|
|
31
|
+
portalContainer,
|
|
32
|
+
isOpen
|
|
33
|
+
} = _ref;
|
|
34
|
+
if (!shouldPortal) {
|
|
35
|
+
return {
|
|
36
|
+
inlineDropdown: node,
|
|
37
|
+
portaledDropdown: null
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (!portalContainer || !isOpen) {
|
|
41
|
+
return {
|
|
42
|
+
inlineDropdown: null,
|
|
43
|
+
portaledDropdown: null
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
inlineDropdown: null,
|
|
48
|
+
portaledDropdown: /*#__PURE__*/(0, _reactDom.createPortal)(node, portalContainer)
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
exports.resolveDropdownMounts = resolveDropdownMounts;
|
|
@@ -319,4 +319,36 @@ const dispatchScroll = () => {
|
|
|
319
319
|
});
|
|
320
320
|
hook.unmount();
|
|
321
321
|
});
|
|
322
|
+
});
|
|
323
|
+
(0, _vitest.describe)('useFloatingPosition — 좌표 불변 갱신', () => {
|
|
324
|
+
(0, _vitest.it)('scroll 뒤에도 top·left·minWidth 가 같으면 이전 style 참조를 유지해 하위 트리 리렌더를 건너뛴다', () => {
|
|
325
|
+
installResizeObserverStub();
|
|
326
|
+
const hook = renderHook(baseParams({
|
|
327
|
+
triggerRef: makeRef(createAnchor()),
|
|
328
|
+
floatingRef: makeRef(createPanel()),
|
|
329
|
+
matchTriggerWidth: true
|
|
330
|
+
}));
|
|
331
|
+
const styleBeforeScroll = hook.latest();
|
|
332
|
+
dispatchScroll();
|
|
333
|
+
(0, _vitest.expect)(hook.latest()).toBe(styleBeforeScroll);
|
|
334
|
+
hook.unmount();
|
|
335
|
+
});
|
|
336
|
+
(0, _vitest.it)('scroll 로 좌표가 바뀌면 새 style 로 다시 렌더링한다', () => {
|
|
337
|
+
installResizeObserverStub();
|
|
338
|
+
const anchor = createAnchor();
|
|
339
|
+
const hook = renderHook(baseParams({
|
|
340
|
+
triggerRef: makeRef(anchor),
|
|
341
|
+
floatingRef: makeRef(createPanel())
|
|
342
|
+
}));
|
|
343
|
+
const moved = makeAnchorRect({
|
|
344
|
+
top: 300,
|
|
345
|
+
bottom: 340
|
|
346
|
+
});
|
|
347
|
+
_vitest.vi.spyOn(anchor, 'getBoundingClientRect').mockReturnValue(toDomRect(moved));
|
|
348
|
+
dispatchScroll();
|
|
349
|
+
(0, _vitest.expect)(hook.latest()).toMatchObject({
|
|
350
|
+
top: moved.bottom + DEFAULT_OFFSET
|
|
351
|
+
});
|
|
352
|
+
hook.unmount();
|
|
353
|
+
});
|
|
322
354
|
});
|
|
@@ -7,6 +7,7 @@ exports.useFloatingPosition = void 0;
|
|
|
7
7
|
var _react = require("react");
|
|
8
8
|
var _portalPosition = require("../utils/portal-position");
|
|
9
9
|
var _useIsomorphicLayoutEffect = require("./useIsomorphicLayoutEffect");
|
|
10
|
+
const isSamePlacement = (prev, next) => prev !== null && prev.top === next.top && prev.left === next.left && prev.minWidth === next.minWidth;
|
|
10
11
|
const useFloatingPosition = _ref => {
|
|
11
12
|
let {
|
|
12
13
|
enabled,
|
|
@@ -31,7 +32,7 @@ const useFloatingPosition = _ref => {
|
|
|
31
32
|
align,
|
|
32
33
|
offset,
|
|
33
34
|
matchAnchorWidth: matchTriggerWidth,
|
|
34
|
-
onUpdate: setStyle
|
|
35
|
+
onUpdate: next => setStyle(prev => isSamePlacement(prev, next) ? prev : next)
|
|
35
36
|
});
|
|
36
37
|
}, [enabled, isOpen, direction, offset, align, matchTriggerWidth, triggerRef, floatingRef]);
|
|
37
38
|
return style;
|
|
@@ -115,6 +115,27 @@ const dispatchWindow = type => window.dispatchEvent(new Event(type));
|
|
|
115
115
|
(0, _vitest.expect)(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.afterScroll);
|
|
116
116
|
cleanup();
|
|
117
117
|
});
|
|
118
|
+
(0, _vitest.it)('panel 자신과 그 내부 요소의 scroll 은 anchor 를 움직이지 않으므로 onChange 를 호출하지 않는다', () => {
|
|
119
|
+
installResizeObserverStub();
|
|
120
|
+
const anchor = createAnchor();
|
|
121
|
+
const panel = createPanel();
|
|
122
|
+
const optionList = document.createElement('ul');
|
|
123
|
+
panel.appendChild(optionList);
|
|
124
|
+
const onChange = _vitest.vi.fn();
|
|
125
|
+
const cleanup = (0, _.observeAnchorGeometry)({
|
|
126
|
+
getAnchor: () => anchor.element,
|
|
127
|
+
getPanel: () => panel,
|
|
128
|
+
onChange
|
|
129
|
+
});
|
|
130
|
+
panel.dispatchEvent(new Event('scroll', {
|
|
131
|
+
bubbles: false
|
|
132
|
+
}));
|
|
133
|
+
optionList.dispatchEvent(new Event('scroll', {
|
|
134
|
+
bubbles: false
|
|
135
|
+
}));
|
|
136
|
+
(0, _vitest.expect)(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.initial);
|
|
137
|
+
cleanup();
|
|
138
|
+
});
|
|
118
139
|
(0, _vitest.it)('anchor 와 panel 각각에 ResizeObserver 를 붙인다', () => {
|
|
119
140
|
const {
|
|
120
141
|
instances
|
|
@@ -113,14 +113,19 @@ const observeAnchorGeometry = _ref5 => {
|
|
|
113
113
|
return noop;
|
|
114
114
|
}
|
|
115
115
|
onChange();
|
|
116
|
-
|
|
116
|
+
// 패널 내부 스크롤은 anchor 를 움직이지 않는다
|
|
117
|
+
const onScroll = event => {
|
|
118
|
+
if (event.target instanceof Node && getPanel()?.contains(event.target)) return;
|
|
119
|
+
onChange();
|
|
120
|
+
};
|
|
121
|
+
window.addEventListener('scroll', onScroll, {
|
|
117
122
|
passive: true,
|
|
118
123
|
capture: true
|
|
119
124
|
});
|
|
120
125
|
window.addEventListener('resize', onChange);
|
|
121
126
|
const observers = [observeElementResize(anchor, onChange), observeElementResize(getPanel(), onChange)];
|
|
122
127
|
return () => {
|
|
123
|
-
window.removeEventListener('scroll',
|
|
128
|
+
window.removeEventListener('scroll', onScroll, {
|
|
124
129
|
capture: true
|
|
125
130
|
});
|
|
126
131
|
window.removeEventListener('resize', onChange);
|
|
@@ -146,7 +151,6 @@ const trackPortalPosition = _ref6 => {
|
|
|
146
151
|
}
|
|
147
152
|
});
|
|
148
153
|
};
|
|
149
|
-
// TODO(#497): vanilla 어댑터 도입 시 소비
|
|
150
154
|
exports.trackPortalPosition = trackPortalPosition;
|
|
151
155
|
const applyPortalPanelStyle = (element, style) => {
|
|
152
156
|
element.style.position = style.position;
|
|
@@ -2,7 +2,6 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { ChevronSelectorVertical, SearchLg } from '@ncds/ui-admin-icon';
|
|
3
3
|
import classNames from 'classnames';
|
|
4
4
|
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
|
|
5
|
-
import { createPortal } from 'react-dom';
|
|
6
5
|
import { useDropdown } from '../../../hooks/dropdown';
|
|
7
6
|
import { useFloatingPosition } from '../../../hooks/useFloatingPosition';
|
|
8
7
|
import { usePortalState } from '../../../hooks/usePortalState';
|
|
@@ -12,6 +11,7 @@ import { Tag } from '../../feedback-and-status/tag';
|
|
|
12
11
|
import { SelectDropdown } from '../../select-dropdown';
|
|
13
12
|
import { HintText } from '../../shared/hintText/HintText';
|
|
14
13
|
import { InputBase } from '../input-base/InputBase';
|
|
14
|
+
import { resolveDropdownMounts, resolvePortalPanelBinding } from './utils';
|
|
15
15
|
const defaultMaxHeight = 275;
|
|
16
16
|
const notifyFormChange = (register, multiple, newValue) => {
|
|
17
17
|
if (register?.onChange) {
|
|
@@ -23,49 +23,7 @@ const notifyFormChange = (register, multiple, newValue) => {
|
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
25
|
};
|
|
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 {
|
|
36
|
-
className: 'ncua-select-dropdown--portal',
|
|
37
|
-
style: floatingStyle ?? undefined
|
|
38
|
-
};
|
|
39
|
-
};
|
|
40
|
-
/**
|
|
41
|
-
* 패널 노드를 인라인·Portal 두 마운트 지점 중 한 곳에만 넘긴다. 양쪽에 두면 dropdownRef 가
|
|
42
|
-
* 나중 노드로 덮여 외부 클릭 판정과 키보드 스크롤이 유령 노드를 가리키게 된다.
|
|
43
|
-
*/
|
|
44
|
-
const resolveDropdownMounts = _ref => {
|
|
45
|
-
let {
|
|
46
|
-
node,
|
|
47
|
-
shouldPortal,
|
|
48
|
-
portalContainer,
|
|
49
|
-
isOpen
|
|
50
|
-
} = _ref;
|
|
51
|
-
if (!shouldPortal) {
|
|
52
|
-
return {
|
|
53
|
-
inlineDropdown: node,
|
|
54
|
-
portaledDropdown: null
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
if (!portalContainer || !isOpen) {
|
|
58
|
-
return {
|
|
59
|
-
inlineDropdown: null,
|
|
60
|
-
portaledDropdown: null
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
return {
|
|
64
|
-
inlineDropdown: null,
|
|
65
|
-
portaledDropdown: /*#__PURE__*/createPortal(node, portalContainer)
|
|
66
|
-
};
|
|
67
|
-
};
|
|
68
|
-
const ComboBox = /*#__PURE__*/forwardRef((_ref2, ref) => {
|
|
26
|
+
const ComboBox = /*#__PURE__*/forwardRef((_ref, ref) => {
|
|
69
27
|
let {
|
|
70
28
|
placeholder = '검색하세요',
|
|
71
29
|
id,
|
|
@@ -91,7 +49,7 @@ const ComboBox = /*#__PURE__*/forwardRef((_ref2, ref) => {
|
|
|
91
49
|
onComplete,
|
|
92
50
|
usePortal = false,
|
|
93
51
|
...props
|
|
94
|
-
} =
|
|
52
|
+
} = _ref;
|
|
95
53
|
const internalRef = useRef(null);
|
|
96
54
|
// 인라인 패널의 offset parent 가 __content 다. Portal 좌표도 같은 박스를 기준으로 잡아야
|
|
97
55
|
// label·hintText 가 있을 때 두 모드의 패널 위치가 어긋나지 않는다.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createPortal } from 'react-dom';
|
|
2
|
+
/**
|
|
3
|
+
* Portal 모드에서만 패널에 `--portal` 클래스와 fixed 좌표를 얹는다.
|
|
4
|
+
* floatingStyle 이 null 인 초기 레이아웃 직전에는 클래스만 붙지만,
|
|
5
|
+
* useLayoutEffect 가 paint 전에 좌표를 채우므로 빈 fixed 가 노출되지 않는다.
|
|
6
|
+
*/
|
|
7
|
+
export const resolvePortalPanelBinding = (shouldPortal, floatingStyle) => {
|
|
8
|
+
if (!shouldPortal) {
|
|
9
|
+
return {};
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
className: 'ncua-select-dropdown--portal',
|
|
13
|
+
style: floatingStyle ?? undefined
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* 패널 노드를 인라인·Portal 두 마운트 지점 중 한 곳에만 넘긴다. 양쪽에 두면 dropdownRef 가
|
|
18
|
+
* 나중 노드로 덮여 외부 클릭 판정과 키보드 스크롤이 유령 노드를 가리키게 된다.
|
|
19
|
+
*/
|
|
20
|
+
export const resolveDropdownMounts = _ref => {
|
|
21
|
+
let {
|
|
22
|
+
node,
|
|
23
|
+
shouldPortal,
|
|
24
|
+
portalContainer,
|
|
25
|
+
isOpen
|
|
26
|
+
} = _ref;
|
|
27
|
+
if (!shouldPortal) {
|
|
28
|
+
return {
|
|
29
|
+
inlineDropdown: node,
|
|
30
|
+
portaledDropdown: null
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (!portalContainer || !isOpen) {
|
|
34
|
+
return {
|
|
35
|
+
inlineDropdown: null,
|
|
36
|
+
portaledDropdown: null
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
inlineDropdown: null,
|
|
41
|
+
portaledDropdown: /*#__PURE__*/createPortal(node, portalContainer)
|
|
42
|
+
};
|
|
43
|
+
};
|
|
@@ -316,4 +316,36 @@ describe('useFloatingPosition — 프롭 변경 재구독 (E-5)', () => {
|
|
|
316
316
|
});
|
|
317
317
|
hook.unmount();
|
|
318
318
|
});
|
|
319
|
+
});
|
|
320
|
+
describe('useFloatingPosition — 좌표 불변 갱신', () => {
|
|
321
|
+
it('scroll 뒤에도 top·left·minWidth 가 같으면 이전 style 참조를 유지해 하위 트리 리렌더를 건너뛴다', () => {
|
|
322
|
+
installResizeObserverStub();
|
|
323
|
+
const hook = renderHook(baseParams({
|
|
324
|
+
triggerRef: makeRef(createAnchor()),
|
|
325
|
+
floatingRef: makeRef(createPanel()),
|
|
326
|
+
matchTriggerWidth: true
|
|
327
|
+
}));
|
|
328
|
+
const styleBeforeScroll = hook.latest();
|
|
329
|
+
dispatchScroll();
|
|
330
|
+
expect(hook.latest()).toBe(styleBeforeScroll);
|
|
331
|
+
hook.unmount();
|
|
332
|
+
});
|
|
333
|
+
it('scroll 로 좌표가 바뀌면 새 style 로 다시 렌더링한다', () => {
|
|
334
|
+
installResizeObserverStub();
|
|
335
|
+
const anchor = createAnchor();
|
|
336
|
+
const hook = renderHook(baseParams({
|
|
337
|
+
triggerRef: makeRef(anchor),
|
|
338
|
+
floatingRef: makeRef(createPanel())
|
|
339
|
+
}));
|
|
340
|
+
const moved = makeAnchorRect({
|
|
341
|
+
top: 300,
|
|
342
|
+
bottom: 340
|
|
343
|
+
});
|
|
344
|
+
vi.spyOn(anchor, 'getBoundingClientRect').mockReturnValue(toDomRect(moved));
|
|
345
|
+
dispatchScroll();
|
|
346
|
+
expect(hook.latest()).toMatchObject({
|
|
347
|
+
top: moved.bottom + DEFAULT_OFFSET
|
|
348
|
+
});
|
|
349
|
+
hook.unmount();
|
|
350
|
+
});
|
|
319
351
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
2
|
import { trackPortalPosition } from '../utils/portal-position';
|
|
3
3
|
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect';
|
|
4
|
+
const isSamePlacement = (prev, next) => prev !== null && prev.top === next.top && prev.left === next.left && prev.minWidth === next.minWidth;
|
|
4
5
|
export const useFloatingPosition = _ref => {
|
|
5
6
|
let {
|
|
6
7
|
enabled,
|
|
@@ -25,7 +26,7 @@ export const useFloatingPosition = _ref => {
|
|
|
25
26
|
align,
|
|
26
27
|
offset,
|
|
27
28
|
matchAnchorWidth: matchTriggerWidth,
|
|
28
|
-
onUpdate: setStyle
|
|
29
|
+
onUpdate: next => setStyle(prev => isSamePlacement(prev, next) ? prev : next)
|
|
29
30
|
});
|
|
30
31
|
}, [enabled, isOpen, direction, offset, align, matchTriggerWidth, triggerRef, floatingRef]);
|
|
31
32
|
return style;
|
|
@@ -112,6 +112,27 @@ describe('observeAnchorGeometry — 구독 (E-6)', () => {
|
|
|
112
112
|
expect(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.afterScroll);
|
|
113
113
|
cleanup();
|
|
114
114
|
});
|
|
115
|
+
it('panel 자신과 그 내부 요소의 scroll 은 anchor 를 움직이지 않으므로 onChange 를 호출하지 않는다', () => {
|
|
116
|
+
installResizeObserverStub();
|
|
117
|
+
const anchor = createAnchor();
|
|
118
|
+
const panel = createPanel();
|
|
119
|
+
const optionList = document.createElement('ul');
|
|
120
|
+
panel.appendChild(optionList);
|
|
121
|
+
const onChange = vi.fn();
|
|
122
|
+
const cleanup = observeAnchorGeometry({
|
|
123
|
+
getAnchor: () => anchor.element,
|
|
124
|
+
getPanel: () => panel,
|
|
125
|
+
onChange
|
|
126
|
+
});
|
|
127
|
+
panel.dispatchEvent(new Event('scroll', {
|
|
128
|
+
bubbles: false
|
|
129
|
+
}));
|
|
130
|
+
optionList.dispatchEvent(new Event('scroll', {
|
|
131
|
+
bubbles: false
|
|
132
|
+
}));
|
|
133
|
+
expect(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.initial);
|
|
134
|
+
cleanup();
|
|
135
|
+
});
|
|
115
136
|
it('anchor 와 panel 각각에 ResizeObserver 를 붙인다', () => {
|
|
116
137
|
const {
|
|
117
138
|
instances
|
|
@@ -104,14 +104,19 @@ export const observeAnchorGeometry = _ref5 => {
|
|
|
104
104
|
return noop;
|
|
105
105
|
}
|
|
106
106
|
onChange();
|
|
107
|
-
|
|
107
|
+
// 패널 내부 스크롤은 anchor 를 움직이지 않는다
|
|
108
|
+
const onScroll = event => {
|
|
109
|
+
if (event.target instanceof Node && getPanel()?.contains(event.target)) return;
|
|
110
|
+
onChange();
|
|
111
|
+
};
|
|
112
|
+
window.addEventListener('scroll', onScroll, {
|
|
108
113
|
passive: true,
|
|
109
114
|
capture: true
|
|
110
115
|
});
|
|
111
116
|
window.addEventListener('resize', onChange);
|
|
112
117
|
const observers = [observeElementResize(anchor, onChange), observeElementResize(getPanel(), onChange)];
|
|
113
118
|
return () => {
|
|
114
|
-
window.removeEventListener('scroll',
|
|
119
|
+
window.removeEventListener('scroll', onScroll, {
|
|
115
120
|
capture: true
|
|
116
121
|
});
|
|
117
122
|
window.removeEventListener('resize', onChange);
|
|
@@ -136,7 +141,6 @@ export const trackPortalPosition = _ref6 => {
|
|
|
136
141
|
}
|
|
137
142
|
});
|
|
138
143
|
};
|
|
139
|
-
// TODO(#497): vanilla 어댑터 도입 시 소비
|
|
140
144
|
export const applyPortalPanelStyle = (element, style) => {
|
|
141
145
|
element.style.position = style.position;
|
|
142
146
|
element.style.top = `${style.top}px`;
|
|
@@ -1,8 +1,7 @@
|
|
|
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
|
|
5
|
-
import { createPortal } from 'react-dom';
|
|
4
|
+
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
|
|
6
5
|
import { useDropdown } from '../../../hooks/dropdown';
|
|
7
6
|
import { useFloatingPosition } from '../../../hooks/useFloatingPosition';
|
|
8
7
|
import { usePortalState } from '../../../hooks/usePortalState';
|
|
@@ -12,6 +11,7 @@ import { Tag } from '../../feedback-and-status/tag';
|
|
|
12
11
|
import { SelectDropdown } from '../../select-dropdown';
|
|
13
12
|
import { HintText } from '../../shared/hintText/HintText';
|
|
14
13
|
import { InputBase } from '../input-base/InputBase';
|
|
14
|
+
import { resolveDropdownMounts, resolvePortalPanelBinding } from './utils';
|
|
15
15
|
const defaultMaxHeight = 275;
|
|
16
16
|
const notifyFormChange = (register, multiple, newValue) => {
|
|
17
17
|
if (register?.onChange) {
|
|
@@ -23,30 +23,6 @@ const notifyFormChange = (register, multiple, newValue) => {
|
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
25
|
};
|
|
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
26
|
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) => {
|
|
51
27
|
const internalRef = useRef(null);
|
|
52
28
|
// 인라인 패널의 offset parent 가 __content 다. Portal 좌표도 같은 박스를 기준으로 잡아야
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
2
|
+
type PortalPanelBinding = {
|
|
3
|
+
className?: string;
|
|
4
|
+
style?: CSSProperties;
|
|
5
|
+
};
|
|
6
|
+
type DropdownMounts = {
|
|
7
|
+
inlineDropdown: ReactNode;
|
|
8
|
+
portaledDropdown: ReactNode;
|
|
9
|
+
};
|
|
10
|
+
type ResolveDropdownMountsParams = {
|
|
11
|
+
node: ReactNode;
|
|
12
|
+
shouldPortal: boolean;
|
|
13
|
+
portalContainer: HTMLElement | null;
|
|
14
|
+
isOpen: boolean;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Portal 모드에서만 패널에 `--portal` 클래스와 fixed 좌표를 얹는다.
|
|
18
|
+
* floatingStyle 이 null 인 초기 레이아웃 직전에는 클래스만 붙지만,
|
|
19
|
+
* useLayoutEffect 가 paint 전에 좌표를 채우므로 빈 fixed 가 노출되지 않는다.
|
|
20
|
+
*/
|
|
21
|
+
export declare const resolvePortalPanelBinding: (shouldPortal: boolean, floatingStyle: CSSProperties | null) => PortalPanelBinding;
|
|
22
|
+
/**
|
|
23
|
+
* 패널 노드를 인라인·Portal 두 마운트 지점 중 한 곳에만 넘긴다. 양쪽에 두면 dropdownRef 가
|
|
24
|
+
* 나중 노드로 덮여 외부 클릭 판정과 키보드 스크롤이 유령 노드를 가리키게 된다.
|
|
25
|
+
*/
|
|
26
|
+
export declare const resolveDropdownMounts: ({ node, shouldPortal, portalContainer, isOpen, }: ResolveDropdownMountsParams) => DropdownMounts;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createPortal } from 'react-dom';
|
|
2
|
+
/**
|
|
3
|
+
* Portal 모드에서만 패널에 `--portal` 클래스와 fixed 좌표를 얹는다.
|
|
4
|
+
* floatingStyle 이 null 인 초기 레이아웃 직전에는 클래스만 붙지만,
|
|
5
|
+
* useLayoutEffect 가 paint 전에 좌표를 채우므로 빈 fixed 가 노출되지 않는다.
|
|
6
|
+
*/
|
|
7
|
+
export const resolvePortalPanelBinding = (shouldPortal, floatingStyle) => {
|
|
8
|
+
if (!shouldPortal) {
|
|
9
|
+
return {};
|
|
10
|
+
}
|
|
11
|
+
return { className: 'ncua-select-dropdown--portal', style: floatingStyle ?? undefined };
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* 패널 노드를 인라인·Portal 두 마운트 지점 중 한 곳에만 넘긴다. 양쪽에 두면 dropdownRef 가
|
|
15
|
+
* 나중 노드로 덮여 외부 클릭 판정과 키보드 스크롤이 유령 노드를 가리키게 된다.
|
|
16
|
+
*/
|
|
17
|
+
export const resolveDropdownMounts = ({ node, shouldPortal, portalContainer, isOpen, }) => {
|
|
18
|
+
if (!shouldPortal) {
|
|
19
|
+
return { inlineDropdown: node, portaledDropdown: null };
|
|
20
|
+
}
|
|
21
|
+
if (!portalContainer || !isOpen) {
|
|
22
|
+
return { inlineDropdown: null, portaledDropdown: null };
|
|
23
|
+
}
|
|
24
|
+
return { inlineDropdown: null, portaledDropdown: createPortal(node, portalContainer) };
|
|
25
|
+
};
|
|
@@ -203,3 +203,27 @@ describe('useFloatingPosition — 프롭 변경 재구독 (E-5)', () => {
|
|
|
203
203
|
hook.unmount();
|
|
204
204
|
});
|
|
205
205
|
});
|
|
206
|
+
describe('useFloatingPosition — 좌표 불변 갱신', () => {
|
|
207
|
+
it('scroll 뒤에도 top·left·minWidth 가 같으면 이전 style 참조를 유지해 하위 트리 리렌더를 건너뛴다', () => {
|
|
208
|
+
installResizeObserverStub();
|
|
209
|
+
const hook = renderHook(baseParams({
|
|
210
|
+
triggerRef: makeRef(createAnchor()),
|
|
211
|
+
floatingRef: makeRef(createPanel()),
|
|
212
|
+
matchTriggerWidth: true,
|
|
213
|
+
}));
|
|
214
|
+
const styleBeforeScroll = hook.latest();
|
|
215
|
+
dispatchScroll();
|
|
216
|
+
expect(hook.latest()).toBe(styleBeforeScroll);
|
|
217
|
+
hook.unmount();
|
|
218
|
+
});
|
|
219
|
+
it('scroll 로 좌표가 바뀌면 새 style 로 다시 렌더링한다', () => {
|
|
220
|
+
installResizeObserverStub();
|
|
221
|
+
const anchor = createAnchor();
|
|
222
|
+
const hook = renderHook(baseParams({ triggerRef: makeRef(anchor), floatingRef: makeRef(createPanel()) }));
|
|
223
|
+
const moved = makeAnchorRect({ top: 300, bottom: 340 });
|
|
224
|
+
vi.spyOn(anchor, 'getBoundingClientRect').mockReturnValue(toDomRect(moved));
|
|
225
|
+
dispatchScroll();
|
|
226
|
+
expect(hook.latest()).toMatchObject({ top: moved.bottom + DEFAULT_OFFSET });
|
|
227
|
+
hook.unmount();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
|
-
import { trackPortalPosition } from '../utils/portal-position';
|
|
2
|
+
import { trackPortalPosition, } from '../utils/portal-position';
|
|
3
3
|
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect';
|
|
4
|
+
const isSamePlacement = (prev, next) => prev !== null && prev.top === next.top && prev.left === next.left && prev.minWidth === next.minWidth;
|
|
4
5
|
export const useFloatingPosition = ({ enabled, isOpen, triggerRef, floatingRef, direction, offset, align, matchTriggerWidth = false, }) => {
|
|
5
6
|
const [style, setStyle] = useState(null);
|
|
6
7
|
useIsomorphicLayoutEffect(() => {
|
|
@@ -15,7 +16,7 @@ export const useFloatingPosition = ({ enabled, isOpen, triggerRef, floatingRef,
|
|
|
15
16
|
align,
|
|
16
17
|
offset,
|
|
17
18
|
matchAnchorWidth: matchTriggerWidth,
|
|
18
|
-
onUpdate: setStyle,
|
|
19
|
+
onUpdate: (next) => setStyle((prev) => (isSamePlacement(prev, next) ? prev : next)),
|
|
19
20
|
});
|
|
20
21
|
}, [enabled, isOpen, direction, offset, align, matchTriggerWidth, triggerRef, floatingRef]);
|
|
21
22
|
return style;
|
|
@@ -71,6 +71,19 @@ describe('observeAnchorGeometry — 구독 (E-6)', () => {
|
|
|
71
71
|
expect(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.afterScroll);
|
|
72
72
|
cleanup();
|
|
73
73
|
});
|
|
74
|
+
it('panel 자신과 그 내부 요소의 scroll 은 anchor 를 움직이지 않으므로 onChange 를 호출하지 않는다', () => {
|
|
75
|
+
installResizeObserverStub();
|
|
76
|
+
const anchor = createAnchor();
|
|
77
|
+
const panel = createPanel();
|
|
78
|
+
const optionList = document.createElement('ul');
|
|
79
|
+
panel.appendChild(optionList);
|
|
80
|
+
const onChange = vi.fn();
|
|
81
|
+
const cleanup = observeAnchorGeometry({ getAnchor: () => anchor.element, getPanel: () => panel, onChange });
|
|
82
|
+
panel.dispatchEvent(new Event('scroll', { bubbles: false }));
|
|
83
|
+
optionList.dispatchEvent(new Event('scroll', { bubbles: false }));
|
|
84
|
+
expect(onChange).toHaveBeenCalledTimes(ON_CHANGE_CALLS.initial);
|
|
85
|
+
cleanup();
|
|
86
|
+
});
|
|
74
87
|
it('anchor 와 panel 각각에 ResizeObserver 를 붙인다', () => {
|
|
75
88
|
const { instances } = installResizeObserverStub();
|
|
76
89
|
const anchor = createAnchor();
|
|
@@ -2,7 +2,7 @@ export type PortalDirection = 'up' | 'down';
|
|
|
2
2
|
export type PortalAlign = 'left' | 'right';
|
|
3
3
|
export type AnchorRect = Readonly<Pick<DOMRectReadOnly, 'top' | 'bottom' | 'left' | 'right' | 'width'>>;
|
|
4
4
|
export declare const PORTAL_PANEL_Z_INDEX = 1500;
|
|
5
|
-
export type
|
|
5
|
+
export type ComputePortalPositionParams = Readonly<{
|
|
6
6
|
anchorRect: AnchorRect;
|
|
7
7
|
panelHeight: number;
|
|
8
8
|
panelWidth: number;
|
|
@@ -14,15 +14,15 @@ export type PortalPosition = Readonly<{
|
|
|
14
14
|
top: number;
|
|
15
15
|
left: number;
|
|
16
16
|
}>;
|
|
17
|
-
export declare const computePortalPosition: ({ anchorRect, panelHeight, panelWidth, direction, align, offset, }:
|
|
18
|
-
export type
|
|
17
|
+
export declare const computePortalPosition: ({ anchorRect, panelHeight, panelWidth, direction, align, offset, }: ComputePortalPositionParams) => PortalPosition;
|
|
18
|
+
export type ResolveDropdownDirectionParams = Readonly<{
|
|
19
19
|
anchorRect: Pick<AnchorRect, 'bottom'>;
|
|
20
20
|
panelHeight: number;
|
|
21
21
|
viewportHeight: number;
|
|
22
22
|
gap?: number;
|
|
23
23
|
}>;
|
|
24
|
-
export declare const resolveDropdownDirection: ({ anchorRect, panelHeight, viewportHeight, gap, }:
|
|
25
|
-
export type
|
|
24
|
+
export declare const resolveDropdownDirection: ({ anchorRect, panelHeight, viewportHeight, gap, }: ResolveDropdownDirectionParams) => PortalDirection;
|
|
25
|
+
export type BuildPortalPanelStyleParams = Readonly<{
|
|
26
26
|
anchorRect: AnchorRect;
|
|
27
27
|
panelHeight: number;
|
|
28
28
|
panelWidth: number | null;
|
|
@@ -42,18 +42,15 @@ export type PortalPanelStyle = Readonly<{
|
|
|
42
42
|
zIndex: number;
|
|
43
43
|
minWidth?: number;
|
|
44
44
|
}>;
|
|
45
|
-
export declare const buildPortalPanelStyle: ({ anchorRect, panelHeight, panelWidth, direction, align, offset, matchAnchorWidth, }:
|
|
46
|
-
export type
|
|
45
|
+
export declare const buildPortalPanelStyle: ({ anchorRect, panelHeight, panelWidth, direction, align, offset, matchAnchorWidth, }: BuildPortalPanelStyleParams) => PortalPanelStyle;
|
|
46
|
+
export type ObserveAnchorGeometryParams = Readonly<{
|
|
47
47
|
getAnchor: () => HTMLElement | null;
|
|
48
|
-
/**
|
|
49
|
-
* 구독 시점에 1회 호출되어 ResizeObserver 가 부착된다.
|
|
50
|
-
* 구독 후 패널이 교체·마운트되어도 옵저버는 갱신되지 않는다.
|
|
51
|
-
*/
|
|
48
|
+
/** ResizeObserver 는 구독 시점의 패널에만 부착된다 — 이후 교체·마운트되어도 옵저버는 갱신되지 않는다. */
|
|
52
49
|
getPanel: () => HTMLElement | null;
|
|
53
50
|
onChange: () => void;
|
|
54
51
|
}>;
|
|
55
|
-
export declare const observeAnchorGeometry: ({ getAnchor, getPanel, onChange
|
|
56
|
-
export type
|
|
52
|
+
export declare const observeAnchorGeometry: ({ getAnchor, getPanel, onChange }: ObserveAnchorGeometryParams) => (() => void);
|
|
53
|
+
export type TrackPortalPositionParams = Readonly<{
|
|
57
54
|
getAnchor: () => HTMLElement | null;
|
|
58
55
|
getPanel: () => HTMLElement | null;
|
|
59
56
|
/** 함수를 넘기면 갱신마다 다시 읽는다 — 열린 뒤 패널 높이가 변하는 소비자가 방향을 재판정할 수 있다 */
|
|
@@ -63,5 +60,5 @@ export type TrackPortalPositionOptions = Readonly<{
|
|
|
63
60
|
matchAnchorWidth?: boolean;
|
|
64
61
|
onUpdate: (style: PortalPanelStyle) => void;
|
|
65
62
|
}>;
|
|
66
|
-
export declare const trackPortalPosition: ({ onUpdate, ...measureOptions }:
|
|
63
|
+
export declare const trackPortalPosition: ({ onUpdate, ...measureOptions }: TrackPortalPositionParams) => (() => void);
|
|
67
64
|
export declare const applyPortalPanelStyle: (element: HTMLElement, style: PortalPanelStyle) => void;
|
|
@@ -47,17 +47,23 @@ export const buildPortalPanelStyle = ({ anchorRect, panelHeight, panelWidth, dir
|
|
|
47
47
|
// 옵션이 짧으면 앵커 너비, 길면 자연 확장 — Portal 이전 absolute 모드와 동일한 UX.
|
|
48
48
|
return matchAnchorWidth ? { ...base, minWidth: anchorRect.width } : base;
|
|
49
49
|
};
|
|
50
|
-
export const observeAnchorGeometry = ({ getAnchor, getPanel, onChange
|
|
50
|
+
export const observeAnchorGeometry = ({ getAnchor, getPanel, onChange }) => {
|
|
51
51
|
const anchor = getAnchor();
|
|
52
52
|
if (anchor === null) {
|
|
53
53
|
return noop;
|
|
54
54
|
}
|
|
55
55
|
onChange();
|
|
56
|
-
|
|
56
|
+
// 패널 내부 스크롤은 anchor 를 움직이지 않는다
|
|
57
|
+
const onScroll = (event) => {
|
|
58
|
+
if (event.target instanceof Node && getPanel()?.contains(event.target))
|
|
59
|
+
return;
|
|
60
|
+
onChange();
|
|
61
|
+
};
|
|
62
|
+
window.addEventListener('scroll', onScroll, { passive: true, capture: true });
|
|
57
63
|
window.addEventListener('resize', onChange);
|
|
58
64
|
const observers = [observeElementResize(anchor, onChange), observeElementResize(getPanel(), onChange)];
|
|
59
65
|
return () => {
|
|
60
|
-
window.removeEventListener('scroll',
|
|
66
|
+
window.removeEventListener('scroll', onScroll, { capture: true });
|
|
61
67
|
window.removeEventListener('resize', onChange);
|
|
62
68
|
for (const observer of observers) {
|
|
63
69
|
observer?.disconnect();
|
|
@@ -74,7 +80,6 @@ export const trackPortalPosition = ({ onUpdate, ...measureOptions }) => observeA
|
|
|
74
80
|
}
|
|
75
81
|
},
|
|
76
82
|
});
|
|
77
|
-
// TODO(#497): vanilla 어댑터 도입 시 소비
|
|
78
83
|
export const applyPortalPanelStyle = (element, style) => {
|
|
79
84
|
element.style.position = style.position;
|
|
80
85
|
element.style.top = `${style.top}px`;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
2
|
+
type PortalPanelBinding = {
|
|
3
|
+
className?: string;
|
|
4
|
+
style?: CSSProperties;
|
|
5
|
+
};
|
|
6
|
+
type DropdownMounts = {
|
|
7
|
+
inlineDropdown: ReactNode;
|
|
8
|
+
portaledDropdown: ReactNode;
|
|
9
|
+
};
|
|
10
|
+
type ResolveDropdownMountsParams = {
|
|
11
|
+
node: ReactNode;
|
|
12
|
+
shouldPortal: boolean;
|
|
13
|
+
portalContainer: HTMLElement | null;
|
|
14
|
+
isOpen: boolean;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Portal 모드에서만 패널에 `--portal` 클래스와 fixed 좌표를 얹는다.
|
|
18
|
+
* floatingStyle 이 null 인 초기 레이아웃 직전에는 클래스만 붙지만,
|
|
19
|
+
* useLayoutEffect 가 paint 전에 좌표를 채우므로 빈 fixed 가 노출되지 않는다.
|
|
20
|
+
*/
|
|
21
|
+
export declare const resolvePortalPanelBinding: (shouldPortal: boolean, floatingStyle: CSSProperties | null) => PortalPanelBinding;
|
|
22
|
+
/**
|
|
23
|
+
* 패널 노드를 인라인·Portal 두 마운트 지점 중 한 곳에만 넘긴다. 양쪽에 두면 dropdownRef 가
|
|
24
|
+
* 나중 노드로 덮여 외부 클릭 판정과 키보드 스크롤이 유령 노드를 가리키게 된다.
|
|
25
|
+
*/
|
|
26
|
+
export declare const resolveDropdownMounts: ({ node, shouldPortal, portalContainer, isOpen, }: ResolveDropdownMountsParams) => DropdownMounts;
|
|
27
|
+
export {};
|
|
@@ -2,7 +2,7 @@ export type PortalDirection = 'up' | 'down';
|
|
|
2
2
|
export type PortalAlign = 'left' | 'right';
|
|
3
3
|
export type AnchorRect = Readonly<Pick<DOMRectReadOnly, 'top' | 'bottom' | 'left' | 'right' | 'width'>>;
|
|
4
4
|
export declare const PORTAL_PANEL_Z_INDEX = 1500;
|
|
5
|
-
export type
|
|
5
|
+
export type ComputePortalPositionParams = Readonly<{
|
|
6
6
|
anchorRect: AnchorRect;
|
|
7
7
|
panelHeight: number;
|
|
8
8
|
panelWidth: number;
|
|
@@ -14,15 +14,15 @@ export type PortalPosition = Readonly<{
|
|
|
14
14
|
top: number;
|
|
15
15
|
left: number;
|
|
16
16
|
}>;
|
|
17
|
-
export declare const computePortalPosition: ({ anchorRect, panelHeight, panelWidth, direction, align, offset, }:
|
|
18
|
-
export type
|
|
17
|
+
export declare const computePortalPosition: ({ anchorRect, panelHeight, panelWidth, direction, align, offset, }: ComputePortalPositionParams) => PortalPosition;
|
|
18
|
+
export type ResolveDropdownDirectionParams = Readonly<{
|
|
19
19
|
anchorRect: Pick<AnchorRect, 'bottom'>;
|
|
20
20
|
panelHeight: number;
|
|
21
21
|
viewportHeight: number;
|
|
22
22
|
gap?: number;
|
|
23
23
|
}>;
|
|
24
|
-
export declare const resolveDropdownDirection: ({ anchorRect, panelHeight, viewportHeight, gap, }:
|
|
25
|
-
export type
|
|
24
|
+
export declare const resolveDropdownDirection: ({ anchorRect, panelHeight, viewportHeight, gap, }: ResolveDropdownDirectionParams) => PortalDirection;
|
|
25
|
+
export type BuildPortalPanelStyleParams = Readonly<{
|
|
26
26
|
anchorRect: AnchorRect;
|
|
27
27
|
panelHeight: number;
|
|
28
28
|
panelWidth: number | null;
|
|
@@ -42,18 +42,15 @@ export type PortalPanelStyle = Readonly<{
|
|
|
42
42
|
zIndex: number;
|
|
43
43
|
minWidth?: number;
|
|
44
44
|
}>;
|
|
45
|
-
export declare const buildPortalPanelStyle: ({ anchorRect, panelHeight, panelWidth, direction, align, offset, matchAnchorWidth, }:
|
|
46
|
-
export type
|
|
45
|
+
export declare const buildPortalPanelStyle: ({ anchorRect, panelHeight, panelWidth, direction, align, offset, matchAnchorWidth, }: BuildPortalPanelStyleParams) => PortalPanelStyle;
|
|
46
|
+
export type ObserveAnchorGeometryParams = Readonly<{
|
|
47
47
|
getAnchor: () => HTMLElement | null;
|
|
48
|
-
/**
|
|
49
|
-
* 구독 시점에 1회 호출되어 ResizeObserver 가 부착된다.
|
|
50
|
-
* 구독 후 패널이 교체·마운트되어도 옵저버는 갱신되지 않는다.
|
|
51
|
-
*/
|
|
48
|
+
/** ResizeObserver 는 구독 시점의 패널에만 부착된다 — 이후 교체·마운트되어도 옵저버는 갱신되지 않는다. */
|
|
52
49
|
getPanel: () => HTMLElement | null;
|
|
53
50
|
onChange: () => void;
|
|
54
51
|
}>;
|
|
55
|
-
export declare const observeAnchorGeometry: ({ getAnchor, getPanel, onChange
|
|
56
|
-
export type
|
|
52
|
+
export declare const observeAnchorGeometry: ({ getAnchor, getPanel, onChange }: ObserveAnchorGeometryParams) => (() => void);
|
|
53
|
+
export type TrackPortalPositionParams = Readonly<{
|
|
57
54
|
getAnchor: () => HTMLElement | null;
|
|
58
55
|
getPanel: () => HTMLElement | null;
|
|
59
56
|
/** 함수를 넘기면 갱신마다 다시 읽는다 — 열린 뒤 패널 높이가 변하는 소비자가 방향을 재판정할 수 있다 */
|
|
@@ -63,5 +60,5 @@ export type TrackPortalPositionOptions = Readonly<{
|
|
|
63
60
|
matchAnchorWidth?: boolean;
|
|
64
61
|
onUpdate: (style: PortalPanelStyle) => void;
|
|
65
62
|
}>;
|
|
66
|
-
export declare const trackPortalPosition: ({ onUpdate, ...measureOptions }:
|
|
63
|
+
export declare const trackPortalPosition: ({ onUpdate, ...measureOptions }: TrackPortalPositionParams) => (() => void);
|
|
67
64
|
export declare const applyPortalPanelStyle: (element: HTMLElement, style: PortalPanelStyle) => void;
|