@ncds/ui-admin 1.8.13 → 1.8.15
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/checkbox/CheckboxInput.js +3 -2
- package/dist/cjs/src/components/forms-and-input/date-picker/DatePicker.js +17 -20
- package/dist/cjs/src/components/forms-and-input/date-picker/__tests__/DatePicker.memo.test.js +53 -0
- package/dist/cjs/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +129 -0
- package/dist/cjs/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +22 -0
- package/dist/cjs/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +95 -0
- package/dist/cjs/src/components/forms-and-input/range-date-picker-with-buttons/RangeDatePickerWithButtons.js +7 -1
- package/dist/cjs/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +57 -0
- package/dist/cjs/src/components/forms-and-input/select/Select.js +12 -1
- package/dist/cjs/src/components/forms-and-input/select-box/SelectBox.js +5 -2
- package/dist/cjs/src/components/forms-and-input/slider/Slider.js +7 -1
- package/dist/cjs/src/components/navigation/pagination/Pagination.js +12 -2
- package/dist/cjs/src/components/navigation/pagination/__tests__/Pagination.test.js +62 -0
- package/dist/cjs/src/components/overlays/tooltip/Tooltip.js +15 -2
- package/dist/cjs/src/components/overlays/tooltip/__tests__/utils.test.js +40 -0
- package/dist/cjs/src/components/overlays/tooltip/utils.js +20 -0
- package/dist/cjs/src/hooks/index.js +11 -0
- package/dist/cjs/src/hooks/useIsomorphicLayoutEffect.js +8 -0
- package/dist/cjs/src/hooks/useMediaQuery.js +3 -0
- package/dist/cjs/src/utils/__tests__/date-picker.test.js +29 -0
- package/dist/cjs/src/utils/date-picker.js +15 -1
- package/dist/esm/src/components/forms-and-input/checkbox/CheckboxInput.js +4 -3
- package/dist/esm/src/components/forms-and-input/date-picker/DatePicker.js +18 -21
- package/dist/esm/src/components/forms-and-input/date-picker/__tests__/DatePicker.memo.test.js +50 -0
- package/dist/esm/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +126 -0
- package/dist/esm/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +22 -0
- package/dist/esm/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +92 -0
- package/dist/esm/src/components/forms-and-input/range-date-picker-with-buttons/RangeDatePickerWithButtons.js +7 -1
- package/dist/esm/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +54 -0
- package/dist/esm/src/components/forms-and-input/select/Select.js +12 -1
- package/dist/esm/src/components/forms-and-input/select-box/SelectBox.js +5 -2
- package/dist/esm/src/components/forms-and-input/slider/Slider.js +7 -1
- package/dist/esm/src/components/navigation/pagination/Pagination.js +12 -2
- package/dist/esm/src/components/navigation/pagination/__tests__/Pagination.test.js +59 -0
- package/dist/esm/src/components/overlays/tooltip/Tooltip.js +15 -2
- package/dist/esm/src/components/overlays/tooltip/__tests__/utils.test.js +38 -0
- package/dist/esm/src/components/overlays/tooltip/utils.js +13 -0
- package/dist/esm/src/hooks/index.js +1 -0
- package/dist/esm/src/hooks/useIsomorphicLayoutEffect.js +2 -0
- package/dist/esm/src/hooks/useMediaQuery.js +3 -0
- package/dist/esm/src/utils/__tests__/date-picker.test.js +26 -0
- package/dist/esm/src/utils/date-picker.js +13 -0
- package/dist/temp/src/components/forms-and-input/checkbox/CheckboxInput.js +4 -3
- package/dist/temp/src/components/forms-and-input/date-picker/DatePicker.js +18 -28
- package/dist/temp/src/components/forms-and-input/date-picker/__tests__/DatePicker.memo.test.d.ts +1 -0
- package/dist/temp/src/components/forms-and-input/date-picker/__tests__/DatePicker.memo.test.js +43 -0
- package/dist/temp/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.d.ts +1 -0
- package/dist/temp/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +109 -0
- package/dist/temp/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +22 -0
- package/dist/temp/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.d.ts +1 -0
- package/dist/temp/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +60 -0
- package/dist/temp/src/components/forms-and-input/range-date-picker-with-buttons/RangeDatePickerWithButtons.js +9 -1
- package/dist/temp/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.d.ts +1 -0
- package/dist/temp/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +42 -0
- package/dist/temp/src/components/forms-and-input/select/Select.d.ts +1 -0
- package/dist/temp/src/components/forms-and-input/select/Select.js +10 -2
- package/dist/temp/src/components/forms-and-input/select-box/SelectBox.d.ts +1 -0
- package/dist/temp/src/components/forms-and-input/select-box/SelectBox.js +5 -4
- package/dist/temp/src/components/forms-and-input/slider/Slider.js +7 -1
- package/dist/temp/src/components/navigation/pagination/Pagination.js +12 -2
- package/dist/temp/src/components/navigation/pagination/__tests__/Pagination.test.d.ts +1 -0
- package/dist/temp/src/components/navigation/pagination/__tests__/Pagination.test.js +59 -0
- package/dist/temp/src/components/overlays/tooltip/Tooltip.js +21 -4
- package/dist/temp/src/components/overlays/tooltip/__tests__/utils.test.d.ts +1 -0
- package/dist/temp/src/components/overlays/tooltip/__tests__/utils.test.js +38 -0
- package/dist/temp/src/components/overlays/tooltip/utils.d.ts +9 -0
- package/dist/temp/src/components/overlays/tooltip/utils.js +13 -0
- package/dist/temp/src/hooks/index.d.ts +1 -0
- package/dist/temp/src/hooks/index.js +1 -0
- package/dist/temp/src/hooks/useIsomorphicLayoutEffect.d.ts +2 -0
- package/dist/temp/src/hooks/useIsomorphicLayoutEffect.js +2 -0
- package/dist/temp/src/hooks/useMediaQuery.js +3 -0
- package/dist/temp/src/utils/__tests__/date-picker.test.d.ts +1 -0
- package/dist/temp/src/utils/__tests__/date-picker.test.js +26 -0
- package/dist/temp/src/utils/date-picker.d.ts +2 -0
- package/dist/temp/src/utils/date-picker.js +13 -0
- package/dist/types/src/components/forms-and-input/date-picker/__tests__/DatePicker.memo.test.d.ts +1 -0
- package/dist/types/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.d.ts +1 -0
- package/dist/types/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.d.ts +1 -0
- package/dist/types/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.d.ts +1 -0
- package/dist/types/src/components/forms-and-input/select/Select.d.ts +1 -0
- package/dist/types/src/components/forms-and-input/select-box/SelectBox.d.ts +1 -0
- package/dist/types/src/components/navigation/pagination/__tests__/Pagination.test.d.ts +1 -0
- package/dist/types/src/components/overlays/tooltip/__tests__/utils.test.d.ts +1 -0
- package/dist/types/src/components/overlays/tooltip/utils.d.ts +9 -0
- package/dist/types/src/hooks/index.d.ts +1 -0
- package/dist/types/src/hooks/useIsomorphicLayoutEffect.d.ts +2 -0
- package/dist/types/src/utils/__tests__/date-picker.test.d.ts +1 -0
- package/dist/types/src/utils/date-picker.d.ts +2 -0
- package/dist/ui-admin/assets/styles/style.css +33 -21
- package/package.json +2 -1
|
@@ -33,6 +33,13 @@ const Pagination = _ref => {
|
|
|
33
33
|
}
|
|
34
34
|
};
|
|
35
35
|
(0, _react.useEffect)(() => {
|
|
36
|
+
const lastGroupStart = Math.max(1, Math.floor((totalPage - 1) / pageCount) * pageCount + 1);
|
|
37
|
+
// totalPage/pageCount 가 줄어 start 가 마지막 그룹을 넘으면 마지막 그룹으로 보정한다.
|
|
38
|
+
// (예: 100→50 페이지로 축소 시 start 가 91 에 머물러 범위 밖 버튼을 표시하던 문제)
|
|
39
|
+
if (start > lastGroupStart) {
|
|
40
|
+
setStart(lastGroupStart);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
36
43
|
if (currentPage === start + pageCount) {
|
|
37
44
|
setStart(start + pageCount);
|
|
38
45
|
}
|
|
@@ -47,10 +54,13 @@ const Pagination = _ref => {
|
|
|
47
54
|
setStart(1);
|
|
48
55
|
}
|
|
49
56
|
if (currentPage === totalPage) {
|
|
50
|
-
const lastGroupStart = Math.floor((totalPage - 1) / pageCount) * pageCount + 1;
|
|
51
57
|
setStart(lastGroupStart);
|
|
52
58
|
}
|
|
53
|
-
|
|
59
|
+
// start 는 이 effect 가 setStart 로 갱신하는 대상이라 deps 에서 제외한다(넣으면 자기 갱신으로
|
|
60
|
+
// 재실행). currentPage 외에 totalPage/pageCount 도 추적해, 이들이 줄어 start 가 범위를 벗어나면
|
|
61
|
+
// 위에서 보정한다.
|
|
62
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
63
|
+
}, [currentPage, totalPage, pageCount]);
|
|
54
64
|
return (0, _jsxRuntime.jsxs)("div", {
|
|
55
65
|
className: (0, _classnames.default)('ncua-pagination', `ncua-pagination--${breakPoint}`, className),
|
|
56
66
|
children: [showJumpPageButton && (0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _react = require("react");
|
|
4
|
+
var _client = require("react-dom/client");
|
|
5
|
+
var _testUtils = require("react-dom/test-utils");
|
|
6
|
+
var _vitest = require("vitest");
|
|
7
|
+
var _Pagination = require("../Pagination");
|
|
8
|
+
// @vitest-environment jsdom
|
|
9
|
+
|
|
10
|
+
const ITEM_COUNT_PER_PAGE = 10;
|
|
11
|
+
const PAGE_GROUP_SIZE = 10;
|
|
12
|
+
const TOTAL_COUNT_100_PAGES = 1000;
|
|
13
|
+
const TOTAL_COUNT_50_PAGES = 500;
|
|
14
|
+
const CURRENT_PAGE = 91; // 100페이지 기준 마지막 그룹(91~100)을 보던 페이지
|
|
15
|
+
const GROUP_START_100_PAGES = 91; // currentPage=91 일 때의 그룹 시작
|
|
16
|
+
const GROUP_START_50_PAGES = 41; // totalPage=50 으로 줄었을 때의 마지막 그룹 시작
|
|
17
|
+
const LAST_PAGE_50 = 50;
|
|
18
|
+
let root = null;
|
|
19
|
+
let container = null;
|
|
20
|
+
function render(props) {
|
|
21
|
+
if (!container) {
|
|
22
|
+
container = document.createElement('div');
|
|
23
|
+
document.body.appendChild(container);
|
|
24
|
+
root = (0, _client.createRoot)(container);
|
|
25
|
+
}
|
|
26
|
+
(0, _testUtils.act)(() => {
|
|
27
|
+
root?.render(/*#__PURE__*/(0, _react.createElement)(_Pagination.Pagination, props));
|
|
28
|
+
});
|
|
29
|
+
return container;
|
|
30
|
+
}
|
|
31
|
+
(0, _vitest.afterEach)(() => {
|
|
32
|
+
if (root && container) {
|
|
33
|
+
(0, _testUtils.act)(() => root?.unmount());
|
|
34
|
+
container.remove();
|
|
35
|
+
}
|
|
36
|
+
root = null;
|
|
37
|
+
container = null;
|
|
38
|
+
});
|
|
39
|
+
const pageNums = el => Array.from(el.querySelectorAll('.ncua-pagination__page-num')).map(b => Number(b.textContent));
|
|
40
|
+
(0, _vitest.describe)('#332 Pagination — totalPage 축소 시 start 보정', () => {
|
|
41
|
+
(0, _vitest.it)('totalPage 가 줄면 start 를 마지막 그룹으로 보정하고 범위 밖 페이지 버튼을 표시하지 않는다', () => {
|
|
42
|
+
// 1) 100 페이지에서 91 페이지 보는 중 → 91~100 그룹 표시
|
|
43
|
+
const el = render({
|
|
44
|
+
totalCount: TOTAL_COUNT_100_PAGES,
|
|
45
|
+
itemCountPerPage: ITEM_COUNT_PER_PAGE,
|
|
46
|
+
pageCount: PAGE_GROUP_SIZE,
|
|
47
|
+
currentPage: CURRENT_PAGE
|
|
48
|
+
});
|
|
49
|
+
(0, _vitest.expect)(pageNums(el)[0]).toBe(GROUP_START_100_PAGES);
|
|
50
|
+
// 2) 데이터가 50 페이지로 줄어듦 (currentPage 는 그대로 — 부모가 clamp 안 한 상황)
|
|
51
|
+
render({
|
|
52
|
+
totalCount: TOTAL_COUNT_50_PAGES,
|
|
53
|
+
itemCountPerPage: ITEM_COUNT_PER_PAGE,
|
|
54
|
+
pageCount: PAGE_GROUP_SIZE,
|
|
55
|
+
currentPage: CURRENT_PAGE
|
|
56
|
+
});
|
|
57
|
+
const nums = pageNums(el);
|
|
58
|
+
// 마지막 그룹(41~50)으로 보정되고, totalPage(50)를 넘는 버튼은 없어야 한다
|
|
59
|
+
(0, _vitest.expect)(nums[0]).toBe(GROUP_START_50_PAGES);
|
|
60
|
+
(0, _vitest.expect)(Math.max(...nums)).toBeLessThanOrEqual(LAST_PAGE_50);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -10,6 +10,7 @@ var _classnames = _interopRequireDefault(require("classnames"));
|
|
|
10
10
|
var _react = require("react");
|
|
11
11
|
var _reactDom = require("react-dom");
|
|
12
12
|
var _ButtonCloseX = require("../../action/button/ButtonCloseX");
|
|
13
|
+
var _utils = require("./utils");
|
|
13
14
|
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
14
15
|
const ICON_SIZE_SM = 14;
|
|
15
16
|
const ICON_SIZE_DEFAULT = 16;
|
|
@@ -71,9 +72,16 @@ const computePanelCoords = (prefer, anchor, panel) => {
|
|
|
71
72
|
}
|
|
72
73
|
left = Math.max(VIEWPORT_MARGIN, Math.min(left, window.innerWidth - panel.width - VIEWPORT_MARGIN));
|
|
73
74
|
top = Math.max(VIEWPORT_MARGIN, Math.min(top, window.innerHeight - panel.height - VIEWPORT_MARGIN));
|
|
75
|
+
// 경계 보정으로 패널이 앵커 중심에서 밀렸을 때도 화살표(::after)가 앵커를 가리키도록
|
|
76
|
+
// 패널 내 X 오프셋 계산 (top/bottom 계열만 — SCSS의 --top/--bottom 규칙이 소비)
|
|
77
|
+
let arrowX = null;
|
|
78
|
+
if (calculatedPosition === 'top' || calculatedPosition === 'bottom') {
|
|
79
|
+
arrowX = (0, _utils.calculateArrowOffsetX)(anchor.left + anchor.width / 2, left, panel.width);
|
|
80
|
+
}
|
|
74
81
|
return {
|
|
75
82
|
top: Math.round(top),
|
|
76
83
|
left: Math.round(left),
|
|
84
|
+
arrowX,
|
|
77
85
|
calculatedPosition
|
|
78
86
|
};
|
|
79
87
|
};
|
|
@@ -86,6 +94,9 @@ const buildPanelStyle = (disablePortal, coords, visible, zIndex) => {
|
|
|
86
94
|
top: `${coords.top}px`,
|
|
87
95
|
left: `${coords.left}px`,
|
|
88
96
|
opacity,
|
|
97
|
+
...(coords.arrowX !== null && {
|
|
98
|
+
'--ncua-tooltip-arrow-x': `${coords.arrowX}px`
|
|
99
|
+
}),
|
|
89
100
|
...(zIndex && {
|
|
90
101
|
zIndex
|
|
91
102
|
})
|
|
@@ -138,7 +149,8 @@ const Tooltip = _ref => {
|
|
|
138
149
|
const [mounted, setMounted] = (0, _react.useState)(false);
|
|
139
150
|
const [coords, setCoords] = (0, _react.useState)({
|
|
140
151
|
top: OFFSCREEN,
|
|
141
|
-
left: OFFSCREEN
|
|
152
|
+
left: OFFSCREEN,
|
|
153
|
+
arrowX: null
|
|
142
154
|
});
|
|
143
155
|
const [calculatedPosition, setCalculatedPosition] = (0, _react.useState)(position === 'auto' ? 'bottom' : position);
|
|
144
156
|
const [isVisible, setIsVisible] = (0, _react.useState)(false);
|
|
@@ -160,7 +172,8 @@ const Tooltip = _ref => {
|
|
|
160
172
|
const next = computePanelCoords(position, anchor, panel);
|
|
161
173
|
setCoords({
|
|
162
174
|
top: next.top,
|
|
163
|
-
left: next.left
|
|
175
|
+
left: next.left,
|
|
176
|
+
arrowX: next.arrowX
|
|
164
177
|
});
|
|
165
178
|
setCalculatedPosition(next.calculatedPosition);
|
|
166
179
|
}, [position, disablePortal]);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _vitest = require("vitest");
|
|
4
|
+
var _utils = require("../utils");
|
|
5
|
+
// 공통 픽스처 — 너비 300px 패널이 viewport 왼쪽 여백(8px)에 클램핑된 상태를 가정
|
|
6
|
+
const PANEL_WIDTH = 300;
|
|
7
|
+
const CLAMPED_LEFT = 8;
|
|
8
|
+
(0, _vitest.describe)('calculateArrowOffsetX — 화살표가 앵커 중심을 가리키도록 패널 내 X 오프셋 계산', () => {
|
|
9
|
+
(0, _vitest.it)('패널이 클램핑되지 않으면(앵커 중심 = 패널 중심) 패널 너비의 절반을 반환한다', () => {
|
|
10
|
+
const anchorCenterX = 200;
|
|
11
|
+
const centeredPanelLeft = 50;
|
|
12
|
+
const panelHalfWidth = 150;
|
|
13
|
+
(0, _vitest.expect)((0, _utils.calculateArrowOffsetX)(anchorCenterX, centeredPanelLeft, PANEL_WIDTH)).toBe(panelHalfWidth);
|
|
14
|
+
});
|
|
15
|
+
(0, _vitest.it)('패널이 오른쪽으로 밀린 경우(왼쪽 경계 클램핑) 화살표는 왼쪽으로 이동해 앵커를 가리킨다', () => {
|
|
16
|
+
const anchorCenterX = 60;
|
|
17
|
+
const expectedOffset = 52; // 60 - 8
|
|
18
|
+
(0, _vitest.expect)((0, _utils.calculateArrowOffsetX)(anchorCenterX, CLAMPED_LEFT, PANEL_WIDTH)).toBe(expectedOffset);
|
|
19
|
+
});
|
|
20
|
+
(0, _vitest.it)('패널이 왼쪽으로 밀린 경우(오른쪽 경계 클램핑) 화살표는 오른쪽으로 이동해 앵커를 가리킨다', () => {
|
|
21
|
+
const anchorCenterX = 950;
|
|
22
|
+
const clampedRightPanelLeft = 692;
|
|
23
|
+
const expectedOffset = 258; // 950 - 692
|
|
24
|
+
(0, _vitest.expect)((0, _utils.calculateArrowOffsetX)(anchorCenterX, clampedRightPanelLeft, PANEL_WIDTH)).toBe(expectedOffset);
|
|
25
|
+
});
|
|
26
|
+
(0, _vitest.it)('앵커가 패널 왼쪽 모서리보다 바깥이면 최소 여백(모서리 radius + 화살표)으로 클램핑한다', () => {
|
|
27
|
+
const outsideLeftAnchorX = 10;
|
|
28
|
+
(0, _vitest.expect)((0, _utils.calculateArrowOffsetX)(outsideLeftAnchorX, CLAMPED_LEFT, PANEL_WIDTH)).toBe(_utils.ARROW_EDGE_MARGIN);
|
|
29
|
+
});
|
|
30
|
+
(0, _vitest.it)('앵커가 패널 오른쪽 모서리보다 바깥이면 최대 여백으로 클램핑한다', () => {
|
|
31
|
+
const outsideRightAnchorX = 999;
|
|
32
|
+
(0, _vitest.expect)((0, _utils.calculateArrowOffsetX)(outsideRightAnchorX, CLAMPED_LEFT, PANEL_WIDTH)).toBe(PANEL_WIDTH - _utils.ARROW_EDGE_MARGIN);
|
|
33
|
+
});
|
|
34
|
+
(0, _vitest.it)('소수 좌표는 반올림한다', () => {
|
|
35
|
+
const fractionalAnchorX = 100.4;
|
|
36
|
+
const panelLeft = 50;
|
|
37
|
+
const expectedRounded = 50; // 100.4 - 50 = 50.4 → 50
|
|
38
|
+
(0, _vitest.expect)((0, _utils.calculateArrowOffsetX)(fractionalAnchorX, panelLeft, PANEL_WIDTH)).toBe(expectedRounded);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.ARROW_EDGE_MARGIN = void 0;
|
|
7
|
+
exports.calculateArrowOffsetX = calculateArrowOffsetX;
|
|
8
|
+
// 화살표가 패널 모서리(border-radius 6px + 화살표 반너비 6px) 안쪽으로 파고들지 않기 위한 최소 여백
|
|
9
|
+
const ARROW_EDGE_MARGIN = exports.ARROW_EDGE_MARGIN = 12;
|
|
10
|
+
/**
|
|
11
|
+
* top/bottom 툴팁 화살표(::after)의 패널 내 X 오프셋.
|
|
12
|
+
* 패널이 viewport 경계 보정으로 앵커 중심에서 밀려도 화살표는 앵커를 가리키도록,
|
|
13
|
+
* 클램핑된 패널 left 기준으로 앵커 중심까지의 거리를 계산한다.
|
|
14
|
+
* 앵커가 패널 밖에 있으면 패널 모서리 여백까지만 이동한다.
|
|
15
|
+
* React(Tooltip.tsx)와 HTML 구현체(assets/scripts/tooltip)가 공유한다.
|
|
16
|
+
*/
|
|
17
|
+
function calculateArrowOffsetX(anchorCenterX, panelLeft, panelWidth) {
|
|
18
|
+
const raw = anchorCenterX - panelLeft;
|
|
19
|
+
return Math.round(Math.max(ARROW_EDGE_MARGIN, Math.min(raw, panelWidth - ARROW_EDGE_MARGIN)));
|
|
20
|
+
}
|
|
@@ -36,6 +36,17 @@ Object.keys(_useFloatingPosition).forEach(function (key) {
|
|
|
36
36
|
}
|
|
37
37
|
});
|
|
38
38
|
});
|
|
39
|
+
var _useIsomorphicLayoutEffect = require("./useIsomorphicLayoutEffect");
|
|
40
|
+
Object.keys(_useIsomorphicLayoutEffect).forEach(function (key) {
|
|
41
|
+
if (key === "default" || key === "__esModule") return;
|
|
42
|
+
if (key in exports && exports[key] === _useIsomorphicLayoutEffect[key]) return;
|
|
43
|
+
Object.defineProperty(exports, key, {
|
|
44
|
+
enumerable: true,
|
|
45
|
+
get: function () {
|
|
46
|
+
return _useIsomorphicLayoutEffect[key];
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
39
50
|
var _useMediaQuery = require("./useMediaQuery");
|
|
40
51
|
Object.keys(_useMediaQuery).forEach(function (key) {
|
|
41
52
|
if (key === "default" || key === "__esModule") return;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.useIsomorphicLayoutEffect = void 0;
|
|
7
|
+
var _react = require("react");
|
|
8
|
+
const useIsomorphicLayoutEffect = exports.useIsomorphicLayoutEffect = typeof window !== 'undefined' ? _react.useLayoutEffect : _react.useEffect;
|
|
@@ -20,6 +20,9 @@ const useMediaQuery = (query, options) => {
|
|
|
20
20
|
return () => {
|
|
21
21
|
mediaQuery.removeEventListener('change', handleChange);
|
|
22
22
|
};
|
|
23
|
+
// options 객체 전체가 아니라 실제 사용하는 onMatched 콜백에만 의존한다. options 를 통째로
|
|
24
|
+
// 넣으면 매 렌더 새 객체로 간주돼 불필요하게 재구독한다.
|
|
25
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
23
26
|
}, [query, options?.onMatched]);
|
|
24
27
|
return matches;
|
|
25
28
|
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _moment = _interopRequireDefault(require("moment"));
|
|
4
|
+
var _vitest = require("vitest");
|
|
5
|
+
var _datePicker = require("../date-picker");
|
|
6
|
+
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
7
|
+
(0, _vitest.describe)('convertToMomentFormat', () => {
|
|
8
|
+
(0, _vitest.it)('날짜 전용 포맷을 moment 토큰으로 변환한다 (기존 동작 보존)', () => {
|
|
9
|
+
(0, _vitest.expect)((0, _datePicker.convertToMomentFormat)('Y-m-d')).toBe('YYYY-MM-DD');
|
|
10
|
+
});
|
|
11
|
+
(0, _vitest.it)('2자리 연도(y)를 YY로 변환한다', () => {
|
|
12
|
+
(0, _vitest.expect)((0, _datePicker.convertToMomentFormat)('y-m-d')).toBe('YY-MM-DD');
|
|
13
|
+
});
|
|
14
|
+
(0, _vitest.it)('시간 포함 포맷의 H/i/S를 모두 변환한다 (버그 수정 지점)', () => {
|
|
15
|
+
(0, _vitest.expect)((0, _datePicker.convertToMomentFormat)('Y-m-d H:i')).toBe('YYYY-MM-DD HH:mm');
|
|
16
|
+
(0, _vitest.expect)((0, _datePicker.convertToMomentFormat)('Y-m-d H:i:S')).toBe('YYYY-MM-DD HH:mm:ss');
|
|
17
|
+
});
|
|
18
|
+
(0, _vitest.it)('월(m)과 분(i)이 함께 있어도 서로 침범하지 않는다', () => {
|
|
19
|
+
// m -> MM(월) 먼저, i -> mm(분) 나중 순서가 지켜져야 함
|
|
20
|
+
(0, _vitest.expect)((0, _datePicker.convertToMomentFormat)('Y-m-d H:i')).not.toContain('i');
|
|
21
|
+
(0, _vitest.expect)((0, _datePicker.convertToMomentFormat)('Y-m-d H:i')).toBe('YYYY-MM-DD HH:mm');
|
|
22
|
+
});
|
|
23
|
+
(0, _vitest.it)('실제 moment 출력이 의도한 문자열과 일치한다 (회귀 핵심)', () => {
|
|
24
|
+
const date = (0, _moment.default)('2024-03-20 14:30:05', 'YYYY-MM-DD HH:mm:ss').toDate();
|
|
25
|
+
(0, _vitest.expect)((0, _moment.default)(date).format((0, _datePicker.convertToMomentFormat)('Y-m-d'))).toBe('2024-03-20');
|
|
26
|
+
(0, _vitest.expect)((0, _moment.default)(date).format((0, _datePicker.convertToMomentFormat)('Y-m-d H:i'))).toBe('2024-03-20 14:30');
|
|
27
|
+
(0, _vitest.expect)((0, _moment.default)(date).format((0, _datePicker.convertToMomentFormat)('Y-m-d H:i:S'))).toBe('2024-03-20 14:30:05');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", {
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
|
-
exports.formatMinuteInput = exports.formatHourInput = exports.formatDateInput = void 0;
|
|
6
|
+
exports.formatMinuteInput = exports.formatHourInput = exports.formatDateInput = exports.convertToMomentFormat = void 0;
|
|
7
7
|
exports.getDateFormat = getDateFormat;
|
|
8
8
|
exports.getSubtractDate = getSubtractDate;
|
|
9
9
|
exports.getToday = getToday;
|
|
@@ -31,6 +31,20 @@ function getSubtractDate(_ref) {
|
|
|
31
31
|
} = _ref;
|
|
32
32
|
return (0, _moment.default)(date).subtract(period, unit).toDate();
|
|
33
33
|
}
|
|
34
|
+
/** flatpickr dateFormat 토큰을 moment 포맷 토큰으로 변환 (단일 패스 치환으로 토큰 간 간섭 방지) */
|
|
35
|
+
const convertToMomentFormat = format => {
|
|
36
|
+
const tokenMap = {
|
|
37
|
+
Y: 'YYYY',
|
|
38
|
+
y: 'YY',
|
|
39
|
+
m: 'MM',
|
|
40
|
+
d: 'DD',
|
|
41
|
+
H: 'HH',
|
|
42
|
+
i: 'mm',
|
|
43
|
+
S: 'ss'
|
|
44
|
+
};
|
|
45
|
+
return format.replace(/[YymdHiS]/g, matched => tokenMap[matched] || matched);
|
|
46
|
+
};
|
|
47
|
+
exports.convertToMomentFormat = convertToMomentFormat;
|
|
34
48
|
const formatDateInput = input => {
|
|
35
49
|
const dateRegex = /^(19|20)\d{2}(0[1-9]|1[012])(0[1-9]|[12][0-9]|3[0-1])$/;
|
|
36
50
|
if (dateRegex.test(input)) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import classNames from 'classnames';
|
|
3
|
-
import { forwardRef,
|
|
3
|
+
import { forwardRef, useRef } from 'react';
|
|
4
|
+
import { useIsomorphicLayoutEffect } from '../../../hooks/useIsomorphicLayoutEffect';
|
|
4
5
|
export const CheckboxInput = /*#__PURE__*/forwardRef((_ref, ref) => {
|
|
5
6
|
let {
|
|
6
7
|
size = 'xs',
|
|
@@ -12,10 +13,10 @@ export const CheckboxInput = /*#__PURE__*/forwardRef((_ref, ref) => {
|
|
|
12
13
|
...props
|
|
13
14
|
} = _ref;
|
|
14
15
|
const inputRef = useRef(null);
|
|
15
|
-
|
|
16
|
+
useIsomorphicLayoutEffect(() => {
|
|
16
17
|
const input = ref && typeof ref !== 'function' ? ref.current : inputRef.current;
|
|
17
18
|
if (input) {
|
|
18
|
-
input.indeterminate = indeterminate;
|
|
19
|
+
input.indeterminate = indeterminate;
|
|
19
20
|
}
|
|
20
21
|
}, [indeterminate, ref]);
|
|
21
22
|
return _jsx("span", {
|
|
@@ -4,7 +4,7 @@ import { Korean } from 'flatpickr/dist/l10n/ko';
|
|
|
4
4
|
import moment from 'moment';
|
|
5
5
|
import { forwardRef, useCallback, useEffect, useId, useMemo, useRef } from 'react';
|
|
6
6
|
import Flatpickr from 'react-flatpickr';
|
|
7
|
-
import { formatDateInput, formatHourInput, formatMinuteInput } from '../../../utils/date-picker';
|
|
7
|
+
import { convertToMomentFormat, formatDateInput, formatHourInput, formatMinuteInput } from '../../../utils/date-picker';
|
|
8
8
|
import { CustomInput } from './CustomInput';
|
|
9
9
|
// ══════════════════════════════════════════
|
|
10
10
|
// 유틸리티 함수
|
|
@@ -176,6 +176,12 @@ const setupTimeInputHandlers = (instance, input, isPortal, onHourInput, onMinute
|
|
|
176
176
|
};
|
|
177
177
|
/** 시간 입력 필드 이벤트 리스너 정리 */
|
|
178
178
|
const cleanupTimeInputHandlers = (instance, input, isPortal, onHourInput, onMinuteInput) => {
|
|
179
|
+
// document 전역 리스너는 캘린더 DOM 조회 성공 여부와 무관하게 항상 먼저 제거한다 (누수 방지)
|
|
180
|
+
const handleMouseDown = instance._handleMouseDown;
|
|
181
|
+
if (handleMouseDown) {
|
|
182
|
+
document.removeEventListener('mousedown', handleMouseDown, true);
|
|
183
|
+
instance._handleMouseDown = undefined;
|
|
184
|
+
}
|
|
179
185
|
const timeContainer = isPortal ? instance.calendarContainer : input.parentElement;
|
|
180
186
|
const timeInputWrapper = timeContainer?.querySelector('.flatpickr-time');
|
|
181
187
|
if (!timeInputWrapper) return;
|
|
@@ -187,10 +193,6 @@ const cleanupTimeInputHandlers = (instance, input, isPortal, onHourInput, onMinu
|
|
|
187
193
|
if (minuteInput) {
|
|
188
194
|
minuteInput.removeEventListener('input', onMinuteInput);
|
|
189
195
|
}
|
|
190
|
-
const handleMouseDown = instance._handleMouseDown;
|
|
191
|
-
if (handleMouseDown) {
|
|
192
|
-
document.removeEventListener('mousedown', handleMouseDown, true);
|
|
193
|
-
}
|
|
194
196
|
};
|
|
195
197
|
// ══════════════════════════════════════════
|
|
196
198
|
// DatePicker 컴포넌트
|
|
@@ -220,7 +222,7 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
|
|
|
220
222
|
const onValidationErrorRef = useRef(onValidationError);
|
|
221
223
|
onValidationErrorRef.current = onValidationError;
|
|
222
224
|
/** portal 컨테이너를 lazily 생성 (이미 있으면 재사용), className은 매번 갱신 */
|
|
223
|
-
const getPortalContainer = () => {
|
|
225
|
+
const getPortalContainer = useCallback(() => {
|
|
224
226
|
if (!portal || typeof document === 'undefined') return undefined;
|
|
225
227
|
const portalClassName = classNames('ncua-date-picker', 'ncua-date-picker--portal', `ncua-date-picker--${size}`, {
|
|
226
228
|
'ncua-date-picker--has-time': hasTimeOption
|
|
@@ -236,7 +238,7 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
|
|
|
236
238
|
document.body.appendChild(el);
|
|
237
239
|
portalContainerRef.current = el;
|
|
238
240
|
return el;
|
|
239
|
-
};
|
|
241
|
+
}, [portal, size, hasTimeOption]);
|
|
240
242
|
/** 컴포넌트 언마운트 시 포탈 컨테이너 및 스크롤 리스너 정리 */
|
|
241
243
|
useEffect(() => {
|
|
242
244
|
return () => {
|
|
@@ -248,7 +250,7 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
|
|
|
248
250
|
// 날짜 유효성 검사
|
|
249
251
|
// ──────────────────────────────────────────────
|
|
250
252
|
/** minDate/maxDate 범위를 벗어났는지 확인 */
|
|
251
|
-
const checkDateViolations = (date, minDate, maxDate) => {
|
|
253
|
+
const checkDateViolations = useCallback((date, minDate, maxDate) => {
|
|
252
254
|
const violations = [];
|
|
253
255
|
const inputDate = moment(date);
|
|
254
256
|
if (!inputDate.isValid()) return violations;
|
|
@@ -265,16 +267,16 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
|
|
|
265
267
|
}
|
|
266
268
|
}
|
|
267
269
|
return violations;
|
|
268
|
-
};
|
|
270
|
+
}, []);
|
|
269
271
|
// ──────────────────────────────────────────────
|
|
270
272
|
// 이벤트 핸들러
|
|
271
273
|
// ──────────────────────────────────────────────
|
|
272
274
|
/** 유효하지 않은 입력 시 이전 날짜로 복원 */
|
|
273
|
-
const restorePreviousDate = (target, instance) => {
|
|
275
|
+
const restorePreviousDate = useCallback((target, instance) => {
|
|
274
276
|
if (instance.selectedDates.length > 0) {
|
|
275
277
|
const prevDate = instance.selectedDates[0];
|
|
276
278
|
if (prevDate instanceof Date && !Number.isNaN(prevDate.getTime())) {
|
|
277
|
-
const momentFormat = dateFormatRef.current
|
|
279
|
+
const momentFormat = convertToMomentFormat(dateFormatRef.current);
|
|
278
280
|
target.value = moment(prevDate).format(momentFormat);
|
|
279
281
|
instance.setDate(prevDate, false);
|
|
280
282
|
return;
|
|
@@ -282,7 +284,7 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
|
|
|
282
284
|
}
|
|
283
285
|
target.value = '';
|
|
284
286
|
instance.setDate('', false);
|
|
285
|
-
};
|
|
287
|
+
}, []);
|
|
286
288
|
/** flatpickr에서 날짜가 변경되었을 때 호출 */
|
|
287
289
|
const onChangeDateHandler = useCallback((dateTimeStamp, dateStr, fpInstance) => {
|
|
288
290
|
const instance = fpInstance;
|
|
@@ -296,7 +298,9 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
|
|
|
296
298
|
const maxDate = instance.config.maxDate;
|
|
297
299
|
const violations = checkDateViolations(selectedDate, minDate, maxDate);
|
|
298
300
|
if (violations.length > 0 && onValidationErrorRef.current) {
|
|
299
|
-
|
|
301
|
+
// flatpickr는 onChange 발화 전에 selectedDates를 위반된 새 날짜로 갱신하므로,
|
|
302
|
+
// 직전 유효 날짜를 보관해 둔 _previousDateBeforeInput을 previousDate로 사용한다
|
|
303
|
+
const prevDate = instance._previousDateBeforeInput;
|
|
300
304
|
const validPrevDate = prevDate instanceof Date && !Number.isNaN(prevDate.getTime()) ? prevDate : undefined;
|
|
301
305
|
onValidationErrorRef.current({
|
|
302
306
|
date: selectedDate,
|
|
@@ -312,7 +316,6 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
|
|
|
312
316
|
isValidDate(formattedDate) ? onChangeDateRef.current(formattedDate) : onChangeDateRef.current(dateStr);
|
|
313
317
|
}, [checkDateViolations, restorePreviousDate]);
|
|
314
318
|
/** input에 직접 타이핑할 때 날짜 형식 자동 변환 및 유효성 검사 */
|
|
315
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
316
319
|
const onInputHandler = useCallback(e => {
|
|
317
320
|
const target = e.target;
|
|
318
321
|
const input = target.value;
|
|
@@ -365,12 +368,6 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
|
|
|
365
368
|
// ──────────────────────────────────────────────
|
|
366
369
|
// flatpickr 옵션
|
|
367
370
|
// ──────────────────────────────────────────────
|
|
368
|
-
// datePickerOptions는 부모에서 매 렌더 새 객체일 수 있으므로 값 기반 비교
|
|
369
|
-
// 주의: JSON.stringify는 함수를 무시하고 Date를 문자열로 변환하므로,
|
|
370
|
-
// datePickerOptions에 함수나 Date 객체를 넘기면 변경 감지가 누락될 수 있음
|
|
371
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
372
|
-
const _datePickerOptionsKey = JSON.stringify(datePickerOptions);
|
|
373
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
374
371
|
const options = useMemo(() => ({
|
|
375
372
|
mode: 'single',
|
|
376
373
|
static: !portal,
|
|
@@ -391,7 +388,7 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
|
|
|
391
388
|
if (!momentDate.isValid()) {
|
|
392
389
|
return '';
|
|
393
390
|
}
|
|
394
|
-
const momentFormat = format
|
|
391
|
+
const momentFormat = convertToMomentFormat(format);
|
|
395
392
|
return momentDate.format(momentFormat);
|
|
396
393
|
} catch (_error) {
|
|
397
394
|
return '';
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { createElement, useState } from 'react';
|
|
3
|
+
import { createRoot } from 'react-dom/client';
|
|
4
|
+
import { act } from 'react-dom/test-utils';
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { DatePicker } from '../DatePicker';
|
|
7
|
+
const {
|
|
8
|
+
captured
|
|
9
|
+
} = vi.hoisted(() => ({
|
|
10
|
+
captured: []
|
|
11
|
+
}));
|
|
12
|
+
// react-flatpickr를 mock하여 DatePicker가 넘기는 options 참조를 렌더마다 수집한다.
|
|
13
|
+
// (vi.mock / vi.hoisted는 vitest가 import 위로 호이스팅하므로 DatePicker import보다 먼저 적용된다)
|
|
14
|
+
vi.mock('react-flatpickr', () => ({
|
|
15
|
+
default: props => {
|
|
16
|
+
captured.push(props.options);
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}));
|
|
20
|
+
describe('#1 options useMemo 안정성', () => {
|
|
21
|
+
it('부모가 같은 props로 강제 리렌더해도 options 참조가 동일하게 유지된다(재생성 안 됨)', () => {
|
|
22
|
+
let bump = () => undefined;
|
|
23
|
+
const Harness = () => {
|
|
24
|
+
const [, setN] = useState(0);
|
|
25
|
+
bump = () => setN(x => x + 1);
|
|
26
|
+
return /*#__PURE__*/createElement(DatePicker, {
|
|
27
|
+
currentDate: '2024-03-20',
|
|
28
|
+
onChangeDate: () => undefined
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
const container = document.createElement('div');
|
|
32
|
+
document.body.appendChild(container);
|
|
33
|
+
const root = createRoot(container);
|
|
34
|
+
act(() => {
|
|
35
|
+
root.render(/*#__PURE__*/createElement(Harness));
|
|
36
|
+
});
|
|
37
|
+
const countAfterMount = captured.length;
|
|
38
|
+
const firstOptions = captured[captured.length - 1];
|
|
39
|
+
act(() => {
|
|
40
|
+
bump();
|
|
41
|
+
});
|
|
42
|
+
const lastOptions = captured[captured.length - 1];
|
|
43
|
+
expect(captured.length).toBeGreaterThan(countAfterMount); // 리렌더가 실제로 일어났는지
|
|
44
|
+
expect(Object.is(firstOptions, lastOptions)).toBe(true); // 동일 참조 = options 재생성 안 됨
|
|
45
|
+
act(() => {
|
|
46
|
+
root.unmount();
|
|
47
|
+
});
|
|
48
|
+
container.remove();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
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 { DatePicker } from '../DatePicker';
|
|
7
|
+
const mounted = [];
|
|
8
|
+
function mount(props) {
|
|
9
|
+
const container = document.createElement('div');
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
const root = createRoot(container);
|
|
12
|
+
act(() => {
|
|
13
|
+
root.render(/*#__PURE__*/createElement(DatePicker, props));
|
|
14
|
+
});
|
|
15
|
+
mounted.push({
|
|
16
|
+
root,
|
|
17
|
+
container
|
|
18
|
+
});
|
|
19
|
+
return container;
|
|
20
|
+
}
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
for (const {
|
|
23
|
+
root,
|
|
24
|
+
container
|
|
25
|
+
} of mounted.splice(0)) {
|
|
26
|
+
act(() => {
|
|
27
|
+
root.unmount();
|
|
28
|
+
});
|
|
29
|
+
container.remove();
|
|
30
|
+
}
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
});
|
|
33
|
+
describe('DatePicker 렌더 (스모크)', () => {
|
|
34
|
+
it('기본 props로 input을 렌더하고 크래시하지 않는다', () => {
|
|
35
|
+
const container = mount({
|
|
36
|
+
currentDate: '2024-03-20',
|
|
37
|
+
onChangeDate: () => undefined
|
|
38
|
+
});
|
|
39
|
+
expect(container.querySelector('input')).not.toBeNull();
|
|
40
|
+
});
|
|
41
|
+
it('enableTime 옵션으로도 정상 렌더한다', () => {
|
|
42
|
+
const container = mount({
|
|
43
|
+
currentDate: '2024-03-20 14:30',
|
|
44
|
+
onChangeDate: () => undefined,
|
|
45
|
+
datePickerOptions: {
|
|
46
|
+
enableTime: true,
|
|
47
|
+
dateFormat: 'Y-m-d H:i'
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
expect(container.querySelector('input')).not.toBeNull();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('#4 document mousedown 리스너 정리', () => {
|
|
54
|
+
it('enableTime 마운트 후 언마운트 시, 추가된 mousedown 리스너가 모두 제거된다(누수 없음)', () => {
|
|
55
|
+
const added = [];
|
|
56
|
+
const removed = [];
|
|
57
|
+
const origAdd = document.addEventListener.bind(document);
|
|
58
|
+
const origRemove = document.removeEventListener.bind(document);
|
|
59
|
+
vi.spyOn(document, 'addEventListener').mockImplementation((type, fn, opts) => {
|
|
60
|
+
if (type === 'mousedown' && fn) added.push(fn);
|
|
61
|
+
return origAdd(type, fn, opts);
|
|
62
|
+
});
|
|
63
|
+
vi.spyOn(document, 'removeEventListener').mockImplementation((type, fn, opts) => {
|
|
64
|
+
if (type === 'mousedown' && fn) removed.push(fn);
|
|
65
|
+
return origRemove(type, fn, opts);
|
|
66
|
+
});
|
|
67
|
+
const container = document.createElement('div');
|
|
68
|
+
document.body.appendChild(container);
|
|
69
|
+
const root = createRoot(container);
|
|
70
|
+
act(() => {
|
|
71
|
+
root.render(/*#__PURE__*/createElement(DatePicker, {
|
|
72
|
+
currentDate: '2024-03-20 14:30',
|
|
73
|
+
onChangeDate: () => undefined,
|
|
74
|
+
datePickerOptions: {
|
|
75
|
+
enableTime: true,
|
|
76
|
+
dateFormat: 'Y-m-d H:i'
|
|
77
|
+
}
|
|
78
|
+
}));
|
|
79
|
+
});
|
|
80
|
+
expect(added.length).toBeGreaterThanOrEqual(1);
|
|
81
|
+
act(() => {
|
|
82
|
+
root.unmount();
|
|
83
|
+
});
|
|
84
|
+
container.remove();
|
|
85
|
+
for (const fn of added) {
|
|
86
|
+
expect(removed).toContain(fn);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('#3 onValidationError.previousDate', () => {
|
|
91
|
+
// 허용 범위(10~25일) 기준: 마지막 유효 선택일과 범위 밖 위반일
|
|
92
|
+
const LAST_VALID_DAY = 15;
|
|
93
|
+
const OUT_OF_RANGE_DAY = 30;
|
|
94
|
+
it('min/max 위반 시 previousDate는 위반 날짜가 아니라 직전 유효 날짜다', () => {
|
|
95
|
+
const onValidationError = vi.fn();
|
|
96
|
+
const container = mount({
|
|
97
|
+
currentDate: '2024-03-20',
|
|
98
|
+
onChangeDate: () => undefined,
|
|
99
|
+
datePickerOptions: {
|
|
100
|
+
minDate: '2024-03-10',
|
|
101
|
+
maxDate: '2024-03-25',
|
|
102
|
+
allowInput: true
|
|
103
|
+
},
|
|
104
|
+
onValidationError
|
|
105
|
+
});
|
|
106
|
+
// flatpickr 인스턴스는 초기화된 input 엘리먼트의 _flatpickr에 부착된다
|
|
107
|
+
const input = container.querySelector('input');
|
|
108
|
+
const instance = input?._flatpickr;
|
|
109
|
+
expect(instance).toBeTruthy();
|
|
110
|
+
// 1) 유효 날짜를 먼저 선택 → 직전 유효값(_previousDateBeforeInput)이 확립됨
|
|
111
|
+
act(() => {
|
|
112
|
+
instance.setDate(`2024-03-${LAST_VALID_DAY}`, true);
|
|
113
|
+
});
|
|
114
|
+
expect(onValidationError).not.toHaveBeenCalled();
|
|
115
|
+
// 2) 허용 범위 밖 날짜 선택 → 위반 분기 진입
|
|
116
|
+
act(() => {
|
|
117
|
+
instance.setDate(`2024-03-${OUT_OF_RANGE_DAY}`, true);
|
|
118
|
+
});
|
|
119
|
+
expect(onValidationError).toHaveBeenCalledTimes(1);
|
|
120
|
+
const arg = onValidationError.mock.calls[0][0];
|
|
121
|
+
expect(arg.violations).toContain('maxDate');
|
|
122
|
+
expect(arg.date.getDate()).toBe(OUT_OF_RANGE_DAY); // 위반 날짜
|
|
123
|
+
expect(arg.previousDate?.getDate()).toBe(LAST_VALID_DAY); // 직전 유효 날짜 (≠ 위반 날짜)
|
|
124
|
+
expect(arg.previousDate?.getTime()).not.toBe(arg.date.getTime());
|
|
125
|
+
});
|
|
126
|
+
});
|