@ncds/ui-admin 1.8.17 → 1.8.18
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/date-picker/DatePicker.js +174 -38
- package/dist/cjs/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +410 -0
- package/dist/cjs/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
- package/dist/cjs/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +61 -0
- package/dist/cjs/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +249 -0
- package/dist/cjs/vitest.config.js +23 -1
- package/dist/esm/src/components/forms-and-input/date-picker/DatePicker.js +174 -38
- package/dist/esm/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +410 -0
- package/dist/esm/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
- package/dist/esm/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +61 -0
- package/dist/esm/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +250 -1
- package/dist/esm/vitest.config.js +22 -1
- package/dist/temp/src/components/forms-and-input/date-picker/DatePicker.js +184 -36
- package/dist/temp/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +323 -0
- package/dist/temp/src/components/forms-and-input/range-date-picker/RangeDatePicker.d.ts +9 -0
- package/dist/temp/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
- package/dist/temp/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +40 -0
- package/dist/temp/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +190 -1
- package/dist/temp/vitest.config.js +24 -0
- package/dist/types/src/components/forms-and-input/range-date-picker/RangeDatePicker.d.ts +9 -0
- package/package.json +5 -3
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import { createElement } from 'react';
|
|
2
|
+
import { createElement, useState } from 'react';
|
|
3
3
|
import { createRoot } from 'react-dom/client';
|
|
4
4
|
import { act } from 'react-dom/test-utils';
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
@@ -51,4 +51,253 @@ describe('#6 periodItems 에 없는 periodKey 방어', () => {
|
|
|
51
51
|
// 유효한 키(TODAY='오늘')는 렌더되고, 정의에 없는 키는 건너뛴다(크래시 없음)
|
|
52
52
|
expect(container.textContent).toContain('오늘');
|
|
53
53
|
});
|
|
54
|
+
});
|
|
55
|
+
describe('#9 allowInput 타이핑 → onDateValidation (#410)', () => {
|
|
56
|
+
const START_DATE = '2026-03-10';
|
|
57
|
+
const END_DATE = '2026-03-20';
|
|
58
|
+
/** 시작일보다 이전 — 종료일에 넣으면 overlap */
|
|
59
|
+
const TYPED_END_DATE = '2026-03-01';
|
|
60
|
+
/** 실제 사용처처럼 부모가 state 를 들고 controlled 로 쓰는 래퍼.
|
|
61
|
+
* bump() 로 날짜와 무관한 리렌더를 임의 시점에 끼워 넣을 수 있다. */
|
|
62
|
+
function mountHarness(onDateValidation) {
|
|
63
|
+
let bump;
|
|
64
|
+
const Harness = () => {
|
|
65
|
+
const [start, setStart] = useState(START_DATE);
|
|
66
|
+
const [end, setEnd] = useState(END_DATE);
|
|
67
|
+
const [, setTick] = useState(0);
|
|
68
|
+
const [buttonId, setButtonId] = useState('NONE');
|
|
69
|
+
bump = () => setTick(tick => tick + 1);
|
|
70
|
+
return /*#__PURE__*/createElement(RangeDatePickerWithButtons, {
|
|
71
|
+
currentButtonId: buttonId,
|
|
72
|
+
setCurrentButtonId: setButtonId,
|
|
73
|
+
periodKeys: ['ENTIRE', 'TODAY'],
|
|
74
|
+
startDateOptions: {
|
|
75
|
+
currentDate: start,
|
|
76
|
+
onChangeDate: setStart,
|
|
77
|
+
datePickerOptions: {
|
|
78
|
+
allowInput: true
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
endDateOptions: {
|
|
82
|
+
currentDate: end,
|
|
83
|
+
onChangeDate: setEnd,
|
|
84
|
+
datePickerOptions: {
|
|
85
|
+
allowInput: true
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
onDateValidation
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
const container = document.createElement('div');
|
|
92
|
+
document.body.appendChild(container);
|
|
93
|
+
const root = createRoot(container);
|
|
94
|
+
act(() => {
|
|
95
|
+
root.render(/*#__PURE__*/createElement(Harness));
|
|
96
|
+
});
|
|
97
|
+
mounted.push({
|
|
98
|
+
root,
|
|
99
|
+
container
|
|
100
|
+
});
|
|
101
|
+
const inputs = Array.from(container.querySelectorAll('input.flatpickr-input'));
|
|
102
|
+
return {
|
|
103
|
+
endInput: inputs[1],
|
|
104
|
+
bump: () => bump?.()
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const typeChar = (input, char) => {
|
|
108
|
+
act(() => {
|
|
109
|
+
input.value = `${input.value}${char}`;
|
|
110
|
+
input.dispatchEvent(new Event('input', {
|
|
111
|
+
bubbles: true
|
|
112
|
+
}));
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
it('종료일에 시작일 이전 날짜를 타이핑하면 overlap 을 통지한다', () => {
|
|
116
|
+
const onDateValidation = vi.fn();
|
|
117
|
+
const {
|
|
118
|
+
endInput
|
|
119
|
+
} = mountHarness(onDateValidation);
|
|
120
|
+
onDateValidation.mockClear();
|
|
121
|
+
endInput.value = '';
|
|
122
|
+
for (const char of TYPED_END_DATE) {
|
|
123
|
+
typeChar(endInput, char);
|
|
124
|
+
}
|
|
125
|
+
expect(onDateValidation).toHaveBeenCalledWith({
|
|
126
|
+
type: 'end',
|
|
127
|
+
errorType: 'overlap',
|
|
128
|
+
newDate: START_DATE,
|
|
129
|
+
currentDate: TYPED_END_DATE
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
it('blur 없이 통지하므로, 통지 이후 부모가 리렌더돼도 결과가 뒤집히지 않는다', () => {
|
|
133
|
+
const onDateValidation = vi.fn();
|
|
134
|
+
const {
|
|
135
|
+
endInput,
|
|
136
|
+
bump
|
|
137
|
+
} = mountHarness(onDateValidation);
|
|
138
|
+
onDateValidation.mockClear();
|
|
139
|
+
endInput.value = '';
|
|
140
|
+
for (const char of TYPED_END_DATE) {
|
|
141
|
+
typeChar(endInput, char);
|
|
142
|
+
}
|
|
143
|
+
onDateValidation.mockClear();
|
|
144
|
+
// 날짜와 무관한 리렌더가 뒤따라도 통지된 값이 유지된다
|
|
145
|
+
act(() => {
|
|
146
|
+
bump();
|
|
147
|
+
});
|
|
148
|
+
expect(endInput.value).toBe(TYPED_END_DATE);
|
|
149
|
+
});
|
|
150
|
+
/**
|
|
151
|
+
* 알려진 한계: 날짜를 다 치기 전에 리렌더가 끼면 react-flatpickr 가 input 을 부모 값으로 덮는다.
|
|
152
|
+
* (static:true 라 wrapper 를 헐고 다시 만들면서 input DOM 이 이동해 포커스까지 빠진다.)
|
|
153
|
+
* 통지 자체는 blur 를 안 기다리므로 위 케이스와 무관하지만, 이 구간은 여전히 취약하다.
|
|
154
|
+
*/
|
|
155
|
+
it('입력이 완성되기 전 리렌더가 끼면 입력값이 부모 값으로 되돌아간다 (알려진 한계)', () => {
|
|
156
|
+
const onDateValidation = vi.fn();
|
|
157
|
+
const {
|
|
158
|
+
endInput,
|
|
159
|
+
bump
|
|
160
|
+
} = mountHarness(onDateValidation);
|
|
161
|
+
endInput.value = '';
|
|
162
|
+
for (const char of TYPED_END_DATE.slice(0, -1)) {
|
|
163
|
+
typeChar(endInput, char);
|
|
164
|
+
}
|
|
165
|
+
act(() => {
|
|
166
|
+
bump();
|
|
167
|
+
});
|
|
168
|
+
expect(endInput.value).toBe(END_DATE);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
describe('#10 같은 날짜를 다시 입력해도 통지된다 (#410 재보고)', () => {
|
|
172
|
+
const START_DATE = '2026-08-10';
|
|
173
|
+
const END_DATE = '2026-08-20';
|
|
174
|
+
/** 시작일 이전 — overlap */
|
|
175
|
+
const TYPED_END_DATE = '2026-08-06';
|
|
176
|
+
const typeChars = (input, text) => {
|
|
177
|
+
input.value = '';
|
|
178
|
+
for (const char of text) {
|
|
179
|
+
act(() => {
|
|
180
|
+
input.value = `${input.value}${char}`;
|
|
181
|
+
input.dispatchEvent(new Event('input', {
|
|
182
|
+
bubbles: true
|
|
183
|
+
}));
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
it('overlap 통지를 받고 종료일을 되돌린 뒤 같은 날짜를 다시 쳐도 매번 통지한다', () => {
|
|
188
|
+
const onDateValidation = vi.fn();
|
|
189
|
+
// 실제 사용처 패턴: overlap 을 받으면 알럿 대신 종료일을 원래대로 되돌린다
|
|
190
|
+
const Harness = () => {
|
|
191
|
+
const [start, setStart] = useState(START_DATE);
|
|
192
|
+
const [end, setEnd] = useState(END_DATE);
|
|
193
|
+
const [buttonId, setButtonId] = useState('NONE');
|
|
194
|
+
return /*#__PURE__*/createElement(RangeDatePickerWithButtons, {
|
|
195
|
+
currentButtonId: buttonId,
|
|
196
|
+
setCurrentButtonId: setButtonId,
|
|
197
|
+
periodKeys: ['ENTIRE', 'TODAY'],
|
|
198
|
+
startDateOptions: {
|
|
199
|
+
currentDate: start,
|
|
200
|
+
onChangeDate: setStart,
|
|
201
|
+
datePickerOptions: {
|
|
202
|
+
allowInput: true
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
endDateOptions: {
|
|
206
|
+
currentDate: end,
|
|
207
|
+
onChangeDate: setEnd,
|
|
208
|
+
datePickerOptions: {
|
|
209
|
+
allowInput: true
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
onDateValidation: params => {
|
|
213
|
+
onDateValidation(params);
|
|
214
|
+
if (params.type === 'end' && params.errorType === 'overlap') setEnd(END_DATE);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
};
|
|
218
|
+
const container = document.createElement('div');
|
|
219
|
+
document.body.appendChild(container);
|
|
220
|
+
const root = createRoot(container);
|
|
221
|
+
act(() => {
|
|
222
|
+
root.render(/*#__PURE__*/createElement(Harness));
|
|
223
|
+
});
|
|
224
|
+
mounted.push({
|
|
225
|
+
root,
|
|
226
|
+
container
|
|
227
|
+
});
|
|
228
|
+
const endInput = Array.from(container.querySelectorAll('input.flatpickr-input'))[1];
|
|
229
|
+
onDateValidation.mockClear();
|
|
230
|
+
// 종전에는 lastNotifiedDate 가 부모 갱신 후에도 남아 있어 2회차부터 통지가 끊겼다
|
|
231
|
+
const ATTEMPT_COUNT = 3;
|
|
232
|
+
const firedPerAttempt = [];
|
|
233
|
+
for (let attempt = 0; attempt < ATTEMPT_COUNT; attempt++) {
|
|
234
|
+
const before = onDateValidation.mock.calls.length;
|
|
235
|
+
typeChars(endInput, TYPED_END_DATE);
|
|
236
|
+
firedPerAttempt.push(onDateValidation.mock.calls.length - before);
|
|
237
|
+
}
|
|
238
|
+
expect(firedPerAttempt).toEqual([1, 1, 1]);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
describe('#11 통지 후 사용처가 날짜를 되돌려도 입력 칸의 포커스가 유지된다 (#416)', () => {
|
|
242
|
+
const START_DATE = '2026-08-10';
|
|
243
|
+
const END_DATE = '2026-08-20';
|
|
244
|
+
/** 시작일 이전 — overlap */
|
|
245
|
+
const TYPED_END_DATE = '2026-08-06';
|
|
246
|
+
it('overlap 통지 → 되돌리기 후에도 input 이 포커스를 잃지 않는다', () => {
|
|
247
|
+
const Harness = () => {
|
|
248
|
+
const [start, setStart] = useState(START_DATE);
|
|
249
|
+
const [end, setEnd] = useState(END_DATE);
|
|
250
|
+
const [buttonId, setButtonId] = useState('NONE');
|
|
251
|
+
return /*#__PURE__*/createElement(RangeDatePickerWithButtons, {
|
|
252
|
+
currentButtonId: buttonId,
|
|
253
|
+
setCurrentButtonId: setButtonId,
|
|
254
|
+
periodKeys: ['ENTIRE', 'TODAY'],
|
|
255
|
+
startDateOptions: {
|
|
256
|
+
currentDate: start,
|
|
257
|
+
onChangeDate: setStart,
|
|
258
|
+
datePickerOptions: {
|
|
259
|
+
allowInput: true
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
endDateOptions: {
|
|
263
|
+
currentDate: end,
|
|
264
|
+
onChangeDate: setEnd,
|
|
265
|
+
datePickerOptions: {
|
|
266
|
+
allowInput: true
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
onDateValidation: params => {
|
|
270
|
+
// 알럿 후 날짜를 되돌리는 사용처 패턴
|
|
271
|
+
if (params.type === 'end' && params.errorType === 'overlap') setEnd(END_DATE);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
const container = document.createElement('div');
|
|
276
|
+
document.body.appendChild(container);
|
|
277
|
+
const root = createRoot(container);
|
|
278
|
+
act(() => {
|
|
279
|
+
root.render(/*#__PURE__*/createElement(Harness));
|
|
280
|
+
});
|
|
281
|
+
mounted.push({
|
|
282
|
+
root,
|
|
283
|
+
container
|
|
284
|
+
});
|
|
285
|
+
const endInput = Array.from(container.querySelectorAll('input.flatpickr-input'))[1];
|
|
286
|
+
// 사용자가 칸을 클릭해 포커스를 쥔 상태로 입력한다
|
|
287
|
+
endInput.focus();
|
|
288
|
+
expect(document.activeElement).toBe(endInput);
|
|
289
|
+
endInput.value = '';
|
|
290
|
+
for (const char of TYPED_END_DATE) {
|
|
291
|
+
act(() => {
|
|
292
|
+
endInput.value = `${endInput.value}${char}`;
|
|
293
|
+
endInput.dispatchEvent(new Event('input', {
|
|
294
|
+
bubbles: true
|
|
295
|
+
}));
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
// 되돌리기 리렌더에서 flatpickr 가 wrapper 를 재생성하며 input 노드를 옮겨 포커스가 빠졌다.
|
|
299
|
+
// 사용자는 알럿을 닫고 이어서 타이핑하는데 입력이 들어가지 않아 콜백이 끊긴 것처럼 보였다.
|
|
300
|
+
expect(endInput.value).toBe(END_DATE);
|
|
301
|
+
expect(document.activeElement).toBe(endInput);
|
|
302
|
+
});
|
|
54
303
|
});
|
|
@@ -1,7 +1,28 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import { defineConfig } from 'vitest/config';
|
|
3
|
+
// lcov 의 SF 경로를 레포 루트 기준으로 기록한다. sonar-scanner 는 레포 루트를 단일
|
|
4
|
+
// projectBaseDir 로 잡으므로, 패키지 루트 기준 상대경로(SF:src/foo.ts)로 두면 경로가
|
|
5
|
+
// 해석되지 않아 커버리지가 통째로 무시된다.
|
|
6
|
+
const REPO_ROOT = path.resolve(__dirname, '../..');
|
|
2
7
|
export default defineConfig({
|
|
3
8
|
test: {
|
|
4
9
|
include: ['scripts/**/__tests__/**/*.test.ts', 'src/**/__tests__/**/*.test.ts', 'assets/scripts/**/__tests__/**/*.test.ts'],
|
|
5
|
-
environment: 'node'
|
|
10
|
+
environment: 'node',
|
|
11
|
+
coverage: {
|
|
12
|
+
provider: 'v8',
|
|
13
|
+
// 테스트가 존재하는 3개 루트를 분모로 잡는다 (test.include 와 동일 범위).
|
|
14
|
+
include: ['src/**/*.{ts,tsx}', 'scripts/**/*.ts', 'assets/scripts/**/*.ts'],
|
|
15
|
+
exclude: ['**/__tests__/**', '**/*.d.ts',
|
|
16
|
+
// ui-admin 의 index.ts 81개는 전부 `export * from ...` barrel 이라 측정 의미가 없다.
|
|
17
|
+
// (step-guide 의 src/index.ts 는 824줄 본체 구현이므로 그쪽에서는 제외하지 않는다.)
|
|
18
|
+
'**/index.ts',
|
|
19
|
+
// MCP 가 읽는 컴포넌트 메타데이터 선언 45개 — 실행 분기 없는 순수 데이터.
|
|
20
|
+
'**/*.meta.ts', 'src/types/**', 'src/constant/**'],
|
|
21
|
+
// 'text' 미포함 — 미커버 파일이 수백 개라 파일별 상세 출력이 CI 로그를 덮는다.
|
|
22
|
+
// 'lcov' 대신 'lcovonly' — 'lcov' 는 HTML 리포트까지 생성한다.
|
|
23
|
+
reporter: ['text-summary', ['lcovonly', {
|
|
24
|
+
projectRoot: REPO_ROOT
|
|
25
|
+
}]]
|
|
26
|
+
}
|
|
6
27
|
}
|
|
7
28
|
});
|
|
@@ -49,6 +49,24 @@ const restoreIfInvalidDate = (date, instance) => {
|
|
|
49
49
|
}
|
|
50
50
|
return true;
|
|
51
51
|
};
|
|
52
|
+
/** Date 를 dateFormat 표기 문자열로 바꾼다. 유효하지 않으면 null. */
|
|
53
|
+
const formatSelectedDate = (selectedDate, dateFormat) => {
|
|
54
|
+
if (!(selectedDate instanceof Date) || Number.isNaN(selectedDate.getTime()))
|
|
55
|
+
return null;
|
|
56
|
+
const formattedDate = moment(selectedDate).format(convertToMomentFormat(dateFormat));
|
|
57
|
+
return formattedDate || null;
|
|
58
|
+
};
|
|
59
|
+
/** 'YYYY-MM-DD' 길이. dateFormat 을 읽기 전(onReady 이전)의 기본값이다 */
|
|
60
|
+
const FULL_DATE_LENGTH = 10;
|
|
61
|
+
/** 연·월·일·시·분·초가 모두 두 자리 이상인 기준 시각. 길이 측정용이라 날짜 값 자체에 의미는 없다 */
|
|
62
|
+
const FULL_VALUE_REFERENCE_DATE = '2026-03-01 04:05:06';
|
|
63
|
+
/**
|
|
64
|
+
* 해당 dateFormat 으로 완성된 값의 길이. 'Y-m-d'=10, 'Y-m-d H:i'=16, 'H:i'=5.
|
|
65
|
+
*
|
|
66
|
+
* 완성 판정을 10 으로 고정하면 enableTime(16자) 에서 아직 덜 친 중간 상태를 완성으로 오판한다.
|
|
67
|
+
* 그러면 `2026-03-01 0` 같은 값이 유효성 검사에 걸려 입력이 통째로 이전 값으로 되돌아간다.
|
|
68
|
+
*/
|
|
69
|
+
const getFullValueLength = (dateFormat) => moment(FULL_VALUE_REFERENCE_DATE).format(convertToMomentFormat(dateFormat)).length || FULL_DATE_LENGTH;
|
|
52
70
|
/** 시간 전용 모드의 기본값 반환 */
|
|
53
71
|
const getTimeOnlyDefault = (hasSeconds, isEndDate) => {
|
|
54
72
|
const endTime = hasSeconds ? '23:59:59' : '23:59';
|
|
@@ -196,7 +214,10 @@ const cleanupTimeInputHandlers = (instance, input, isPortal, onHourInput, onMinu
|
|
|
196
214
|
export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size = 'xs', onChangeDate, datePickerOptions, isEndDate = false, onValidationError, className, portal = false, ...attrs }, ref) => {
|
|
197
215
|
const flatpickrInstanceRef = useRef(null);
|
|
198
216
|
const dateFormatRef = useRef('Y-m-d');
|
|
199
|
-
|
|
217
|
+
/** 현재 dateFormat 기준 완성 값의 길이 (getFullValueLength 주석 참고) */
|
|
218
|
+
const fullValueLengthRef = useRef(FULL_DATE_LENGTH);
|
|
219
|
+
/** 인스턴스 파괴 직전에 input 이 포커스를 쥐고 있었는지 */
|
|
220
|
+
const hadFocusBeforeDestroyRef = useRef(false);
|
|
200
221
|
/** portal 모드: 캘린더를 담을 persistent 컨테이너 (한 번 생성, 언마운트 시 제거) */
|
|
201
222
|
const portalContainerRef = useRef(null);
|
|
202
223
|
const hasTimeOption = datePickerOptions && Object.hasOwn(datePickerOptions, 'enableTime');
|
|
@@ -205,6 +226,12 @@ export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size =
|
|
|
205
226
|
onChangeDateRef.current = onChangeDate;
|
|
206
227
|
const onValidationErrorRef = useRef(onValidationError);
|
|
207
228
|
onValidationErrorRef.current = onValidationError;
|
|
229
|
+
/** flatpickr에 실제로 로드된 날짜 문자열. onReady 클로저는 최초 렌더 값에 고정되므로 ref로 최신값을 본다 */
|
|
230
|
+
const loadedDateRef = useRef('');
|
|
231
|
+
/** 마지막으로 부모에 통지한 날짜. blur 동기화의 중복 통지를 막는다 */
|
|
232
|
+
const lastNotifiedDateRef = useRef(null);
|
|
233
|
+
/** blur 동기화 지연 타이머 */
|
|
234
|
+
const blurSyncTimerRef = useRef(null);
|
|
208
235
|
/** portal 컨테이너를 lazily 생성 (이미 있으면 재사용), className은 매번 갱신 */
|
|
209
236
|
const getPortalContainer = useCallback(() => {
|
|
210
237
|
if (!portal || typeof document === 'undefined')
|
|
@@ -224,11 +251,15 @@ export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size =
|
|
|
224
251
|
portalContainerRef.current = el;
|
|
225
252
|
return el;
|
|
226
253
|
}, [portal, size, hasTimeOption]);
|
|
227
|
-
/** 컴포넌트 언마운트 시 포탈 컨테이너 및
|
|
254
|
+
/** 컴포넌트 언마운트 시 포탈 컨테이너 및 지연 타이머 정리 */
|
|
228
255
|
useEffect(() => {
|
|
229
256
|
return () => {
|
|
230
257
|
portalContainerRef.current?.remove();
|
|
231
258
|
portalContainerRef.current = null;
|
|
259
|
+
if (blurSyncTimerRef.current) {
|
|
260
|
+
clearTimeout(blurSyncTimerRef.current);
|
|
261
|
+
blurSyncTimerRef.current = null;
|
|
262
|
+
}
|
|
232
263
|
};
|
|
233
264
|
}, []);
|
|
234
265
|
// ──────────────────────────────────────────────
|
|
@@ -271,6 +302,72 @@ export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size =
|
|
|
271
302
|
target.value = '';
|
|
272
303
|
instance.setDate('', false);
|
|
273
304
|
}, []);
|
|
305
|
+
/**
|
|
306
|
+
* 부모가 이미 그 값을 알고 있으면 true — 통지할 것이 없다.
|
|
307
|
+
*
|
|
308
|
+
* flatpickr 는 값을 확정하는 경로가 4개(캘린더 선택·Enter·타이핑·바깥클릭)이고 서로의 실행을
|
|
309
|
+
* 모른다. 그래서 같은 값이 여러 경로에서 중복 통지되기 쉬운데, 판정을 이 한 곳에만 둔다.
|
|
310
|
+
* - loadedDate: 부모가 prop 으로 들고 있는 값과 같으면 바뀐 게 없다
|
|
311
|
+
* - lastNotifiedDate: 부모가 state 를 갱신하지 않는 사용처(uncontrolled)에서도 중복을 막는다
|
|
312
|
+
*/
|
|
313
|
+
const isAlreadyNotified = useCallback((nextDate) => nextDate === loadedDateRef.current || nextDate === lastNotifiedDateRef.current, []);
|
|
314
|
+
/**
|
|
315
|
+
* 부모로 날짜를 통지하는 유일한 창구. 통지했으면 true.
|
|
316
|
+
* lastNotifiedDateRef 를 쓰는 곳도 여기 하나뿐이다.
|
|
317
|
+
*/
|
|
318
|
+
const commitDate = useCallback((nextDate) => {
|
|
319
|
+
if (isAlreadyNotified(nextDate))
|
|
320
|
+
return false;
|
|
321
|
+
lastNotifiedDateRef.current = nextDate;
|
|
322
|
+
onChangeDateRef.current(nextDate);
|
|
323
|
+
return true;
|
|
324
|
+
}, [isAlreadyNotified]);
|
|
325
|
+
/**
|
|
326
|
+
* min/max 위반이면 onValidationError 로 보고하고 true(통지 중단)를 반환한다.
|
|
327
|
+
* 캘린더 선택 경로(onChangeDateHandler)와 동일한 방어를 blur 동기화에도 적용하기 위한 것으로,
|
|
328
|
+
* 이 검사가 없으면 범위 밖 날짜를 타이핑한 뒤 마우스로 빠져나갈 때 방어가 뚫린다.
|
|
329
|
+
*/
|
|
330
|
+
const reportViolationIfAny = useCallback((selectedDate, instance, previousDate) => {
|
|
331
|
+
const minDate = instance.config.minDate;
|
|
332
|
+
const maxDate = instance.config.maxDate;
|
|
333
|
+
const violations = checkDateViolations(selectedDate, minDate, maxDate);
|
|
334
|
+
if (violations.length === 0 || !onValidationErrorRef.current)
|
|
335
|
+
return false;
|
|
336
|
+
const validPreviousDate = previousDate instanceof Date && !Number.isNaN(previousDate.getTime()) ? previousDate : undefined;
|
|
337
|
+
onValidationErrorRef.current({
|
|
338
|
+
date: selectedDate,
|
|
339
|
+
minDate,
|
|
340
|
+
maxDate,
|
|
341
|
+
violations,
|
|
342
|
+
previousDate: validPreviousDate,
|
|
343
|
+
});
|
|
344
|
+
return true;
|
|
345
|
+
}, [checkDateViolations]);
|
|
346
|
+
/**
|
|
347
|
+
* blur 이후 flatpickr가 끝내 통지하지 않은 값을 부모로 올린다.
|
|
348
|
+
*
|
|
349
|
+
* flatpickr는 경로마다 onChange 발화 시점이 다르다. Enter/Tab 은 blur 보다 앞이고, 캘린더 선택은
|
|
350
|
+
* blur 보다 뒤다. 그래서 blur 시점에 동기적으로 판정하면 어느 한쪽이 반드시 중복 통지된다.
|
|
351
|
+
* 한 틱 미뤄 "결국 아무도 통지하지 않은 경우"만 남겨서 처리한다.
|
|
352
|
+
*/
|
|
353
|
+
const scheduleBlurSync = useCallback((previousDate) => {
|
|
354
|
+
if (blurSyncTimerRef.current)
|
|
355
|
+
clearTimeout(blurSyncTimerRef.current);
|
|
356
|
+
blurSyncTimerRef.current = setTimeout(() => {
|
|
357
|
+
blurSyncTimerRef.current = null;
|
|
358
|
+
const instance = flatpickrInstanceRef.current;
|
|
359
|
+
if (!instance || instance.selectedDates.length === 0)
|
|
360
|
+
return;
|
|
361
|
+
const selectedDate = instance.selectedDates[0];
|
|
362
|
+
const dateToNotify = formatSelectedDate(selectedDate, dateFormatRef.current);
|
|
363
|
+
// 바뀐 게 없으면 위반 보고도 하지 않는다. 안 그러면 blur 마다 알럿이 반복된다
|
|
364
|
+
if (dateToNotify === null || isAlreadyNotified(dateToNotify))
|
|
365
|
+
return;
|
|
366
|
+
if (reportViolationIfAny(selectedDate, instance, previousDate))
|
|
367
|
+
return;
|
|
368
|
+
commitDate(dateToNotify);
|
|
369
|
+
}, 0);
|
|
370
|
+
}, [commitDate, isAlreadyNotified, reportViolationIfAny]);
|
|
274
371
|
/** flatpickr에서 날짜가 변경되었을 때 호출 */
|
|
275
372
|
const onChangeDateHandler = useCallback((dateTimeStamp, dateStr, fpInstance) => {
|
|
276
373
|
const instance = fpInstance;
|
|
@@ -281,27 +378,42 @@ export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size =
|
|
|
281
378
|
if (restoreIfInvalidDate(dateTimeStamp[0], instance))
|
|
282
379
|
return;
|
|
283
380
|
const selectedDate = dateTimeStamp[0];
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if (violations.length > 0 && onValidationErrorRef.current) {
|
|
288
|
-
// flatpickr는 onChange 발화 전에 selectedDates를 위반된 새 날짜로 갱신하므로,
|
|
289
|
-
// 직전 유효 날짜를 보관해 둔 _previousDateBeforeInput을 previousDate로 사용한다
|
|
290
|
-
const prevDate = instance._previousDateBeforeInput;
|
|
291
|
-
const validPrevDate = prevDate instanceof Date && !Number.isNaN(prevDate.getTime()) ? prevDate : undefined;
|
|
292
|
-
onValidationErrorRef.current({
|
|
293
|
-
date: selectedDate,
|
|
294
|
-
minDate,
|
|
295
|
-
maxDate,
|
|
296
|
-
violations,
|
|
297
|
-
previousDate: validPrevDate,
|
|
298
|
-
});
|
|
381
|
+
// flatpickr는 onChange 발화 전에 selectedDates를 위반된 새 날짜로 갱신하므로,
|
|
382
|
+
// 직전 유효 날짜를 보관해 둔 _previousDateBeforeInput을 previousDate로 넘긴다
|
|
383
|
+
if (reportViolationIfAny(selectedDate, instance, instance._previousDateBeforeInput))
|
|
299
384
|
return;
|
|
300
|
-
}
|
|
301
385
|
instance._previousDateBeforeInput = selectedDate;
|
|
302
386
|
const formattedDate = formatDateInput(dateStr);
|
|
303
|
-
isValidDate(formattedDate) ?
|
|
304
|
-
}, [
|
|
387
|
+
commitDate(isValidDate(formattedDate) ? formattedDate : dateStr);
|
|
388
|
+
}, [commitDate, reportViolationIfAny, restorePreviousDate]);
|
|
389
|
+
/**
|
|
390
|
+
* 타이핑으로 완성된 날짜를 blur 를 기다리지 않고 즉시 부모로 올린다. 통지했으면 true.
|
|
391
|
+
*
|
|
392
|
+
* blur 까지 미루면, 타이핑 중 부모 리렌더가 한 번이라도 끼는 순간 react-flatpickr 의
|
|
393
|
+
* "value !== input.value 면 setDate(value, false)" effect 가 타이핑 값을 부모가 들고 있던
|
|
394
|
+
* 값으로 되돌려 버린다. 그러면 blur 시점에는 바뀐 게 없다고 판정돼 onChangeDate 도,
|
|
395
|
+
* 뒤이은 범위 검증(onDateValidation)도 돌지 않는다. (#410 재보고분)
|
|
396
|
+
*/
|
|
397
|
+
const notifyTypedDate = useCallback((instance, formattedInput) => {
|
|
398
|
+
const typedDate = moment(formattedInput, convertToMomentFormat(dateFormatRef.current), true).toDate();
|
|
399
|
+
// min/max 는 instance.config 를 단일 소스로 쓴다. onReady 시점 값을 ref 에 담아두면,
|
|
400
|
+
// 부모가 minDate/maxDate 를 바꿨을 때 타이핑 경로와 blur/캘린더 경로의 판정이 갈릴 수 있다
|
|
401
|
+
const violations = checkDateViolations(typedDate, instance.config.minDate, instance.config.maxDate);
|
|
402
|
+
// 범위 위반은 타이핑 중에 보고하지 않는다 — 한 글자마다 알럿이 뜬다.
|
|
403
|
+
// 입력을 마치고 빠져나갈 때 scheduleBlurSync 의 reportViolationIfAny 가 한 번만 보고한다.
|
|
404
|
+
if (violations.length > 0)
|
|
405
|
+
return false;
|
|
406
|
+
const dateToNotify = formatSelectedDate(typedDate, dateFormatRef.current);
|
|
407
|
+
// 입력 문자열이 이미 최종 표기와 같을 때만 통지한다. 다르면(예: enableTime 인데 시간을
|
|
408
|
+
// 아직 안 친 상태) 아래 setDate 가 input 값을 다시 써서 타이핑 중 커서가 튄다.
|
|
409
|
+
// 그 경우는 종전대로 blur 시점 동기화에 맡긴다.
|
|
410
|
+
if (dateToNotify === null || dateToNotify !== formattedInput)
|
|
411
|
+
return false;
|
|
412
|
+
if (isAlreadyNotified(dateToNotify))
|
|
413
|
+
return false;
|
|
414
|
+
instance.setDate(typedDate, false);
|
|
415
|
+
return commitDate(dateToNotify);
|
|
416
|
+
}, [checkDateViolations, commitDate, isAlreadyNotified]);
|
|
305
417
|
/** input에 직접 타이핑할 때 날짜 형식 자동 변환 및 유효성 검사 */
|
|
306
418
|
const onInputHandler = useCallback((e) => {
|
|
307
419
|
const target = e.target;
|
|
@@ -312,7 +424,7 @@ export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size =
|
|
|
312
424
|
if (!input?.trim()) {
|
|
313
425
|
target.value = '';
|
|
314
426
|
instance.setDate('', false);
|
|
315
|
-
|
|
427
|
+
commitDate('');
|
|
316
428
|
return;
|
|
317
429
|
}
|
|
318
430
|
if (!/[0-9]/.test(input)) {
|
|
@@ -321,22 +433,21 @@ export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size =
|
|
|
321
433
|
}
|
|
322
434
|
const formattedInput = formatDateInput(input);
|
|
323
435
|
if (formattedInput !== input) {
|
|
436
|
+
// 여기서 멈추지 않는다. 8자리 숫자를 다 치거나 붙여넣으면 이 시점에 이미 완성된
|
|
437
|
+
// 날짜이고, 우리가 다시 쓴 값에 대해 브라우저는 input 이벤트를 더 주지 않는다.
|
|
324
438
|
target.value = formattedInput;
|
|
325
|
-
return;
|
|
326
439
|
}
|
|
327
|
-
|
|
440
|
+
// 완성 전에는 검증하지 않는다. 중간 상태를 완성으로 오판하면 아래에서 입력이 되돌아간다
|
|
441
|
+
if (formattedInput.length < fullValueLengthRef.current)
|
|
328
442
|
return;
|
|
329
|
-
|
|
330
|
-
|
|
443
|
+
// 포맷을 넘겨 strict 로 파싱한다. 포맷 없이 moment(값) 만 쓰면 시간 전용 모드의
|
|
444
|
+
// '14:30' 같은 값을 파싱하지 못해 정상 입력이 되돌려진다
|
|
445
|
+
if (!moment(formattedInput, convertToMomentFormat(dateFormatRef.current), true).isValid()) {
|
|
331
446
|
restorePreviousDate(target, instance);
|
|
332
447
|
return;
|
|
333
448
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if (violations.length > 0) {
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
}, [checkDateViolations, restorePreviousDate]);
|
|
449
|
+
notifyTypedDate(instance, formattedInput);
|
|
450
|
+
}, [commitDate, notifyTypedDate, restorePreviousDate]);
|
|
340
451
|
/** 시간 입력 필드 - 시(hour) 값 포맷팅 (0~23) */
|
|
341
452
|
const onHourInputHandler = useCallback((e) => {
|
|
342
453
|
const target = e.target;
|
|
@@ -398,15 +509,16 @@ export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size =
|
|
|
398
509
|
return;
|
|
399
510
|
flatpickrInstanceRef.current = instance;
|
|
400
511
|
dateFormatRef.current = instance.config.dateFormat || 'Y-m-d';
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
};
|
|
405
|
-
// blur 시 현재 날짜를 저장하여 잘못된 입력 시 복원에 사용
|
|
512
|
+
fullValueLengthRef.current = getFullValueLength(dateFormatRef.current);
|
|
513
|
+
// blur 시 현재 날짜를 저장하여 잘못된 입력 시 복원에 사용하고,
|
|
514
|
+
// flatpickr가 조용히 반영한 값이 남아 있으면 부모로 올린다 (scheduleBlurSync 주석 참고)
|
|
406
515
|
const onBlurHandler = (_e) => {
|
|
516
|
+
// 아래에서 덮어쓰기 전의 값을 넘긴다. 위반 보고 시 previousDate 로 쓰인다
|
|
517
|
+
const previousDate = instance?._previousDateBeforeInput;
|
|
407
518
|
if (instance && instance.selectedDates.length > 0) {
|
|
408
519
|
instance._previousDateBeforeInput = instance.selectedDates[0];
|
|
409
520
|
}
|
|
521
|
+
scheduleBlurSync(previousDate);
|
|
410
522
|
};
|
|
411
523
|
input.addEventListener('input', onInputHandler);
|
|
412
524
|
input.addEventListener('blur', onBlurHandler);
|
|
@@ -432,6 +544,8 @@ export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size =
|
|
|
432
544
|
const input = instance.input;
|
|
433
545
|
if (!input)
|
|
434
546
|
return;
|
|
547
|
+
// 파괴 직전에 이 input 이 포커스를 쥐고 있었는지 기억한다 (restoreFocus 주석 참고)
|
|
548
|
+
hadFocusBeforeDestroyRef.current = typeof document !== 'undefined' && document.activeElement === input;
|
|
435
549
|
flatpickrInstanceRef.current = null;
|
|
436
550
|
input.removeEventListener('input', onInputHandler);
|
|
437
551
|
const onBlurHandler = instance._onBlurHandler;
|
|
@@ -452,6 +566,7 @@ export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size =
|
|
|
452
566
|
onMinuteInputHandler,
|
|
453
567
|
datePickerOptions,
|
|
454
568
|
getPortalContainer,
|
|
569
|
+
scheduleBlurSync,
|
|
455
570
|
]);
|
|
456
571
|
const iconName = hasTimeOption && datePickerOptions && Object.hasOwn(datePickerOptions, 'noCalendar') ? 'clock' : 'calendar';
|
|
457
572
|
// ──────────────────────────────────────────────
|
|
@@ -470,6 +585,39 @@ export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size =
|
|
|
470
585
|
return getTimeOnlyDefault(hasSeconds, isEndDate);
|
|
471
586
|
return getDateTimeDefault(currentDate, hasSeconds, isEndDate);
|
|
472
587
|
}, [currentDate, isEndDate, options.enableSeconds, options.enableTime, options.noCalendar]);
|
|
588
|
+
// onReady 클로저는 최초 렌더 값에 고정되므로, blur 동기화가 최신 로드값과 비교할 수 있도록 ref에 반영
|
|
589
|
+
if (loadedDateRef.current !== processedCurrentDate) {
|
|
590
|
+
loadedDateRef.current = processedCurrentDate;
|
|
591
|
+
// 부모가 값을 갱신했으면 "이미 통지했다"는 기억은 무효다.
|
|
592
|
+
// 남겨두면, 사용처가 overlap 통지를 받고 날짜를 되돌린 뒤 사용자가 같은 날짜를 다시 입력할 때
|
|
593
|
+
// 중복으로 오판해 통지를 건너뛴다 — 콜백이 첫 회만 오고 그 뒤로 안 오는 증상이 된다
|
|
594
|
+
lastNotifiedDateRef.current = null;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* 리렌더로 잃은 포커스를 되돌린다.
|
|
598
|
+
*
|
|
599
|
+
* react-flatpickr 는 렌더마다 flatpickr 인스턴스를 destroy/create 하는데, portal 을 안 쓰면
|
|
600
|
+
* static:true 라 destroy 가 input 을 감싼 .flatpickr-wrapper 를 헐면서 input 노드를 DOM 에서
|
|
601
|
+
* 옮긴다. 포커스를 쥔 노드를 옮기면 포커스가 풀린다.
|
|
602
|
+
*
|
|
603
|
+
* 그래서 사용처가 onDateValidation 을 받고 날짜를 되돌리면, 그 리렌더에서 입력 칸의 포커스가
|
|
604
|
+
* 빠진다. 사용자는 알럿을 닫고 이어서 타이핑하는데 입력이 아무 데도 들어가지 않아,
|
|
605
|
+
* 콜백이 "됐다 안 됐다" 하는 것처럼 보인다. (#416)
|
|
606
|
+
*
|
|
607
|
+
* 자식(Flatpickr)의 effect 가 먼저 실행되므로 여기서 마지막에 되돌린다.
|
|
608
|
+
* 포커스가 복원되면 flatpickr 가 캘린더를 여는데, 이는 사용자가 칸을 직접 클릭했을 때와 같은
|
|
609
|
+
* 상태이므로 그대로 둔다.
|
|
610
|
+
*/
|
|
611
|
+
// 의도적으로 deps 없음 — 자식 Flatpickr effect 가 인스턴스를 재생성한 뒤에 실행되어야 한다
|
|
612
|
+
useEffect(() => {
|
|
613
|
+
if (!hadFocusBeforeDestroyRef.current)
|
|
614
|
+
return;
|
|
615
|
+
hadFocusBeforeDestroyRef.current = false;
|
|
616
|
+
const input = flatpickrInstanceRef.current?.input;
|
|
617
|
+
if (!input || document.activeElement === input)
|
|
618
|
+
return;
|
|
619
|
+
input.focus({ preventScroll: true });
|
|
620
|
+
});
|
|
473
621
|
// ──────────────────────────────────────────────
|
|
474
622
|
// 렌더링
|
|
475
623
|
// ──────────────────────────────────────────────
|