@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.
Files changed (21) hide show
  1. package/dist/cjs/src/components/forms-and-input/date-picker/DatePicker.js +174 -38
  2. package/dist/cjs/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +410 -0
  3. package/dist/cjs/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
  4. package/dist/cjs/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +61 -0
  5. package/dist/cjs/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +249 -0
  6. package/dist/cjs/vitest.config.js +23 -1
  7. package/dist/esm/src/components/forms-and-input/date-picker/DatePicker.js +174 -38
  8. package/dist/esm/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +410 -0
  9. package/dist/esm/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
  10. package/dist/esm/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +61 -0
  11. package/dist/esm/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +250 -1
  12. package/dist/esm/vitest.config.js +22 -1
  13. package/dist/temp/src/components/forms-and-input/date-picker/DatePicker.js +184 -36
  14. package/dist/temp/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +323 -0
  15. package/dist/temp/src/components/forms-and-input/range-date-picker/RangeDatePicker.d.ts +9 -0
  16. package/dist/temp/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
  17. package/dist/temp/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +40 -0
  18. package/dist/temp/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +190 -1
  19. package/dist/temp/vitest.config.js +24 -0
  20. package/dist/types/src/components/forms-and-input/range-date-picker/RangeDatePicker.d.ts +9 -0
  21. 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
- const minMaxDateRef = useRef({});
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
- const minDate = instance.config.minDate;
285
- const maxDate = instance.config.maxDate;
286
- const violations = checkDateViolations(selectedDate, minDate, maxDate);
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) ? onChangeDateRef.current(formattedDate) : onChangeDateRef.current(dateStr);
304
- }, [checkDateViolations, restorePreviousDate]);
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
- onChangeDateRef.current('');
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
- if (!formattedInput || formattedInput.length < 10)
440
+ // 완성 전에는 검증하지 않는다. 중간 상태를 완성으로 오판하면 아래에서 입력이 되돌아간다
441
+ if (formattedInput.length < fullValueLengthRef.current)
328
442
  return;
329
- const parsedDate = moment(formattedInput);
330
- if (!parsedDate.isValid()) {
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
- const parsedDateObj = parsedDate.toDate();
335
- const violations = checkDateViolations(parsedDateObj, minMaxDateRef.current.minDate, minMaxDateRef.current.maxDate);
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
- minMaxDateRef.current = {
402
- minDate: instance.config.minDate,
403
- maxDate: instance.config.maxDate,
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
  // ──────────────────────────────────────────────