@ncds/ui-admin 1.8.16 → 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 (62) 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/file-input/FileInput.js +25 -7
  4. package/dist/cjs/src/components/forms-and-input/file-input/__tests__/FileInput.test.js +168 -0
  5. package/dist/cjs/src/components/forms-and-input/image-file-input/ImageFileInput.js +76 -19
  6. package/dist/cjs/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.js +533 -0
  7. package/dist/cjs/src/components/forms-and-input/image-file-input/components/ImagePreview.js +10 -3
  8. package/dist/cjs/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
  9. package/dist/cjs/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +61 -0
  10. package/dist/cjs/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +249 -0
  11. package/dist/cjs/src/hooks/__tests__/useObjectUrl.test.js +120 -0
  12. package/dist/cjs/src/hooks/index.js +11 -0
  13. package/dist/cjs/src/hooks/useObjectUrl.js +39 -0
  14. package/dist/cjs/vitest.config.js +23 -1
  15. package/dist/esm/src/components/forms-and-input/date-picker/DatePicker.js +174 -38
  16. package/dist/esm/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +410 -0
  17. package/dist/esm/src/components/forms-and-input/file-input/FileInput.js +25 -7
  18. package/dist/esm/src/components/forms-and-input/file-input/__tests__/FileInput.test.js +165 -0
  19. package/dist/esm/src/components/forms-and-input/image-file-input/ImageFileInput.js +71 -19
  20. package/dist/esm/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.js +530 -0
  21. package/dist/esm/src/components/forms-and-input/image-file-input/components/ImagePreview.js +10 -3
  22. package/dist/esm/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
  23. package/dist/esm/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +61 -0
  24. package/dist/esm/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +250 -1
  25. package/dist/esm/src/hooks/__tests__/useObjectUrl.test.js +117 -0
  26. package/dist/esm/src/hooks/index.js +2 -1
  27. package/dist/esm/src/hooks/useObjectUrl.js +32 -0
  28. package/dist/esm/vitest.config.js +22 -1
  29. package/dist/temp/src/components/forms-and-input/date-picker/DatePicker.js +184 -36
  30. package/dist/temp/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +323 -0
  31. package/dist/temp/src/components/forms-and-input/file-input/FileInput.d.ts +8 -0
  32. package/dist/temp/src/components/forms-and-input/file-input/FileInput.js +16 -3
  33. package/dist/temp/src/components/forms-and-input/file-input/__tests__/FileInput.test.d.ts +1 -0
  34. package/dist/temp/src/components/forms-and-input/file-input/__tests__/FileInput.test.js +139 -0
  35. package/dist/temp/src/components/forms-and-input/image-file-input/ImageFileInput.d.ts +33 -1
  36. package/dist/temp/src/components/forms-and-input/image-file-input/ImageFileInput.js +63 -18
  37. package/dist/temp/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.d.ts +1 -0
  38. package/dist/temp/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.js +435 -0
  39. package/dist/temp/src/components/forms-and-input/image-file-input/components/ImagePreview.d.ts +20 -2
  40. package/dist/temp/src/components/forms-and-input/image-file-input/components/ImagePreview.js +7 -2
  41. package/dist/temp/src/components/forms-and-input/range-date-picker/RangeDatePicker.d.ts +9 -0
  42. package/dist/temp/src/components/forms-and-input/range-date-picker/RangeDatePicker.js +9 -6
  43. package/dist/temp/src/components/forms-and-input/range-date-picker/__tests__/RangeDatePicker.test.js +40 -0
  44. package/dist/temp/src/components/forms-and-input/range-date-picker-with-buttons/__tests__/RangeDatePickerWithButtons.test.js +190 -1
  45. package/dist/temp/src/hooks/__tests__/useObjectUrl.test.d.ts +1 -0
  46. package/dist/temp/src/hooks/__tests__/useObjectUrl.test.js +96 -0
  47. package/dist/temp/src/hooks/index.d.ts +1 -0
  48. package/dist/temp/src/hooks/index.js +1 -0
  49. package/dist/temp/src/hooks/useObjectUrl.d.ts +16 -0
  50. package/dist/temp/src/hooks/useObjectUrl.js +32 -0
  51. package/dist/temp/vitest.config.js +24 -0
  52. package/dist/types/src/components/forms-and-input/file-input/FileInput.d.ts +8 -0
  53. package/dist/types/src/components/forms-and-input/file-input/__tests__/FileInput.test.d.ts +1 -0
  54. package/dist/types/src/components/forms-and-input/image-file-input/ImageFileInput.d.ts +33 -1
  55. package/dist/types/src/components/forms-and-input/image-file-input/__tests__/ImageFileInput.test.d.ts +1 -0
  56. package/dist/types/src/components/forms-and-input/image-file-input/components/ImagePreview.d.ts +20 -2
  57. package/dist/types/src/components/forms-and-input/range-date-picker/RangeDatePicker.d.ts +9 -0
  58. package/dist/types/src/hooks/__tests__/useObjectUrl.test.d.ts +1 -0
  59. package/dist/types/src/hooks/index.d.ts +1 -0
  60. package/dist/types/src/hooks/useObjectUrl.d.ts +16 -0
  61. package/dist/ui-admin/assets/styles/style.css +20 -4
  62. package/package.json +5 -3
@@ -51,6 +51,23 @@ const restoreIfInvalidDate = (date, instance) => {
51
51
  }
52
52
  return true;
53
53
  };
54
+ /** Date 를 dateFormat 표기 문자열로 바꾼다. 유효하지 않으면 null. */
55
+ const formatSelectedDate = (selectedDate, dateFormat) => {
56
+ if (!(selectedDate instanceof Date) || Number.isNaN(selectedDate.getTime())) return null;
57
+ const formattedDate = moment(selectedDate).format(convertToMomentFormat(dateFormat));
58
+ return formattedDate || null;
59
+ };
60
+ /** 'YYYY-MM-DD' 길이. dateFormat 을 읽기 전(onReady 이전)의 기본값이다 */
61
+ const FULL_DATE_LENGTH = 10;
62
+ /** 연·월·일·시·분·초가 모두 두 자리 이상인 기준 시각. 길이 측정용이라 날짜 값 자체에 의미는 없다 */
63
+ const FULL_VALUE_REFERENCE_DATE = '2026-03-01 04:05:06';
64
+ /**
65
+ * 해당 dateFormat 으로 완성된 값의 길이. 'Y-m-d'=10, 'Y-m-d H:i'=16, 'H:i'=5.
66
+ *
67
+ * 완성 판정을 10 으로 고정하면 enableTime(16자) 에서 아직 덜 친 중간 상태를 완성으로 오판한다.
68
+ * 그러면 `2026-03-01 0` 같은 값이 유효성 검사에 걸려 입력이 통째로 이전 값으로 되돌아간다.
69
+ */
70
+ const getFullValueLength = dateFormat => moment(FULL_VALUE_REFERENCE_DATE).format(convertToMomentFormat(dateFormat)).length || FULL_DATE_LENGTH;
54
71
  /** 시간 전용 모드의 기본값 반환 */
55
72
  const getTimeOnlyDefault = (hasSeconds, isEndDate) => {
56
73
  const endTime = hasSeconds ? '23:59:59' : '23:59';
@@ -212,7 +229,10 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
212
229
  } = _ref;
213
230
  const flatpickrInstanceRef = useRef(null);
214
231
  const dateFormatRef = useRef('Y-m-d');
215
- const minMaxDateRef = useRef({});
232
+ /** 현재 dateFormat 기준 완성 값의 길이 (getFullValueLength 주석 참고) */
233
+ const fullValueLengthRef = useRef(FULL_DATE_LENGTH);
234
+ /** 인스턴스 파괴 직전에 input 이 포커스를 쥐고 있었는지 */
235
+ const hadFocusBeforeDestroyRef = useRef(false);
216
236
  /** portal 모드: 캘린더를 담을 persistent 컨테이너 (한 번 생성, 언마운트 시 제거) */
217
237
  const portalContainerRef = useRef(null);
218
238
  const hasTimeOption = datePickerOptions && Object.hasOwn(datePickerOptions, 'enableTime');
@@ -221,6 +241,12 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
221
241
  onChangeDateRef.current = onChangeDate;
222
242
  const onValidationErrorRef = useRef(onValidationError);
223
243
  onValidationErrorRef.current = onValidationError;
244
+ /** flatpickr에 실제로 로드된 날짜 문자열. onReady 클로저는 최초 렌더 값에 고정되므로 ref로 최신값을 본다 */
245
+ const loadedDateRef = useRef('');
246
+ /** 마지막으로 부모에 통지한 날짜. blur 동기화의 중복 통지를 막는다 */
247
+ const lastNotifiedDateRef = useRef(null);
248
+ /** blur 동기화 지연 타이머 */
249
+ const blurSyncTimerRef = useRef(null);
224
250
  /** portal 컨테이너를 lazily 생성 (이미 있으면 재사용), className은 매번 갱신 */
225
251
  const getPortalContainer = useCallback(() => {
226
252
  if (!portal || typeof document === 'undefined') return undefined;
@@ -239,11 +265,15 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
239
265
  portalContainerRef.current = el;
240
266
  return el;
241
267
  }, [portal, size, hasTimeOption]);
242
- /** 컴포넌트 언마운트 시 포탈 컨테이너 및 스크롤 리스너 정리 */
268
+ /** 컴포넌트 언마운트 시 포탈 컨테이너 및 지연 타이머 정리 */
243
269
  useEffect(() => {
244
270
  return () => {
245
271
  portalContainerRef.current?.remove();
246
272
  portalContainerRef.current = null;
273
+ if (blurSyncTimerRef.current) {
274
+ clearTimeout(blurSyncTimerRef.current);
275
+ blurSyncTimerRef.current = null;
276
+ }
247
277
  };
248
278
  }, []);
249
279
  // ──────────────────────────────────────────────
@@ -285,6 +315,66 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
285
315
  target.value = '';
286
316
  instance.setDate('', false);
287
317
  }, []);
318
+ /**
319
+ * 부모가 이미 그 값을 알고 있으면 true — 통지할 것이 없다.
320
+ *
321
+ * flatpickr 는 값을 확정하는 경로가 4개(캘린더 선택·Enter·타이핑·바깥클릭)이고 서로의 실행을
322
+ * 모른다. 그래서 같은 값이 여러 경로에서 중복 통지되기 쉬운데, 판정을 이 한 곳에만 둔다.
323
+ * - loadedDate: 부모가 prop 으로 들고 있는 값과 같으면 바뀐 게 없다
324
+ * - lastNotifiedDate: 부모가 state 를 갱신하지 않는 사용처(uncontrolled)에서도 중복을 막는다
325
+ */
326
+ const isAlreadyNotified = useCallback(nextDate => nextDate === loadedDateRef.current || nextDate === lastNotifiedDateRef.current, []);
327
+ /**
328
+ * 부모로 날짜를 통지하는 유일한 창구. 통지했으면 true.
329
+ * lastNotifiedDateRef 를 쓰는 곳도 여기 하나뿐이다.
330
+ */
331
+ const commitDate = useCallback(nextDate => {
332
+ if (isAlreadyNotified(nextDate)) return false;
333
+ lastNotifiedDateRef.current = nextDate;
334
+ onChangeDateRef.current(nextDate);
335
+ return true;
336
+ }, [isAlreadyNotified]);
337
+ /**
338
+ * min/max 위반이면 onValidationError 로 보고하고 true(통지 중단)를 반환한다.
339
+ * 캘린더 선택 경로(onChangeDateHandler)와 동일한 방어를 blur 동기화에도 적용하기 위한 것으로,
340
+ * 이 검사가 없으면 범위 밖 날짜를 타이핑한 뒤 마우스로 빠져나갈 때 방어가 뚫린다.
341
+ */
342
+ const reportViolationIfAny = useCallback((selectedDate, instance, previousDate) => {
343
+ const minDate = instance.config.minDate;
344
+ const maxDate = instance.config.maxDate;
345
+ const violations = checkDateViolations(selectedDate, minDate, maxDate);
346
+ if (violations.length === 0 || !onValidationErrorRef.current) return false;
347
+ const validPreviousDate = previousDate instanceof Date && !Number.isNaN(previousDate.getTime()) ? previousDate : undefined;
348
+ onValidationErrorRef.current({
349
+ date: selectedDate,
350
+ minDate,
351
+ maxDate,
352
+ violations,
353
+ previousDate: validPreviousDate
354
+ });
355
+ return true;
356
+ }, [checkDateViolations]);
357
+ /**
358
+ * blur 이후 flatpickr가 끝내 통지하지 않은 값을 부모로 올린다.
359
+ *
360
+ * flatpickr는 경로마다 onChange 발화 시점이 다르다. Enter/Tab 은 blur 보다 앞이고, 캘린더 선택은
361
+ * blur 보다 뒤다. 그래서 blur 시점에 동기적으로 판정하면 어느 한쪽이 반드시 중복 통지된다.
362
+ * 한 틱 미뤄 "결국 아무도 통지하지 않은 경우"만 남겨서 처리한다.
363
+ */
364
+ const scheduleBlurSync = useCallback(previousDate => {
365
+ if (blurSyncTimerRef.current) clearTimeout(blurSyncTimerRef.current);
366
+ blurSyncTimerRef.current = setTimeout(() => {
367
+ blurSyncTimerRef.current = null;
368
+ const instance = flatpickrInstanceRef.current;
369
+ if (!instance || instance.selectedDates.length === 0) return;
370
+ const selectedDate = instance.selectedDates[0];
371
+ const dateToNotify = formatSelectedDate(selectedDate, dateFormatRef.current);
372
+ // 바뀐 게 없으면 위반 보고도 하지 않는다. 안 그러면 blur 마다 알럿이 반복된다
373
+ if (dateToNotify === null || isAlreadyNotified(dateToNotify)) return;
374
+ if (reportViolationIfAny(selectedDate, instance, previousDate)) return;
375
+ commitDate(dateToNotify);
376
+ }, 0);
377
+ }, [commitDate, isAlreadyNotified, reportViolationIfAny]);
288
378
  /** flatpickr에서 날짜가 변경되었을 때 호출 */
289
379
  const onChangeDateHandler = useCallback((dateTimeStamp, dateStr, fpInstance) => {
290
380
  const instance = fpInstance;
@@ -294,27 +384,38 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
294
384
  }
295
385
  if (restoreIfInvalidDate(dateTimeStamp[0], instance)) return;
296
386
  const selectedDate = dateTimeStamp[0];
297
- const minDate = instance.config.minDate;
298
- const maxDate = instance.config.maxDate;
299
- const violations = checkDateViolations(selectedDate, minDate, maxDate);
300
- if (violations.length > 0 && onValidationErrorRef.current) {
301
- // flatpickr는 onChange 발화 전에 selectedDates를 위반된 새 날짜로 갱신하므로,
302
- // 직전 유효 날짜를 보관해 둔 _previousDateBeforeInput을 previousDate로 사용한다
303
- const prevDate = instance._previousDateBeforeInput;
304
- const validPrevDate = prevDate instanceof Date && !Number.isNaN(prevDate.getTime()) ? prevDate : undefined;
305
- onValidationErrorRef.current({
306
- date: selectedDate,
307
- minDate,
308
- maxDate,
309
- violations,
310
- previousDate: validPrevDate
311
- });
312
- return;
313
- }
387
+ // flatpickr는 onChange 발화 전에 selectedDates를 위반된 새 날짜로 갱신하므로,
388
+ // 직전 유효 날짜를 보관해 둔 _previousDateBeforeInput을 previousDate로 넘긴다
389
+ if (reportViolationIfAny(selectedDate, instance, instance._previousDateBeforeInput)) return;
314
390
  instance._previousDateBeforeInput = selectedDate;
315
391
  const formattedDate = formatDateInput(dateStr);
316
- isValidDate(formattedDate) ? onChangeDateRef.current(formattedDate) : onChangeDateRef.current(dateStr);
317
- }, [checkDateViolations, restorePreviousDate]);
392
+ commitDate(isValidDate(formattedDate) ? formattedDate : dateStr);
393
+ }, [commitDate, reportViolationIfAny, restorePreviousDate]);
394
+ /**
395
+ * 타이핑으로 완성된 날짜를 blur 를 기다리지 않고 즉시 부모로 올린다. 통지했으면 true.
396
+ *
397
+ * blur 까지 미루면, 타이핑 중 부모 리렌더가 한 번이라도 끼는 순간 react-flatpickr 의
398
+ * "value !== input.value 면 setDate(value, false)" effect 가 타이핑 값을 부모가 들고 있던
399
+ * 값으로 되돌려 버린다. 그러면 blur 시점에는 바뀐 게 없다고 판정돼 onChangeDate 도,
400
+ * 뒤이은 범위 검증(onDateValidation)도 돌지 않는다. (#410 재보고분)
401
+ */
402
+ const notifyTypedDate = useCallback((instance, formattedInput) => {
403
+ const typedDate = moment(formattedInput, convertToMomentFormat(dateFormatRef.current), true).toDate();
404
+ // min/max 는 instance.config 를 단일 소스로 쓴다. onReady 시점 값을 ref 에 담아두면,
405
+ // 부모가 minDate/maxDate 를 바꿨을 때 타이핑 경로와 blur/캘린더 경로의 판정이 갈릴 수 있다
406
+ const violations = checkDateViolations(typedDate, instance.config.minDate, instance.config.maxDate);
407
+ // 범위 위반은 타이핑 중에 보고하지 않는다 — 한 글자마다 알럿이 뜬다.
408
+ // 입력을 마치고 빠져나갈 때 scheduleBlurSync 의 reportViolationIfAny 가 한 번만 보고한다.
409
+ if (violations.length > 0) return false;
410
+ const dateToNotify = formatSelectedDate(typedDate, dateFormatRef.current);
411
+ // 입력 문자열이 이미 최종 표기와 같을 때만 통지한다. 다르면(예: enableTime 인데 시간을
412
+ // 아직 안 친 상태) 아래 setDate 가 input 값을 다시 써서 타이핑 중 커서가 튄다.
413
+ // 그 경우는 종전대로 blur 시점 동기화에 맡긴다.
414
+ if (dateToNotify === null || dateToNotify !== formattedInput) return false;
415
+ if (isAlreadyNotified(dateToNotify)) return false;
416
+ instance.setDate(typedDate, false);
417
+ return commitDate(dateToNotify);
418
+ }, [checkDateViolations, commitDate, isAlreadyNotified]);
318
419
  /** input에 직접 타이핑할 때 날짜 형식 자동 변환 및 유효성 검사 */
319
420
  const onInputHandler = useCallback(e => {
320
421
  const target = e.target;
@@ -324,7 +425,7 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
324
425
  if (!input?.trim()) {
325
426
  target.value = '';
326
427
  instance.setDate('', false);
327
- onChangeDateRef.current('');
428
+ commitDate('');
328
429
  return;
329
430
  }
330
431
  if (!/[0-9]/.test(input)) {
@@ -333,21 +434,20 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
333
434
  }
334
435
  const formattedInput = formatDateInput(input);
335
436
  if (formattedInput !== input) {
437
+ // 여기서 멈추지 않는다. 8자리 숫자를 다 치거나 붙여넣으면 이 시점에 이미 완성된
438
+ // 날짜이고, 우리가 다시 쓴 값에 대해 브라우저는 input 이벤트를 더 주지 않는다.
336
439
  target.value = formattedInput;
337
- return;
338
440
  }
339
- if (!formattedInput || formattedInput.length < 10) return;
340
- const parsedDate = moment(formattedInput);
341
- if (!parsedDate.isValid()) {
441
+ // 완성 전에는 검증하지 않는다. 중간 상태를 완성으로 오판하면 아래에서 입력이 되돌아간다
442
+ if (formattedInput.length < fullValueLengthRef.current) return;
443
+ // 포맷을 넘겨 strict 로 파싱한다. 포맷 없이 moment() 만 쓰면 시간 전용 모드의
444
+ // '14:30' 같은 값을 파싱하지 못해 정상 입력이 되돌려진다
445
+ if (!moment(formattedInput, convertToMomentFormat(dateFormatRef.current), true).isValid()) {
342
446
  restorePreviousDate(target, instance);
343
447
  return;
344
448
  }
345
- const parsedDateObj = parsedDate.toDate();
346
- const violations = checkDateViolations(parsedDateObj, minMaxDateRef.current.minDate, minMaxDateRef.current.maxDate);
347
- if (violations.length > 0) {
348
- return;
349
- }
350
- }, [checkDateViolations, restorePreviousDate]);
449
+ notifyTypedDate(instance, formattedInput);
450
+ }, [commitDate, notifyTypedDate, restorePreviousDate]);
351
451
  /** 시간 입력 필드 - 시(hour) 값 포맷팅 (0~23) */
352
452
  const onHourInputHandler = useCallback(e => {
353
453
  const target = e.target;
@@ -405,15 +505,16 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
405
505
  if (!input) return;
406
506
  flatpickrInstanceRef.current = instance;
407
507
  dateFormatRef.current = instance.config.dateFormat || 'Y-m-d';
408
- minMaxDateRef.current = {
409
- minDate: instance.config.minDate,
410
- maxDate: instance.config.maxDate
411
- };
412
- // blur 시 현재 날짜를 저장하여 잘못된 입력 시 복원에 사용
508
+ fullValueLengthRef.current = getFullValueLength(dateFormatRef.current);
509
+ // blur 시 현재 날짜를 저장하여 잘못된 입력 시 복원에 사용하고,
510
+ // flatpickr가 조용히 반영한 값이 남아 있으면 부모로 올린다 (scheduleBlurSync 주석 참고)
413
511
  const onBlurHandler = _e => {
512
+ // 아래에서 덮어쓰기 전의 값을 넘긴다. 위반 보고 시 previousDate 로 쓰인다
513
+ const previousDate = instance?._previousDateBeforeInput;
414
514
  if (instance && instance.selectedDates.length > 0) {
415
515
  instance._previousDateBeforeInput = instance.selectedDates[0];
416
516
  }
517
+ scheduleBlurSync(previousDate);
417
518
  };
418
519
  input.addEventListener('input', onInputHandler);
419
520
  input.addEventListener('blur', onBlurHandler);
@@ -437,6 +538,8 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
437
538
  const instance = fpInstance;
438
539
  const input = instance.input;
439
540
  if (!input) return;
541
+ // 파괴 직전에 이 input 이 포커스를 쥐고 있었는지 기억한다 (restoreFocus 주석 참고)
542
+ hadFocusBeforeDestroyRef.current = typeof document !== 'undefined' && document.activeElement === input;
440
543
  flatpickrInstanceRef.current = null;
441
544
  input.removeEventListener('input', onInputHandler);
442
545
  const onBlurHandler = instance._onBlurHandler;
@@ -449,7 +552,7 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
449
552
  ...datePickerOptions,
450
553
  // 어드민 환경 — 모바일에서 native picker 로 fallback 되지 않도록 강제 (datePickerOptions 로 override 불가)
451
554
  disableMobile: true
452
- }), [portal, onChangeDateHandler, onInputHandler, onHourInputHandler, onMinuteInputHandler, datePickerOptions, getPortalContainer]);
555
+ }), [portal, onChangeDateHandler, onInputHandler, onHourInputHandler, onMinuteInputHandler, datePickerOptions, getPortalContainer, scheduleBlurSync]);
453
556
  const iconName = hasTimeOption && datePickerOptions && Object.hasOwn(datePickerOptions, 'noCalendar') ? 'clock' : 'calendar';
454
557
  // ──────────────────────────────────────────────
455
558
  // 시간 옵션에 따른 currentDate 전처리
@@ -464,6 +567,39 @@ export const DatePicker = /*#__PURE__*/forwardRef((_ref, ref) => {
464
567
  if (isTimeOnly) return getTimeOnlyDefault(hasSeconds, isEndDate);
465
568
  return getDateTimeDefault(currentDate, hasSeconds, isEndDate);
466
569
  }, [currentDate, isEndDate, options.enableSeconds, options.enableTime, options.noCalendar]);
570
+ // onReady 클로저는 최초 렌더 값에 고정되므로, blur 동기화가 최신 로드값과 비교할 수 있도록 ref에 반영
571
+ if (loadedDateRef.current !== processedCurrentDate) {
572
+ loadedDateRef.current = processedCurrentDate;
573
+ // 부모가 값을 갱신했으면 "이미 통지했다"는 기억은 무효다.
574
+ // 남겨두면, 사용처가 overlap 통지를 받고 날짜를 되돌린 뒤 사용자가 같은 날짜를 다시 입력할 때
575
+ // 중복으로 오판해 통지를 건너뛴다 — 콜백이 첫 회만 오고 그 뒤로 안 오는 증상이 된다
576
+ lastNotifiedDateRef.current = null;
577
+ }
578
+ /**
579
+ * 리렌더로 잃은 포커스를 되돌린다.
580
+ *
581
+ * react-flatpickr 는 렌더마다 flatpickr 인스턴스를 destroy/create 하는데, portal 을 안 쓰면
582
+ * static:true 라 destroy 가 input 을 감싼 .flatpickr-wrapper 를 헐면서 input 노드를 DOM 에서
583
+ * 옮긴다. 포커스를 쥔 노드를 옮기면 포커스가 풀린다.
584
+ *
585
+ * 그래서 사용처가 onDateValidation 을 받고 날짜를 되돌리면, 그 리렌더에서 입력 칸의 포커스가
586
+ * 빠진다. 사용자는 알럿을 닫고 이어서 타이핑하는데 입력이 아무 데도 들어가지 않아,
587
+ * 콜백이 "됐다 안 됐다" 하는 것처럼 보인다. (#416)
588
+ *
589
+ * 자식(Flatpickr)의 effect 가 먼저 실행되므로 여기서 마지막에 되돌린다.
590
+ * 포커스가 복원되면 flatpickr 가 캘린더를 여는데, 이는 사용자가 칸을 직접 클릭했을 때와 같은
591
+ * 상태이므로 그대로 둔다.
592
+ */
593
+ // 의도적으로 deps 없음 — 자식 Flatpickr effect 가 인스턴스를 재생성한 뒤에 실행되어야 한다
594
+ useEffect(() => {
595
+ if (!hadFocusBeforeDestroyRef.current) return;
596
+ hadFocusBeforeDestroyRef.current = false;
597
+ const input = flatpickrInstanceRef.current?.input;
598
+ if (!input || document.activeElement === input) return;
599
+ input.focus({
600
+ preventScroll: true
601
+ });
602
+ });
467
603
  // ──────────────────────────────────────────────
468
604
  // 렌더링
469
605
  // ──────────────────────────────────────────────