@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
@@ -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
  // ──────────────────────────────────────────────
@@ -107,3 +107,326 @@ describe('#3 onValidationError.previousDate', () => {
107
107
  expect(arg.previousDate?.getTime()).not.toBe(arg.date.getTime());
108
108
  });
109
109
  });
110
+ describe('#5 blur 동기화 — flatpickr가 통지하지 않은 값 보정 (#410)', () => {
111
+ const getInstance = (container) => {
112
+ const input = container.querySelector('input');
113
+ expect(input._flatpickr).toBeTruthy();
114
+ return { input, instance: input._flatpickr };
115
+ };
116
+ /** blur 동기화는 한 틱 뒤에 실행되므로 매크로태스크를 한 번 흘려보낸다 */
117
+ const flushBlurSync = async () => {
118
+ await act(async () => {
119
+ await new Promise((resolve) => setTimeout(resolve, 0));
120
+ });
121
+ };
122
+ it('onChange 없이 값만 반영된 채 blur 되면 onChangeDate 로 통지한다', async () => {
123
+ const onChangeDate = vi.fn();
124
+ const container = mount({
125
+ currentDate: '2024-03-01',
126
+ onChangeDate,
127
+ datePickerOptions: { allowInput: true },
128
+ });
129
+ const { input, instance } = getInstance(container);
130
+ // flatpickr documentClick 이 하는 것과 동일하게, onChange 를 발화시키지 않고 값만 반영한다
131
+ act(() => {
132
+ instance.setDate('2024-03-20', false);
133
+ });
134
+ onChangeDate.mockClear();
135
+ act(() => {
136
+ input.dispatchEvent(new Event('blur'));
137
+ });
138
+ await flushBlurSync();
139
+ expect(onChangeDate).toHaveBeenCalledWith('2024-03-20');
140
+ });
141
+ it('flatpickr가 이미 onChange 로 통지했으면 blur 가 중복 통지하지 않는다', async () => {
142
+ const onChangeDate = vi.fn();
143
+ const container = mount({
144
+ currentDate: '2024-03-01',
145
+ onChangeDate,
146
+ datePickerOptions: { allowInput: true },
147
+ });
148
+ const { input, instance } = getInstance(container);
149
+ act(() => {
150
+ instance.setDate('2024-03-20', true);
151
+ });
152
+ expect(onChangeDate).toHaveBeenCalledTimes(1);
153
+ act(() => {
154
+ input.dispatchEvent(new Event('blur'));
155
+ });
156
+ await flushBlurSync();
157
+ expect(onChangeDate).toHaveBeenCalledTimes(1);
158
+ });
159
+ it('min/max 위반 값은 blur 로도 통지하지 않고 onValidationError 로 보고한다', async () => {
160
+ const onChangeDate = vi.fn();
161
+ const onValidationError = vi.fn();
162
+ const container = mount({
163
+ currentDate: '2024-03-15',
164
+ onChangeDate,
165
+ onValidationError,
166
+ datePickerOptions: { minDate: '2024-03-10', maxDate: '2024-03-25', allowInput: true },
167
+ });
168
+ const { input, instance } = getInstance(container);
169
+ // 범위 밖 날짜가 onChange 없이 값만 반영된 상태 (마우스로 캘린더 밖 클릭 시 flatpickr 동작)
170
+ act(() => {
171
+ instance.setDate('2024-04-30', false);
172
+ });
173
+ onChangeDate.mockClear();
174
+ onValidationError.mockClear();
175
+ act(() => {
176
+ input.dispatchEvent(new Event('blur'));
177
+ });
178
+ await flushBlurSync();
179
+ expect(onChangeDate).not.toHaveBeenCalled();
180
+ expect(onValidationError).toHaveBeenCalledTimes(1);
181
+ expect(onValidationError.mock.calls[0][0].violations).toContain('maxDate');
182
+ });
183
+ it('enableTime 모드에서 값 변경 없이 blur 해도 통지하지 않는다', async () => {
184
+ const onChangeDate = vi.fn();
185
+ const container = mount({
186
+ currentDate: '2024-03-20',
187
+ onChangeDate,
188
+ datePickerOptions: { enableTime: true, dateFormat: 'Y-m-d H:i', allowInput: true },
189
+ });
190
+ const { input } = getInstance(container);
191
+ onChangeDate.mockClear();
192
+ act(() => {
193
+ input.dispatchEvent(new Event('blur'));
194
+ });
195
+ await flushBlurSync();
196
+ expect(onChangeDate).not.toHaveBeenCalled();
197
+ });
198
+ it('noCalendar(시간 전용) 모드에서 값 변경 없이 blur 해도 통지하지 않는다', async () => {
199
+ const onChangeDate = vi.fn();
200
+ const container = mount({
201
+ currentDate: '',
202
+ onChangeDate,
203
+ datePickerOptions: { enableTime: true, noCalendar: true, dateFormat: 'H:i', allowInput: true },
204
+ });
205
+ const { input } = getInstance(container);
206
+ onChangeDate.mockClear();
207
+ act(() => {
208
+ input.dispatchEvent(new Event('blur'));
209
+ });
210
+ await flushBlurSync();
211
+ expect(onChangeDate).not.toHaveBeenCalled();
212
+ });
213
+ it('noCalendar(시간 전용) 모드에서 통지 없이 시간만 바뀐 채 blur 되면 통지한다', async () => {
214
+ const onChangeDate = vi.fn();
215
+ const container = mount({
216
+ currentDate: '',
217
+ onChangeDate,
218
+ datePickerOptions: { enableTime: true, noCalendar: true, dateFormat: 'H:i', allowInput: true },
219
+ });
220
+ const { input, instance } = getInstance(container);
221
+ act(() => {
222
+ instance.setDate('14:30', false);
223
+ });
224
+ onChangeDate.mockClear();
225
+ act(() => {
226
+ input.dispatchEvent(new Event('blur'));
227
+ });
228
+ await flushBlurSync();
229
+ expect(onChangeDate).toHaveBeenCalledWith('14:30');
230
+ });
231
+ it('portal 모드에서도 통지 없이 값만 반영된 채 blur 되면 통지한다', async () => {
232
+ const onChangeDate = vi.fn();
233
+ const container = mount({
234
+ currentDate: '2024-03-01',
235
+ onChangeDate,
236
+ portal: true,
237
+ datePickerOptions: { allowInput: true },
238
+ });
239
+ const { input, instance } = getInstance(container);
240
+ act(() => {
241
+ instance.setDate('2024-03-20', false);
242
+ });
243
+ onChangeDate.mockClear();
244
+ act(() => {
245
+ input.dispatchEvent(new Event('blur'));
246
+ });
247
+ await flushBlurSync();
248
+ expect(onChangeDate).toHaveBeenCalledWith('2024-03-20');
249
+ });
250
+ it('값이 바뀌지 않았으면 blur 만으로는 통지하지 않는다', async () => {
251
+ const onChangeDate = vi.fn();
252
+ const container = mount({
253
+ currentDate: '2024-03-01',
254
+ onChangeDate,
255
+ datePickerOptions: { allowInput: true },
256
+ });
257
+ const { input } = getInstance(container);
258
+ onChangeDate.mockClear();
259
+ act(() => {
260
+ input.dispatchEvent(new Event('blur'));
261
+ });
262
+ await flushBlurSync();
263
+ expect(onChangeDate).not.toHaveBeenCalled();
264
+ });
265
+ });
266
+ describe('#7 타이핑 즉시 통지 — blur 를 기다리지 않는다 (#410)', () => {
267
+ const getInput = (container) => container.querySelector('input.flatpickr-input');
268
+ /** 사용자가 한 글자씩 타이핑하는 것과 동일하게 input 이벤트를 발생시킨다 */
269
+ const typeInto = (input, text) => {
270
+ input.value = '';
271
+ for (const char of text) {
272
+ act(() => {
273
+ input.value = `${input.value}${char}`;
274
+ input.dispatchEvent(new Event('input', { bubbles: true }));
275
+ });
276
+ }
277
+ };
278
+ const flushBlurSync = async () => {
279
+ await act(async () => {
280
+ await new Promise((resolve) => setTimeout(resolve, 0));
281
+ });
282
+ };
283
+ it('완성된 유효 날짜를 타이핑하면 blur 전에 통지한다', () => {
284
+ const onChangeDate = vi.fn();
285
+ const container = mount({
286
+ currentDate: '2024-03-01',
287
+ onChangeDate,
288
+ datePickerOptions: { allowInput: true },
289
+ });
290
+ typeInto(getInput(container), '2024-03-20');
291
+ expect(onChangeDate).toHaveBeenCalledWith('2024-03-20');
292
+ });
293
+ it('구분자 없이 8자리 숫자만 타이핑해도 통지한다', () => {
294
+ const onChangeDate = vi.fn();
295
+ const container = mount({
296
+ currentDate: '2024-03-01',
297
+ onChangeDate,
298
+ datePickerOptions: { allowInput: true },
299
+ });
300
+ // 8자리를 다 치는 순간 핸들러가 값을 '2024-03-20' 으로 다시 쓴다.
301
+ // 그 뒤로는 브라우저가 input 이벤트를 더 주지 않으므로 이 시점에 통지해야 한다.
302
+ typeInto(getInput(container), '20240320');
303
+ expect(onChangeDate).toHaveBeenCalledWith('2024-03-20');
304
+ });
305
+ it('아직 완성되지 않은 입력은 통지하지 않는다', () => {
306
+ const onChangeDate = vi.fn();
307
+ const container = mount({
308
+ currentDate: '2024-03-01',
309
+ onChangeDate,
310
+ datePickerOptions: { allowInput: true },
311
+ });
312
+ typeInto(getInput(container), '2024-03-');
313
+ expect(onChangeDate).not.toHaveBeenCalled();
314
+ });
315
+ it('타이핑으로 이미 통지했으면 blur 가 중복 통지하지 않는다', async () => {
316
+ const onChangeDate = vi.fn();
317
+ const container = mount({
318
+ currentDate: '2024-03-01',
319
+ onChangeDate,
320
+ datePickerOptions: { allowInput: true },
321
+ });
322
+ const input = getInput(container);
323
+ typeInto(input, '2024-03-20');
324
+ expect(onChangeDate).toHaveBeenCalledTimes(1);
325
+ act(() => {
326
+ input.dispatchEvent(new Event('blur'));
327
+ });
328
+ await flushBlurSync();
329
+ expect(onChangeDate).toHaveBeenCalledTimes(1);
330
+ });
331
+ it('min/max 범위 밖 날짜는 타이핑 중에 통지하지 않는다 (blur 시점에 한 번만 보고)', () => {
332
+ const onChangeDate = vi.fn();
333
+ const onValidationError = vi.fn();
334
+ const container = mount({
335
+ currentDate: '2024-03-15',
336
+ onChangeDate,
337
+ onValidationError,
338
+ datePickerOptions: { minDate: '2024-03-10', maxDate: '2024-03-25', allowInput: true },
339
+ });
340
+ typeInto(getInput(container), '2024-04-30');
341
+ expect(onChangeDate).not.toHaveBeenCalled();
342
+ expect(onValidationError).not.toHaveBeenCalled();
343
+ });
344
+ it('같은 값은 경로가 달라도 두 번 통지하지 않는다', () => {
345
+ const onChangeDate = vi.fn();
346
+ const container = mount({
347
+ currentDate: '2024-03-01',
348
+ onChangeDate,
349
+ datePickerOptions: { allowInput: true },
350
+ });
351
+ const input = getInput(container);
352
+ // 타이핑으로 통지한 뒤, 캘린더가 같은 날짜로 onChange 를 다시 발화시키는 상황
353
+ typeInto(input, '2024-03-20');
354
+ act(() => {
355
+ input._flatpickr.setDate('2024-03-20', true);
356
+ });
357
+ expect(onChangeDate).toHaveBeenCalledTimes(1);
358
+ });
359
+ it('enableTime 모드에서 날짜만 입력한 상태는 타이핑 중에 통지하지 않는다', () => {
360
+ const onChangeDate = vi.fn();
361
+ const container = mount({
362
+ currentDate: '2024-03-01 00:00',
363
+ onChangeDate,
364
+ datePickerOptions: { enableTime: true, dateFormat: 'Y-m-d H:i', allowInput: true },
365
+ });
366
+ // '2024-03-20' 은 최종 표기('2024-03-20 00:00')와 다르다.
367
+ // 여기서 통지하면 setDate 가 input 을 다시 써서 타이핑 중 커서가 튄다.
368
+ typeInto(getInput(container), '2024-03-20');
369
+ expect(onChangeDate).not.toHaveBeenCalled();
370
+ });
371
+ });
372
+ describe('#8 완성 판정·파싱을 dateFormat 기준으로 한다', () => {
373
+ const getInput = (container) => container.querySelector('input.flatpickr-input');
374
+ const typeInto = (input, text) => {
375
+ input.value = '';
376
+ for (const char of text) {
377
+ act(() => {
378
+ input.value = `${input.value}${char}`;
379
+ input.dispatchEvent(new Event('input', { bubbles: true }));
380
+ });
381
+ }
382
+ };
383
+ it('enableTime 에서 한 글자씩 쳐도 입력이 유지되고 통지된다', () => {
384
+ const onChangeDate = vi.fn();
385
+ const container = mount({
386
+ currentDate: '2026-03-20 23:59',
387
+ onChangeDate,
388
+ datePickerOptions: { allowInput: true, enableTime: true, dateFormat: 'Y-m-d H:i' },
389
+ });
390
+ const input = getInput(container);
391
+ // 완성 길이를 10 으로 고정하면 '2026-03-01 0' 을 완성으로 오판해
392
+ // moment 검증에 걸리고, 입력이 이전 값('2026-03-20 23:59')으로 되돌아갔다
393
+ typeInto(input, '2026-03-01 00:00');
394
+ expect(input.value).toBe('2026-03-01 00:00');
395
+ expect(onChangeDate).toHaveBeenCalledWith('2026-03-01 00:00');
396
+ });
397
+ it('enableTime 에서 시간을 아직 안 친 중간 상태는 통지하지 않는다', () => {
398
+ const onChangeDate = vi.fn();
399
+ const container = mount({
400
+ currentDate: '2026-03-20 23:59',
401
+ onChangeDate,
402
+ datePickerOptions: { allowInput: true, enableTime: true, dateFormat: 'Y-m-d H:i' },
403
+ });
404
+ typeInto(getInput(container), '2026-03-01 00');
405
+ expect(onChangeDate).not.toHaveBeenCalled();
406
+ });
407
+ it('시간 전용(noCalendar) 모드에서 타이핑한 값이 되돌려지지 않는다', () => {
408
+ const onChangeDate = vi.fn();
409
+ const container = mount({
410
+ currentDate: '23:59',
411
+ onChangeDate,
412
+ datePickerOptions: { allowInput: true, enableTime: true, noCalendar: true, dateFormat: 'H:i' },
413
+ });
414
+ const input = getInput(container);
415
+ // 포맷 없이 moment('14:30') 으로 파싱하면 invalid 라 이전 값으로 복원됐다
416
+ typeInto(input, '14:30');
417
+ expect(input.value).toBe('14:30');
418
+ expect(onChangeDate).toHaveBeenCalledWith('14:30');
419
+ });
420
+ it('완성 길이에 도달했지만 실재하지 않는 날짜는 이전 값으로 복원한다', () => {
421
+ const onChangeDate = vi.fn();
422
+ const container = mount({
423
+ currentDate: '2026-03-20',
424
+ onChangeDate,
425
+ datePickerOptions: { allowInput: true },
426
+ });
427
+ const input = getInput(container);
428
+ typeInto(input, '2026-13-45');
429
+ expect(input.value).not.toBe('2026-13-45');
430
+ expect(onChangeDate).not.toHaveBeenCalledWith('2026-13-45');
431
+ });
432
+ });
@@ -57,5 +57,13 @@ export interface FileInputProps extends Omit<InputBaseProps, 'clearText' | 'onCl
57
57
  * Hint text to display
58
58
  */
59
59
  hintText?: string;
60
+ /**
61
+ * Whether to render the built-in file tag list
62
+ *
63
+ * Set to `false` when the file list is rendered outside the component —
64
+ * the selected files still arrive through `onChange` / `onFileSelect`, and
65
+ * file count/duplicate validation keeps working.
66
+ */
67
+ showFileTags?: boolean;
60
68
  }
61
69
  export declare const FileInput: import("react").ForwardRefExoticComponent<FileInputProps & import("react").RefAttributes<HTMLInputElement>>;